diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 8a317a327595..86b68fc6ca4c 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -741,11 +741,13 @@ org.openhab.binding.lifx ${project.version} - + org.openhab.addons.bundles org.openhab.binding.linuxinput @@ -1476,6 +1478,11 @@ org.openhab.binding.tradfri ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.twitter + ${project.version} + org.openhab.addons.bundles org.openhab.binding.unifi diff --git a/bundles/org.openhab.automation.groovyscripting/README.md b/bundles/org.openhab.automation.groovyscripting/README.md index 6f1f78721554..4cfb17e86607 100644 --- a/bundles/org.openhab.automation.groovyscripting/README.md +++ b/bundles/org.openhab.automation.groovyscripting/README.md @@ -1,6 +1,6 @@ # Groovy Scripting -This add-on provides support for [Groovy](https://groovy-lang.org/) 3.0.8 that can be used as a scripting language within automation rules and which eliminates the need to manually install Groovy. +This add-on provides support for [Groovy](https://groovy-lang.org/) 3.0.9 that can be used as a scripting language within automation rules and which eliminates the need to manually install Groovy. ## Creating Groovy Scripts diff --git a/bundles/org.openhab.automation.groovyscripting/pom.xml b/bundles/org.openhab.automation.groovyscripting/pom.xml index 008e2b30b6bc..14472d023c51 100644 --- a/bundles/org.openhab.automation.groovyscripting/pom.xml +++ b/bundles/org.openhab.automation.groovyscripting/pom.xml @@ -16,7 +16,7 @@ com.ibm.icu.*;resolution:=optional,groovy.runtime.metaclass;resolution:=optional,groovyjarjarantlr4.stringtemplate;resolution:=optional,org.abego.treelayout.*;resolution:=optional,org.apache.ivy.*;resolution:=optional,org.stringtemplate.v4.*;resolution:=optional - 3.0.8 + 3.0.9 diff --git a/bundles/org.openhab.binding.bsblan/src/main/resources/OH-INF/thing/bridge.xml b/bundles/org.openhab.binding.bsblan/src/main/resources/OH-INF/thing/bridge.xml index 81583d1f8bab..73b6aa29e601 100644 --- a/bundles/org.openhab.binding.bsblan/src/main/resources/OH-INF/thing/bridge.xml +++ b/bundles/org.openhab.binding.bsblan/src/main/resources/OH-INF/thing/bridge.xml @@ -7,7 +7,12 @@ A bridge to connect a BSB-LAN device + + + + + network-address diff --git a/bundles/org.openhab.binding.bsblan/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.bsblan/src/main/resources/OH-INF/thing/thing-types.xml index 9703981f6953..46af28828ac3 100644 --- a/bundles/org.openhab.binding.bsblan/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.bsblan/src/main/resources/OH-INF/thing/thing-types.xml @@ -23,16 +23,20 @@ + + + + Specific parameter identifier - + Parameter identifier used for change requests. Defaults to the value of Parameter ID true - + Message type used for change requests. Defaults to SET. SET diff --git a/bundles/org.openhab.binding.doorbird/README.md b/bundles/org.openhab.binding.doorbird/README.md index b0fd966ff128..52aa88eefa8f 100644 --- a/bundles/org.openhab.binding.doorbird/README.md +++ b/bundles/org.openhab.binding.doorbird/README.md @@ -38,6 +38,7 @@ The following configuration parameters are available on the Doorbird A1081 Contr | Hostname | doorbirdHost | Required | The hostname or IP address of the Doorbird device. | | User ID | userId | Required | User Id of a Doorbird user that has permissions to access the API. The User ID and Password must be created using the Doorbird smart phone application. | | Password | userPassword | Required | Password of a Doorbird user. | +| Controller Id | controllerId | Optional | Doorbird Id of the controller to reliable target the relays of this device. E.g. "gggaaa" | ## Discovery diff --git a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/api/DoorbirdInfo.java b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/api/DoorbirdInfo.java index 26dbec9d5134..0edb0d43c440 100644 --- a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/api/DoorbirdInfo.java +++ b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/api/DoorbirdInfo.java @@ -13,6 +13,7 @@ package org.openhab.binding.doorbird.internal.api; import java.util.ArrayList; +import java.util.Arrays; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -35,7 +36,6 @@ public class DoorbirdInfo { private @Nullable String primaryMacAddress; private @Nullable String wifiMacAddress; private @Nullable String deviceType; - private @Nullable String controllerId; private ArrayList relays = new ArrayList<>(); @SuppressWarnings("null") @@ -51,13 +51,7 @@ public DoorbirdInfo(String infoJson) throws JsonSyntaxException { primaryMacAddress = doorbirdInfo.primaryMacAddress; wifiMacAddress = doorbirdInfo.wifiMacAddress; deviceType = doorbirdInfo.deviceType; - for (String relay : doorbirdInfo.relays) { - relays.add(relay); - String[] parts = relay.split("@"); - if (parts.length == 2) { - controllerId = parts[0]; - } - } + relays.addAll(Arrays.asList(doorbirdInfo.relays)); } } } @@ -86,15 +80,12 @@ public DoorbirdInfo(String infoJson) throws JsonSyntaxException { return deviceType; } - public @Nullable String getControllerId() { - return controllerId; + public @Nullable String getControllerId(@Nullable String configId) { + return relays.stream().map(relay -> relay.split("@")).filter(parts -> parts.length == 2).map(parts -> parts[0]) + .filter(id -> configId == null || id.equals(configId)).reduce((first, second) -> second).orElse(null); } public ArrayList getRelays() { return relays; } - - public void addRelay(String relay) { - relays.add(relay); - } } diff --git a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/config/ControllerConfiguration.java b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/config/ControllerConfiguration.java index 3c172607faae..bddab107014d 100644 --- a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/config/ControllerConfiguration.java +++ b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/config/ControllerConfiguration.java @@ -37,4 +37,9 @@ public class ControllerConfiguration { * Password of the Doorbird doorbell to which the controller is assigned */ public @Nullable String userPassword; + + /** + * Id of the Doorbird device + */ + public @Nullable String controllerId; } diff --git a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/handler/ControllerHandler.java b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/handler/ControllerHandler.java index dd8869145062..f9780b352e65 100644 --- a/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/handler/ControllerHandler.java +++ b/bundles/org.openhab.binding.doorbird/src/main/java/org/openhab/binding/doorbird/internal/handler/ControllerHandler.java @@ -68,7 +68,7 @@ public void initialize() { api.setAuthorization(host, user, password); // Get the Id of the controller for use in the open door API - controllerId = getControllerId(); + controllerId = getControllerId(config.controllerId); if (controllerId != null) { updateStatus(ThingStatus.ONLINE); } else { @@ -105,8 +105,8 @@ private void handleOpenDoor(Command command, String doorNumber) { } } - private @Nullable String getControllerId() { + private @Nullable String getControllerId(@Nullable String configId) { DoorbirdInfo info = api.getDoorbirdInfo(); - return info == null ? null : info.getControllerId(); + return info == null ? null : info.getControllerId(configId); } } diff --git a/bundles/org.openhab.binding.doorbird/src/test/java/org/openhab/binding/doorbird/internal/DoorbirdInfoTest.java b/bundles/org.openhab.binding.doorbird/src/test/java/org/openhab/binding/doorbird/internal/DoorbirdInfoTest.java index 7397eaa94d9e..3941578acf39 100644 --- a/bundles/org.openhab.binding.doorbird/src/test/java/org/openhab/binding/doorbird/internal/DoorbirdInfoTest.java +++ b/bundles/org.openhab.binding.doorbird/src/test/java/org/openhab/binding/doorbird/internal/DoorbirdInfoTest.java @@ -81,7 +81,7 @@ public void testParsingWithoutControllerId() { public void testGetControllerId() { DoorbirdInfo info = new DoorbirdInfo(infoWithControllerId); - assertEquals("gggaaa", info.getControllerId()); + assertEquals("gggaaa", info.getControllerId(null)); assertTrue(info.getRelays().contains("gggaaa@1")); assertTrue(info.getRelays().contains("gggaaa@2")); @@ -92,6 +92,6 @@ public void testGetControllerId() { public void testControllerIdIsNull() { DoorbirdInfo info = new DoorbirdInfo(infoWithoutControllerId); - assertNull(info.getControllerId()); + assertNull(info.getControllerId(null)); } } diff --git a/bundles/org.openhab.binding.hdpowerview/README.md b/bundles/org.openhab.binding.hdpowerview/README.md index ab9fe8f76f07..d387c46160d5 100644 --- a/bundles/org.openhab.binding.hdpowerview/README.md +++ b/bundles/org.openhab.binding.hdpowerview/README.md @@ -44,6 +44,7 @@ If in the future, you add additional shades or scenes to your system, the bindin | host | The host name or IP address of the hub on your network. | | refresh | The number of milli-seconds between fetches of the PowerView hub's shade state (default 60'000 one minute). | | hardRefresh | The number of minutes between hard refreshes of the PowerView hub's shade state (default 180 three hours). See [Refreshing the PowerView Hub Cache](#Refreshing-the-PowerView-Hub-Cache). | +| hardRefreshBatteryLevel | The number of hours between hard refreshes of battery levels from the PowerView Hub (or 0 to disable, defaulting to weekly). See [Refreshing the PowerView Hub Cache](#Refreshing-the-PowerView-Hub-Cache). | ### Thing Configuration for PowerView Shades @@ -74,11 +75,12 @@ If it is a dual action (top-down plus bottom-up) shade, there is also a roller s All of these channels appear in the binding, but only those which have a physical implementation in the shade, will have any physical effect. | Channel | Item Type | Description | -|----------------|--------------------------|------------| +|----------------|--------------------------|-------------| | position | Rollershutter | The vertical position of the shade's rail -- see [next chapter](#Roller-Shutter-Up/Down-Position-vs.-Open/Close-State). Up/Down commands will move the rail completely up or completely down. Percentage commands will move the rail to an intermediate position. Stop commands will halt any current movement of the rail. | | secondary | Rollershutter | The vertical position of the secondary rail (if any). Its function is basically identical to the `position` channel above -- but see [next chapter](#Roller-Shutter-Up/Down-Position-vs.-Open/Close-State). | | vane | Dimmer | The degree of opening of the slats or vanes. Setting this to a non-zero value will first move the shade `position` fully down, since the slats or vanes can only have a defined state if the shade is in its down position -- see [Interdependency between Channel positions](#Interdependency-between-Channel-positions). | | lowBattery | Switch | Indicates ON when the battery level of the shade is low, as determined by the hub's internal rules. | +| batteryLevel | Number | Battery level (10% = low, 50% = medium, 100% = high) | batteryVoltage | Number:ElectricPotential | Battery voltage reported by the shade. | | signalStrength | Number | Signal strength (0 for no or unknown signal, 1 for weak, 2 for average, 3 for good or 4 for excellent) | @@ -134,6 +136,10 @@ The hub periodically does a _**"hard refresh"**_ in order to overcome this issue The time interval between hard refreshes is set in the `hardRefresh` configuration parameter. To disable periodic hard refreshes, set `hardRefresh` to zero. +Similarly, the battery level is transient and is only updated automatically by the hub once a week. +To change this interval, set `hardRefreshBatteryLevel` to number of hours between refreshes. +To use default hub behavior (weekly updates), set `hardRefreshBatteryLevel` to zero. + Note: You can also force the hub to refresh itself by sending a `REFRESH` command in a rule to an item that is connected to a channel in the hub as follows: ``` diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewBindingConstants.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewBindingConstants.java index 1b710cb277a7..da235dd22c8d 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewBindingConstants.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewBindingConstants.java @@ -41,6 +41,7 @@ public class HDPowerViewBindingConstants { public static final String CHANNEL_SHADE_SECONDARY_POSITION = "secondary"; public static final String CHANNEL_SHADE_VANE = "vane"; public static final String CHANNEL_SHADE_LOW_BATTERY = "lowBattery"; + public static final String CHANNEL_SHADE_BATTERY_LEVEL = "batteryLevel"; public static final String CHANNEL_SHADE_BATTERY_VOLTAGE = "batteryVoltage"; public static final String CHANNEL_SHADE_SIGNAL_STRENGTH = "signalStrength"; diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewWebTargets.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewWebTargets.java index 4d40d9927bee..eaa17cfbfe3a 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewWebTargets.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewWebTargets.java @@ -234,8 +234,8 @@ private synchronized String invoke(HttpMethod method, String url, @Nullable Quer /** * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on - * a specific shade; fetches a JSON package that describes that shade, and wraps - * it in a Shade class instance + * a specific shade's position; fetches a JSON package that describes that shade, + * and wraps it in a Shade class instance * * @param shadeId id of the shade to be refreshed * @return Shade class instance @@ -243,13 +243,31 @@ private synchronized String invoke(HttpMethod method, String url, @Nullable Quer * @throws HubProcessingException if there is any processing error * @throws HubMaintenanceException if the hub is down for maintenance */ - public @Nullable Shade refreshShade(int shadeId) + public @Nullable Shade refreshShadePosition(int shadeId) throws JsonParseException, HubProcessingException, HubMaintenanceException { String json = invoke(HttpMethod.GET, shades + Integer.toString(shadeId), Query.of("refresh", Boolean.toString(true)), null); return gson.fromJson(json, Shade.class); } + /** + * Instructs the hub to do a hard refresh (discovery on the hubs RF network) on + * a specific shade's battery level; fetches a JSON package that describes that shade, + * and wraps it in a Shade class instance + * + * @param shadeId id of the shade to be refreshed + * @return Shade class instance + * @throws JsonParseException if there is a JSON parsing error + * @throws HubProcessingException if there is any processing error + * @throws HubMaintenanceException if the hub is down for maintenance + */ + public @Nullable Shade refreshShadeBatteryLevel(int shadeId) + throws JsonParseException, HubProcessingException, HubMaintenanceException { + String json = invoke(HttpMethod.GET, shades + Integer.toString(shadeId), + Query.of("updateBatteryLevel", Boolean.toString(true)), null); + return gson.fromJson(json, Shade.class); + } + /** * Tells the hub to stop movement of a specific shade * diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/config/HDPowerViewHubConfiguration.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/config/HDPowerViewHubConfiguration.java index c93df90aeec4..dd599c3265ca 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/config/HDPowerViewHubConfiguration.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/config/HDPowerViewHubConfiguration.java @@ -29,4 +29,5 @@ public class HDPowerViewHubConfiguration { public long refresh; public long hardRefresh; + public long hardRefreshBatteryLevel; } diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewHubHandler.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewHubHandler.java index 0827a682e31e..d617bb06cfbe 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewHubHandler.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewHubHandler.java @@ -67,11 +67,13 @@ public class HDPowerViewHubHandler extends BaseBridgeHandler { private final HttpClient httpClient; private long refreshInterval; - private long hardRefreshInterval; + private long hardRefreshPositionInterval; + private long hardRefreshBatteryLevelInterval; private @Nullable HDPowerViewWebTargets webTargets; private @Nullable ScheduledFuture pollFuture; - private @Nullable ScheduledFuture hardRefreshFuture; + private @Nullable ScheduledFuture hardRefreshPositionFuture; + private @Nullable ScheduledFuture hardRefreshBatteryLevelFuture; private final ChannelTypeUID sceneChannelTypeUID = new ChannelTypeUID(HDPowerViewBindingConstants.BINDING_ID, HDPowerViewBindingConstants.CHANNELTYPE_SCENE_ACTIVATE); @@ -84,7 +86,7 @@ public HDPowerViewHubHandler(Bridge bridge, HttpClient httpClient) { @Override public void handleCommand(ChannelUID channelUID, Command command) { if (RefreshType.REFRESH.equals(command)) { - requestRefreshShades(); + requestRefreshShadePositions(); return; } @@ -119,7 +121,8 @@ public void initialize() { webTargets = new HDPowerViewWebTargets(httpClient, host); refreshInterval = config.refresh; - hardRefreshInterval = config.hardRefresh; + hardRefreshPositionInterval = config.hardRefresh; + hardRefreshBatteryLevelInterval = config.hardRefreshBatteryLevel; schedulePoll(); } @@ -147,14 +150,24 @@ private void schedulePoll() { logger.debug("Scheduling poll for 5000ms out, then every {}ms", refreshInterval); this.pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 5000, refreshInterval, TimeUnit.MILLISECONDS); - future = this.hardRefreshFuture; + future = this.hardRefreshPositionFuture; if (future != null) { future.cancel(false); } - if (hardRefreshInterval > 0) { - logger.debug("Scheduling hard refresh every {}minutes", hardRefreshInterval); - this.hardRefreshFuture = scheduler.scheduleWithFixedDelay(this::requestRefreshShades, 1, - hardRefreshInterval, TimeUnit.MINUTES); + if (hardRefreshPositionInterval > 0) { + logger.debug("Scheduling hard position refresh every {} minutes", hardRefreshPositionInterval); + this.hardRefreshPositionFuture = scheduler.scheduleWithFixedDelay(this::requestRefreshShadePositions, 1, + hardRefreshPositionInterval, TimeUnit.MINUTES); + } + + future = this.hardRefreshBatteryLevelFuture; + if (future != null) { + future.cancel(false); + } + if (hardRefreshBatteryLevelInterval > 0) { + logger.debug("Scheduling hard battery level refresh every {} hours", hardRefreshBatteryLevelInterval); + this.hardRefreshBatteryLevelFuture = scheduler.scheduleWithFixedDelay( + this::requestRefreshShadeBatteryLevels, 1, hardRefreshBatteryLevelInterval, TimeUnit.HOURS); } } @@ -165,11 +178,17 @@ private synchronized void stopPoll() { } this.pollFuture = null; - future = this.hardRefreshFuture; + future = this.hardRefreshPositionFuture; if (future != null) { future.cancel(true); } - this.hardRefreshFuture = null; + this.hardRefreshPositionFuture = null; + + future = this.hardRefreshBatteryLevelFuture; + if (future != null) { + future.cancel(true); + } + this.hardRefreshBatteryLevelFuture = null; } private synchronized void poll() { @@ -304,13 +323,27 @@ private Map getIdChannelMap() { return ret; } - private void requestRefreshShades() { + private void requestRefreshShadePositions() { + Map thingIdMap = getThingIdMap(); + for (Entry item : thingIdMap.entrySet()) { + Thing thing = item.getKey(); + ThingHandler handler = thing.getHandler(); + if (handler instanceof HDPowerViewShadeHandler) { + ((HDPowerViewShadeHandler) handler).requestRefreshShadePosition(); + } else { + String shadeId = item.getValue(); + logger.debug("Shade '{}' handler not initialized", shadeId); + } + } + } + + private void requestRefreshShadeBatteryLevels() { Map thingIdMap = getThingIdMap(); for (Entry item : thingIdMap.entrySet()) { Thing thing = item.getKey(); ThingHandler handler = thing.getHandler(); if (handler instanceof HDPowerViewShadeHandler) { - ((HDPowerViewShadeHandler) handler).requestRefreshShade(); + ((HDPowerViewShadeHandler) handler).requestRefreshShadeBatteryLevel(); } else { String shadeId = item.getValue(); logger.debug("Shade '{}' handler not initialized", shadeId); diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewShadeHandler.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewShadeHandler.java index 960d182fddd7..42514597bafb 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewShadeHandler.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewShadeHandler.java @@ -19,6 +19,8 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import javax.ws.rs.NotSupportedException; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.hdpowerview.internal.HDPowerViewWebTargets; @@ -57,10 +59,16 @@ @NonNullByDefault public class HDPowerViewShadeHandler extends AbstractHubbedThingHandler { + private enum RefreshKind { + POSITION, + BATTERY_LEVEL + } + private final Logger logger = LoggerFactory.getLogger(HDPowerViewShadeHandler.class); private static final int REFRESH_DELAY_SEC = 10; - private @Nullable ScheduledFuture refreshFuture = null; + private @Nullable ScheduledFuture refreshPositionFuture = null; + private @Nullable ScheduledFuture refreshBatteryLevelFuture = null; public HDPowerViewShadeHandler(Thing thing) { super(thing); @@ -85,7 +93,7 @@ public void initialize() { @Override public void handleCommand(ChannelUID channelUID, Command command) { if (RefreshType.REFRESH.equals(command)) { - requestRefreshShade(); + requestRefreshShadePosition(); return; } @@ -137,8 +145,10 @@ protected void onReceiveUpdate(@Nullable ShadeData shadeData) { if (shadeData != null) { updateStatus(ThingStatus.ONLINE); updateBindingStates(shadeData.positions); - updateState(CHANNEL_SHADE_LOW_BATTERY, shadeData.batteryStatus == 1 ? OnOffType.ON : OnOffType.OFF); - updateState(CHANNEL_SHADE_BATTERY_VOLTAGE, new QuantityType<>(shadeData.batteryStrength / 10, Units.VOLT)); + updateBatteryLevel(shadeData.batteryStatus); + updateState(CHANNEL_SHADE_BATTERY_VOLTAGE, + shadeData.batteryStrength > 0 ? new QuantityType<>(shadeData.batteryStrength / 10, Units.VOLT) + : UnDefType.UNDEF); updateState(CHANNEL_SHADE_SIGNAL_STRENGTH, new DecimalType(shadeData.signalStrength)); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); @@ -157,6 +167,28 @@ private void updateBindingStates(@Nullable ShadePosition shadePos) { } } + private void updateBatteryLevel(int batteryStatus) { + int mappedValue; + switch (batteryStatus) { + case 1: // Low + mappedValue = 10; + break; + case 2: // Medium + mappedValue = 50; + break; + case 3: // High + case 4: // Plugged in + mappedValue = 100; + break; + default: // No status available (0) or invalid + updateState(CHANNEL_SHADE_LOW_BATTERY, UnDefType.UNDEF); + updateState(CHANNEL_SHADE_BATTERY_LEVEL, UnDefType.UNDEF); + return; + } + updateState(CHANNEL_SHADE_LOW_BATTERY, batteryStatus == 1 ? OnOffType.ON : OnOffType.OFF); + updateState(CHANNEL_SHADE_BATTERY_LEVEL, new DecimalType(mappedValue)); + } + private void moveShade(ActuatorClass actuatorClass, CoordinateSystem coordSys, int newPercent) { try { HDPowerViewHubHandler bridge; @@ -223,7 +255,7 @@ private void stopShade() { } int shadeId = getShadeId(); webTargets.stopShade(shadeId); - requestRefreshShade(); + requestRefreshShadePosition(); } catch (HubProcessingException | NumberFormatException e) { logger.warn("Unexpected error: {}", e.getMessage()); return; @@ -234,15 +266,36 @@ private void stopShade() { } /** - * Request that the shade shall undergo a 'hard' refresh + * Request that the shade shall undergo a 'hard' refresh for querying its current position */ - protected synchronized void requestRefreshShade() { - if (refreshFuture == null) { - refreshFuture = scheduler.schedule(this::doRefreshShade, REFRESH_DELAY_SEC, TimeUnit.SECONDS); + protected synchronized void requestRefreshShadePosition() { + if (refreshPositionFuture == null) { + refreshPositionFuture = scheduler.schedule(this::doRefreshShadePosition, REFRESH_DELAY_SEC, + TimeUnit.SECONDS); } } - private void doRefreshShade() { + /** + * Request that the shade shall undergo a 'hard' refresh for querying its battery level state + */ + protected synchronized void requestRefreshShadeBatteryLevel() { + if (refreshBatteryLevelFuture == null) { + refreshBatteryLevelFuture = scheduler.schedule(this::doRefreshShadeBatteryLevel, REFRESH_DELAY_SEC, + TimeUnit.SECONDS); + } + } + + private void doRefreshShadePosition() { + this.doRefreshShade(RefreshKind.POSITION); + refreshPositionFuture = null; + } + + private void doRefreshShadeBatteryLevel() { + this.doRefreshShade(RefreshKind.BATTERY_LEVEL); + refreshBatteryLevelFuture = null; + } + + private void doRefreshShade(RefreshKind kind) { try { HDPowerViewHubHandler bridge; if ((bridge = getBridgeHandler()) == null) { @@ -253,7 +306,17 @@ private void doRefreshShade() { throw new HubProcessingException("Web targets not initialized"); } int shadeId = getShadeId(); - Shade shade = webTargets.refreshShade(shadeId); + Shade shade; + switch (kind) { + case POSITION: + shade = webTargets.refreshShadePosition(shadeId); + break; + case BATTERY_LEVEL: + shade = webTargets.refreshShadeBatteryLevel(shadeId); + break; + default: + throw new NotSupportedException("Unsupported refresh kind " + kind.toString()); + } if (shade != null) { ShadeData shadeData = shade.shade; if (shadeData != null) { @@ -267,6 +330,5 @@ private void doRefreshShade() { } catch (HubMaintenanceException e) { // exceptions are logged in HDPowerViewWebTargets } - refreshFuture = null; } } diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/thing/thing-types.xml index 19e8e9a8a18f..c852a49b7529 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/thing/thing-types.xml @@ -26,10 +26,17 @@ 60000 - - The number of minutes between hard refreshes of the PowerView Hub (or 0 to disable) + + The number of minutes between hard refreshes of positions from the PowerView Hub (or 0 to disable) 180 + + + The number of hours between hard refreshes of battery levels from the PowerView Hub (or 0 to disable, + default is weekly) + true + 0 + @@ -48,6 +55,7 @@ + diff --git a/bundles/org.openhab.binding.hdpowerview/src/test/java/org/openhab/binding/hdpowerview/HDPowerViewJUnitTests.java b/bundles/org.openhab.binding.hdpowerview/src/test/java/org/openhab/binding/hdpowerview/HDPowerViewJUnitTests.java index 75a56d610295..46240065fbaf 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/test/java/org/openhab/binding/hdpowerview/HDPowerViewJUnitTests.java +++ b/bundles/org.openhab.binding.hdpowerview/src/test/java/org/openhab/binding/hdpowerview/HDPowerViewJUnitTests.java @@ -218,7 +218,7 @@ public void testOnlineCommunication() { Shade shade = null; try { assertNotEquals(0, shadeId); - shade = webTargets.refreshShade(shadeId); + shade = webTargets.refreshShadePosition(shadeId); assertNotNull(shade); } catch (HubProcessingException | HubMaintenanceException e) { fail(e.getMessage()); @@ -356,6 +356,8 @@ public void testOfflineJsonParsing() { pos = shadePos.getState(PRIMARY_ACTUATOR, VANE_COORDS); assertEquals(UnDefType.class, pos.getClass()); + assertEquals(3, shadeData.batteryStatus); + assertEquals(4, shadeData.signalStrength); } catch (JsonParseException e) { fail(e.getMessage()); diff --git a/bundles/org.openhab.binding.hdpowerview/src/test/resources/duette.json b/bundles/org.openhab.binding.hdpowerview/src/test/resources/duette.json index 4e0e8a7d5291..ff4e03e01c00 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/test/resources/duette.json +++ b/bundles/org.openhab.binding.hdpowerview/src/test/resources/duette.json @@ -6,8 +6,8 @@ { "id": 63778, "type": 8, - "batteryStatus": 0, - "batteryStrength": 0, + "batteryStatus": 3, + "batteryStrength": 168, "roomId": 891, "firmware": { "revision": 1, diff --git a/bundles/org.openhab.binding.homematic/README.md b/bundles/org.openhab.binding.homematic/README.md index c923afcfc843..1dd7842f7fc8 100644 --- a/bundles/org.openhab.binding.homematic/README.md +++ b/bundles/org.openhab.binding.homematic/README.md @@ -343,16 +343,17 @@ A virtual datapoint (String) to simulate a key press, available on all channels Available values: -- `SHORT_PRESS`: triggered on a short key press -- `LONG_PRESS`: triggered on a key press longer than `LONG_PRESS_TIME` (variable configuration per key, default is 0.4 s) -- `DOUBLE_PRESS`: triggered on a short key press but only if the latest `SHORT_PRESS` or `DOUBLE_PRESS` event is not older than 2.0 s (not related to `DBL_PRESS_TIME` configuration, which is more like a key lock because if it is other than `0.0` single presses are not notified anymore) +- `SHORT_PRESSED`: triggered on a short key press +- `LONG_PRESSED`: triggered on a key press longer than `LONG_PRESS_TIME` (variable configuration per key, default is 0.4 s) +- `LONG_REPEATED`: triggered on long key press repetition, that is, in `LONG_PRESS_TIME` intervals as long as key is held +- `LONG_RELEASED`: triggered when a key is released after being long pressed **Example:** to capture a short key press on the 19 button remote control in a rule ```javascript rule "example trigger rule" when - Channel 'homematic:HM-RC-19-B:ccu:KEQ0012345:1#BUTTON' triggered SHORT_PRESS + Channel 'homematic:HM-RC-19-B:ccu:KEQ0012345:1#BUTTON' triggered SHORT_PRESSED then ... end diff --git a/bundles/org.openhab.binding.homematic/src/main/java/org/openhab/binding/homematic/internal/communicator/virtual/ButtonVirtualDatapointHandler.java b/bundles/org.openhab.binding.homematic/src/main/java/org/openhab/binding/homematic/internal/communicator/virtual/ButtonVirtualDatapointHandler.java index b4feca685fad..db121ccb13cb 100644 --- a/bundles/org.openhab.binding.homematic/src/main/java/org/openhab/binding/homematic/internal/communicator/virtual/ButtonVirtualDatapointHandler.java +++ b/bundles/org.openhab.binding.homematic/src/main/java/org/openhab/binding/homematic/internal/communicator/virtual/ButtonVirtualDatapointHandler.java @@ -33,6 +33,9 @@ public class ButtonVirtualDatapointHandler extends AbstractVirtualDatapointHandler { private final Logger logger = LoggerFactory.getLogger(ButtonVirtualDatapointHandler.class); + private static final String LONG_REPEATED_EVENT = "LONG_REPEATED"; + private static final String LONG_RELEASED_EVENT = "LONG_RELEASED"; + @Override public String getName() { return VIRTUAL_DATAPOINT_NAME_BUTTON; @@ -45,7 +48,7 @@ public void initialize(HmDevice device) { HmDatapoint dp = addDatapoint(device, channel.getNumber(), getName(), HmValueType.STRING, null, false); dp.setTrigger(true); dp.setOptions(new String[] { CommonTriggerEvents.SHORT_PRESSED, CommonTriggerEvents.LONG_PRESSED, - CommonTriggerEvents.DOUBLE_PRESSED }); + LONG_REPEATED_EVENT, LONG_RELEASED_EVENT }); } } } @@ -57,33 +60,59 @@ public boolean canHandleEvent(HmDatapoint dp) { @Override public void handleEvent(VirtualGateway gateway, HmDatapoint dp) { - HmDatapoint vdp = getVirtualDatapoint(dp.getChannel()); + HmChannel channel = dp.getChannel(); + HmDatapoint vdp = getVirtualDatapoint(channel); + int usPos = dp.getName().indexOf("_"); + String pressType = usPos == -1 ? dp.getName() : dp.getName().substring(usPos + 1); + boolean isLongPressActive = CommonTriggerEvents.LONG_PRESSED.equals(vdp.getValue()) + || LONG_REPEATED_EVENT.equals(vdp.getValue()); if (MiscUtils.isTrueValue(dp.getValue())) { - int usPos = dp.getName().indexOf("_"); - String pressType = usPos == -1 ? dp.getName() : dp.getName().substring(usPos + 1); switch (pressType) { - case "SHORT": - if (dp.getValue() == null || !dp.getValue().equals(dp.getPreviousValue())) { - vdp.setValue(CommonTriggerEvents.SHORT_PRESSED); - } else { - // two (or more) PRESS_SHORT events were received - // within AbstractHomematicGateway#DEFAULT_DISABLE_DELAY seconds - vdp.setValue(CommonTriggerEvents.DOUBLE_PRESSED); - } + case "SHORT": { + vdp.setValue(null); // Force sending new event + vdp.setValue(CommonTriggerEvents.SHORT_PRESSED); break; + } case "LONG": - vdp.setValue(CommonTriggerEvents.LONG_PRESSED); + if (LONG_REPEATED_EVENT.equals(vdp.getValue())) { + // Suppress long press events during an ongoing long press + vdp.setValue(LONG_REPEATED_EVENT); + } else { + vdp.setValue(CommonTriggerEvents.LONG_PRESSED); + } break; case "LONG_RELEASE": + // Only send release events if we sent a pressed event before + vdp.setValue(isLongPressActive ? LONG_RELEASED_EVENT : null); + break; case "CONT": + // Clear previous value to force re-triggering of repetition vdp.setValue(null); + // Only send repetitions if there was a pressed event before + // (a CONT might arrive simultaneously with the initial LONG event) + if (isLongPressActive) { + vdp.setValue(LONG_REPEATED_EVENT); + } break; default: vdp.setValue(null); logger.warn("Unexpected vaule '{}' for PRESS virtual datapoint", pressType); } } else { - vdp.setValue(null); + if ("LONG".equals(pressType) && LONG_REPEATED_EVENT.equals(vdp.getValue())) { + // If we're currently processing a repeated long-press event, don't let the initial LONG + // event time out the repetitions, the CONT delay handler will take care of it + vdp.setValue(LONG_REPEATED_EVENT); + } else if (isLongPressActive) { + // We seemingly missed an event (either a CONT or the final LONG_RELEASE), + // so end the long press cycle now + vdp.setValue(LONG_RELEASED_EVENT); + } else { + vdp.setValue(null); + } } + logger.debug("Handled virtual button event on {}:{}: press type {}, value {}, button state {} -> {}", + channel.getDevice().getAddress(), channel.getNumber(), pressType, dp.getValue(), vdp.getPreviousValue(), + vdp.getValue()); } } diff --git a/bundles/org.openhab.binding.homematic/src/main/java/org/openhab/binding/homematic/internal/handler/HomematicThingHandler.java b/bundles/org.openhab.binding.homematic/src/main/java/org/openhab/binding/homematic/internal/handler/HomematicThingHandler.java index 39899a5d652c..204e29dc458f 100644 --- a/bundles/org.openhab.binding.homematic/src/main/java/org/openhab/binding/homematic/internal/handler/HomematicThingHandler.java +++ b/bundles/org.openhab.binding.homematic/src/main/java/org/openhab/binding/homematic/internal/handler/HomematicThingHandler.java @@ -384,8 +384,9 @@ protected void updateDatapointState(HmDatapoint dp) { private void updateChannelState(final HmDatapoint dp, Channel channel) throws IOException, GatewayNotAvailableException, ConverterException { if (dp.isTrigger()) { - if (dp.getValue() != null) { - triggerChannel(channel.getUID(), dp.getValue() == null ? "" : dp.getValue().toString()); + final Object value = dp.getValue(); + if (value != null && !value.equals(dp.getPreviousValue())) { + triggerChannel(channel.getUID(), value.toString()); } } else if (isLinked(channel)) { loadHomematicChannelValues(dp.getChannel()); diff --git a/bundles/org.openhab.binding.homematic/src/test/java/org/openhab/binding/homematic/internal/communicator/virtual/ButtonDatapointTest.java b/bundles/org.openhab.binding.homematic/src/test/java/org/openhab/binding/homematic/internal/communicator/virtual/ButtonDatapointTest.java index be58072c3b20..02ac032cdca6 100644 --- a/bundles/org.openhab.binding.homematic/src/test/java/org/openhab/binding/homematic/internal/communicator/virtual/ButtonDatapointTest.java +++ b/bundles/org.openhab.binding.homematic/src/test/java/org/openhab/binding/homematic/internal/communicator/virtual/ButtonDatapointTest.java @@ -17,7 +17,6 @@ import java.io.IOException; -import org.junit.Ignore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.openhab.binding.homematic.internal.misc.HomematicClientException; @@ -66,8 +65,15 @@ public void testLongPress() throws IOException, HomematicClientException { HmDatapoint buttonVirtualDatapoint = getButtonVirtualDatapoint(longPressDp); mockEventReceiver.eventReceived(longPressDp); - assertThat(buttonVirtualDatapoint.getValue(), is(CommonTriggerEvents.LONG_PRESSED)); + + HmDatapoint contPressDp = createPressDatapointFrom(longPressDp, "PRESS_CONT", Boolean.TRUE); + mockEventReceiver.eventReceived(contPressDp); + assertThat(buttonVirtualDatapoint.getValue(), is("LONG_REPEATED")); + + HmDatapoint releaseDp = createPressDatapointFrom(longPressDp, "PRESS_LONG_RELEASE", Boolean.TRUE); + mockEventReceiver.eventReceived(releaseDp); + assertThat(buttonVirtualDatapoint.getValue(), is("LONG_RELEASED")); } @Test @@ -83,35 +89,14 @@ public void testUnsupportedEvents() throws IOException, HomematicClientException mockEventReceiver.eventReceived(releaseDp); HmDatapoint crapDp = createPressDatapoint("CRAP", Boolean.TRUE); - HmDatapoint crapButtonVirtualDatapoint = getButtonVirtualDatapoint(releaseDp); + HmDatapoint crapButtonVirtualDatapoint = getButtonVirtualDatapoint(crapDp); mockEventReceiver.eventReceived(crapDp); + // CONT and LONG_RELEASE events without previous LONG event are supposed to yield no trigger assertThat(contButtonVirtualDatapoint.getValue(), nullValue()); assertThat(releaseButtonVirtualDatapoint.getValue(), nullValue()); - assertThat(crapButtonVirtualDatapoint.getValue(), nullValue()); - } - - @Test - @Ignore(value = "Test is unstable see #10753") - public void testDoublePress() throws IOException, HomematicClientException, InterruptedException { - HmDatapoint shortPressDp = createPressDatapoint("PRESS_SHORT", Boolean.TRUE); - HmDatapoint buttonVirtualDatapoint = getButtonVirtualDatapoint(shortPressDp); - - mockEventReceiver.eventReceived(shortPressDp); - assertThat(buttonVirtualDatapoint.getValue(), is(CommonTriggerEvents.SHORT_PRESSED)); - - Thread.sleep(DISABLE_DATAPOINT_DELAY / 2); - - shortPressDp.setValue(Boolean.TRUE); - mockEventReceiver.eventReceived(shortPressDp); - assertThat(buttonVirtualDatapoint.getValue(), is(CommonTriggerEvents.DOUBLE_PRESSED)); - - Thread.sleep(DISABLE_DATAPOINT_DELAY * 2); - - shortPressDp.setValue(Boolean.TRUE); - mockEventReceiver.eventReceived(shortPressDp); - assertThat(buttonVirtualDatapoint.getValue(), is(CommonTriggerEvents.SHORT_PRESSED)); + assertThat(crapButtonVirtualDatapoint, nullValue()); } private HmDatapoint createPressDatapoint(String channelName, Object value) { @@ -127,6 +112,15 @@ private HmDatapoint createPressDatapoint(String channelName, Object value) { return pressDp; } + private HmDatapoint createPressDatapointFrom(HmDatapoint originalDatapoint, String channelName, Object value) { + HmDatapoint pressDp = new HmDatapoint(channelName, "", HmValueType.ACTION, value, true, HmParamsetType.VALUES); + HmChannel hmChannel = originalDatapoint.getChannel(); + hmChannel.addDatapoint(pressDp); + pressDp.setChannel(hmChannel); + + return pressDp; + } + private HmDatapoint getButtonVirtualDatapoint(HmDatapoint originalDatapoint) { return originalDatapoint.getChannel().getDatapoints().stream() .filter(dp -> HomematicConstants.VIRTUAL_DATAPOINT_NAME_BUTTON.equals(dp.getName())).findFirst() diff --git a/bundles/org.openhab.binding.icalendar/pom.xml b/bundles/org.openhab.binding.icalendar/pom.xml index 0c12afd983a6..768f07224abf 100644 --- a/bundles/org.openhab.binding.icalendar/pom.xml +++ b/bundles/org.openhab.binding.icalendar/pom.xml @@ -17,12 +17,12 @@ net.sf.biweekly biweekly - 0.6.4 + 0.6.6 compile com.fasterxml.jackson.core - * + jackson-databind @@ -39,12 +39,6 @@ ${jackson.version} compile - - com.fasterxml.jackson.core - jackson-annotations - ${jackson.version} - compile - com.fasterxml.jackson.core jackson-databind diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendar.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendar.java index 61d2f9a25184..d38e9e4454ef 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendar.java +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendar.java @@ -88,55 +88,14 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar { @Override public List getJustBegunEvents(Instant frameBegin, Instant frameEnd) { - final List eventList = new ArrayList<>(); - // process all the events in the iCalendar - for (final VEvent event : usedCalendar.getEvents()) { - // iterate over all begin dates - final DateIterator begDates = getRecurredEventDateIterator(event); - while (begDates.hasNext()) { - final Instant begInst = begDates.next().toInstant(); - if (begInst.isBefore(frameBegin)) { - continue; - } else if (begInst.isAfter(frameEnd)) { - break; - } - // fall through => means we are within the time frame - Duration duration = getEventLength(event); - if (duration == null) { - duration = Duration.ofMinutes(1); - } - eventList.add(new VEventWPeriod(event, begInst, begInst.plus(duration)).toEvent()); - break; - } - } - return eventList; + return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0).stream().map(e -> e.toEvent()) + .collect(Collectors.toList()); } @Override public List getJustEndedEvents(Instant frameBegin, Instant frameEnd) { - final List eventList = new ArrayList<>(); - // process all the events in the iCalendar - for (final VEvent event : usedCalendar.getEvents()) { - final Duration duration = getEventLength(event); - if (duration == null) { - continue; - } - // iterate over all begin dates - final DateIterator begDates = getRecurredEventDateIterator(event); - while (begDates.hasNext()) { - final Instant begInst = begDates.next().toInstant(); - final Instant endInst = begInst.plus(duration); - if (endInst.isBefore(frameBegin)) { - continue; - } else if (endInst.isAfter(frameEnd)) { - break; - } - // fall through => means we are within the time frame - eventList.add(new VEventWPeriod(event, begInst, endInst).toEvent()); - break; - } - } - return eventList; + return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0, true).stream().map(e -> e.toEvent()) + .collect(Collectors.toList()); } @Override @@ -247,6 +206,20 @@ public List getFilteredEventsBetween(Instant begin, Instant end, @Nullabl * @return All events which begin in the time frame. */ private List getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries) { + return this.getVEventWPeriodsBetween(frameBegin, frameEnd, maximumPerSeries, false); + } + + /** + * Finds events which begin in the given frame by end time and date + * + * @param frameBegin Begin of the frame where to search events. + * @param frameEnd End of the time frame where to search events. The Instant is inclusive when searchByEnd is true. + * @param maximumPerSeries Limit the results per series. Set to 0 for no limit. + * @param searchByEnd Whether to search by begin of the event or by end. + * @return All events which begin in the time frame. + */ + private List getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries, + boolean searchByEnd) { final List positiveEvents = new ArrayList<>(); final List negativeEvents = new ArrayList<>(); classifyEvents(positiveEvents, negativeEvents); @@ -254,16 +227,22 @@ private List getVEventWPeriodsBetween(Instant frameBegin, Instant final List eventList = new ArrayList<>(); for (final VEvent positiveEvent : positiveEvents) { final DateIterator positiveBeginDates = getRecurredEventDateIterator(positiveEvent); - positiveBeginDates.advanceTo(Date.from(frameBegin)); + Duration duration = getEventLength(positiveEvent); + if (duration == null) { + duration = Duration.ZERO; + } + positiveBeginDates.advanceTo(Date.from(frameBegin.minus(searchByEnd ? duration : Duration.ZERO))); int foundInSeries = 0; while (positiveBeginDates.hasNext()) { final Instant begInst = positiveBeginDates.next().toInstant(); - if (begInst.isAfter(frameEnd) || begInst.equals(frameEnd)) { + if ((!searchByEnd && (begInst.isAfter(frameEnd) || begInst.equals(frameEnd))) + || (searchByEnd && begInst.plus(duration).isAfter(frameEnd))) { break; } - Duration duration = getEventLength(positiveEvent); - if (duration == null) { - duration = Duration.ZERO; + // biweekly is not as precise as java.time. An exact check is required. + if ((!searchByEnd && begInst.isBefore(frameBegin)) + || (searchByEnd && begInst.plus(duration).isBefore(frameBegin))) { + continue; } final VEventWPeriod resultingVEWP = new VEventWPeriod(positiveEvent, begInst, begInst.plus(duration)); diff --git a/bundles/org.openhab.binding.icalendar/src/test/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendarTest.java b/bundles/org.openhab.binding.icalendar/src/test/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendarTest.java index 7c60c4fb68f3..c4857336c7da 100644 --- a/bundles/org.openhab.binding.icalendar/src/test/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendarTest.java +++ b/bundles/org.openhab.binding.icalendar/src/test/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendarTest.java @@ -49,6 +49,7 @@ public class BiweeklyPresentableCalendarTest { private AbstractPresentableCalendar calendar3; private AbstractPresentableCalendar calendar_issue9647; private AbstractPresentableCalendar calendar_issue10808; + private AbstractPresentableCalendar calendar_issue11084; @BeforeEach public void setUp() throws IOException, CalendarException { @@ -59,6 +60,8 @@ public void setUp() throws IOException, CalendarException { new FileInputStream("src/test/resources/test-issue9647.ics")); calendar_issue10808 = new BiweeklyPresentableCalendar( new FileInputStream("src/test/resources/test-issue10808.ics")); + calendar_issue11084 = new BiweeklyPresentableCalendar( + new FileInputStream("src/test/resources/test-issue11084.ics")); } /** @@ -132,6 +135,13 @@ public void testGetCurrentEvent() { Event currentEvent4 = calendar_issue10808.getCurrentEvent(Instant.parse("2021-06-05T17:18:05Z")); assertNotNull(currentEvent4); assertTrue("Test event 1".contentEquals(currentEvent4.title)); + + Event currentEvent5 = calendar_issue11084.getCurrentEvent(Instant.parse("2021-08-16T16:30:05Z")); + assertNull(currentEvent5); + + Event currentEvent6 = calendar_issue11084.getCurrentEvent(Instant.parse("2021-08-16T16:45:05Z")); + assertNotNull(currentEvent6); + assertTrue("TEST_REPEATING_EVENT_3".contentEquals(currentEvent6.title)); } /** @@ -563,6 +573,17 @@ public void testCommandTagCode() { cmd7 = cmdTags.get(7).getCommand(); assertNotNull(cmd7); assertEquals(DecimalType.class, cmd7.getClass()); + + // issue 11084: Command tags from moved events are also executed + List events2 = calendar_issue11084.getJustBegunEvents(Instant.parse("2021-08-16T16:29:55Z"), + Instant.parse("2021-08-16T17:00:05Z")); + assertEquals(1, events2.size()); + assertEquals(Instant.parse("2021-08-16T16:45:00Z"), events2.get(0).start); + + List events3 = calendar_issue11084.getJustEndedEvents(Instant.parse("2021-08-16T16:29:55Z"), + Instant.parse("2021-08-16T17:00:05Z")); + assertEquals(1, events3.size()); + assertEquals(Instant.parse("2021-08-16T17:00:00Z"), events3.get(0).end); } @SuppressWarnings("null") @@ -621,5 +642,9 @@ public void testGetFilteredEventsBetween() { LocalDate.parse("2021-01-04").atStartOfDay(ZoneId.systemDefault()).toInstant(), LocalDate.parse("2021-01-05").atStartOfDay(ZoneId.systemDefault()).toInstant(), null, 3); assertArrayEquals(expectedFilteredEvents8, realFilteredEvents8.toArray(new Event[] {})); + + List realFilteredEvents9 = calendar_issue11084.getFilteredEventsBetween( + Instant.parse("2021-08-16T16:45:00.123456Z"), Instant.parse("2021-08-16T16:46:00.768643Z"), null, 3); + assertEquals(0, realFilteredEvents9.size()); } } diff --git a/bundles/org.openhab.binding.icalendar/src/test/resources/test-issue11084.ics b/bundles/org.openhab.binding.icalendar/src/test/resources/test-issue11084.ics new file mode 100644 index 000000000000..38c208b09ea0 --- /dev/null +++ b/bundles/org.openhab.binding.icalendar/src/test/resources/test-issue11084.ics @@ -0,0 +1,56 @@ +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:ohtest +X-WR-TIMEZONE:UTC +BEGIN:VTIMEZONE +TZID:Europe/Brussels +X-LIC-LOCATION:Europe/Brussels +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID=Europe/Brussels:20210816T184500 +DTEND;TZID=Europe/Brussels:20210816T190000 +DTSTAMP:20210816T174418Z +UID:pseudo7346893o7r8328zheh@google.com +RECURRENCE-ID;TZID=Europe/Brussels:20210816T183000 +CREATED:20210816T161602Z +DESCRIPTION:BEGIN:E_Test_Cal:ON +LAST-MODIFIED:20210816T162009Z +LOCATION: +SEQUENCE:1 +STATUS:CONFIRMED +SUMMARY:TEST_REPEATING_EVENT_3 +TRANSP:OPAQUE +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=Europe/Brussels:20210816T183000 +DTEND;TZID=Europe/Brussels:20210816T184500 +RRULE:FREQ=DAILY +DTSTAMP:20210816T174418Z +UID:pseudo7346893o7r8328zheh@google.com +CREATED:20210816T161602Z +DESCRIPTION:BEGIN:E_Test_Cal:ON +LAST-MODIFIED:20210816T162009Z +LOCATION: +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:TEST_REPEATING_EVENT_3 +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/bundles/org.openhab.binding.kaleidescape/README.md b/bundles/org.openhab.binding.kaleidescape/README.md index cb6dbaeb4ef4..29b00a059b7f 100644 --- a/bundles/org.openhab.binding.kaleidescape/README.md +++ b/bundles/org.openhab.binding.kaleidescape/README.md @@ -32,9 +32,8 @@ The supported thing types are: ## Discovery -Manually initiated Auto-discovery is supported if Kaleidescape components are accessible on the same IP subnet of the openHAB server. -Since discovery involves scanning all IP addresses in the subnet range for an open socket, the discovery must be initiated by the user. -In the Inbox, select Search For Things and then choose the Kaleidescape System Binding to initiate discovery. +Manually initiated Auto-discovery will locate all suported Kaleidescape components if they are on the same IP subnet of the openHAB server. +In the Inbox, select Search For Things and then choose the Kaleidescape System Binding to initiate a discovery scan. ## Binding Configuration @@ -45,14 +44,16 @@ All settings are through thing configuration parameters. The thing has the following configuration parameters: -| Parameter Label | Parameter ID | Description | Accepted values | -|------------------------|---------------|------------------------------------------------------------------------------------|------------------------------------------------------| -| Address | host | Host name or IP address of the Kaleidescape component | A host name or IP address | -| Port | port | Communication port of the IP connection | 10000 (default - should not need to change) | -| Serial Port | serialPort | Serial port for connecting directly a component | Serial port name (optional) | -| Update Period | updatePeriod | Tells the component how often time status updates should be sent (see notes below) | 0 or 1 are the currently accepted values (default 0) | -| Volume Control Enabled | volumeEnabled | Enable the volume and mute controls in the K iPad & phone apps | Boolean (default false) | -| Initial Volume Setting | initialVolume | Initial volume level set when the binding starts up | 0 to 75 (default 25) | +| Parameter Label | Parameter ID | Description | Accepted values | +|--------------------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------| +| Address | host | Host name or IP address of the Kaleidescape component | A host name or IP address | +| Port | port | Communication port of the IP connection | 10000 (default - should not need to change) | +| Serial Port | serialPort | Serial port for connecting directly a component | Serial port name (optional) | +| Update Period | updatePeriod | Tells the component how often time status updates should be sent (see notes below) | 0 or 1 are the currently accepted values (default 0) | +| Volume Control Enabled | volumeEnabled | Enable the volume and mute controls in the K iPad & phone apps | Boolean (default false) | +| Initial Volume Setting | initialVolume | Initial volume level set when the binding starts up | 0 to 75 (default 25) | +| Load Highlighted Details | loadHighlightedDetails | When enabled the binding will automatically load the the metadata channels when the selected item in the UI (Movie or Album) changes | Boolean (default false) | +| Load Album Details | loadAlbumDetails | When enabled the binding will automatically load the metadata channels for the currently playing Album | Boolean (default false) N/A for Alto and Strato | Some notes: @@ -152,14 +153,16 @@ The following channels are available: kaleidescape.things: -```java -kaleidescape:player:myzone1 "M500 Living Rm" [host="192.168.1.10", updatePeriod=0, volumeEnabled=true, initialVolume=20] -kaleidescape:cinemaone:myzone2 "My Cinema One" [host="192.168.1.11", updatePeriod=0, volumeEnabled=true, initialVolume=20] +``` +kaleidescape:player:myzone1 "M500 Living Rm" [ host="192.168.1.10", updatePeriod=0, loadHighlightedDetails=true, loadAlbumDetails=true ] +kaleidescape:cinemaone:myzone2 "My Cinema One" [ host="192.168.1.11", updatePeriod=0, loadHighlightedDetails=true, loadAlbumDetails=true ] +kaleidescape:strato:myzone3 "Strato Theater Rm" [ host="192.168.1.12", updatePeriod=0, loadHighlightedDetails=true ] + ``` kaleidescape.items: -```java +``` // Virtual switch to send a command, see sitemap and rules below Switch z1_GoMovieCovers "Go to Movie Covers" @@ -239,11 +242,12 @@ String z1_Detail_ColorDescription "Color Description: [%s]" { channel="kaleidesc String z1_Detail_Country "Country: [%s]" { channel="kaleidescape:player:myzone1:detail#country" } String z1_Detail_AspectRatio "Aspect Ratio: [%s]" { channel="kaleidescape:player:myzone1:detail#aspect_ratio" } String z1_Detail_DiscLocation "Disc Location: [%s]" { channel="kaleidescape:player:myzone1:detail#disc_location" } + ``` ksecondsformat.js: -```java +``` (function(totalSeconds) { if (isNaN(totalSeconds)) { return '-'; @@ -265,7 +269,7 @@ ksecondsformat.js: kaleidescape.sitemap: -```perl +``` sitemap kaleidescape label="Kaleidescape" { Frame label="Zone 1" { Image item=z1_Detail_CoverArt @@ -351,7 +355,7 @@ sitemap kaleidescape label="Kaleidescape" { kaleidescape.rules: -```java +``` var int lightPercent val kactions = getActions("kaleidescape","kaleidescape:player:myzone1") @@ -360,7 +364,7 @@ rule "Go to Movie Covers" when Item z1_GoMovieCovers received command then - if(null === kactions) { + if (null === kactions) { logInfo("kactions", "Actions not found, check thing ID") return } @@ -372,7 +376,7 @@ rule "Play Script - Great Vistas" when Item z1_PlayScript received command then - if(null === kactions) { + if (null === kactions) { logInfo("kactions", "Actions not found, check thing ID") return } @@ -389,28 +393,6 @@ then } end -rule "Load selected item Metadata" -when - Item z1_Ui_HighlightedSelection changed -then - if(null === kactions) { - logInfo("kactions", "Actions not found, check thing ID") - return - } - kactions.sendKCommand("GET_CONTENT_DETAILS:" + z1_Ui_HighlightedSelection.state.toString + ":") -end - -rule "Load Metadata for currently playing album" -when - Item z1_Music_AlbumHandle changed -then - if(null === kactions) { - logInfo("kactions", "Actions not found, check thing ID") - return - } - kactions.sendKCommand("GET_CONTENT_DETAILS:" + z1_Music_AlbumHandle.state.toString + ":") -end - rule "Bring up Lights when movie is over" when Item z1_Ui_MovieLocation changed from "Main content" to "End Credits" @@ -420,7 +402,7 @@ then while (lightPercent < 100) { lightPercent = lightPercent + 5 logInfo("k rules", "lights at " + lightPercent.toString + " percent") - //myLightItem.sendCommand(lightPercent) + // myLightItem.sendCommand(lightPercent) Thread::sleep(5000) } end @@ -429,7 +411,7 @@ rule "Bring up Lights at 20 percent during intermission" when Item z1_Ui_MovieLocation changed from "Main content" to "Intermission" then - //myLightItem.sendCommand(20) + // myLightItem.sendCommand(20) logInfo("k rules", "intermission started") end @@ -437,7 +419,32 @@ rule "Turn lights back off when intermission over" when Item z1_Ui_MovieLocation changed from "Intermission" to "Main content" then - //myLightItem.sendCommand(OFF) + // myLightItem.sendCommand(OFF) logInfo("k rules", "intermission over") end + +// The following are no longer required since the thing configuration will enable automatic loading of metatdata. +// However the examples are still valid for advanced use cases where retrieving metadata from an arbitrary content handle is desired. + +rule "Load selected item Metadata" +when + Item z1_Ui_HighlightedSelection changed +then + if (null === kactions) { + logInfo("kactions", "Actions not found, check thing ID") + return + } + kactions.sendKCommand("GET_CONTENT_DETAILS:" + z1_Ui_HighlightedSelection.state.toString + ":") +end + +rule "Load Metadata for currently playing album" +when + Item z1_Music_AlbumHandle changed +then + if (null === kactions) { + logInfo("kactions", "Actions not found, check thing ID") + return + } + kactions.sendKCommand("GET_CONTENT_DETAILS:" + z1_Music_AlbumHandle.state.toString + ":") +end ``` diff --git a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/KaleidescapeBindingConstants.java b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/KaleidescapeBindingConstants.java index 6b327fa42391..cb95ab3df1ab 100644 --- a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/KaleidescapeBindingConstants.java +++ b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/KaleidescapeBindingConstants.java @@ -166,6 +166,7 @@ public class KaleidescapeBindingConstants { public static final String GET_CONTENT_COLOR = "GET_CONTENT_COLOR"; public static final String SET_STATUS_CUE_PERIOD_1 = "SET_STATUS_CUE_PERIOD:1"; public static final String GET_TIME = "GET_TIME"; + public static final String GET_CONTENT_DETAILS = "GET_CONTENT_DETAILS:"; public static final String LEAVE_STANDBY = "LEAVE_STANDBY"; public static final String ENTER_STANDBY = "ENTER_STANDBY"; diff --git a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/communication/KaleidescapeConnector.java b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/communication/KaleidescapeConnector.java index 6f6b5188770f..73100f4cb55d 100644 --- a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/communication/KaleidescapeConnector.java +++ b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/communication/KaleidescapeConnector.java @@ -185,7 +185,7 @@ public void sendCommand(@Nullable String cmd) throws KaleidescapeException { */ public void sendCommand(@Nullable String cmd, @Nullable String cachedMessage) throws KaleidescapeException { // if sent a cachedMessage, just send out an event with the data so KaleidescapeMessageHandler will process it - if (cachedMessage != null) { + if (cmd != null && cachedMessage != null) { logger.debug("Command: '{}' returning cached value: '{}'", cmd, cachedMessage); // change GET_SOMETHING into SOMETHING and special case GET_PLAYING_TITLE_NAME into TITLE_NAME dispatchKeyValue(cmd.replace("GET_", "").replace("PLAYING_TITLE_NAME", "TITLE_NAME"), cachedMessage, true); diff --git a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/communication/KaleidescapeFormatter.java b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/communication/KaleidescapeFormatter.java index 0dbe8727f53e..627c675d7459 100644 --- a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/communication/KaleidescapeFormatter.java +++ b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/communication/KaleidescapeFormatter.java @@ -12,6 +12,8 @@ */ package org.openhab.binding.kaleidescape.internal.communication; +import static org.openhab.binding.kaleidescape.internal.KaleidescapeBindingConstants.*; + import org.apache.commons.lang3.StringEscapeUtils; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -23,7 +25,7 @@ @NonNullByDefault public class KaleidescapeFormatter { public static String formatString(String input) { - if (!input.equals("")) { + if (!EMPTY.equals(input)) { // convert || back to : input = input.replace("||", ":"); diff --git a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/configuration/KaleidescapeThingConfiguration.java b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/configuration/KaleidescapeThingConfiguration.java index 20e8e2f1f8d8..8b7ee83a9994 100644 --- a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/configuration/KaleidescapeThingConfiguration.java +++ b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/configuration/KaleidescapeThingConfiguration.java @@ -28,4 +28,6 @@ public class KaleidescapeThingConfiguration { public @Nullable Integer updatePeriod; public boolean volumeEnabled; public Integer initialVolume = 0; + public boolean loadHighlightedDetails; + public boolean loadAlbumDetails; } diff --git a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/discovery/KaleidescapeDiscoveryJob.java b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/discovery/KaleidescapeDiscoveryJob.java deleted file mode 100644 index f9f5b6130e90..000000000000 --- a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/discovery/KaleidescapeDiscoveryJob.java +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Copyright (c) 2010-2021 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.kaleidescape.internal.discovery; - -import static org.openhab.binding.kaleidescape.internal.KaleidescapeBindingConstants.*; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.PrintWriter; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.net.UnknownHostException; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.core.thing.ThingTypeUID; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The {@link KaleidescapeDiscoveryJob} class allow manual discovery of - * Kaleidescape components for a single IP address. This is used - * for threading to make discovery faster. - * - * @author Chris Graham - Initial contribution - * @author Michael Lobstein - Adapted for the Kaleidescape binding - * - */ -@NonNullByDefault -public class KaleidescapeDiscoveryJob implements Runnable { - private final Logger logger = LoggerFactory.getLogger(KaleidescapeDiscoveryJob.class); - - // Component Types - private static final String PLAYER = "Player"; - private static final String CINEMA_ONE = "Cinema One"; - private static final String ALTO = "Alto"; - private static final String STRATO = "Strato"; - private static final String STRATO_S = "Strato S"; - private static final String DISC_VAULT = "Disc Vault"; - - private static final Set ALLOWED_DEVICES = new HashSet( - Arrays.asList(PLAYER, CINEMA_ONE, ALTO, STRATO, STRATO_S, DISC_VAULT)); - - private KaleidescapeDiscoveryService discoveryClass; - - private ThingTypeUID thingTypeUid = THING_TYPE_PLAYER; - private String ipAddress = EMPTY; - private String friendlyName = EMPTY; - private String serialNumber = EMPTY; - - public KaleidescapeDiscoveryJob(KaleidescapeDiscoveryService service, String ip) { - this.discoveryClass = service; - this.ipAddress = ip; - } - - @Override - public void run() { - if (hasKaleidescapeDevice(this.ipAddress)) { - discoveryClass.submitDiscoveryResults(this.thingTypeUid, this.ipAddress, this.friendlyName, - this.serialNumber); - } - } - - /** - * Determines if a Kaleidescape component with a movie player zone is available at a given IP address. - * - * @param ip IP address of the Kaleidescape component as a string. - * @return True if a component is found, false if not. - */ - private boolean hasKaleidescapeDevice(String ip) { - try { - InetAddress address = InetAddress.getByName(ip); - - if (isKaleidescapeDevice(address, DEFAULT_API_PORT)) { - return true; - } else { - logger.debug("No Kaleidescape component found at IP address ({})", ip); - return false; - } - } catch (UnknownHostException e) { - logger.debug("Unknown host: {} - {}", ip, e.getMessage()); - return false; - } - } - - /** - * Tries to establish a connection to a hostname and port and then interrogate the component - * - * @param host Hostname or IP address to connect to. - * @param port Port to attempt to connect to. - * @return True if the component found is one the binding supports - */ - private boolean isKaleidescapeDevice(InetAddress host, int port) { - try (Socket socket = new Socket()) { - socket.connect(new InetSocketAddress(host, port), DISCOVERY_DEFAULT_IP_TIMEOUT_RATE_MS); - - OutputStream output = socket.getOutputStream(); - PrintWriter writer = new PrintWriter(output, true); - - // query the component to see if it has video zones, the device type, friendly name, and serial number - writer.println("01/1/GET_NUM_ZONES:"); - writer.println("01/1/GET_DEVICE_TYPE_NAME:"); - writer.println("01/1/GET_FRIENDLY_NAME:"); - writer.println("01/1/GET_DEVICE_INFO:"); - - InputStream input = socket.getInputStream(); - - BufferedReader reader = new BufferedReader(new InputStreamReader(input)); - - String componentType = EMPTY; - String line; - String videoZone = null; - String audioZone = null; - int lineCount = 0; - - while ((line = reader.readLine()) != null) { - String[] strArr = line.split(":"); - - if (strArr.length >= 4) { - switch (strArr[1]) { - case "NUM_ZONES": - videoZone = strArr[2]; - audioZone = strArr[3]; - break; - case "DEVICE_TYPE_NAME": - componentType = strArr[2]; - break; - case "FRIENDLY_NAME": - friendlyName = strArr[2]; - break; - case "DEVICE_INFO": - serialNumber = strArr[3].trim(); // take off leading zeros - break; - } - } else { - logger.debug("isKaleidescapeDevice() - Unable to process line: {}", line); - } - - lineCount++; - - // stop after reading four lines - if (lineCount > 3) { - break; - } - } - - // see if we have a video zone - if ("01".equals(videoZone)) { - // now check if we are one of the allowed types - if (ALLOWED_DEVICES.contains(componentType)) { - if (STRATO_S.equals(componentType) || STRATO.equals(componentType)) { - thingTypeUid = THING_TYPE_STRATO; - return true; - } - - // A 'Player' without an audio zone is really a Strato C - // does not work yet, Strato C erroneously reports "01" for audio zones - // so we are unable to differentiate a Strato C from a Premiere player - if ("00".equals(audioZone) && PLAYER.equals(componentType)) { - thingTypeUid = THING_TYPE_STRATO; - return true; - } - - // Alto - if (ALTO.equals(componentType)) { - thingTypeUid = THING_TYPE_ALTO; - return true; - } - - // Cinema One - if (CINEMA_ONE.equals(componentType)) { - thingTypeUid = THING_TYPE_CINEMA_ONE; - return true; - } - - // A Disc Vault with a video zone (the M700 vault), just call it a THING_TYPE_PLAYER - if (DISC_VAULT.equals(componentType)) { - thingTypeUid = THING_TYPE_PLAYER; - return true; - } - - // default returns THING_TYPE_PLAYER - return true; - } - } - } catch (IOException e) { - logger.debug("isKaleidescapeDevice() IOException: {}", e.getMessage()); - return false; - } - - return false; - } -} diff --git a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/discovery/KaleidescapeDiscoveryService.java b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/discovery/KaleidescapeDiscoveryService.java index 2505e7fd4fef..8bb2388bbc85 100644 --- a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/discovery/KaleidescapeDiscoveryService.java +++ b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/discovery/KaleidescapeDiscoveryService.java @@ -14,26 +14,30 @@ import static org.openhab.binding.kaleidescape.internal.KaleidescapeBindingConstants.*; -import java.net.Inet4Address; -import java.net.Inet6Address; -import java.net.InetAddress; -import java.net.InterfaceAddress; -import java.net.NetworkInterface; -import java.net.SocketException; -import java.net.UnknownHostException; -import java.util.ArrayList; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketTimeoutException; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; -import java.util.List; +import java.util.HashSet; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.apache.commons.net.util.SubnetUtils; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.common.NamedThreadFactory; import org.openhab.core.config.discovery.AbstractDiscoveryService; import org.openhab.core.config.discovery.DiscoveryResultBuilder; @@ -50,7 +54,7 @@ * * @author Chris Graham - Initial contribution * @author Michael Lobstein - Adapted for the Kaleidescape binding - * + * */ @NonNullByDefault @Component(service = DiscoveryService.class, configurationPid = "discovery.kaleidescape") @@ -60,6 +64,29 @@ public class KaleidescapeDiscoveryService extends AbstractDiscoveryService { .unmodifiableSet(Stream.of(THING_TYPE_PLAYER, THING_TYPE_CINEMA_ONE, THING_TYPE_ALTO, THING_TYPE_STRATO) .collect(Collectors.toSet())); + private static final int K_HEARTBEAT_PORT = 1443; + + // Component Types + private static final String PLAYER = "Player"; + private static final String CINEMA_ONE = "Cinema One"; + private static final String ALTO = "Alto"; + private static final String STRATO = "Strato"; + private static final String STRATO_S = "Strato S"; + private static final String DISC_VAULT = "Disc Vault"; + + private static final Set ALLOWED_DEVICES = new HashSet( + Arrays.asList(PLAYER, CINEMA_ONE, ALTO, STRATO, STRATO_S, DISC_VAULT)); + + @Nullable + private ExecutorService executorService = null; + + /** + * Whether we are currently scanning or not + */ + private boolean scanning; + + private Set foundIPs = new HashSet(); + @Activate public KaleidescapeDiscoveryService() { super(SUPPORTED_THING_TYPES_UIDS, DISCOVERY_DEFAULT_TIMEOUT_RATE_MS, DISCOVERY_DEFAULT_AUTO_DISCOVER); @@ -70,83 +97,214 @@ public Set getSupportedThingTypes() { return SUPPORTED_THING_TYPES_UIDS; } + /** + * {@inheritDoc} + * + * Starts the scan. This discovery will: + *
    + *
  • Create a listening thread that opens up a broadcast {@link DatagramSocket} on port {@link #K_HEARTBEAT_PORT} + * and will receive any {@link DatagramPacket} that comes in
  • + *
  • The source IP address of the {@link DatagramPacket} is interrogated to verify it is a Kaleidescape component + * and will create a new thing from it
  • + *
+ * The process will continue until {@link #stopScan()} is called. + */ @Override protected void startScan() { logger.debug("Starting discovery of Kaleidescape components."); - try { - List ipList = getIpAddressScanList(); + if (executorService != null) { + stopScan(); + } - ExecutorService discoverySearchPool = Executors.newFixedThreadPool(DISCOVERY_THREAD_POOL_SIZE, - new NamedThreadFactory("OH-binding-discovery.kaleidescape", true)); + final ExecutorService service = Executors.newFixedThreadPool(DISCOVERY_THREAD_POOL_SIZE, + new NamedThreadFactory("OH-binding-discovery.kaleidescape", true)); + executorService = service; - for (String ip : ipList) { - discoverySearchPool.execute(new KaleidescapeDiscoveryJob(this, ip)); - } + scanning = true; + foundIPs.clear(); - discoverySearchPool.shutdown(); - } catch (Exception exp) { - logger.debug("Kaleidescape discovery service encountered an error while scanning for components: {}", - exp.getMessage()); - } + service.execute(() -> { + try { + DatagramSocket dSocket = new DatagramSocket(K_HEARTBEAT_PORT); + dSocket.setSoTimeout(DISCOVERY_DEFAULT_TIMEOUT_RATE_MS); + dSocket.setBroadcast(true); - logger.debug("Completed discovery of Kaleidescape components."); + while (scanning) { + DatagramPacket receivePacket = new DatagramPacket(new byte[1], 1); + try { + dSocket.receive(receivePacket); + + if (!foundIPs.contains(receivePacket.getAddress().getHostAddress())) { + String foundIp = receivePacket.getAddress().getHostAddress(); + logger.debug("RECEIVED Kaleidescape packet from: {}", foundIp); + foundIPs.add(foundIp); + isKaleidescapeDevice(foundIp); + } + } catch (SocketTimeoutException e) { + // ignore + continue; + } + } + + dSocket.close(); + } catch (IOException e) { + logger.debug("KaleidescapeDiscoveryService IOException: {}", e.getMessage(), e); + } + }); } /** - * Create a new Thing with an IP address and Component type given. Uses default port. + * {@inheritDoc} * - * @param thingTypeUid ThingTypeUID of detected Kaleidescape component. - * @param ip IP address of the Kaleidescape component as a string. - * @param friendlyName Name of Kaleidescape component as a string. - * @param serialNumber Serial Number of Kaleidescape component as a string. + * Stops the discovery scan. We set {@link #scanning} to false (allowing the listening thread to end naturally + * within {@link #TIMEOUT) * 5 time then shutdown the {@link #executorService} */ - public void submitDiscoveryResults(ThingTypeUID thingTypeUid, String ip, String friendlyName, String serialNumber) { - ThingUID uid = new ThingUID(thingTypeUid, serialNumber); - - HashMap properties = new HashMap<>(); + @Override + protected synchronized void stopScan() { + super.stopScan(); + ExecutorService service = executorService; + if (service == null) { + return; + } - properties.put("host", ip); - properties.put("port", DEFAULT_API_PORT); + scanning = false; - thingDiscovered(DiscoveryResultBuilder.create(uid).withProperties(properties).withRepresentationProperty("host") - .withLabel(friendlyName).build()); + try { + service.awaitTermination(DISCOVERY_DEFAULT_TIMEOUT_RATE_MS * 5, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + } + service.shutdown(); + executorService = null; } /** - * Provide a string list of all the IP addresses associated with the network interfaces on - * this machine. + * Tries to establish a connection to the specified ip address and then interrogate the component, + * creates a discovery result if a valid component is found. * - * @return String list of IP addresses. - * @throws UnknownHostException - * @throws SocketException + * @param ipAddress IP address to connect to */ - private List getIpAddressScanList() throws UnknownHostException, SocketException { - List results = new ArrayList<>(); + private void isKaleidescapeDevice(String ipAddress) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(ipAddress, DEFAULT_API_PORT), DISCOVERY_DEFAULT_IP_TIMEOUT_RATE_MS); + + OutputStream output = socket.getOutputStream(); + PrintWriter writer = new PrintWriter(output, true); + + // query the component to see if it has video zones, the device type, friendly name, and serial number + writer.println("01/1/GET_NUM_ZONES:"); + writer.println("01/1/GET_DEVICE_TYPE_NAME:"); + writer.println("01/1/GET_FRIENDLY_NAME:"); + writer.println("01/1/GET_DEVICE_INFO:"); + + InputStream input = socket.getInputStream(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(input)); + + ThingTypeUID thingTypeUid = THING_TYPE_PLAYER; + String friendlyName = EMPTY; + String serialNumber = EMPTY; + String componentType = EMPTY; + String line; + String videoZone = null; + String audioZone = null; + int lineCount = 0; - InetAddress localHost = InetAddress.getLocalHost(); - NetworkInterface networkInterface = NetworkInterface.getByInetAddress(localHost); + while ((line = reader.readLine()) != null) { + String[] strArr = line.split(":"); - for (InterfaceAddress address : networkInterface.getInterfaceAddresses()) { - InetAddress ipAddress = address.getAddress(); + if (strArr.length >= 4) { + switch (strArr[1]) { + case "NUM_ZONES": + videoZone = strArr[2]; + audioZone = strArr[3]; + break; + case "DEVICE_TYPE_NAME": + componentType = strArr[2]; + break; + case "FRIENDLY_NAME": + friendlyName = strArr[2]; + break; + case "DEVICE_INFO": + serialNumber = strArr[3].trim(); // take off leading zeros + break; + } + } else { + logger.debug("isKaleidescapeDevice() - Unable to process line: {}", line); + } - String cidrSubnet = ipAddress.getHostAddress() + "/" + address.getNetworkPrefixLength(); + lineCount++; - /* Apache Subnet Utils only supports IP v4 for creating string list of IP's */ - if (ipAddress instanceof Inet4Address) { - logger.debug("Found interface IPv4 address to scan: {}", cidrSubnet); + // stop after reading four lines + if (lineCount > 3) { + break; + } + } + + // see if we have a video zone + if ("01".equals(videoZone)) { + // now check if we are one of the allowed types + if (ALLOWED_DEVICES.contains(componentType)) { + if (STRATO_S.equals(componentType) || STRATO.equals(componentType)) { + thingTypeUid = THING_TYPE_STRATO; + } + + // A 'Player' without an audio zone is really a Strato C + // does not work yet, Strato C erroneously reports "01" for audio zones + // so we are unable to differentiate a Strato C from a Premiere player + if ("00".equals(audioZone) && PLAYER.equals(componentType)) { + thingTypeUid = THING_TYPE_STRATO; + } + + // Alto + if (ALTO.equals(componentType)) { + thingTypeUid = THING_TYPE_ALTO; + } - SubnetUtils utils = new SubnetUtils(cidrSubnet); + // Cinema One + if (CINEMA_ONE.equals(componentType)) { + thingTypeUid = THING_TYPE_CINEMA_ONE; + } - results.addAll(Arrays.asList(utils.getInfo().getAllAddresses())); // not sure how to do this without the - // Apache libraries - } else if (ipAddress instanceof Inet6Address) { - logger.debug("Found interface IPv6 address to scan: {}, ignoring", cidrSubnet); + // A Disc Vault with a video zone (the M700 vault), just call it a THING_TYPE_PLAYER + if (DISC_VAULT.equals(componentType)) { + thingTypeUid = THING_TYPE_PLAYER; + } + + // default THING_TYPE_PLAYER + submitDiscoveryResults(thingTypeUid, ipAddress, friendlyName, serialNumber); + } } else { - logger.debug("Found interface unknown IP type address to scan: {}", cidrSubnet); + logger.debug("No Suitable Kaleidescape component found at IP address ({})", ipAddress); } + reader.close(); + input.close(); + writer.close(); + output.close(); + socket.close(); + } catch (IOException e) { + logger.debug("isKaleidescapeDevice() IOException: {}", e.getMessage()); } + } - return results; + /** + * Create a new Thing with an IP address and Component type given. Uses default port. + * + * @param thingTypeUid ThingTypeUID of detected Kaleidescape component. + * @param ip IP address of the Kaleidescape component as a string. + * @param friendlyName Name of Kaleidescape component as a string. + * @param serialNumber Serial Number of Kaleidescape component as a string. + */ + private void submitDiscoveryResults(ThingTypeUID thingTypeUid, String ip, String friendlyName, + String serialNumber) { + ThingUID uid = new ThingUID(thingTypeUid, serialNumber); + + HashMap properties = new HashMap<>(); + + properties.put("host", ip); + properties.put("port", DEFAULT_API_PORT); + + thingDiscovered(DiscoveryResultBuilder.create(uid).withProperties(properties).withRepresentationProperty("host") + .withLabel(friendlyName).build()); } } diff --git a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/handler/KaleidescapeHandler.java b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/handler/KaleidescapeHandler.java index 2cbcc821d068..8d8a87fccce1 100644 --- a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/handler/KaleidescapeHandler.java +++ b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/handler/KaleidescapeHandler.java @@ -90,6 +90,8 @@ public class KaleidescapeHandler extends BaseThingHandler implements Kaleidescap protected int volume = 0; protected boolean volumeEnabled = false; protected boolean isMuted = false; + protected boolean isLoadHighlightedDetails = false; + protected boolean isLoadAlbumDetails = false; protected String friendlyName = EMPTY; protected Object sequenceLock = new Object(); @@ -124,6 +126,8 @@ public void initialize() { final String host = config.host; final Integer port = config.port; final Integer updatePeriod = config.updatePeriod; + this.isLoadHighlightedDetails = config.loadHighlightedDetails; + this.isLoadAlbumDetails = config.loadAlbumDetails; if ((serialPort == null || serialPort.isEmpty()) && (host == null || host.isEmpty())) { configError = "undefined serialPort and host configuration settings; please set one of them"; @@ -166,10 +170,10 @@ public void initialize() { return; } + updateStatus(ThingStatus.UNKNOWN); + scheduleReconnectJob(); schedulePollingJob(); - - updateStatus(ThingStatus.UNKNOWN); } @Override diff --git a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/handler/KaleidescapeMessageHandler.java b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/handler/KaleidescapeMessageHandler.java index 2469d65d2d2a..22f26dd3d050 100644 --- a/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/handler/KaleidescapeMessageHandler.java +++ b/bundles/org.openhab.binding.kaleidescape/src/main/java/org/openhab/binding/kaleidescape/internal/handler/KaleidescapeMessageHandler.java @@ -57,9 +57,19 @@ public void handleMessage(String message, KaleidescapeHandler handler) { } }, HIGHLIGHTED_SELECTION { + private final Logger logger = LoggerFactory.getLogger(KaleidescapeMessageHandler.class); + @Override public void handleMessage(String message, KaleidescapeHandler handler) { handler.updateChannel(KaleidescapeBindingConstants.HIGHLIGHTED_SELECTION, new StringType(message)); + + if (handler.isLoadHighlightedDetails) { + try { + handler.connector.sendCommand(GET_CONTENT_DETAILS + message + ":"); + } catch (KaleidescapeException e) { + logger.debug("GET_CONTENT_DETAILS - exception loading content details for handle: {}", message); + } + } } }, DEVICE_POWER_STATE { @@ -273,6 +283,15 @@ public void handleMessage(String message, KaleidescapeHandler handler) { handler.updateChannel(MUSIC_ALBUM_HANDLE, new StringType(matcher.group(5))); handler.updateChannel(MUSIC_NOWPLAY_HANDLE, new StringType(matcher.group(6))); + + if (handler.isLoadAlbumDetails) { + try { + handler.connector.sendCommand(GET_CONTENT_DETAILS + matcher.group(5) + ":"); + } catch (KaleidescapeException e) { + logger.debug("GET_CONTENT_DETAILS - exception loading album details for handle: {}", + matcher.group(5)); + } + } } else { logger.debug("MUSIC_TITLE - no match on message: {}", message); } diff --git a/bundles/org.openhab.binding.kaleidescape/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.kaleidescape/src/main/resources/OH-INF/config/config.xml index 8b4a294ba426..0d851f32c07d 100644 --- a/bundles/org.openhab.binding.kaleidescape/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.binding.kaleidescape/src/main/resources/OH-INF/config/config.xml @@ -42,5 +42,17 @@ When the binding starts up, set the Inital Volume level to this value (Default 25). 25 + + + When enabled the binding will automatically load the metadata channels when the selected item in the UI + (Movie or Album) changes. + false + + + + When enabled the binding will automatically load the metadata channels for the currently playing Album. + Not applicable for Alto and Strato components. + false + diff --git a/bundles/org.openhab.binding.kodi/README.md b/bundles/org.openhab.binding.kodi/README.md index 5cf912208ef5..35653967854a 100644 --- a/bundles/org.openhab.binding.kodi/README.md +++ b/bundles/org.openhab.binding.kodi/README.md @@ -83,6 +83,7 @@ The Kodi thing supports the following channels: | input | String | Sends a key stroke to Kodi to navigate in the UI. Valid commands are: `Back`, `ContextMenu`, `Down`, `Home`, `Info`, `Left`, `Right`, `Select`, `ShowCodec`, `ShowOSD`, `ShowPlayerProcessInfo` and `Up`. `ExecuteAction` and `SendText` should be used with the dedicated channels `inputaction` and `inputtext`. | | inputtext | String | Sends a generic input (unicode) text to Kodi. | | inputaction | String | Sends a predefined action to Kodi to control the UI and/or perform other tasks. Valid commands are: `left`, `right`, `up`, `down`, `pageup`, `pagedown`, `select`, `highlight`, `parentdir`, `parentfolder`, `back`, `menu`, `previousmenu`, `info`, `pause`, `stop`, `skipnext`, `skipprevious`, `fullscreen`, `aspectratio`, `stepforward`, `stepback`, `bigstepforward`, `bigstepback`, `chapterorbigstepforward`, `chapterorbigstepback`, `osd`, `showsubtitles`, `nextsubtitle`, `cyclesubtitle`, `playerdebug`, `codecinfo`, `playerprocessinfo`, `nextpicture`, `previouspicture`, `zoomout`, `zoomin`, `playlist`, `queue`, `zoomnormal`, `zoomlevel1`, `zoomlevel2`, `zoomlevel3`, `zoomlevel4`, `zoomlevel5`, `zoomlevel6`, `zoomlevel7`, `zoomlevel8`, `zoomlevel9`, `nextcalibration`, `resetcalibration`, `analogmove`, `analogmovex`, `analogmovey`, `rotate`, `rotateccw`, `close`, `subtitledelayminus`, `subtitledelay`, `subtitledelayplus`, `audiodelayminus`, `audiodelay`, `audiodelayplus`, `subtitleshiftup`, `subtitleshiftdown`, `subtitlealign`, `audionextlanguage`, `verticalshiftup`, `verticalshiftdown`, `nextresolution`, `audiotoggledigital`, `number0`, `number1`, `number2`, `number3`, `number4`, `number5`, `number6`, `number7`, `number8`, `number9`, `smallstepback`, `fastforward`, `rewind`, `play`, `playpause`, `switchplayer`, `delete`, `copy`, `move`, `screenshot`, `rename`, `togglewatched`, `scanitem`, `reloadkeymaps`, `volumeup`, `volumedown`, `mute`, `backspace`, `scrollup`, `scrolldown`, `analogfastforward`, `analogrewind`, `moveitemup`, `moveitemdown`, `contextmenu`, `shift`, `symbols`, `cursorleft`, `cursorright`, `showtime`, `analogseekforward`, `analogseekback`, `showpreset`, `nextpreset`, `previouspreset`, `lockpreset`, `randompreset`, `increasevisrating`, `decreasevisrating`, `showvideomenu`, `enter`, `increaserating`, `decreaserating`, `setrating`, `togglefullscreen`, `nextscene`, `previousscene`, `nextletter`, `prevletter`, `jumpsms2`, `jumpsms3`, `jumpsms4`, `jumpsms5`, `jumpsms6`, `jumpsms7`, `jumpsms8`, `jumpsms9`, `filter`, `filterclear`, `filtersms2`, `filtersms3`, `filtersms4`, `filtersms5`, `filtersms6`, `filtersms7`, `filtersms8`, `filtersms9`, `firstpage`, `lastpage`, `guiprofile`, `red`, `green`, `yellow`, `blue`, `increasepar`, `decreasepar`, `volampup`, `volampdown`, `volumeamplification`, `createbookmark`, `createepisodebookmark`, `settingsreset`, `settingslevelchange`, `stereomode`, `nextstereomode`, `previousstereomode`, `togglestereomode`, `stereomodetomono`, `channelup`, `channeldown`, `previouschannelgroup`, `nextchannelgroup`, `playpvr`, `playpvrtv`, `playpvrradio`, `record`, `togglecommskip`, `showtimerrule`, `leftclick`, `rightclick`, `middleclick`, `doubleclick`, `longclick`, `wheelup`, `wheeldown`, `mousedrag`, `mousemove`, `tap`, `longpress`, `pangesture`, `zoomgesture`, `rotategesture`, `swipeleft`, `swiperight`, `swipeup`, `swipedown`, `error`, `noop`. | +| inputbuttonevent | String | Send a button press event. The parameter can have the format "`