[hdpowerview] Add support for Generation 3 (#13355)

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
This commit is contained in:
Andrew Fiddian-Green
2023-05-24 12:00:14 +01:00
committed by GitHub
parent 96d0293913
commit df9c270acf
36 changed files with 15342 additions and 106 deletions

View File

@@ -0,0 +1,439 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal;
import java.io.Closeable;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLSession;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.sse.InboundSseEvent;
import javax.ws.rs.sse.SseEventSource;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.hdpowerview.internal.HDPowerViewWebTargets.Query;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Info;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Scene;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Shade;
import org.openhab.binding.hdpowerview.internal.dto.gen3.ShadeEvent;
import org.openhab.binding.hdpowerview.internal.dto.gen3.ShadePosition;
import org.openhab.binding.hdpowerview.internal.dto.requests.ShadeMotion;
import org.openhab.binding.hdpowerview.internal.exceptions.HubProcessingException;
import org.openhab.binding.hdpowerview.internal.handler.GatewayBridgeHandler;
import org.openhab.core.thing.Thing;
import org.osgi.service.jaxrs.client.SseEventSourceFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonParseException;
/**
* JAX-RS targets for communicating with an HD PowerView Generation 3 Gateway.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class GatewayWebTargets implements Closeable, HostnameVerifier {
private static final String IDS = "ids";
private static final int SLEEP_SECONDS = 360;
private static final Set<Integer> HTTP_OK_CODES = Set.of(HttpStatus.OK_200, HttpStatus.NO_CONTENT_204);
private final Logger logger = LoggerFactory.getLogger(GatewayWebTargets.class);
private final Gson jsonParser = new Gson();
private final String shades;
private final String scenes;
private final String sceneActivate;
private final String shadeMotion;
private final String shadePositions;
private final String shadeSingle;
private final String shadeStop;
private final String info;
private final String register;
private final String shadeEvents;
private final HttpClient httpClient;
private final ClientBuilder clientBuilder;
private final SseEventSourceFactory eventSourceFactory;
private final GatewayBridgeHandler hubHandler;
private final String ipAddress;
private boolean registered;
private boolean closing;
private @Nullable SseEventSource shadeEventSource;
private @Nullable ScheduledFuture<?> sseQuietCheck;
/**
* Initialize the web targets
*
* @param httpClient the HTTP client (the binding)
* @param ipAddress the IP address of the server (the hub)
*/
public GatewayWebTargets(GatewayBridgeHandler hubHandler, HttpClient httpClient, ClientBuilder clientBuilder,
SseEventSourceFactory eventSourceFactory, String ipAddress) {
this.ipAddress = ipAddress;
this.httpClient = httpClient;
this.clientBuilder = clientBuilder;
this.eventSourceFactory = eventSourceFactory;
this.hubHandler = hubHandler;
String base = "http://" + ipAddress + "/";
String home = base + "home/";
shades = home + "shades";
scenes = home + "scenes";
sceneActivate = home + "scenes/%d/activate";
shadeMotion = home + "shades/%d/motion";
shadePositions = home + "shades/positions";
shadeSingle = home + "shades/%d";
shadeStop = home + "shades/stop";
shadeEvents = home + "shades/events?sse=true";
info = base + "gateway/info";
/*
* Hunter Douglas keeps a statistical count of systems (e.g. openHAB, Home Assistant, Amazon etc.) that are
* using their Generation 3 REST API to connect to their gateways. So we are asked to register with the gateway
* on startup. => So do not change the 'openhab.org' tag below !!
*/
register = home + "integration/openhab.org";
}
/**
* Issue a command to activate a scene.
*
* @param sceneId the scene to be activated.
* @throws HubProcessingException if any error occurs.
*/
public void activateScene(int sceneId) throws HubProcessingException {
invoke(HttpMethod.PUT, String.format(sceneActivate, sceneId), null, null);
}
@Override
public void close() throws IOException {
closing = true;
sseClose();
}
/**
* Register the binding with the hub (if not already registered).
*
* @throws HubProcessingException if any error occurs.
*/
public boolean gatewayRegister() throws HubProcessingException {
if (!registered) {
invoke(HttpMethod.PUT, register, null, null);
registered = true;
}
return registered;
}
/**
* Get hub properties.
*
* @return a map containing the hub properties.
* @throws HubProcessingException if any error occurs.
*/
public Map<String, String> getInformation() throws HubProcessingException {
String json = invoke(HttpMethod.GET, info, null, null);
try {
Info result = jsonParser.fromJson(json, Info.class);
if (result == null) {
throw new HubProcessingException("getInformation(): missing response");
}
return Map.of( //
Thing.PROPERTY_FIRMWARE_VERSION, result.getFwVersion(), //
Thing.PROPERTY_SERIAL_NUMBER, result.getSerialNumber());
} catch (JsonParseException e) {
throw new HubProcessingException("getFirmwareVersions(): JsonParseException");
}
}
/**
* Get the list of scenes.
*
* @return the list of scenes.
* @throws HubProcessingException if any error occurs.
*/
public List<Scene> getScenes() throws HubProcessingException {
String json = invoke(HttpMethod.GET, scenes, null, null);
try {
return List.of(jsonParser.fromJson(json, Scene[].class));
} catch (JsonParseException e) {
throw new HubProcessingException("getScenes() JsonParseException");
}
}
/**
* Get the data for a single shade.
*
* @param shadeId the id of the shade to get.
* @return the shade.
* @throws HubProcessingException if any error occurs.
*/
public Shade getShade(int shadeId) throws HubProcessingException {
String json = invoke(HttpMethod.GET, String.format(shadeSingle, shadeId), null, null);
try {
Shade result = jsonParser.fromJson(json, Shade.class);
if (result == null) {
throw new HubProcessingException("getShade() missing response");
}
return result;
} catch (JsonParseException e) {
throw new HubProcessingException("getShade() JsonParseException");
}
}
/**
* Get the list of shades.
*
* @return the list of shades.
* @throws HubProcessingException if any error occurs.
*/
public List<Shade> getShades() throws HubProcessingException {
String json = invoke(HttpMethod.GET, shades, null, null);
try {
return List.of(jsonParser.fromJson(json, Shade[].class));
} catch (JsonParseException e) {
throw new HubProcessingException("getShades() JsonParseException");
}
}
/**
* Invoke a call on the hub server to retrieve information or send a command.
*
* @param method GET or PUT.
* @param url the host URL to be called.
* @param query the HTTP query parameter.
* @param jsonCommand the request command content (as a JSON string).
* @return the response content (as a JSON string).
* @throws HubProcessingException if something goes wrong.
*/
protected synchronized String invoke(HttpMethod method, String url, @Nullable Query query,
@Nullable String jsonCommand) throws HubProcessingException {
if (logger.isTraceEnabled()) {
if (query != null) {
logger.trace("invoke() method:{}, url:{}, query:{}", method, url, query);
} else {
logger.trace("invoke() method:{}, url:{}", method, url);
}
if (jsonCommand != null) {
logger.trace("invoke() request JSON:{}", jsonCommand);
}
}
Request request = httpClient.newRequest(url).method(method).header("Connection", "close").accept("*/*");
if (query != null) {
request.param(query.getKey(), query.getValue());
}
if (jsonCommand != null) {
request.header(HttpHeader.CONTENT_TYPE, "application/json").content(new StringContentProvider(jsonCommand));
}
ContentResponse response;
try {
response = request.send();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
} catch (TimeoutException | ExecutionException e) {
throw new HubProcessingException(String.format("%s: \"%s\"", e.getClass().getName(), e.getMessage()));
}
int statusCode = response.getStatus();
if (!HTTP_OK_CODES.contains(statusCode)) {
throw new HubProcessingException(String.format("HTTP %d error", statusCode));
}
String jsonResponse = response.getContentAsString();
if (logger.isTraceEnabled()) {
logger.trace("invoke() response JSON:{}", jsonResponse);
}
if (method == HttpMethod.GET && jsonResponse.isEmpty()) {
throw new HubProcessingException("Empty response entity");
}
return jsonResponse;
}
/**
* Issue a jog command to a shade.
*
* @param shadeId the shade to be jogged.
* @throws HubProcessingException if any error occurs.
*/
public void jogShade(int shadeId) throws HubProcessingException {
String json = jsonParser.toJson(new ShadeMotion(ShadeMotion.Type.JOG));
invoke(HttpMethod.PUT, String.format(shadeMotion, shadeId), null, json);
}
/**
* Issue a command to move a shade.
*
* @param shadeId the shade to be moved.
* @param shade DTO with the new position.
* @throws HubProcessingException if any error occurs.
*/
public void moveShade(int shadeId, Shade shade) throws HubProcessingException {
invoke(HttpMethod.PUT, shadePositions, Query.of(IDS, Integer.toString(shadeId)), jsonParser.toJson(shade));
}
/**
* Called when the SSE event channel has not received any events for a long time. This could mean that the event
* source socket has dropped. So restart the SSE connection.
*/
public void onSseQuiet() {
if (!closing) {
sseReOpen();
}
}
/**
* Handle SSE errors. For the time being just log them, because the framework should automatically recover itself.
*
* @param e the error that was thrown.
*/
private void onSseError(Throwable e) {
if (!closing) {
logger.debug("onSseShadeError() {}", e.getMessage(), e);
}
}
/**
* Handle inbound shade SSE events.
*
* @param sseEvent the inbound event.
*/
private void onSSeEvent(InboundSseEvent sseEvent) {
if (closing) {
return;
}
ScheduledFuture<?> task = sseQuietCheck;
if (task != null && !task.isCancelled()) {
task.cancel(true);
}
sseQuietCheck = hubHandler.getScheduler().schedule(this::onSseQuiet, SLEEP_SECONDS, TimeUnit.SECONDS);
if (sseEvent.isEmpty()) {
return;
}
String json = sseEvent.readData();
if (json == null) {
return;
}
logger.trace("onSseShadeEvent() json:{}", json);
ShadeEvent shadeEvent = jsonParser.fromJson(json, ShadeEvent.class);
if (shadeEvent != null) {
ShadePosition positions = shadeEvent.getCurrentPositions();
hubHandler
.onShadeEvent(new Shade().setId(shadeEvent.getId()).setShadePosition(positions).setPartialState());
}
}
/**
* Close the SSE links.
*/
private synchronized void sseClose() {
logger.debug("sseClose() called");
ScheduledFuture<?> task = sseQuietCheck;
if (task != null) {
task.cancel(true);
sseQuietCheck = null;
}
SseEventSource source;
source = this.shadeEventSource;
if (source != null) {
source.close();
this.shadeEventSource = null;
}
}
/**
* Open the SSE links.
*/
public synchronized void sseOpen() {
sseClose();
logger.debug("sseOpen() called");
Client client = clientBuilder.sslContext(httpClient.getSslContextFactory().getSslContext())
.hostnameVerifier(null).hostnameVerifier(this).readTimeout(0, TimeUnit.SECONDS).build();
try {
// open SSE channel for shades
SseEventSource shadeEventSource = eventSourceFactory.newSource(client.target(shadeEvents));
shadeEventSource.register(this::onSSeEvent, this::onSseError);
shadeEventSource.open();
this.shadeEventSource = shadeEventSource;
} catch (Exception e) {
// SSE documentation does not say what exceptions may be thrown, so catch everything
logger.warn("sseOpen() {}", e.getMessage(), e);
}
}
/**
* Reopen the SSE links. If the event source already exists, try first to simply close and re-open it, but if that
* fails, then completely destroy and re-create it.
*/
private synchronized void sseReOpen() {
logger.debug("sseReOpen() called");
SseEventSource shadeEventSource = this.shadeEventSource;
if (shadeEventSource != null) {
try {
if (shadeEventSource.isOpen()) {
shadeEventSource.close();
}
if (!shadeEventSource.isOpen()) {
shadeEventSource.open();
}
return;
} catch (Exception e) {
// SSE documentation does not say what exceptions may be thrown, so catch everything
logger.warn("sseReOpen() {}", e.getMessage(), e);
}
}
sseOpen();
}
/**
* Issue a stop command to a shade.
*
* @param shadeId the shade to be stopped.
* @throws HubProcessingException if any error occurs.
*/
public void stopShade(int shadeId) throws HubProcessingException {
invoke(HttpMethod.PUT, shadeStop, Query.of(IDS, Integer.valueOf(shadeId).toString()), null);
}
/**
* HostnameVerifier method implementation that validates the host name when opening SSE connections.
*
* @param hostName the host name to be verified.
* @param sslSession (not used).
* @return true if the host name matches our own.
*/
@Override
public boolean verify(@Nullable String hostName, @Nullable SSLSession sslSession) {
return this.ipAddress.equals(hostName);
}
}

View File

@@ -15,6 +15,7 @@ package org.openhab.binding.hdpowerview.internal;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
@@ -74,6 +75,17 @@ public class HDPowerViewBindingConstants {
public static final List<String> NETBIOS_NAMES = Arrays.asList("PDBU-Hub3.0", "PowerView-Hub");
public static final Pattern VALID_IP_V4_ADDRESS = Pattern
.compile("\\b((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}\\b");
// generation 3
public static final ThingTypeUID THING_TYPE_GATEWAY = new ThingTypeUID(BINDING_ID, "gateway");
public static final ThingTypeUID THING_TYPE_SHADE3 = new ThingTypeUID(BINDING_ID, "shade3");
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_HUB, THING_TYPE_SHADE,
THING_TYPE_REPEATER);
THING_TYPE_REPEATER, THING_TYPE_GATEWAY, THING_TYPE_SHADE3);
public static final String PROPERTY_NAME = "name";
public static final String PROPERTY_POWER_TYPE = "powerType";
public static final String PROPERTY_BLE_NAME = "bleName";
}

View File

@@ -14,13 +14,18 @@ package org.openhab.binding.hdpowerview.internal;
import java.util.Hashtable;
import javax.ws.rs.client.ClientBuilder;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.hdpowerview.internal.discovery.HDPowerViewDeviceDiscoveryService;
import org.openhab.binding.hdpowerview.internal.discovery.ShadeDiscoveryService;
import org.openhab.binding.hdpowerview.internal.handler.GatewayBridgeHandler;
import org.openhab.binding.hdpowerview.internal.handler.HDPowerViewHubHandler;
import org.openhab.binding.hdpowerview.internal.handler.HDPowerViewRepeaterHandler;
import org.openhab.binding.hdpowerview.internal.handler.HDPowerViewShadeHandler;
import org.openhab.binding.hdpowerview.internal.handler.ShadeThingHandler;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
@@ -35,6 +40,7 @@ import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.jaxrs.client.SseEventSourceFactory;
/**
* The {@link HDPowerViewHandlerFactory} is responsible for creating things and thing
@@ -48,15 +54,20 @@ public class HDPowerViewHandlerFactory extends BaseThingHandlerFactory {
private final HttpClient httpClient;
private final HDPowerViewTranslationProvider translationProvider;
private final ClientBuilder clientBuilder;
private final SseEventSourceFactory eventSourceFactory;
@Activate
public HDPowerViewHandlerFactory(@Reference HttpClientFactory httpClientFactory,
final @Reference TranslationProvider i18nProvider, final @Reference LocaleProvider localeProvider,
final @Reference ClientBuilder clientBuilder, final @Reference SseEventSourceFactory eventSourceFactory,
ComponentContext componentContext) {
super.activate(componentContext);
this.httpClient = httpClientFactory.getCommonHttpClient();
this.translationProvider = new HDPowerViewTranslationProvider(getBundleContext().getBundle(), i18nProvider,
localeProvider);
this.clientBuilder = clientBuilder;
this.eventSourceFactory = eventSourceFactory;
}
@Override
@@ -67,8 +78,14 @@ public class HDPowerViewHandlerFactory extends BaseThingHandlerFactory {
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (HDPowerViewBindingConstants.THING_TYPE_HUB.equals(thingTypeUID)) {
if (HDPowerViewBindingConstants.THING_TYPE_GATEWAY.equals(thingTypeUID)) {
GatewayBridgeHandler handler = new GatewayBridgeHandler((Bridge) thing, httpClient, translationProvider,
clientBuilder, eventSourceFactory);
registerService(new ShadeDiscoveryService(handler));
return handler;
} else if (HDPowerViewBindingConstants.THING_TYPE_SHADE3.equals(thingTypeUID)) {
return new ShadeThingHandler(thing);
} else if (HDPowerViewBindingConstants.THING_TYPE_HUB.equals(thingTypeUID)) {
HDPowerViewHubHandler handler = new HDPowerViewHubHandler((Bridge) thing, httpClient, translationProvider);
registerService(new HDPowerViewDeviceDiscoveryService(handler));
return handler;
@@ -77,7 +94,6 @@ public class HDPowerViewHandlerFactory extends BaseThingHandlerFactory {
} else if (HDPowerViewBindingConstants.THING_TYPE_REPEATER.equals(thingTypeUID)) {
return new HDPowerViewRepeaterHandler(thing);
}
return null;
}

View File

@@ -102,9 +102,9 @@ public class HDPowerViewWebTargets {
private final HttpClient httpClient;
/**
* private helper class for passing http url query parameters
* helper class for passing http url query parameters
*/
private static class Query {
public static class Query {
private final String key;
private final String value;
@@ -425,7 +425,7 @@ public class HDPowerViewWebTargets {
/**
* Enables or disables a scheduled event in the hub.
*
*
* @param scheduledEventId id of the scheduled event to be enabled or disabled
* @param enable true to enable scheduled event, false to disable
* @throws HubInvalidResponseException if response is invalid
@@ -527,7 +527,7 @@ public class HDPowerViewWebTargets {
/**
* Enables or disables blinking for a repeater
*
*
* @param repeaterId id of the repeater for which to be enable or disable blinking
* @param enable true to enable blinking, false to disable
* @return RepeaterData class instance

View File

@@ -17,11 +17,14 @@ import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.hdpowerview.internal.GatewayWebTargets;
import org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants;
import org.openhab.binding.hdpowerview.internal.HDPowerViewWebTargets;
import org.openhab.binding.hdpowerview.internal.dto.ShadeData;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Shade;
import org.openhab.binding.hdpowerview.internal.dto.responses.RepeaterData;
import org.openhab.binding.hdpowerview.internal.exceptions.HubException;
import org.openhab.binding.hdpowerview.internal.handler.GatewayBridgeHandler;
import org.openhab.binding.hdpowerview.internal.handler.HDPowerViewHubHandler;
import org.openhab.core.io.console.Console;
import org.openhab.core.io.console.ConsoleCommandCompleter;
@@ -65,7 +68,7 @@ public class HDPowerViewCommandExtension extends AbstractConsoleCommandExtension
for (Thing thing : thingRegistry.getAll()) {
ThingHandler thingHandler = thing.getHandler();
if (thingHandler instanceof HDPowerViewHubHandler) {
console.println("API bridge: " + thing.getLabel());
console.println("Generation 1/2 API hub: " + thing.getLabel());
HDPowerViewWebTargets webTargets = ((HDPowerViewHubHandler) thingHandler).getWebTargets();
try {
@@ -87,6 +90,21 @@ public class HDPowerViewCommandExtension extends AbstractConsoleCommandExtension
} catch (HubException e) {
console.println("Error retrieving ID's: " + e.getMessage());
}
} else if (thingHandler instanceof GatewayBridgeHandler) {
console.println("Generation 3 API gateway: " + thing.getLabel());
GatewayWebTargets webTargets = ((GatewayBridgeHandler) thingHandler).getWebTargets();
try {
List<Shade> shades = webTargets.getShades();
if (!shades.isEmpty()) {
console.println(" - Shades:");
for (Shade shade : shades) {
console.println(" - ID: " + shade.getId() + " (" + shade.getName() + ")");
}
}
} catch (HubException e) {
console.println("Error retrieving ID's: " + e.getMessage());
}
}
}
}

View File

@@ -129,7 +129,7 @@ public class ShadeCapabilitiesDatabase {
protected Type() {
}
protected Type(int type) {
public Type(int type) {
intValue = type;
}

View File

@@ -0,0 +1,81 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.discovery;
import static org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants.*;
import java.util.Set;
import javax.jmdns.ServiceInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.hdpowerview.internal.config.HDPowerViewHubConfiguration;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Discovers HD PowerView Generation 3 Gateways by means of mDNS.
*
* @author Andrew Fiddian-Green - Initial contribution.
*/
@NonNullByDefault
@Component
public class GatewayDiscoveryParticipant implements MDNSDiscoveryParticipant {
private static final String LABEL_KEY = "discovery.gateway.label";
private final Logger logger = LoggerFactory.getLogger(GatewayDiscoveryParticipant.class);
@Override
public @Nullable DiscoveryResult createResult(ServiceInfo service) {
for (String host : service.getHostAddresses()) {
if (VALID_IP_V4_ADDRESS.matcher(host).matches()) {
ThingUID thingUID = new ThingUID(THING_TYPE_GATEWAY, host.replace('.', '_'));
DiscoveryResult hub = DiscoveryResultBuilder.create(thingUID)
.withProperty(HDPowerViewHubConfiguration.HOST, host)
.withRepresentationProperty(HDPowerViewHubConfiguration.HOST)
.withLabel(String.format("@text/%s [\"%s\"]", LABEL_KEY, host)).build();
logger.debug("mDNS discovered Gen 3 gateway on host '{}'", host);
return hub;
}
}
return null;
}
@Override
public String getServiceType() {
return "_powerview-g3._tcp.local.";
}
@Override
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
return Set.of(THING_TYPE_GATEWAY);
}
@Override
public @Nullable ThingUID getThingUID(ServiceInfo service) {
for (String host : service.getHostAddresses()) {
if (VALID_IP_V4_ADDRESS.matcher(host).matches()) {
return new ThingUID(THING_TYPE_GATEWAY, host.replace('.', '_'));
}
}
return null;
}
}

View File

@@ -55,7 +55,7 @@ public class HDPowerViewDeviceDiscoveryService extends AbstractDiscoveryService
private final ShadeCapabilitiesDatabase db = new ShadeCapabilitiesDatabase();
public HDPowerViewDeviceDiscoveryService(HDPowerViewHubHandler hub) {
super(Collections.singleton(HDPowerViewBindingConstants.THING_TYPE_SHADE), 600, true);
super(Collections.singleton(HDPowerViewBindingConstants.THING_TYPE_SHADE), 60, true);
this.hub = hub;
this.scanner = createScanner();
}

View File

@@ -16,7 +16,6 @@ import static org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstan
import java.util.Collections;
import java.util.Set;
import java.util.regex.Pattern;
import javax.jmdns.ServiceInfo;
@@ -41,10 +40,9 @@ import org.slf4j.LoggerFactory;
@Component
public class HDPowerViewHubDiscoveryParticipant implements MDNSDiscoveryParticipant {
private final Logger logger = LoggerFactory.getLogger(HDPowerViewHubDiscoveryParticipant.class);
private static final String LABEL_KEY = "discovery.hub.label";
private static final Pattern VALID_IP_V4_ADDRESS = Pattern
.compile("\\b((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}\\b");
private final Logger logger = LoggerFactory.getLogger(HDPowerViewHubDiscoveryParticipant.class);
@Override
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
@@ -64,8 +62,8 @@ public class HDPowerViewHubDiscoveryParticipant implements MDNSDiscoveryParticip
DiscoveryResult hub = DiscoveryResultBuilder.create(thingUID)
.withProperty(HDPowerViewHubConfiguration.HOST, host)
.withRepresentationProperty(HDPowerViewHubConfiguration.HOST)
.withLabel("PowerView Hub (" + host + ")").build();
logger.debug("mDNS discovered hub on host '{}'", host);
.withLabel(String.format("@text/%s [\"%s\"]", LABEL_KEY, host)).build();
logger.debug("mDNS discovered Gen 1/2 hub on host '{}'", host);
return hub;
}
}

View File

@@ -48,7 +48,7 @@ public class HDPowerViewHubDiscoveryService extends AbstractDiscoveryService {
private @Nullable ScheduledFuture<?> backgroundFuture;
public HDPowerViewHubDiscoveryService() {
super(Collections.singleton(THING_TYPE_HUB), 600, true);
super(Collections.singleton(THING_TYPE_HUB), 60, true);
scanner = createScanner();
}

View File

@@ -0,0 +1,111 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.discovery;
import java.util.Collections;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.hdpowerview.internal.GatewayWebTargets;
import org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants;
import org.openhab.binding.hdpowerview.internal.config.HDPowerViewShadeConfiguration;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Shade;
import org.openhab.binding.hdpowerview.internal.exceptions.HubProcessingException;
import org.openhab.binding.hdpowerview.internal.handler.GatewayBridgeHandler;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Discovers shades in an HD PowerView Generation 3 Gateway.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class ShadeDiscoveryService extends AbstractDiscoveryService {
private final Logger logger = LoggerFactory.getLogger(ShadeDiscoveryService.class);
private final GatewayBridgeHandler hub;
private final Runnable scanner;
private @Nullable ScheduledFuture<?> backgroundFuture;
public ShadeDiscoveryService(GatewayBridgeHandler hub) {
super(Collections.singleton(HDPowerViewBindingConstants.THING_TYPE_SHADE3), 60, true);
this.hub = hub;
this.scanner = createScanner();
}
@Override
protected void startScan() {
scheduler.execute(scanner);
}
@Override
protected void startBackgroundDiscovery() {
ScheduledFuture<?> backgroundFuture = this.backgroundFuture;
if (backgroundFuture != null && !backgroundFuture.isDone()) {
backgroundFuture.cancel(true);
}
this.backgroundFuture = scheduler.scheduleWithFixedDelay(scanner, 0, 60, TimeUnit.SECONDS);
}
@Override
protected void stopBackgroundDiscovery() {
ScheduledFuture<?> backgroundFuture = this.backgroundFuture;
if (backgroundFuture != null && !backgroundFuture.isDone()) {
backgroundFuture.cancel(true);
this.backgroundFuture = null;
}
super.stopBackgroundDiscovery();
}
private Runnable createScanner() {
return () -> {
try {
GatewayWebTargets webTargets = hub.getWebTargets();
discoverShades(webTargets);
} catch (HubProcessingException e) {
logger.warn("Unexpected exception:{}, message:{}", e.getClass().getSimpleName(), e.getMessage());
} catch (IllegalStateException e) {
// ignore
}
stopScan();
};
}
private void discoverShades(GatewayWebTargets webTargets) throws HubProcessingException {
ThingUID bridgeUid = hub.getThing().getUID();
for (Shade shade : webTargets.getShades()) {
if (shade.getId() == 0) {
continue;
}
String id = Integer.toString(shade.getId());
ThingUID thingUID = new ThingUID(HDPowerViewBindingConstants.THING_TYPE_SHADE3, bridgeUid, id);
DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(thingUID).withLabel(shade.getName())
.withBridge(bridgeUid).withProperty(HDPowerViewShadeConfiguration.ID, id)
.withRepresentationProperty(HDPowerViewShadeConfiguration.ID);
String type = shade.getTypeString();
if (type != null) {
builder.withProperty(HDPowerViewBindingConstants.PROPERTY_SHADE_TYPE, type);
}
logger.debug("Hub discovered shade '{}'", id);
thingDiscovered(builder.build());
}
}
}

View File

@@ -75,7 +75,8 @@ public class ShadePosition {
* @param posKindCoords the actuator class (coordinate system) whose state is to be changed.
* @param percent the new position value.
*/
private void setPosition1(Capabilities shadeCapabilities, CoordinateSystem posKindCoords, int percent) {
private void setPosition1(Capabilities shadeCapabilities, CoordinateSystem posKindCoords, int percentArg) {
int percent = percentArg;
switch (posKindCoords) {
case PRIMARY_POSITION:
/*
@@ -202,7 +203,8 @@ public class ShadePosition {
* @param posKindCoords the actuator class (coordinate system) whose state is to be changed.
* @param percent the new position value.
*/
private void setPosition2(Capabilities shadeCapabilities, CoordinateSystem posKindCoords, int percent) {
private void setPosition2(Capabilities shadeCapabilities, CoordinateSystem posKindCoords, int percentArg) {
int percent = percentArg;
switch (posKindCoords) {
case PRIMARY_POSITION:
/*

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.dto.gen3;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* DTO for the Generation 3 Gateway information.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class Info {
private @NonNullByDefault({}) String fwVersion;
private @NonNullByDefault({}) String serialNumber;
public String getFwVersion() {
return fwVersion;
}
public String getSerialNumber() {
return serialNumber;
}
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.dto.gen3;
/**
* Enum for Generation 3 shade power types.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
public enum PowerType {
BATTERY,
HARDWIRED,
RECHARGEABLE;
}

View File

@@ -0,0 +1,59 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.dto.gen3;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* DTO for a scene as returned by an HD PowerView Generation 3 Gateway.
*
* @author Andrew Fiddian-Green - Initial contribution.
*/
@NonNullByDefault
public class Scene {
private int id;
private @NonNullByDefault({}) String name;
private @NonNullByDefault({}) String ptName;
private @NonNullByDefault({}) String color;
private @NonNullByDefault({}) String icon;
private @NonNullByDefault({}) List<Integer> roomIds;
public String getColor() {
return color;
}
public String getIcon() {
return icon;
}
public int getId() {
return id;
}
public String getName() {
return String.join(" ", new String(Base64.getDecoder().decode(name), StandardCharsets.UTF_8), ptName);
}
public String getPtName() {
return ptName;
}
public List<Integer> getRoomIds() {
List<Integer> roomIds = this.roomIds;
return roomIds != null ? roomIds : List.of();
}
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.dto.gen3;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* DTO for scene SSE event object as supplied an HD PowerView Generation 3 Gateway.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class SceneEvent {
private int id;
private @NonNullByDefault({}) Scene scene;
public int getId() {
return id;
}
public Scene getScene() {
return scene;
}
}

View File

@@ -0,0 +1,160 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.dto.gen3;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.hdpowerview.internal.dto.CoordinateSystem;
import org.openhab.binding.hdpowerview.internal.dto.Firmware;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* DTO for a shade as returned by an HD PowerView Generation 3 Gateway.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class Shade {
private @Nullable Integer id;
private @Nullable Integer type;
private @Nullable String name;
private @Nullable @SuppressWarnings("unused") String ptName;
private @Nullable Integer capabilities;
private @Nullable Integer powerType;
private @Nullable Integer batteryStatus;
private @Nullable Integer signalStrength;
private @Nullable String bleName;
private @Nullable Firmware firmware;
private @Nullable ShadePosition positions;
private transient boolean partialState;
public State getBatteryLevel() {
Integer batteryStatus = this.batteryStatus;
return batteryStatus == null ? UnDefType.UNDEF
: new DecimalType(Math.max(0, Math.min(100, (100 * batteryStatus) / 3)));
}
public @Nullable String getBleName() {
return bleName;
}
public @Nullable Integer getCapabilities() {
return capabilities;
}
public @Nullable String getCapabilitieString() {
Integer capabilities = this.capabilities;
return capabilities == null ? null : Integer.toString(capabilities);
}
public @Nullable String getFirmware() {
Firmware firmware = this.firmware;
return firmware == null ? null
: String.format("%d.%d.%d", firmware.revision, firmware.subRevision, firmware.build);
}
public int getId() {
Integer id = this.id;
return id != null ? id.intValue() : 0;
}
public State getLowBattery() {
Integer batteryStatus = this.batteryStatus;
return batteryStatus == null ? UnDefType.UNDEF : OnOffType.from(batteryStatus == 0);
}
public String getName() {
return new String(Base64.getDecoder().decode(name), StandardCharsets.UTF_8);
}
public State getPosition(CoordinateSystem posKindCoords) {
ShadePosition positions = this.positions;
return positions == null ? UnDefType.UNDEF : positions.getState(posKindCoords);
}
public PowerType getPowerType() {
Integer powerType = this.powerType;
return PowerType.values()[powerType != null ? powerType.intValue() : 0];
}
public @Nullable ShadePosition getShadePositions() {
return positions;
}
public State getSignalStrength() {
Integer signalStrength = this.signalStrength;
return signalStrength != null ? new QuantityType<>(signalStrength, Units.DECIBEL_MILLIWATTS) : UnDefType.UNDEF;
}
public @Nullable Integer getType() {
return type;
}
public @Nullable String getTypeString() {
Integer type = this.type;
return type == null ? null : Integer.toString(type);
}
public boolean hasFullState() {
return !partialState;
}
public boolean isMainsPowered() {
return getPowerType() == PowerType.HARDWIRED;
}
public Shade setCapabilities(int capabilities) {
this.capabilities = capabilities;
return this;
}
public Shade setId(int id) {
this.id = id;
return this;
}
public Shade setPartialState() {
this.partialState = true;
return this;
}
public Shade setPosition(CoordinateSystem coordinates, PercentType percent) {
ShadePosition positions = this.positions;
if (positions == null) {
positions = new ShadePosition();
this.positions = positions;
}
positions.setPosition(coordinates, percent);
return this;
}
public Shade setShadePosition(ShadePosition position) {
this.positions = position;
return this;
}
public Shade setType(int type) {
this.type = type;
return this;
}
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.dto.gen3;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* DTO for a shade SSE event object as supplied an HD PowerView Generation 3 Gateway.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class ShadeEvent {
private int id;
private @NonNullByDefault({}) ShadePosition currentPositions;
public ShadePosition getCurrentPositions() {
return currentPositions;
}
public int getId() {
return id;
}
}

View File

@@ -0,0 +1,79 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.dto.gen3;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.hdpowerview.internal.dto.CoordinateSystem;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* DTO for the position of a shade as returned by an HD PowerView Generation 3 Gateway.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class ShadePosition {
private static final MathContext MATH_CONTEXT = new MathContext(4, RoundingMode.HALF_UP);
private @NonNullByDefault({}) Double primary;
private @NonNullByDefault({}) Double secondary;
private @NonNullByDefault({}) Double tilt;
public State getState(CoordinateSystem posKindCoords) {
Double value;
switch (posKindCoords) {
case PRIMARY_POSITION:
value = primary;
break;
case SECONDARY_POSITION:
value = secondary;
break;
case VANE_TILT_POSITION:
value = tilt;
break;
default:
value = null;
}
return value != null ? new PercentType(new BigDecimal(value * 100f, MATH_CONTEXT)) : UnDefType.UNDEF;
}
/**
* Set a new position value for this object based on the given coordinates, and the given new value.
*
* @param coordinates which of the position fields shall be set.
* @param percent the new value in percent.
* @return this object.
*/
public ShadePosition setPosition(CoordinateSystem coordinates, PercentType percent) {
Double value = percent.doubleValue() / 100f;
switch (coordinates) {
case PRIMARY_POSITION:
primary = value;
break;
case SECONDARY_POSITION:
secondary = value;
break;
case VANE_TILT_POSITION:
tilt = value;
break;
default:
}
return this;
}
}

View File

@@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
class ShadeMotion {
public class ShadeMotion {
public enum Type {
STOP("stop"),

View File

@@ -0,0 +1,321 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.handler;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import javax.ws.rs.client.ClientBuilder;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.hdpowerview.internal.GatewayWebTargets;
import org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants;
import org.openhab.binding.hdpowerview.internal.HDPowerViewTranslationProvider;
import org.openhab.binding.hdpowerview.internal.config.HDPowerViewHubConfiguration;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Scene;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Shade;
import org.openhab.binding.hdpowerview.internal.exceptions.HubProcessingException;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.type.AutoUpdatePolicy;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.osgi.service.jaxrs.client.SseEventSourceFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Bridge handler for an HD PowerView Generation 3 Gateway.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class GatewayBridgeHandler extends BaseBridgeHandler {
private final Logger logger = LoggerFactory.getLogger(GatewayBridgeHandler.class);
private final String channelTypeId = HDPowerViewBindingConstants.CHANNELTYPE_SCENE_ACTIVATE;
private final String channelGroupId = HDPowerViewBindingConstants.CHANNEL_GROUP_SCENES;
private final HttpClient httpClient;
private final HDPowerViewTranslationProvider translationProvider;
private final ClientBuilder clientBuilder;
private final SseEventSourceFactory eventSourceFactory;
private @Nullable GatewayWebTargets webTargets;
private @Nullable ScheduledFuture<?> refreshTask;
private boolean scenesLoaded;
private boolean propertiesLoaded;
private boolean disposing;
public GatewayBridgeHandler(Bridge bridge, HttpClient httpClient,
HDPowerViewTranslationProvider translationProvider, ClientBuilder clientBuilder,
SseEventSourceFactory eventSourceFactory) {
super(bridge);
this.httpClient = httpClient;
this.translationProvider = translationProvider;
this.clientBuilder = clientBuilder;
this.eventSourceFactory = eventSourceFactory;
}
@Override
public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
if (childHandler instanceof ShadeThingHandler) {
refreshShade(((ShadeThingHandler) childHandler).getShadeId());
}
}
@Override
public void dispose() {
disposing = true;
ScheduledFuture<?> future = this.refreshTask;
if (future != null) {
future.cancel(true);
}
this.refreshTask = null;
GatewayWebTargets webTargets = this.webTargets;
if (webTargets != null) {
scheduler.submit(() -> disposeWebTargets(webTargets));
this.webTargets = null;
}
}
private void disposeWebTargets(GatewayWebTargets webTargets) {
try {
webTargets.close();
} catch (IOException e) {
}
}
/**
* Refresh the state of all things. Normally the thing's position state is updated by SSE. However we must do a
* refresh once on start up in order to get the initial state. Also the other properties (battery, signal strength
* etc.) are not updated by SSE. Furthermore we need to do periodic refreshes just in case the SSE connection may
* have been lost.
*/
private void doRefresh() {
logger.debug("doRefresh()");
try {
if (getWebTargets().gatewayRegister()) {
updateStatus(ThingStatus.ONLINE);
getWebTargets().sseOpen();
refreshProperties();
refreshShades();
refreshScenes();
}
} catch (IllegalStateException | HubProcessingException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
logger.debug("doRefresh() exception:{}, message:{}", e.getClass().getSimpleName(), e.getMessage(), e);
}
}
public ScheduledExecutorService getScheduler() {
return scheduler;
}
/**
* Getter for the list of all child shade thing handlers.
*
* @return the list of shade handlers.
* @throws IllegalStateException if the bridge is not properly initialized.
*/
private List<ShadeThingHandler> getShadeThingHandlers() throws IllegalStateException {
logger.debug("getShadeThingHandlers()");
return getThing().getThings().stream().map(Thing::getHandler).filter(ShadeThingHandler.class::isInstance)
.map(ShadeThingHandler.class::cast).collect(Collectors.toList());
}
/**
* Getter for the webTargets.
*
* @return the webTargets.
* @throws IllegalStateException if webTargets is not initialized.
*/
public GatewayWebTargets getWebTargets() throws IllegalStateException {
GatewayWebTargets webTargets = this.webTargets;
if (webTargets != null) {
return webTargets;
}
throw new IllegalStateException("WebTargets not initialized.");
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (RefreshType.REFRESH == command) {
scheduler.submit(() -> doRefresh());
return;
}
Channel channel = getThing().getChannel(channelUID.getId());
if (channel == null) {
return;
}
ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
if (channelTypeUID == null) {
return;
}
if (channelTypeId.equals(channelTypeUID.getId()) && OnOffType.ON == command) {
try {
getWebTargets().activateScene(Integer.parseInt(channelUID.getIdWithoutGroup()));
} catch (HubProcessingException | IllegalStateException e) {
logger.warn("handleCommand() exception:{}, message:{}", e.getClass().getSimpleName(), e.getMessage());
}
}
}
@Override
public void initialize() {
HDPowerViewHubConfiguration config = getConfigAs(HDPowerViewHubConfiguration.class);
String host = config.host;
if (host == null || host.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.conf-error.no-host-address");
return;
}
webTargets = new GatewayWebTargets(this, httpClient, clientBuilder, eventSourceFactory, host);
scenesLoaded = false;
propertiesLoaded = false;
disposing = false;
/*
* Normally the thing's position state is updated by SSE. However we must do a refresh once on start up in order
* to get the initial state. Also the other properties (battery, signal strength etc.) are not updated by SSE.
* Furthermore we need to do periodic refreshes just in case the SSE connection may have been lost. So we
* schedule the refresh at the 'hardRefresh' interval.
*/
refreshTask = scheduler.scheduleWithFixedDelay(() -> doRefresh(), 0, config.hardRefresh, TimeUnit.MINUTES);
updateStatus(ThingStatus.UNKNOWN);
}
/**
* Method that is called when a scene changes state.
*
* @param scene the one that changed.
*/
public void onSceneEvent(Scene scene) {
// TODO perhaps we should trigger an OH core event here ??
}
/**
* Method that is called when a shade changes state.
*
* @param shade the one that changed.
*/
public void onShadeEvent(Shade shade) {
if (disposing) {
return;
}
try {
for (ShadeThingHandler handler : getShadeThingHandlers()) {
if (handler.notify(shade)) {
break;
}
}
} catch (IllegalStateException e) {
logger.warn("onShadeEvent() exception:{}, message:{}", e.getClass().getSimpleName(), e.getMessage(), e);
}
}
private void refreshProperties() throws HubProcessingException, IllegalStateException {
if (propertiesLoaded || disposing) {
return;
}
logger.debug("refreshProperties()");
thing.setProperties(getWebTargets().getInformation());
propertiesLoaded = true;
}
/**
* Create the dynamic list of scene channels.
*
* @throws HubProcessingException if the web target connection caused an error.
* @throws IllegalStateException if this handler is in an illegal state.
*/
private void refreshScenes() throws HubProcessingException, IllegalStateException {
if (scenesLoaded || disposing) {
return;
}
logger.debug("refreshScenes()");
ChannelTypeUID typeUID = new ChannelTypeUID(HDPowerViewBindingConstants.BINDING_ID, channelTypeId);
ChannelGroupUID groupUID = new ChannelGroupUID(thing.getUID(), channelGroupId);
List<Channel> channels = new ArrayList<>();
for (Scene scene : getWebTargets().getScenes()) {
ChannelUID channelUID = new ChannelUID(groupUID, Integer.toString(scene.getId()));
String name = scene.getName();
String description = translationProvider.getText("dynamic-channel.scene-activate.description", name);
channels.add(ChannelBuilder.create(channelUID, CoreItemFactory.SWITCH).withType(typeUID).withLabel(name)
.withDescription(description).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build());
}
updateThing(editThing().withChannels(channels).build());
scenesLoaded = true;
}
/**
* Refresh a single shade.
*
* @param shadeId the id of the shade to be refreshed.
*/
private void refreshShade(int shadeId) {
try {
Shade shade = getWebTargets().getShade(shadeId);
for (ShadeThingHandler handler : getShadeThingHandlers()) {
if (handler.notify(shade)) {
break;
}
}
} catch (HubProcessingException | IllegalStateException e) {
logger.warn("refreshShade() exception:{}, message:{}", e.getClass().getSimpleName(), e.getMessage(), e);
}
}
/**
* Get the full list of shades data and notify each of the thing handlers.
*
* @throws HubProcessingException if the web target connection caused an error.
* @throws IllegalStateException if this handler is in an illegal state.
*/
private void refreshShades() throws HubProcessingException, IllegalStateException {
if (disposing) {
return;
}
logger.debug("refreshShades()");
List<ShadeThingHandler> handlers = getShadeThingHandlers();
for (Shade shade : getWebTargets().getShades()) {
for (ShadeThingHandler handler : handlers) {
if (handler.notify(shade)) {
break;
}
}
}
}
}

View File

@@ -0,0 +1,334 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.handler;
import static org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants.*;
import static org.openhab.binding.hdpowerview.internal.dto.CoordinateSystem.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.hdpowerview.internal.GatewayWebTargets;
import org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants;
import org.openhab.binding.hdpowerview.internal.config.HDPowerViewShadeConfiguration;
import org.openhab.binding.hdpowerview.internal.database.ShadeCapabilitiesDatabase;
import org.openhab.binding.hdpowerview.internal.database.ShadeCapabilitiesDatabase.Capabilities;
import org.openhab.binding.hdpowerview.internal.database.ShadeCapabilitiesDatabase.Type;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Shade;
import org.openhab.binding.hdpowerview.internal.dto.gen3.ShadePosition;
import org.openhab.binding.hdpowerview.internal.exceptions.HubProcessingException;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.StopMoveType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.types.UpDownType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.thing.binding.BridgeHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Thing handler for shades in an HD PowerView Generation 3 Gateway.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class ShadeThingHandler extends BaseThingHandler {
private static final String INVALID_CHANNEL = "invalid channel";
private static final String INVALID_COMMAND = "invalid command";
private static final String COMMAND_IDENTIFY = "IDENTIFY";
private static final ShadeCapabilitiesDatabase DB = new ShadeCapabilitiesDatabase();
private final Logger logger = LoggerFactory.getLogger(ShadeThingHandler.class);
private int shadeId;
private boolean isInitialized;
private @Nullable Capabilities capabilities;
public ShadeThingHandler(Thing thing) {
super(thing);
}
/**
* Getter for the hub handler.
*
* @return the hub handler.
* @throws IllegalStateException if the bridge or its handler are not initialized.
*/
private GatewayBridgeHandler getBridgeHandler() throws IllegalStateException {
Bridge bridge = this.getBridge();
if (bridge == null) {
throw new IllegalStateException("Bridge not initialised.");
}
BridgeHandler handler = bridge.getHandler();
if (!(handler instanceof GatewayBridgeHandler)) {
throw new IllegalStateException("Bridge handler not initialised.");
}
return (GatewayBridgeHandler) handler;
}
public int getShadeId() {
return shadeId;
}
private Type getType(Shade shade) {
Integer type = shade.getType();
return type != null ? DB.getType(type) : new ShadeCapabilitiesDatabase.Type(0);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (RefreshType.REFRESH == command) {
getBridgeHandler().handleCommand(channelUID, command);
return;
}
GatewayWebTargets webTargets = getBridgeHandler().getWebTargets();
ShadePosition position = new ShadePosition();
try {
switch (channelUID.getId()) {
case CHANNEL_SHADE_POSITION:
if (command instanceof PercentType) {
position.setPosition(PRIMARY_POSITION, ((PercentType) command));
webTargets.moveShade(shadeId, new Shade().setShadePosition(position));
break;
} else if (command instanceof UpDownType) {
position.setPosition(PRIMARY_POSITION,
(UpDownType.UP == command) && !Objects.requireNonNull(capabilities).isPrimaryInverted()
? PercentType.HUNDRED
: PercentType.ZERO);
webTargets.moveShade(shadeId, new Shade().setShadePosition(position));
break;
} else if (StopMoveType.STOP == command) {
webTargets.stopShade(shadeId);
break;
}
throw new IllegalArgumentException(INVALID_COMMAND);
case CHANNEL_SHADE_SECONDARY_POSITION:
if (command instanceof PercentType) {
position.setPosition(SECONDARY_POSITION, ((PercentType) command));
webTargets.moveShade(shadeId, new Shade().setShadePosition(position));
break;
} else if (command instanceof UpDownType) {
position.setPosition(SECONDARY_POSITION,
(UpDownType.UP == command)
&& !Objects.requireNonNull(capabilities).supportsSecondaryOverlapped()
? PercentType.ZERO
: PercentType.HUNDRED);
webTargets.moveShade(shadeId, new Shade().setShadePosition(position));
break;
} else if (StopMoveType.STOP == command) {
webTargets.stopShade(shadeId);
break;
}
throw new IllegalArgumentException(INVALID_COMMAND);
case CHANNEL_SHADE_VANE:
if (command instanceof PercentType) {
position.setPosition(VANE_TILT_POSITION, ((PercentType) command));
webTargets.moveShade(shadeId, new Shade().setShadePosition(position));
break;
} else if (command instanceof UpDownType) {
position.setPosition(VANE_TILT_POSITION,
UpDownType.UP == command ? PercentType.HUNDRED : PercentType.ZERO);
webTargets.moveShade(shadeId, new Shade().setShadePosition(position));
break;
}
throw new IllegalArgumentException(INVALID_COMMAND);
case CHANNEL_SHADE_COMMAND:
if ((command instanceof StringType) && COMMAND_IDENTIFY.equals(((StringType) command).toString())) {
webTargets.jogShade(shadeId);
break;
}
throw new IllegalArgumentException(INVALID_COMMAND);
default:
throw new IllegalArgumentException(INVALID_CHANNEL);
}
} catch (HubProcessingException | IllegalArgumentException e) {
logger.warn("handleCommand() shadeId:{}, channelUID:{}, command:{}, exception:{}, message:{}", shadeId,
channelUID, command, e.getClass().getSimpleName(), e.getMessage());
}
}
private boolean hasPrimary() {
return Objects.requireNonNull(capabilities).supportsPrimary();
}
private boolean hasSecondary() {
Capabilities capabilities = Objects.requireNonNull(this.capabilities);
return capabilities.supportsSecondary() || capabilities.supportsSecondaryOverlapped();
}
private boolean hasVane() {
Capabilities capabilities = Objects.requireNonNull(this.capabilities);
return capabilities.supportsTilt180() || capabilities.supportsTiltAnywhere()
|| capabilities.supportsTiltOnClosed();
}
@Override
public void initialize() {
shadeId = getConfigAs(HDPowerViewShadeConfiguration.class).id;
Bridge bridge = getBridge();
BridgeHandler bridgeHandler = bridge != null ? bridge.getHandler() : null;
if (!(bridgeHandler instanceof GatewayBridgeHandler)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.conf-error.invalid-bridge-handler");
return;
}
isInitialized = false;
updateStatus(ThingStatus.UNKNOWN);
}
/**
* Handle shade state change notifications.
*
* @param shade the new shade state.
* @return true if we handled the call.
*/
public boolean notify(Shade shade) {
if (shadeId == shade.getId()) {
updateStatus(ThingStatus.ONLINE);
if (!isInitialized && shade.hasFullState()) {
updateCapabilities(shade);
updateProperties(shade);
updateDynamicChannels(shade);
isInitialized = true;
}
updateChannels(shade);
return true;
}
return false;
}
/**
* Update the capabilities object based on the data in the passed shade instance.
*
* @param shade containing the channel data.
*/
private void updateCapabilities(Shade shade) {
Capabilities capabilities = this.capabilities;
if (capabilities == null) {
capabilities = DB.getCapabilities(shade.getCapabilities());
this.capabilities = capabilities;
}
}
/**
* Update channels based on the data in the passed shade instance.
*
* @param shade containing the channel data.
*/
private void updateChannels(Shade shade) {
updateState(CHANNEL_SHADE_POSITION, hasPrimary() ? shade.getPosition(PRIMARY_POSITION) : UnDefType.UNDEF);
updateState(CHANNEL_SHADE_VANE, hasVane() ? shade.getPosition(VANE_TILT_POSITION) : UnDefType.UNDEF);
updateState(CHANNEL_SHADE_SECONDARY_POSITION,
hasSecondary() ? shade.getPosition(SECONDARY_POSITION) : UnDefType.UNDEF);
if (shade.hasFullState()) {
updateState(CHANNEL_SHADE_LOW_BATTERY, shade.getLowBattery());
updateState(CHANNEL_SHADE_BATTERY_LEVEL, shade.getBatteryLevel());
updateState(CHANNEL_SHADE_SIGNAL_STRENGTH, shade.getSignalStrength());
}
}
/**
* If the given channel exists in the thing, but is NOT required in the thing, then add it to a list of channels to
* be removed. Or if the channel does NOT exist in the thing, but is required in the thing, then log a warning.
*
* @param removeList the list of channels to be removed from the thing.
* @param channelId the id of the channel to be (eventually) removed.
* @param channelRequired true if the thing requires this channel.
*/
private void updateDynamicChannel(List<Channel> removeList, String channelId, boolean channelRequired) {
Channel channel = thing.getChannel(channelId);
if (!channelRequired && channel != null) {
removeList.add(channel);
} else if (channelRequired && channel == null) {
logger.warn("updateDynamicChannel() shadeId:{} is missing channel:{} => please recreate the thing", shadeId,
channelId);
}
}
/**
* Remove previously statically created channels if the shade does not support them or they are not relevant.
*
* @param shade containing the channel data.
*/
private void updateDynamicChannels(Shade shade) {
List<Channel> removeChannels = new ArrayList<>();
updateDynamicChannel(removeChannels, CHANNEL_SHADE_POSITION, hasPrimary());
updateDynamicChannel(removeChannels, CHANNEL_SHADE_SECONDARY_POSITION, hasSecondary());
updateDynamicChannel(removeChannels, CHANNEL_SHADE_VANE, hasVane());
updateDynamicChannel(removeChannels, CHANNEL_SHADE_BATTERY_LEVEL, !shade.isMainsPowered());
updateDynamicChannel(removeChannels, CHANNEL_SHADE_LOW_BATTERY, !shade.isMainsPowered());
if (!removeChannels.isEmpty()) {
if (logger.isDebugEnabled()) {
StringJoiner joiner = new StringJoiner(", ");
removeChannels.forEach(c -> joiner.add(c.getUID().getId()));
logger.debug("updateDynamicChannels() shadeId:{}, removing unsupported channels:{}", shadeId,
joiner.toString());
}
updateThing(editThing().withoutChannels(removeChannels).build());
}
}
/**
* Update thing properties based on the data in the passed shade instance.
*
* @param shade containing the property data.
*/
private void updateProperties(Shade shade) {
thing.setProperties(Stream.of(new String[][] { //
{ HDPowerViewBindingConstants.PROPERTY_NAME, shade.getName() },
{ HDPowerViewBindingConstants.PROPERTY_SHADE_TYPE, getType(shade).toString() },
{ HDPowerViewBindingConstants.PROPERTY_SHADE_CAPABILITIES,
Objects.requireNonNull(capabilities).toString() },
{ HDPowerViewBindingConstants.PROPERTY_POWER_TYPE, shade.getPowerType().name().toLowerCase() },
{ HDPowerViewBindingConstants.PROPERTY_BLE_NAME, shade.getBleName() },
{ Thing.PROPERTY_FIRMWARE_VERSION, shade.getFirmware() } //
}).collect(Collectors.toMap(data -> data[0], data -> data[1])));
}
/**
* Override base method to only update the channel if it actually exists.
*
* @param channelID id of the channel, which was updated
* @param state new state
*/
@Override
protected void updateState(String channelID, State state) {
if (thing.getChannel(channelID) != null) {
super.updateState(channelID, state);
}
}
}

View File

@@ -14,7 +14,7 @@
<config-description uri="thing-type:hdpowerview:shade">
<parameter name="id" type="integer" min="1" required="true">
<label>ID</label>
<description>The numeric ID of the PowerView Shade in the Hub</description>
<description>The numeric ID of the PowerView Shade in the Hub/Gateway</description>
</parameter>
</config-description>

View File

@@ -5,6 +5,8 @@ addon.hdpowerview.description = The Hunter Douglas PowerView binding provides ac
# thing types
thing-type.hdpowerview.gateway.label = PowerView Gen3 Gateway
thing-type.hdpowerview.gateway.description = Hunter Douglas (Luxaflex) PowerView Generation 3 Gateway/Gateway Pro
thing-type.hdpowerview.hub.label = PowerView Hub
thing-type.hdpowerview.hub.description = Hunter Douglas (Luxaflex) PowerView Hub
thing-type.hdpowerview.repeater.label = PowerView Repeater
@@ -12,28 +14,36 @@ thing-type.hdpowerview.repeater.description = Hunter Douglas (Luxaflex) PowerVie
thing-type.hdpowerview.repeater.channel.brightness.description = Controls the brightness of the LED ring
thing-type.hdpowerview.repeater.channel.color.description = Controls the color of the LED ring
thing-type.hdpowerview.shade.label = PowerView Shade
thing-type.hdpowerview.shade.description = Hunter Douglas (Luxaflex) PowerView Shade
thing-type.hdpowerview.shade.description = Hunter Douglas (Luxaflex) PowerView Gen 1/2 Shade
thing-type.hdpowerview.shade.channel.hubRssi.label = Hub RSSI
thing-type.hdpowerview.shade.channel.hubRssi.description = Received Signal Strength Indicator for Hub
thing-type.hdpowerview.shade.channel.repeaterRssi.label = Repeater RSSI
thing-type.hdpowerview.shade.channel.repeaterRssi.description = Received Signal Strength Indicator for Repeater
thing-type.hdpowerview.shade.channel.secondary.label = Secondary Position
thing-type.hdpowerview.shade.channel.secondary.description = The secondary vertical position (on top-down/bottom-up shades)
thing-type.hdpowerview.shade3.label = PowerView Shade
thing-type.hdpowerview.shade3.description = Hunter Douglas (Luxaflex) PowerView Gen3 Shade
thing-type.hdpowerview.shade3.channel.secondary.label = Secondary Position
thing-type.hdpowerview.shade3.channel.secondary.description = The secondary vertical position (on top-down/bottom-up shades)
# thing types config
thing-type.config.hdpowerview.gateway.hardRefresh.label = Hard Refresh Interval
thing-type.config.hdpowerview.gateway.hardRefresh.description = The number of minutes between hard refreshes of the PowerView Gateway
thing-type.config.hdpowerview.gateway.host.label = Host
thing-type.config.hdpowerview.gateway.host.description = The host address of the PowerView Gateway
thing-type.config.hdpowerview.hub.hardRefresh.label = Hard Position Refresh Interval
thing-type.config.hdpowerview.hub.hardRefresh.description = The number of minutes between hard refreshes of positions from the PowerView Hub (or 0 to disable)
thing-type.config.hdpowerview.hub.hardRefreshBatteryLevel.label = Hard Battery Level Refresh Interval
thing-type.config.hdpowerview.hub.hardRefreshBatteryLevel.description = The number of hours between hard refreshes of battery levels from the PowerView Hub (or 0 to disable, default is weekly)
thing-type.config.hdpowerview.hub.host.label = Host
thing-type.config.hdpowerview.hub.host.description = The Host address of the PowerView Hub
thing-type.config.hdpowerview.hub.host.description = The host address of the PowerView Hub
thing-type.config.hdpowerview.hub.refresh.label = Refresh Interval
thing-type.config.hdpowerview.hub.refresh.description = The number of milliseconds between fetches of the PowerView Hub shade state
thing-type.config.hdpowerview.repeater.id.label = ID
thing-type.config.hdpowerview.repeater.id.description = The numeric ID of the PowerView Repeater in the Hub
thing-type.config.hdpowerview.shade.id.label = ID
thing-type.config.hdpowerview.shade.id.description = The numeric ID of the PowerView Shade in the Hub
thing-type.config.hdpowerview.shade.id.description = The numeric ID of the PowerView Shade in the Hub/Gateway
# channel group types
@@ -46,6 +56,8 @@ channel-group-type.hdpowerview.scenes.label = Scenes
channel-type.hdpowerview.automation-enabled.label = Enable
channel-type.hdpowerview.battery-voltage.label = Battery Voltage
channel-type.hdpowerview.battery-voltage.description = Battery voltage reported by the shade
channel-type.hdpowerview.ble-signal-strength.label = Signal Strength
channel-type.hdpowerview.ble-signal-strength.description = Signal strength of Bluetooth Low Energy communication
channel-type.hdpowerview.repeater-blinking-enabled.label = Blinking Enabled
channel-type.hdpowerview.repeater-blinking-enabled.description = Blink during commands
channel-type.hdpowerview.repeater-identify.label = Identify
@@ -63,12 +75,20 @@ channel-type.hdpowerview.shade-position.label = Position
channel-type.hdpowerview.shade-position.description = The vertical position of the shade
channel-type.hdpowerview.shade-vane.label = Vane
channel-type.hdpowerview.shade-vane.description = The opening of the slats in the shade
channel-type.hdpowerview.shade3-command.label = Command
channel-type.hdpowerview.shade3-command.description = Send a command to identify the shade
channel-type.hdpowerview.shade3-command.command.option.IDENTIFY = Identify
# thing status descriptions
offline.conf-error.no-host-address = Host address must be set
offline.conf-error.invalid-bridge-handler = Invalid bridge handler
offline.gone.shade-unknown-to-hub = Shade is unknown to Hub
offline.gone.shade-unknown-to-hub = Shade is unknown to Hub/Gateway
# discovery
discovery.hub.label = PowerView Gen 1/2 Hub ({0})
discovery.gateway.label = PowerView Gen 3 Gateway ({0})
# dynamic channels

View File

@@ -24,7 +24,7 @@
<config-description>
<parameter name="host" type="text" required="true">
<label>Host</label>
<description>The Host address of the PowerView Hub</description>
<description>The host address of the PowerView Hub</description>
<context>network-address</context>
</parameter>
<parameter name="refresh" type="integer" required="false">
@@ -47,4 +47,33 @@
</config-description>
</bridge-type>
<bridge-type id="gateway">
<label>PowerView Gen3 Gateway</label>
<description>Hunter Douglas (Luxaflex) PowerView Generation 3 Gateway/Gateway Pro</description>
<channel-groups>
<channel-group id="scenes" typeId="scenes"/>
</channel-groups>
<properties>
<property name="vendor">Hunter Douglas (Luxaflex)</property>
<property name="modelId">PowerView Gen 3 Gateway</property>
</properties>
<representation-property>host</representation-property>
<config-description>
<parameter name="host" type="text" required="true">
<label>Host</label>
<description>The host address of the PowerView Gateway</description>
<context>network-address</context>
</parameter>
<parameter name="hardRefresh" type="integer" required="false">
<label>Hard Refresh Interval</label>
<description>The number of minutes between hard refreshes of the PowerView Gateway</description>
<default>180</default>
</parameter>
</config-description>
</bridge-type>
</thing:thing-descriptions>

View File

@@ -8,13 +8,15 @@
<item-type>Rollershutter</item-type>
<label>Position</label>
<description>The vertical position of the shade</description>
<category>blinds</category>
<category>Blinds</category>
<state min="0" max="100" pattern="%.1f %%"/>
</channel-type>
<channel-type id="shade-vane">
<item-type>Dimmer</item-type>
<label>Vane</label>
<description>The opening of the slats in the shade</description>
<state min="0" max="100" pattern="%.1f %%"/>
</channel-type>
<channel-type id="shade-command" advanced="true">
@@ -30,6 +32,18 @@
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
<channel-type id="shade3-command" advanced="true">
<item-type>String</item-type>
<label>Command</label>
<description>Send a command to identify the shade</description>
<command>
<options>
<option value="IDENTIFY">Identify</option>
</options>
</command>
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
<channel-type id="scene-activate">
<item-type>Switch</item-type>
<label>Activate</label>
@@ -77,6 +91,14 @@
<description>Blink during commands</description>
</channel-type>
<channel-type id="ble-signal-strength">
<item-type>Number:Power</item-type>
<label>Signal Strength</label>
<description>Signal strength of Bluetooth Low Energy communication</description>
<category>QualityOfService</category>
<state readOnly="true" pattern="%.0f dBm"/>
</channel-type>
<channel-group-type id="scenes">
<label>Scenes</label>
</channel-group-type>

View File

@@ -9,7 +9,7 @@
<bridge-type-ref id="hub"/>
</supported-bridge-type-refs>
<label>PowerView Shade</label>
<description>Hunter Douglas (Luxaflex) PowerView Shade</description>
<description>Hunter Douglas (Luxaflex) PowerView Gen 1/2 Shade</description>
<channels>
<channel id="position" typeId="shade-position"/>
@@ -43,4 +43,34 @@
<config-description-ref uri="thing-type:hdpowerview:shade"/>
</thing-type>
<thing-type id="shade3">
<supported-bridge-type-refs>
<bridge-type-ref id="gateway"/>
</supported-bridge-type-refs>
<label>PowerView Shade</label>
<description>Hunter Douglas (Luxaflex) PowerView Gen3 Shade</description>
<channels>
<channel id="position" typeId="shade-position"/>
<channel id="secondary" typeId="shade-position">
<label>Secondary Position</label>
<description>The secondary vertical position (on top-down/bottom-up shades)</description>
</channel>
<channel id="vane" typeId="shade-vane"/>
<channel id="command" typeId="shade3-command"/>
<channel id="lowBattery" typeId="system.low-battery"/>
<channel id="batteryLevel" typeId="system.battery-level"/>
<channel id="signalStrength" typeId="ble-signal-strength"/>
</channels>
<properties>
<property name="vendor">Hunter Douglas (Luxaflex)</property>
<property name="modelId">PowerView Motorized Shade</property>
</properties>
<representation-property>id</representation-property>
<config-description-ref uri="thing-type:hdpowerview:shade"/>
</thing-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<update:update-descriptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:update="https://openhab.org/schemas/update-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/update-description/v1.0.0 https://openhab.org/schemas/update-description-1.0.0.xsd">
<thing-type uid="powerview:shade">
<instruction-set targetVersion="1">
<update-channel id="position">
<type>powerview:shade-position</type>
</update-channel>
<update-channel id="secondary">
<type>powerview:shade-position</type>
<label>Secondary Position</label>
<description>The secondary vertical position (on top-down/bottom-up shades)</description>
</update-channel>
<update-channel id="vane">
<type>powerview:shade-vane</type>
</update-channel>
</instruction-set>
</thing-type>
</update:update-descriptions>

View File

@@ -0,0 +1,284 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* 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.openhab.binding.hdpowerview.internal.gen3;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.hdpowerview.internal.HDPowerViewJUnitTests;
import org.openhab.binding.hdpowerview.internal.database.ShadeCapabilitiesDatabase;
import org.openhab.binding.hdpowerview.internal.database.ShadeCapabilitiesDatabase.Capabilities;
import org.openhab.binding.hdpowerview.internal.dto.CoordinateSystem;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Scene;
import org.openhab.binding.hdpowerview.internal.dto.gen3.SceneEvent;
import org.openhab.binding.hdpowerview.internal.dto.gen3.Shade;
import org.openhab.binding.hdpowerview.internal.dto.gen3.ShadeEvent;
import org.openhab.binding.hdpowerview.internal.dto.gen3.ShadePosition;
import org.openhab.binding.hdpowerview.internal.handler.ShadeThingHandler;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandlerCallback;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.types.UnDefType;
import com.google.gson.Gson;
/**
* Unit tests for Generation 3 DTO's.
*
* @author Andrew Fiddian-Green - Initial contribution
*/
@NonNullByDefault
public class Generation3DtoTest {
private final Gson gson = new Gson();
private final ShadeCapabilitiesDatabase db = new ShadeCapabilitiesDatabase();
private String loadJson(String filename) throws IOException {
try (InputStream inputStream = HDPowerViewJUnitTests.class.getResourceAsStream(filename)) {
if (inputStream == null) {
throw new IOException("inputstream is null");
}
byte[] bytes = inputStream.readAllBytes();
if (bytes == null) {
throw new IOException("Resulting byte-array empty");
}
return new String(bytes, StandardCharsets.UTF_8);
}
}
/**
* Test JSON scene event response.
*/
@Test
public void testSceneEventParsing() throws IOException {
String json = loadJson("gen3/scene-event.json");
SceneEvent sceneEvent = gson.fromJson(json, SceneEvent.class);
assertNotNull(sceneEvent);
Scene scene = sceneEvent.getScene();
assertNotNull(scene);
assertEquals("Open All Office Shades\n Open All Office Shades", scene.getName());
assertEquals(234, scene.getId());
}
/**
* Test JSON scenes response.
*/
@Test
public void testScenesParsing() throws IOException {
String json = loadJson("gen3/scenes.json");
List<Scene> sceneList = List.of(gson.fromJson(json, Scene[].class));
assertNotNull(sceneList);
assertEquals(1, sceneList.size());
Scene scene = sceneList.get(0);
assertEquals("Open All Office Shades\n ABC", scene.getName());
assertEquals(234, scene.getId());
}
/**
* Test JSON shade event response.
*/
@Test
public void testShadeEventParsing() throws IOException {
String json = loadJson("gen3/shade-event.json");
ShadeEvent shadeEvent = gson.fromJson(json, ShadeEvent.class);
assertNotNull(shadeEvent);
ShadePosition position = shadeEvent.getCurrentPositions();
assertNotNull(position);
assertEquals(PercentType.valueOf("99"), position.getState(CoordinateSystem.PRIMARY_POSITION));
assertEquals(PercentType.valueOf("98"), position.getState(CoordinateSystem.SECONDARY_POSITION));
assertEquals(PercentType.ZERO, position.getState(CoordinateSystem.VANE_TILT_POSITION));
}
/**
* Test JSON shade position setting.
*/
@Test
public void testShadePositions() {
ShadePosition pos;
pos = new ShadePosition();
pos.setPosition(CoordinateSystem.PRIMARY_POSITION, new PercentType(11));
assertEquals(PercentType.valueOf("11"), pos.getState(CoordinateSystem.PRIMARY_POSITION));
assertEquals(UnDefType.UNDEF, pos.getState(CoordinateSystem.SECONDARY_POSITION));
assertEquals(UnDefType.UNDEF, pos.getState(CoordinateSystem.VANE_TILT_POSITION));
pos = new ShadePosition();
pos.setPosition(CoordinateSystem.PRIMARY_POSITION, new PercentType(11));
pos.setPosition(CoordinateSystem.SECONDARY_POSITION, new PercentType(22));
pos.setPosition(CoordinateSystem.VANE_TILT_POSITION, new PercentType(33));
assertEquals(PercentType.valueOf("11"), pos.getState(CoordinateSystem.PRIMARY_POSITION));
assertEquals(PercentType.valueOf("22"), pos.getState(CoordinateSystem.SECONDARY_POSITION));
assertEquals(PercentType.valueOf("33"), pos.getState(CoordinateSystem.VANE_TILT_POSITION));
}
/**
* Test JSON shades response.
*/
@Test
public void testShadesParsing() throws IOException {
String json = loadJson("gen3/shades.json");
List<Shade> shadeList = List.of(gson.fromJson(json, Shade[].class));
assertNotNull(shadeList);
assertEquals(2, shadeList.size());
Shade shadeData = shadeList.get(0);
assertEquals("Upper Left", shadeData.getName());
assertEquals(2, shadeData.getId());
assertFalse(shadeData.isMainsPowered());
assertEquals(new DecimalType(66), shadeData.getBatteryLevel());
assertEquals(OnOffType.OFF, shadeData.getLowBattery());
ShadePosition positions = shadeData.getShadePositions();
assertNotNull(positions);
Integer caps = shadeData.getCapabilities();
assertNotNull(caps);
Capabilities capabilities = db.getCapabilities(caps);
assertTrue(capabilities.supportsPrimary());
assertFalse(capabilities.supportsSecondary());
assertFalse(capabilities.supportsTilt180());
assertFalse(capabilities.supportsTiltAnywhere());
assertFalse(capabilities.supportsTiltOnClosed());
shadeData = shadeList.get(1);
assertEquals(3, shadeData.getId());
assertTrue(shadeData.isMainsPowered());
positions = shadeData.getShadePositions();
assertNotNull(positions);
caps = shadeData.getCapabilities();
assertNotNull(caps);
capabilities = db.getCapabilities(caps);
assertTrue(capabilities.supportsPrimary());
assertFalse(capabilities.supportsSecondary());
assertFalse(capabilities.supportsTilt180());
assertFalse(capabilities.supportsTiltAnywhere());
assertTrue(capabilities.supportsTiltOnClosed());
}
/**
* Test sending properties and dynamic channel values to a shade handler.
*/
@Test
public void testShadeHandlerPropertiesAndChannels() throws IOException {
ThingTypeUID thingTypeUID = new ThingTypeUID("hdpowerview:shade");
ThingUID thingUID = new ThingUID(thingTypeUID, "test");
List<Channel> channels = new ArrayList<Channel>();
for (String channelId : Set.of(CHANNEL_SHADE_POSITION, CHANNEL_SHADE_SECONDARY_POSITION, CHANNEL_SHADE_VANE,
CHANNEL_SHADE_BATTERY_LEVEL, CHANNEL_SHADE_LOW_BATTERY, CHANNEL_SHADE_SIGNAL_STRENGTH)) {
ChannelUID channelUID = new ChannelUID(thingUID, channelId);
channels.add(ChannelBuilder.create(channelUID).build());
}
String json = loadJson("gen3/shades.json");
List<Shade> shadeList = List.of(gson.fromJson(json, Shade[].class));
assertNotNull(shadeList);
assertEquals(2, shadeList.size());
Thing thing = ThingBuilder.create(thingTypeUID, thingUID).withChannels(channels).build();
ShadeThingHandler shadeThingHandler;
Shade shadeData;
/*
* Use the first JSON Shade entry.
* It should support 4 dynamic channels.
*/
shadeThingHandler = new ShadeThingHandler(thing);
shadeThingHandler.setCallback(mock(ThingHandlerCallback.class));
shadeData = shadeList.get(0).setId(0);
assertTrue(shadeData.hasFullState());
shadeThingHandler.notify(shadeData);
Thing handlerThing = shadeThingHandler.getThing();
assertEquals("Duette (6)", handlerThing.getProperties().get("type"));
assertEquals("battery", handlerThing.getProperties().get("powerType"));
assertEquals("3.0.359", handlerThing.getProperties().get("firmwareVersion"));
assertEquals(new QuantityType<>(-50, Units.DECIBEL_MILLIWATTS), shadeData.getSignalStrength());
assertEquals(4, handlerThing.getChannels().size());
/*
* Use the second JSON Shade entry.
* It should support only 3 dynamic channels.
*/
shadeThingHandler = new ShadeThingHandler(thing);
shadeThingHandler.setCallback(mock(ThingHandlerCallback.class));
shadeData = shadeList.get(1).setId(0);
assertTrue(shadeData.hasFullState());
shadeThingHandler.notify(shadeData);
handlerThing = shadeThingHandler.getThing();
assertEquals("Silhouette (23)", handlerThing.getProperties().get("type"));
assertEquals("hardwired", handlerThing.getProperties().get("powerType"));
assertEquals("3.0.359", handlerThing.getProperties().get("firmwareVersion"));
assertEquals(new QuantityType<>(-51, Units.DECIBEL_MILLIWATTS), shadeData.getSignalStrength());
assertEquals(3, handlerThing.getChannels().size());
}
/**
* Test sending state change events to shade handler.
*/
@Test
public void testShadeHandlerEvents() throws IOException {
ThingTypeUID thingTypeUID = new ThingTypeUID("hdpowerview:shade");
ThingUID thingUID = new ThingUID(thingTypeUID, "test");
List<Channel> channels = new ArrayList<Channel>();
for (String channelId : Set.of(CHANNEL_SHADE_POSITION, CHANNEL_SHADE_SECONDARY_POSITION, CHANNEL_SHADE_VANE,
CHANNEL_SHADE_BATTERY_LEVEL, CHANNEL_SHADE_LOW_BATTERY, CHANNEL_SHADE_SIGNAL_STRENGTH)) {
ChannelUID channelUID = new ChannelUID(thingUID, channelId);
channels.add(ChannelBuilder.create(channelUID).build());
}
String json = loadJson("gen3/shades.json");
List<Shade> shadeList = List.of(gson.fromJson(json, Shade[].class));
assertNotNull(shadeList);
assertEquals(2, shadeList.size());
Thing thing = ThingBuilder.create(thingTypeUID, thingUID).withChannels(channels).build();
ShadeThingHandler shadeThingHandler;
Shade shadeData;
/*
* Use the second JSON Shade entry, which only has a primary channel.
*/
shadeThingHandler = new ShadeThingHandler(thing);
shadeThingHandler.setCallback(mock(ThingHandlerCallback.class));
shadeData = shadeList.get(1).setId(0);
shadeThingHandler.notify(shadeData);
/*
* And try to update it with an event that has all 3 channels.
*/
json = loadJson("gen3/shade-event.json");
ShadeEvent event = gson.fromJson(json, ShadeEvent.class);
assertNotNull(event);
shadeData = new Shade().setId(0).setShadePosition(event.getCurrentPositions()).setPartialState();
assertFalse(shadeData.hasFullState());
shadeThingHandler.notify(shadeData);
}
}

View File

@@ -0,0 +1,13 @@
[
{
"id": 33,
"type": 6,
"enabled": true,
"days": "24",
"hour": 4,
"min": 5,
"bleId": 83,
"sceneId": 116,
"errorShd_Ids": null
}
]

View File

@@ -0,0 +1,17 @@
{
"evt": "scene-activated",
"isoDate": "2021-12-06T20:01:11.934Z",
"id": 55,
"scene": {
"id": 234,
"name": "T3BlbiBBbGwgT2ZmaWNlIFNoYWRlcwo=",
"ptName": "Open All Office Shades",
"color": "37",
"icon": "669",
"networkNumber": 7383,
"roomIds": [
55,
66
]
}
}

View File

@@ -0,0 +1,14 @@
[
{
"id": 234,
"name": "T3BlbiBBbGwgT2ZmaWNlIFNoYWRlcwo=",
"ptName": "ABC",
"color": "37",
"icon": "669",
"networkNumber": 7383,
"roomIds": [
55,
66
]
}
]

View File

@@ -0,0 +1,17 @@
{
"evt": "motion-started",
"isoDate": "2021-12-06T20:01:11.934Z",
"bleName": "PIR:1233",
"id": 55,
"currentPositions": {
"primary": 0.99,
"secondary": 0.98,
"tilt": 0
},
"targetPositions": {
"etaInSeconds": 7,
"primary": 0.99,
"secondary": 0.99,
"tilt": 0
}
}

View File

@@ -0,0 +1,54 @@
[
{
"id": 2,
"type": 6,
"name": "VXBwZXIgTGVmdA==",
"ptName": "Upper Left",
"motion": null,
"capabilities": 0,
"powerType": 0,
"batteryStatus": 2,
"roomId": 1,
"firmware": {
"revision": 3,
"subRevision": 0,
"build": 359
},
"positions": {
"primary": 0.5,
"secondary": 0,
"tilt": 0,
"velocity": 0
},
"signalStrength": -50,
"bleName": "DUE:1444",
"shadeGroupIds": [
]
},
{
"id": 3,
"type": 23,
"name": "VXBwZXIgUmlnaHQ=",
"ptName": "Upper Right",
"motion": null,
"capabilities": 1,
"powerType": 1,
"batteryStatus": 3,
"roomId": 1,
"firmware": {
"revision": 3,
"subRevision": 0,
"build": 359
},
"positions": {
"primary": 0,
"secondary": 0,
"tilt": 0,
"velocity": 0
},
"signalStrength": -51,
"bleName": "SIL:5AA5",
"shadeGroupIds": [
]
}
]