org.eclipse.smarthome.io.rest.sitemap.SitemapSubscriptionService.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.smarthome.io.rest.sitemap.SitemapSubscriptionService.java

Source

/**
 * Copyright (c) 2014,2018 Contributors to the Eclipse Foundation
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
package org.eclipse.smarthome.io.rest.sitemap;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import org.apache.commons.lang.StringUtils;
import org.eclipse.emf.common.util.BasicEList;
import org.eclipse.emf.common.util.EList;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.smarthome.core.events.Event;
import org.eclipse.smarthome.core.events.EventFilter;
import org.eclipse.smarthome.core.events.EventSubscriber;
import org.eclipse.smarthome.core.items.GroupItem;
import org.eclipse.smarthome.core.items.Item;
import org.eclipse.smarthome.core.items.events.ItemStatePredictedEvent;
import org.eclipse.smarthome.io.rest.sitemap.internal.PageChangeListener;
import org.eclipse.smarthome.io.rest.sitemap.internal.SitemapEvent;
import org.eclipse.smarthome.model.core.EventType;
import org.eclipse.smarthome.model.core.ModelRepositoryChangeListener;
import org.eclipse.smarthome.model.sitemap.LinkableWidget;
import org.eclipse.smarthome.model.sitemap.Sitemap;
import org.eclipse.smarthome.model.sitemap.SitemapProvider;
import org.eclipse.smarthome.model.sitemap.Widget;
import org.eclipse.smarthome.ui.items.ItemUIRegistry;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This is a service that provides the possibility to manage subscriptions to sitemaps.
 * As such subscriptions are stateful, they need to be created and removed upon disposal.
 * The subscription mechanism makes sure that only events for widgets of the currently active sitemap page are sent as
 * events to the subscriber.
 * For this to work correctly, the subscriber needs to make sure that setPageId is called whenever it switches to a new
 * page.
 *
 * @author Kai Kreuzer - Initial contribution and API
 */
@Component(service = { SitemapSubscriptionService.class,
        EventSubscriber.class }, configurationPid = "org.eclipse.smarthome.sitemapsubscription")
public class SitemapSubscriptionService implements ModelRepositoryChangeListener, EventSubscriber {

    private static final String SITEMAP_PAGE_SEPARATOR = "#";
    private static final String SITEMAP_SUFFIX = ".sitemap";
    private static final int DEFAULT_MAX_SUBSCRIPTIONS = 50;

    private final Logger logger = LoggerFactory.getLogger(SitemapSubscriptionService.class);

    public interface SitemapSubscriptionCallback {

        void onEvent(SitemapEvent event);

        void onRelease(String subscriptionId);
    }

    private ItemUIRegistry itemUIRegistry;
    private final List<SitemapProvider> sitemapProviders = new ArrayList<>();

    /* subscription id -> sitemap+page */
    private final Map<String, String> pageOfSubscription = new ConcurrentHashMap<>();

    /* subscription id -> callback */
    private final Map<String, SitemapSubscriptionCallback> callbacks = new ConcurrentHashMap<>();

    /* subscription id -> creation date */
    private final Map<String, Long> creationDates = new ConcurrentHashMap<>();

    /* sitemap+page -> listener */
    private final Map<String, PageChangeListener> pageChangeListeners = new ConcurrentHashMap<>();

    /* Max number of subscriptions at the same time */
    private int maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS;

    public SitemapSubscriptionService() {
    }

    @Activate
    protected void activate(Map<String, Object> config) {
        applyConfig(config);
    }

    @Deactivate
    protected void deactivate() {
        pageOfSubscription.clear();
        callbacks.clear();
        for (PageChangeListener listener : pageChangeListeners.values()) {
            listener.dispose();
        }
        pageChangeListeners.clear();
    }

    @Modified
    protected void modified(Map<String, Object> config) {
        applyConfig(config);
    }

    private void applyConfig(Map<String, Object> config) {
        if (config == null) {
            return;
        }
        final String max = Objects.toString(config.get("maxSubscriptions"), null);
        if (max != null) {
            try {
                maxSubscriptions = Integer.parseInt(max);
            } catch (NumberFormatException e) {
                logger.debug("Setting 'maxSubscriptions' must be a number; value '{}' ignored.", max);
            }
        }
    }

    @Reference
    protected void setItemUIRegistry(ItemUIRegistry itemUIRegistry) {
        this.itemUIRegistry = itemUIRegistry;
    }

    protected void unsetItemUIRegistry(ItemUIRegistry itemUIRegistry) {
        this.itemUIRegistry = null;
    }

    @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
    protected void addSitemapProvider(SitemapProvider provider) {
        sitemapProviders.add(provider);
        provider.addModelChangeListener(this);
    }

    protected void removeSitemapProvider(SitemapProvider provider) {
        sitemapProviders.remove(provider);
        provider.removeModelChangeListener(this);
    }

    /**
     * Creates a new subscription with the given id.
     *
     * @param callback an instance that should receive the events
     * @returns a unique id that identifies the subscription or null if the limit of subscriptions is already reached
     */
    public String createSubscription(SitemapSubscriptionCallback callback) {
        if (maxSubscriptions >= 0 && callbacks.size() >= maxSubscriptions) {
            logger.debug("No new subscription delivered as limit ({}) is already reached", maxSubscriptions);
            return null;
        }
        String subscriptionId = UUID.randomUUID().toString();
        callbacks.put(subscriptionId, callback);
        creationDates.put(subscriptionId, System.currentTimeMillis());
        logger.debug("Created new subscription with id {} ({} active subscriptions for a max of {})",
                subscriptionId, callbacks.size(), maxSubscriptions);
        return subscriptionId;
    }

    /**
     * Removes an existing subscription
     *
     * @param subscriptionId the id of the subscription to remove
     */
    public void removeSubscription(String subscriptionId) {
        creationDates.remove(subscriptionId);
        callbacks.remove(subscriptionId);
        String sitemapPage = pageOfSubscription.remove(subscriptionId);
        if (sitemapPage != null && !pageOfSubscription.values().contains(sitemapPage)) {
            // this was the only subscription listening on this page, so we can dispose the listener
            PageChangeListener listener = pageChangeListeners.remove(sitemapPage);
            if (listener != null) {
                listener.dispose();
            }
        }
        logger.debug("Removed subscription with id {} ({} active subscriptions)", subscriptionId, callbacks.size());
    }

    /**
     * Checks whether a subscription with a given id (still) exists.
     *
     * @param subscriptionId the id of the subscription to check
     * @return true, if it exists, false otherwise
     */
    public boolean exists(String subscriptionId) {
        return callbacks.containsKey(subscriptionId);
    }

    /**
     * Retrieves the current page id for a subscription.
     *
     * @param subscriptionId the subscription to get the page id for
     * @return the id of the currently active page or null if no page is currently set for the subscription
     */
    public String getPageId(String subscriptionId) {
        String sitemapWithPageId = pageOfSubscription.get(subscriptionId);
        return (sitemapWithPageId == null) ? null : extractPageId(sitemapWithPageId);
    }

    /**
     * Retrieves the current sitemap name for a subscription.
     *
     * @param subscriptionId the subscription to get the sitemap name for
     * @return the name of the current sitemap or null if no sitemap is currently set for the subscription
     */
    public String getSitemapName(String subscriptionId) {
        String sitemapWithPageId = pageOfSubscription.get(subscriptionId);
        return (sitemapWithPageId == null) ? null : extractSitemapName(sitemapWithPageId);
    }

    private String extractSitemapName(String sitemapWithPageId) {
        return sitemapWithPageId.split(SITEMAP_PAGE_SEPARATOR)[0];
    }

    private String extractPageId(String sitemapWithPageId) {
        return sitemapWithPageId.split(SITEMAP_PAGE_SEPARATOR)[1];
    }

    /**
     * Updates the subscription to send events for the provided page id.
     *
     * @param subscriptionId the subscription to update
     * @param sitemapName the current sitemap name
     * @param pageId the current page id
     */
    public void setPageId(String subscriptionId, String sitemapName, String pageId) {
        SitemapSubscriptionCallback callback = callbacks.get(subscriptionId);
        if (callback != null) {
            String oldSitemapPage = pageOfSubscription.remove(subscriptionId);
            if (oldSitemapPage != null) {
                removeCallbackFromListener(oldSitemapPage, callback);
            }
            addCallbackToListener(sitemapName, pageId, callback);
            pageOfSubscription.put(subscriptionId, getValue(sitemapName, pageId));

            logger.debug("Subscription {} changed to page {} of sitemap {} ({} active subscriptions}",
                    new Object[] { subscriptionId, pageId, sitemapName, callbacks.size() });
        } else {
            throw new IllegalArgumentException("Subscription " + subscriptionId + " does not exist!");
        }
    }

    private void addCallbackToListener(String sitemapName, String pageId, SitemapSubscriptionCallback callback) {
        PageChangeListener listener = pageChangeListeners.get(getValue(sitemapName, pageId));
        if (listener == null) {
            // there is no listener for this page yet, so let's try to create one
            listener = new PageChangeListener(sitemapName, pageId, itemUIRegistry,
                    collectWidgets(sitemapName, pageId));
            pageChangeListeners.put(getValue(sitemapName, pageId), listener);
        }
        if (listener != null) {
            listener.addCallback(callback);
        }
    }

    private EList<Widget> collectWidgets(String sitemapName, String pageId) {
        EList<Widget> widgets = new BasicEList<Widget>();

        Sitemap sitemap = getSitemap(sitemapName);
        if (sitemap != null) {
            if (pageId.equals(sitemap.getName())) {
                widgets = itemUIRegistry.getChildren(sitemap);
            } else {
                Widget pageWidget = itemUIRegistry.getWidget(sitemap, pageId);
                if (pageWidget instanceof LinkableWidget) {
                    widgets = itemUIRegistry.getChildren((LinkableWidget) pageWidget);
                    // We add the page widget. It will help any UI to update the page title.
                    widgets.add(pageWidget);
                }
            }
        }
        return widgets;
    }

    private void removeCallbackFromListener(String sitemapPage, SitemapSubscriptionCallback callback) {
        PageChangeListener oldListener = pageChangeListeners.get(sitemapPage);
        if (oldListener != null) {
            oldListener.removeCallback(callback);
            if (!pageOfSubscription.values().contains(sitemapPage)) {
                // no other callbacks are left here, so we can safely dispose the listener
                oldListener.dispose();
                pageChangeListeners.remove(sitemapPage);
            }
        }
    }

    private String getValue(String sitemapName, String pageId) {
        return sitemapName + SITEMAP_PAGE_SEPARATOR + pageId;
    }

    private Sitemap getSitemap(String sitemapName) {
        for (SitemapProvider provider : sitemapProviders) {
            Sitemap sitemap = provider.getSitemap(sitemapName);
            if (sitemap != null) {
                return sitemap;
            }
        }
        return null;
    }

    @Override
    public void modelChanged(String modelName, EventType type) {
        if (type != EventType.MODIFIED || !modelName.endsWith(SITEMAP_SUFFIX)) {
            return; // we process only sitemap modifications here
        }

        String changedSitemapName = StringUtils.removeEnd(modelName, SITEMAP_SUFFIX);

        for (Entry<String, PageChangeListener> listenerEntry : pageChangeListeners.entrySet()) {
            String sitemapWithPage = listenerEntry.getKey();
            String sitemapName = extractSitemapName(sitemapWithPage);
            String pageId = extractPageId(sitemapWithPage);

            if (sitemapName.equals(changedSitemapName)) {
                EList<Widget> widgets = collectWidgets(sitemapName, pageId);
                listenerEntry.getValue().sitemapContentChanged(widgets);
            }
        }
    }

    public void checkAliveClients() {
        // Release the subscriptions that are not attached to a page
        for (Entry<String, Long> dateEntry : creationDates.entrySet()) {
            String subscriptionId = dateEntry.getKey();
            SitemapSubscriptionCallback callback = callbacks.get(subscriptionId);
            if (getPageId(subscriptionId) == null && callback != null
                    && (dateEntry.getValue().longValue() + 30000) < System.currentTimeMillis()) {
                logger.debug("Release subscription {} as sitemap page is not set", subscriptionId);
                removeSubscription(subscriptionId);
                callback.onRelease(subscriptionId);
            }
        }
        // Send an ALIVE event to all subscribers to trigger an exception for dead subscribers
        for (Entry<String, PageChangeListener> listenerEntry : pageChangeListeners.entrySet()) {
            listenerEntry.getValue().sendAliveEvent();
        }
    }

    @Override
    public Set<String> getSubscribedEventTypes() {
        return Collections.singleton(ItemStatePredictedEvent.TYPE);
    }

    @Override
    public @Nullable EventFilter getEventFilter() {
        return null;
    }

    @Override
    public void receive(Event event) {
        if (event instanceof ItemStatePredictedEvent) {
            ItemStatePredictedEvent prediction = (ItemStatePredictedEvent) event;
            Item item = itemUIRegistry.get(prediction.getItemName());
            if (item instanceof GroupItem) {
                // don't send out auto-update events for group items as those will calculate their state based on their
                // members and predictions aren't really possible in that case (or at least would be highly complex).
                return;
            }
            for (PageChangeListener pageChangeListener : pageChangeListeners.values()) {
                if (prediction.isConfirmation()) {
                    pageChangeListener.keepCurrentState(item);
                } else {
                    pageChangeListener.changeStateTo(item, prediction.getPredictedState());
                }
            }
        }
    }
}