Compare commits

...

9 Commits

Author SHA1 Message Date
David Pace
3f027918e7
Merge 9a5b8f9d7c into f6efa87fb2 2025-01-09 08:51:39 +01:00
mlobstein
f6efa87fb2
[roku] Add End Time and Media Progress channels (#18059)
Signed-off-by: Michael Lobstein <michael.lobstein@gmail.com>
2025-01-09 08:12:57 +01:00
Jacob Laursen
98ff656400
Fix headers (#18070)
Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
2025-01-08 23:25:39 +01:00
Robert Michalak
adacdebb9f
[digiplex] Handle erroneous responses and restart the bridge (#18035)
Signed-off-by: Robert Michalak <rbrt.michalak@gmail.com>
2025-01-08 22:21:07 +01:00
jimtng
d36b2a8d82
[basicprofiles] Add a table-of-contents at top of README.md (#18058)
Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
2025-01-08 20:15:25 +01:00
Mark Herwege
5ac2780749
fix offline when image not available (#18066)
Signed-off-by: Mark Herwege <mark.herwege@telenet.be>
2025-01-08 20:03:21 +01:00
David Pace
9a5b8f9d7c add unit test for resolveRoomId()
Signed-off-by: David Pace <dev@davidpace.de>
2025-01-06 21:28:18 +01:00
David Pace
9aa64a2972
Apply suggestions from code review
Co-authored-by: Jacob Laursen <jacob-github@vindvejr.dk>
Signed-off-by: David Pace <dev@davidpace.de>
2025-01-06 21:01:16 +01:00
David Pace
1b8696ef34 [boschshc] Update location properties when initializing things
When a device is initialized, an attempt is made to look up the room
name for the room id specified for the device and to store the room name
in a thing property. This property is also updated if a room change is
detected.

The legacy property `Location` is removed if present. From now on the
property `location` (with proper lower case spelling) is used.

* add constants for location properties
* implement location updates in abstract device handler
* extend bridge handler to provide a cached list of rooms
* add unit tests

Signed-off-by: David Pace <dev@davidpace.de>
2024-12-12 15:36:45 +01:00
24 changed files with 696 additions and 58 deletions

View File

@ -128,4 +128,8 @@ public class BoschSHCBindingConstants {
// static device/service names
public static final String SERVICE_INTRUSION_DETECTION = "intrusionDetectionSystem";
// thing properties
public static final String PROPERTY_LOCATION_LEGACY = "Location";
public static final String PROPERTY_LOCATION = "location";
}

View File

@ -12,6 +12,10 @@
*/
package org.openhab.binding.boschshc.internal.devices;
import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.PROPERTY_LOCATION;
import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.PROPERTY_LOCATION_LEGACY;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
@ -83,9 +87,42 @@ public abstract class BoschSHCDeviceHandler extends BoschSHCHandler {
* otherwise
*/
protected boolean processDeviceInfo(Device deviceInfo) {
try {
updateLocationPropertiesIfApplicable(deviceInfo);
} catch (BoschSHCException e) {
logger.warn("Error while updating location properties for thing {}.", getThing().getUID(), e);
}
// do not cancel thing initialization if location properties cannot be updated
return true;
}
private void updateLocationPropertiesIfApplicable(Device deviceInfo) throws BoschSHCException {
Map<String, String> thingProperties = getThing().getProperties();
removeLegacyLocationPropertyIfApplicable(thingProperties);
updateLocationPropertyIfApplicable(thingProperties, deviceInfo);
}
private void updateLocationPropertyIfApplicable(Map<String, String> thingProperties, Device deviceInfo)
throws BoschSHCException {
String roomName = getBridgeHandler().resolveRoomId(deviceInfo.roomId);
if (roomName != null) {
String currentLocation = thingProperties.get(PROPERTY_LOCATION);
if (!roomName.equals(currentLocation)) {
logger.debug("Updating property '{}' of thing {} to '{}'.", PROPERTY_LOCATION, getThing().getUID(),
roomName);
updateProperty(PROPERTY_LOCATION, roomName);
}
}
}
private void removeLegacyLocationPropertyIfApplicable(Map<String, String> thingProperties) {
if (thingProperties.containsKey(PROPERTY_LOCATION_LEGACY)) {
logger.debug("Removing legacy property '{}' from thing {}.", PROPERTY_LOCATION_LEGACY, getThing().getUID());
// null value indicates that the property should be removed
updateProperty(PROPERTY_LOCATION_LEGACY, null);
}
}
/**
* Attempts to obtain information about the device with the specified ID via a REST call.
* <p>

View File

@ -17,6 +17,7 @@ import static org.eclipse.jetty.http.HttpMethod.POST;
import static org.eclipse.jetty.http.HttpMethod.PUT;
import java.lang.reflect.Type;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@ -54,6 +55,7 @@ import org.openhab.binding.boschshc.internal.exceptions.PairingFailedException;
import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
import org.openhab.binding.boschshc.internal.services.dto.JsonRestExceptionResponse;
import org.openhab.core.cache.ExpiringCache;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Channel;
@ -88,6 +90,8 @@ public class BridgeHandler extends BaseBridgeHandler {
private static final String HTTP_CLIENT_NOT_INITIALIZED = "HttpClient not initialized";
private static final Duration ROOM_CACHE_DURATION = Duration.ofMinutes(2);
private final Logger logger = LoggerFactory.getLogger(BridgeHandler.class);
/**
@ -107,13 +111,22 @@ public class BridgeHandler extends BaseBridgeHandler {
/**
* SHC thing/device discovery service instance.
* Registered and unregistered if service is actived/deactived.
* Registered and unregistered if service is activated/deactivated.
* Used to scan for things after bridge is paired with SHC.
*/
private @Nullable ThingDiscoveryService thingDiscoveryService;
private final ScenarioHandler scenarioHandler;
private ExpiringCache<List<Room>> roomCache = new ExpiringCache<>(ROOM_CACHE_DURATION, () -> {
try {
return getRooms();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
});
public BridgeHandler(Bridge bridge) {
super(bridge);
scenarioHandler = new ScenarioHandler();
@ -437,6 +450,24 @@ public class BridgeHandler extends BaseBridgeHandler {
}
}
public @Nullable List<Room> getRoomsWithCache() {
return roomCache.getValue();
}
public @Nullable String resolveRoomId(@Nullable String roomId) {
if (roomId == null) {
return null;
}
@Nullable
List<Room> rooms = getRoomsWithCache();
if (rooms != null) {
return rooms.stream().filter(r -> r.id.equals(roomId)).map(r -> r.name).findAny().orElse(null);
}
return null;
}
/**
* Get public information from Bosch SHC.
*/

View File

@ -242,7 +242,7 @@ public class ThingDiscoveryService extends AbstractThingHandlerDiscoveryService<
discoveryResult.withBridge(thingHandler.getThing().getUID());
if (!roomName.isEmpty()) {
discoveryResult.withProperty("Location", roomName);
discoveryResult.withProperty(BoschSHCBindingConstants.PROPERTY_LOCATION, roomName);
}
thingDiscovered(discoveryResult.build());

View File

@ -14,14 +14,20 @@ package org.openhab.binding.boschshc.internal.devices;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
@ -42,11 +48,34 @@ import org.openhab.core.thing.ThingStatusDetail;
public abstract class AbstractBoschSHCDeviceHandlerTest<T extends BoschSHCDeviceHandler>
extends AbstractBoschSHCHandlerTest<T> {
protected static final String TAG_LEGACY_LOCATION_PROPERTY = "LegacyLocationProperty";
protected static final String TAG_LOCATION_PROPERTY = "LocationProperty";
protected static final String DEFAULT_ROOM_ID = "hz_1";
@Override
protected void configureDevice(Device device) {
super.configureDevice(device);
device.id = getDeviceID();
device.roomId = DEFAULT_ROOM_ID;
}
@Override
protected void beforeHandlerInitialization(TestInfo testInfo) {
super.beforeHandlerInitialization(testInfo);
Set<String> tags = testInfo.getTags();
if (tags.contains(TAG_LEGACY_LOCATION_PROPERTY) || tags.contains(TAG_LOCATION_PROPERTY)) {
Map<String, String> properties = new HashMap<>();
when(getThing().getProperties()).thenReturn(properties);
if (tags.contains(TAG_LEGACY_LOCATION_PROPERTY)) {
properties.put(BoschSHCBindingConstants.PROPERTY_LOCATION_LEGACY, "Living Room");
}
if (tags.contains(TAG_LOCATION_PROPERTY)) {
when(getBridgeHandler().resolveRoomId(DEFAULT_ROOM_ID)).thenReturn("Kitchen");
}
}
}
@Override
@ -80,4 +109,44 @@ public abstract class AbstractBoschSHCDeviceHandlerTest<T extends BoschSHCDevice
argThat(status -> status.getStatus().equals(ThingStatus.OFFLINE)
&& status.getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR)));
}
@Tag(TAG_LEGACY_LOCATION_PROPERTY)
@Test
protected void deleteLegacyLocationProperty() {
verify(getThing()).setProperty(BoschSHCBindingConstants.PROPERTY_LOCATION_LEGACY, null);
verify(getCallback()).thingUpdated(getThing());
}
@Tag(TAG_LOCATION_PROPERTY)
@Test
protected void locationPropertyDidNotChange() {
verify(getThing()).setProperty(BoschSHCBindingConstants.PROPERTY_LOCATION, "Kitchen");
verify(getCallback()).thingUpdated(getThing());
getThing().getProperties().put(BoschSHCBindingConstants.PROPERTY_LOCATION, "Kitchen");
// re-initialize
getFixture().initialize();
verify(getThing()).setProperty(BoschSHCBindingConstants.PROPERTY_LOCATION, "Kitchen");
verify(getCallback()).thingUpdated(getThing());
}
@Tag(TAG_LOCATION_PROPERTY)
@Test
protected void locationPropertyDidChange() {
verify(getThing()).setProperty(BoschSHCBindingConstants.PROPERTY_LOCATION, "Kitchen");
verify(getCallback()).thingUpdated(getThing());
getThing().getProperties().put(BoschSHCBindingConstants.PROPERTY_LOCATION, "Kitchen");
getDevice().roomId = "hz_2";
when(getBridgeHandler().resolveRoomId("hz_2")).thenReturn("Dining Room");
// re-initialize
getFixture().initialize();
verify(getThing()).setProperty(BoschSHCBindingConstants.PROPERTY_LOCATION, "Dining Room");
verify(getCallback(), times(2)).thingUpdated(getThing());
}
}

View File

@ -12,6 +12,8 @@
*/
package org.openhab.binding.boschshc.internal.devices.bridge;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ -1126,4 +1128,33 @@ class BridgeHandlerTest {
verify(httpClient).createRequest(any(), same(HttpMethod.GET));
verify(httpClient).sendRequest(any(), same(PublicInformation.class), any(), isNull());
}
@Test
void resolveRoomId() throws InterruptedException, TimeoutException, ExecutionException {
Request request = mock(Request.class);
when(httpClient.createRequest(any(), eq(HttpMethod.GET))).thenReturn(request);
ContentResponse contentResponse = mock(ContentResponse.class);
when(request.send()).thenReturn(contentResponse);
when(contentResponse.getStatus()).thenReturn(200);
String roomsJson = """
[
{
"@type": "room",
"id": "hz_1",
"iconId": "icon_room_living_room",
"name": "Living Room"
},
{
"@type": "room",
"id": "hz_2",
"iconId": "icon_room_dining_room",
"name": "Dining Room"
}
]
""";
when(contentResponse.getContentAsString()).thenReturn(roomsJson);
assertThat(fixture.resolveRoomId("hz_1"), is("Living Room"));
assertThat(fixture.resolveRoomId("hz_2"), is("Dining Room"));
assertThat(fixture.resolveRoomId(null), is(nullValue()));
}
}

View File

@ -12,8 +12,11 @@
*/
package org.openhab.binding.boschshc.internal.devices.relay;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
import static org.hamcrest.collection.IsMapContaining.hasKey;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
@ -376,4 +379,55 @@ class RelayHandlerTest extends AbstractPowerSwitchHandlerTest<RelayHandler> {
verify(getCallback(), times(2)).thingUpdated(argThat(t -> ImpulseSwitchService.IMPULSE_SWITCH_SERVICE_NAME
.equals(t.getProperties().get(RelayHandler.PROPERTY_MODE))));
}
/**
* This has to be tested differently for the RelayHandler because the thing mock
* will be replaced by a real thing during the first initialization, which
* modifies the channels.
*/
@Test
@Tag(TAG_LEGACY_LOCATION_PROPERTY)
@Override
protected void deleteLegacyLocationProperty() {
ArgumentCaptor<Thing> thingCaptor = ArgumentCaptor.forClass(Thing.class);
verify(getCallback(), times(3)).thingUpdated(thingCaptor.capture());
List<Thing> allValues = thingCaptor.getAllValues();
assertThat(allValues, hasSize(3));
assertThat(allValues.get(2).getProperties(), not(hasKey(BoschSHCBindingConstants.PROPERTY_LOCATION_LEGACY)));
}
/**
* This has to be tested differently for the RelayHandler because the thing mock
* will be replaced by a real thing during the first initialization, which
* modifies the channels.
*/
@Test
@Tag(TAG_LOCATION_PROPERTY)
@Override
protected void locationPropertyDidNotChange() {
// re-initialize
getFixture().initialize();
verify(getCallback(), times(3)).thingUpdated(
argThat(t -> t.getProperties().get(BoschSHCBindingConstants.PROPERTY_LOCATION).equals("Kitchen")));
}
/**
* This has to be tested differently for the RelayHandler because the thing mock
* will be replaced by a real thing during the first initialization, which
* modifies the channels.
*/
@Test
@Tag(TAG_LOCATION_PROPERTY)
@Override
protected void locationPropertyDidChange() {
getDevice().roomId = "hz_2";
when(getBridgeHandler().resolveRoomId("hz_2")).thenReturn("Dining Room");
// re-initialize
getFixture().initialize();
verify(getCallback(), times(4)).thingUpdated(
argThat(t -> t.getProperties().get(BoschSHCBindingConstants.PROPERTY_LOCATION).equals("Dining Room")));
}
}

View File

@ -194,7 +194,8 @@ class ThingDiscoveryServiceTest {
assertThat(result.getThingUID().getId(), is("testDevice_ID"));
assertThat(result.getBridgeUID().getId(), is("testSHC"));
assertThat(result.getLabel(), is("Test Name"));
assertThat(String.valueOf(result.getProperties().get("Location")), is("TestRoom"));
assertThat(String.valueOf(result.getProperties().get(BoschSHCBindingConstants.PROPERTY_LOCATION)),
is("TestRoom"));
}
@Test

View File

@ -51,7 +51,6 @@ public class DigiplexBindingConstants {
public static final String BRIDGE_MESSAGES_SENT = "statistics#messages_sent";
public static final String BRIDGE_RESPONSES_RECEIVED = "statistics#responses_received";
public static final String BRIDGE_EVENTS_RECEIVED = "statistics#events_received";
public static final String BRIDGE_TLM_TROUBLE = "troubles#tlm_trouble";
public static final String BRIDGE_AC_FAILURE = "troubles#ac_failure";
public static final String BRIDGE_BATTERY_FAILURE = "troubles#battery_failure";

View File

@ -52,6 +52,9 @@ public interface DigiplexMessageHandler {
default void handleUnknownResponse(UnknownResponse response) {
}
default void handleErroneousResponse(ErroneousResponse response) {
}
// Events
default void handleZoneEvent(ZoneEvent event) {
}

View File

@ -13,6 +13,7 @@
package org.openhab.binding.digiplex.internal.communication;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.digiplex.internal.communication.events.AreaEvent;
import org.openhab.binding.digiplex.internal.communication.events.AreaEventType;
import org.openhab.binding.digiplex.internal.communication.events.GenericEvent;
@ -29,21 +30,19 @@ import org.openhab.binding.digiplex.internal.communication.events.ZoneStatusEven
* Resolves serial messages to appropriate classes
*
* @author Robert Michalak - Initial contribution
*
*/
@NonNullByDefault
public class DigiplexResponseResolver {
private static final String OK = "&ok";
// TODO: handle failures
private static final String FAIL = "&fail";
public static DigiplexResponse resolveResponse(String message) {
if (message.length() < 4) { // sanity check: try to filter out malformed responses
return new UnknownResponse(message);
return new ErroneousResponse(message);
}
int zoneNo, areaNo;
Integer zoneNo, areaNo;
String commandType = message.substring(0, 2);
switch (commandType) {
case "CO": // communication status
@ -53,24 +52,36 @@ public class DigiplexResponseResolver {
return CommunicationStatus.OK;
}
case "ZL": // zone label
zoneNo = Integer.valueOf(message.substring(2, 5));
zoneNo = getZoneOrArea(message);
if (zoneNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return ZoneLabelResponse.failure(zoneNo);
} else {
return ZoneLabelResponse.success(zoneNo, message.substring(5).trim());
}
case "AL": // area label
areaNo = Integer.valueOf(message.substring(2, 5));
areaNo = getZoneOrArea(message);
if (areaNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return AreaLabelResponse.failure(areaNo);
} else {
return AreaLabelResponse.success(areaNo, message.substring(5).trim());
}
case "RZ": // zone status
zoneNo = Integer.valueOf(message.substring(2, 5));
zoneNo = getZoneOrArea(message);
if (zoneNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return ZoneStatusResponse.failure(zoneNo);
} else {
if (message.length() < 10) {
return new ErroneousResponse(message);
}
return ZoneStatusResponse.success(zoneNo, // zone number
ZoneStatus.fromMessage(message.charAt(5)), // status
toBoolean(message.charAt(6)), // alarm
@ -79,10 +90,16 @@ public class DigiplexResponseResolver {
toBoolean(message.charAt(9))); // battery low
}
case "RA": // area status
areaNo = Integer.valueOf(message.substring(2, 5));
areaNo = getZoneOrArea(message);
if (areaNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return AreaStatusResponse.failure(areaNo);
} else {
if (message.length() < 12) {
return new ErroneousResponse(message);
}
return AreaStatusResponse.success(areaNo, // zone number
AreaStatus.fromMessage(message.charAt(5)), // status
toBoolean(message.charAt(6)), // zone in memory
@ -95,7 +112,10 @@ public class DigiplexResponseResolver {
case "AA": // area arm
case "AQ": // area quick arm
case "AD": // area disarm
areaNo = Integer.valueOf(message.substring(2, 5));
areaNo = getZoneOrArea(message);
if (areaNo == null) {
return new ErroneousResponse(message);
}
if (message.contains(FAIL)) {
return AreaArmDisarmResponse.failure(areaNo, ArmDisarmType.fromMessage(commandType));
} else {
@ -105,21 +125,41 @@ public class DigiplexResponseResolver {
case "PG": // PGM events
default:
if (message.startsWith("G")) {
return resolveSystemEvent(message);
if (message.length() >= 12) {
return resolveSystemEvent(message);
} else {
return new ErroneousResponse(message);
}
} else {
return new UnknownResponse(message);
}
}
}
private static @Nullable Integer getZoneOrArea(String message) {
if (message.length() < 5) {
return null;
}
try {
return Integer.valueOf(message.substring(2, 5));
} catch (NumberFormatException e) {
return null;
}
}
private static boolean toBoolean(char value) {
return value != 'O';
}
private static DigiplexResponse resolveSystemEvent(String message) {
int eventGroup = Integer.parseInt(message.substring(1, 4));
int eventNumber = Integer.parseInt(message.substring(5, 8));
int areaNumber = Integer.parseInt(message.substring(9, 12));
int eventGroup, eventNumber, areaNumber;
try {
eventGroup = Integer.parseInt(message.substring(1, 4));
eventNumber = Integer.parseInt(message.substring(5, 8));
areaNumber = Integer.parseInt(message.substring(9, 12));
} catch (NumberFormatException e) {
return new ErroneousResponse(message);
}
switch (eventGroup) {
case 0:
return new ZoneStatusEvent(eventNumber, ZoneStatus.CLOSED, areaNumber);

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2010-2025 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.digiplex.internal.communication;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Erroneous message from PRT3.
*
* Message that is invalid, which happens sometimes due to communication errors.
*
* @author Robert Michalak - Initial contribution
*
*/
@NonNullByDefault
public class ErroneousResponse implements DigiplexResponse {
public final String message;
public ErroneousResponse(String message) {
this.message = message;
}
@Override
public void accept(DigiplexMessageHandler visitor) {
visitor.handleErroneousResponse(this);
}
}

View File

@ -15,7 +15,9 @@ package org.openhab.binding.digiplex.internal.communication;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Unknown message from PRT3
* Unknown message from PRT3.
*
* Message that is otherwise valid, but not handled in this binding.
*
* @author Robert Michalak - Initial contribution
*

View File

@ -38,6 +38,7 @@ import org.openhab.binding.digiplex.internal.communication.DigiplexMessageHandle
import org.openhab.binding.digiplex.internal.communication.DigiplexRequest;
import org.openhab.binding.digiplex.internal.communication.DigiplexResponse;
import org.openhab.binding.digiplex.internal.communication.DigiplexResponseResolver;
import org.openhab.binding.digiplex.internal.communication.ErroneousResponse;
import org.openhab.binding.digiplex.internal.communication.events.AbstractEvent;
import org.openhab.binding.digiplex.internal.communication.events.TroubleEvent;
import org.openhab.binding.digiplex.internal.communication.events.TroubleStatus;
@ -295,6 +296,12 @@ public class DigiplexBridgeHandler extends BaseBridgeHandler implements SerialPo
updateState(channel, state);
}
}
@Override
public void handleErroneousResponse(ErroneousResponse response) {
logger.debug("Erroneous response: {}", response.message);
handleCommunicationError();
}
}
private class DigiplexReceiverThread extends Thread {

View File

@ -0,0 +1,221 @@
/*
* Copyright (c) 2010-2025 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.digiplex.internal.communication;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.openhab.binding.digiplex.internal.communication.events.GenericEvent;
/**
* Tests for {@link DigiplexResponseResolver}
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class DigiplexResponseResolverTest {
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsErroneousResponseWhenMessageIsMalformed")
void resolveResponseReturnsErroneousResponseWhenMessageIsMalformed(String message) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(ErroneousResponse.class)));
if (actual instanceof ErroneousResponse erroneousResponse) {
assertThat(erroneousResponse.message, is(equalTo(message)));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsErroneousResponseWhenMessageIsMalformed() {
return Stream.of( //
Arguments.of("CO&"), Arguments.of("ZL&fail"), Arguments.of("ZL12"), Arguments.of("AL&fail"),
Arguments.of("AL12"), Arguments.of("RZZZ3COOOO&fail"), Arguments.of("RZ123C"),
Arguments.of("RZ123COOO"), Arguments.of("RA&fail"), Arguments.of("RA123DOOXOO"),
Arguments.of("AA&fail"), Arguments.of("GGGGGGGGGGGG"), Arguments.of("G1234567890"));
}
@Test
void resolveResponseReturnsCommunicationStatusSuccessWhenWellformed() {
String message = "CO&ok";
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(CommunicationStatus.class)));
if (actual instanceof CommunicationStatus communicationStatus) {
assertThat(communicationStatus.success, is(true));
}
}
@Test
void resolveResponseReturnsCommunicationStatusFailureWhenMessageContainsFail() {
String message = "CO&fail";
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(CommunicationStatus.class)));
if (actual instanceof CommunicationStatus communicationStatus) {
assertThat(communicationStatus.success, is(false));
}
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsZoneLabelResponse")
void resolveResponseReturnsZoneLabelResponse(String message, boolean expectedSuccess, int expectedZoneNo,
String expectedName) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(ZoneLabelResponse.class)));
if (actual instanceof ZoneLabelResponse zoneLabelResponse) {
assertThat(zoneLabelResponse.success, is(expectedSuccess));
assertThat(zoneLabelResponse.zoneNo, is(expectedZoneNo));
assertThat(zoneLabelResponse.zoneName, is(expectedName));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsZoneLabelResponse() {
return Stream.of( //
Arguments.of("ZL123", true, 123, ""), Arguments.of("ZL123test ", true, 123, "test"),
Arguments.of("ZL123&fail", false, 123, null), Arguments.of("ZL123test&fail", false, 123, null));
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsAreaLabelResponse")
void resolveResponseReturnsAreaLabelResponse(String message, boolean expectedSuccess, int expectedAreaNo,
String expectedName) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(AreaLabelResponse.class)));
if (actual instanceof AreaLabelResponse areaLabelResponse) {
assertThat(areaLabelResponse.success, is(expectedSuccess));
assertThat(areaLabelResponse.areaNo, is(expectedAreaNo));
assertThat(areaLabelResponse.areaName, is(expectedName));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsAreaLabelResponse() {
return Stream.of( //
Arguments.of("AL123", true, 123, ""), Arguments.of("AL123test ", true, 123, "test"),
Arguments.of("AL123&fail", false, 123, null), Arguments.of("AL123test&fail", false, 123, null));
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsZoneStatusResponse")
void resolveResponseReturnsZoneStatusResponse(String message, boolean expectedSuccess, int expectedZoneNo,
ZoneStatus expectedZoneStatus, boolean expectedAlarm, boolean expectedFireAlarm,
boolean expectedSupervisionLost, boolean expectedLowBattery) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(ZoneStatusResponse.class)));
if (actual instanceof ZoneStatusResponse zoneStatusResponse) {
assertThat(zoneStatusResponse.success, is(expectedSuccess));
assertThat(zoneStatusResponse.zoneNo, is(expectedZoneNo));
assertThat(zoneStatusResponse.status, is(expectedZoneStatus));
assertThat(zoneStatusResponse.alarm, is(expectedAlarm));
assertThat(zoneStatusResponse.fireAlarm, is(expectedFireAlarm));
assertThat(zoneStatusResponse.supervisionLost, is(expectedSupervisionLost));
assertThat(zoneStatusResponse.lowBattery, is(expectedLowBattery));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsZoneStatusResponse() {
return Stream.of( //
Arguments.of("RZ123COOOO", true, 123, ZoneStatus.CLOSED, false, false, false, false),
Arguments.of("RZ123OOOOO", true, 123, ZoneStatus.OPEN, false, false, false, false),
Arguments.of("RZ123TOOOO", true, 123, ZoneStatus.TAMPERED, false, false, false, false),
Arguments.of("RZ123FOOOO", true, 123, ZoneStatus.FIRE_LOOP_TROUBLE, false, false, false, false),
Arguments.of("RZ123uOOOO", true, 123, ZoneStatus.UNKNOWN, false, false, false, false),
Arguments.of("RZ123cOOOO", true, 123, ZoneStatus.UNKNOWN, false, false, false, false),
Arguments.of("RZ123cXOOO", true, 123, ZoneStatus.UNKNOWN, true, false, false, false),
Arguments.of("RZ123cOXOO", true, 123, ZoneStatus.UNKNOWN, false, true, false, false),
Arguments.of("RZ123cOOXO", true, 123, ZoneStatus.UNKNOWN, false, false, true, false),
Arguments.of("RZ123cOOOX", true, 123, ZoneStatus.UNKNOWN, false, false, false, true),
Arguments.of("RZ123&fail", false, 123, null, false, false, false, false),
Arguments.of("RZ123COOOO&fail", false, 123, null, false, false, false, false));
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsAreaStatusResponse")
void resolveResponseReturnsAreaStatusResponse(String message, boolean expectedSuccess, int expectedAreaNo,
AreaStatus expectedAreaStatus, boolean expectedZoneInMemory, boolean expectedTrouble, boolean expectedReady,
boolean expectedInProgramming, boolean expectedAlarm, boolean expectedStrobe) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(AreaStatusResponse.class)));
if (actual instanceof AreaStatusResponse areaStatusResponse) {
assertThat(areaStatusResponse.success, is(expectedSuccess));
assertThat(areaStatusResponse.areaNo, is(expectedAreaNo));
assertThat(areaStatusResponse.status, is(expectedAreaStatus));
assertThat(areaStatusResponse.zoneInMemory, is(expectedZoneInMemory));
assertThat(areaStatusResponse.trouble, is(expectedTrouble));
assertThat(areaStatusResponse.ready, is(expectedReady));
assertThat(areaStatusResponse.inProgramming, is(expectedInProgramming));
assertThat(areaStatusResponse.alarm, is(expectedAlarm));
assertThat(areaStatusResponse.strobe, is(expectedStrobe));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsAreaStatusResponse() {
return Stream.of( //
Arguments.of("RA123DOOXOOO", true, 123, AreaStatus.DISARMED, false, false, false, false, false, false),
Arguments.of("RA123AOOXOOO", true, 123, AreaStatus.ARMED, false, false, false, false, false, false),
Arguments.of("RA123FOOXOOO", true, 123, AreaStatus.ARMED_FORCE, false, false, false, false, false,
false),
Arguments.of("RA123SOOXOOO", true, 123, AreaStatus.ARMED_STAY, false, false, false, false, false,
false),
Arguments.of("RA123IOOXOOO", true, 123, AreaStatus.ARMED_INSTANT, false, false, false, false, false,
false),
Arguments.of("RA123uOOXOOO", true, 123, AreaStatus.UNKNOWN, false, false, false, false, false, false),
Arguments.of("RA123dOOXOOO", true, 123, AreaStatus.UNKNOWN, false, false, false, false, false, false),
Arguments.of("RA123dXOXOOO", true, 123, AreaStatus.UNKNOWN, true, false, false, false, false, false),
Arguments.of("RA123dOXxOOO", true, 123, AreaStatus.UNKNOWN, false, true, false, false, false, false),
Arguments.of("RA123dOOOOOO", true, 123, AreaStatus.UNKNOWN, false, false, true, false, false, false),
Arguments.of("RA123dOOXXOO", true, 123, AreaStatus.UNKNOWN, false, false, false, true, false, false),
Arguments.of("RA123dOOXOXO", true, 123, AreaStatus.UNKNOWN, false, false, false, false, true, false),
Arguments.of("RA123dOOXOOX", true, 123, AreaStatus.UNKNOWN, false, false, false, false, false, true),
Arguments.of("RA123&fail", false, 123, null, false, false, false, false, false, false),
Arguments.of("RA123DOOXOOO&fail", false, 123, null, false, false, false, false, false, false));
}
@ParameterizedTest
@MethodSource("provideTestCasesForResolveResponseReturnsAreaArmDisarmResponse")
void resolveResponseReturnsAreaArmDisarmResponse(String message, boolean expectedSuccess, int expectedAreaNo,
ArmDisarmType expectedType) {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse(message);
assertThat(actual, is(instanceOf(AreaArmDisarmResponse.class)));
if (actual instanceof AreaArmDisarmResponse armDisarmResponse) {
assertThat(armDisarmResponse.success, is(expectedSuccess));
assertThat(armDisarmResponse.areaNo, is(expectedAreaNo));
assertThat(armDisarmResponse.type, is(expectedType));
}
}
private static Stream<Arguments> provideTestCasesForResolveResponseReturnsAreaArmDisarmResponse() {
return Stream.of( //
Arguments.of("AA123", true, 123, ArmDisarmType.ARM),
Arguments.of("AQ123", true, 123, ArmDisarmType.QUICK_ARM),
Arguments.of("AD123", true, 123, ArmDisarmType.DISARM),
Arguments.of("AA123&fail", false, 123, ArmDisarmType.ARM),
Arguments.of("AQ123&fail", false, 123, ArmDisarmType.QUICK_ARM),
Arguments.of("AD123&fail", false, 123, ArmDisarmType.DISARM));
}
@Test
void resolveResponseReturnsGenericEventWhenWellformed() {
DigiplexResponse actual = DigiplexResponseResolver.resolveResponse("G123 456 789");
assertThat(actual, is(instanceOf(GenericEvent.class)));
if (actual instanceof GenericEvent genericEvent) {
assertThat(genericEvent.getEventGroup(), is(123));
assertThat(genericEvent.getEventNumber(), is(456));
assertThat(genericEvent.getAreaNo(), is(789));
}
}
}

View File

@ -46,6 +46,8 @@ The following channels are available:
| playMode | String | The current playback mode ie: stop, play, pause (ReadOnly). |
| timeElapsed | Number:Time | The total number of seconds of playback time elapsed for the current playing title (ReadOnly). |
| timeTotal | Number:Time | The total length of the current playing title in seconds (ReadOnly). This data is not provided by all streaming apps. |
| endTime | DateTime | The date/time when the currently playing media will end (ReadOnly). N/A if timeTotal is not provided by the current streaming app. |
| progress | Dimmer | The current progress [0-100%] of playing media (ReadOnly). N/A if timeTotal is not provided by the current streaming app. |
| activeChannel | String | A dropdown containing a list of available TV channels on the Roku TV. The channel currently tuned is automatically selected. The list updates every 10 minutes. |
| signalMode | String | The signal type of the current TV channel, ie: 1080i (ReadOnly). |
| signalQuality | Number:Dimensionless | The signal quality of the current TV channel, 0-100% (ReadOnly). |
@ -59,6 +61,7 @@ The following channels are available:
Some Notes:
- The values for `activeApp`, `activeAppName`, `playMode`, `timeElapsed`, `timeTotal`, `activeChannel`, `signalMode`, `signalQuality`, `channelName`, `programTitle`, `programDescription`, `programRating`, `power` & `powerState` refresh automatically per the configured `refresh` interval.
- The `endTime` and `progress` channels may not be accurate for some streaming apps especially 'live' streams where the `timeTotal` value constantly increases.
**List of available button commands for Roku streaming devices:**
@ -113,32 +116,36 @@ roku:roku_tv:mytv1 "My Roku TV" [ hostName="192.168.10.1", refresh=10 ]
```java
// Roku streaming media player items:
String Player_ActiveApp "Current App: [%s]" { channel="roku:roku_player:myplayer1:activeApp" }
String Player_ActiveAppName "Current App Name: [%s]" { channel="roku:roku_player:myplayer1:activeAppName" }
String Player_Button "Send Command to Roku" { channel="roku:roku_player:myplayer1:button" }
Player Player_Control "Control" { channel="roku:roku_player:myplayer1:control" }
String Player_PlayMode "Status: [%s]" { channel="roku:roku_player:myplayer1:playMode" }
Number:Time Player_TimeElapsed "Elapsed Time: [%d %unit%]" { channel="roku:roku_player:myplayer1:timeElapsed" }
Number:Time Player_TimeTotal "Total Time: [%d %unit%]" { channel="roku:roku_player:myplayer1:timeTotal" }
String Player_ActiveApp "Current App: [%s]" { channel="roku:roku_player:myplayer1:activeApp" }
String Player_ActiveAppName "Current App Name: [%s]" { channel="roku:roku_player:myplayer1:activeAppName" }
String Player_Button "Send Command to Roku" { channel="roku:roku_player:myplayer1:button" }
Player Player_Control "Control" { channel="roku:roku_player:myplayer1:control" }
String Player_PlayMode "Status: [%s]" { channel="roku:roku_player:myplayer1:playMode" }
Number:Time Player_TimeElapsed "Elapsed Time: [%d %unit%]" { channel="roku:roku_player:myplayer1:timeElapsed" }
Number:Time Player_TimeTotal "Total Time: [%d %unit%]" { channel="roku:roku_player:myplayer1:timeTotal" }
DateTime Player_EndTime "End Time: [%1$tl:%1$tM %1$tp]" { channel="roku:roku_player:myplayer1:endTime" }
Dimmer Player_Progress "Progress [%.0f%%]" { channel="roku:roku_player:myplayer1:progress" }
// Roku TV items:
Switch Player_Power "Power: [%s]" { channel="roku:roku_tv:mytv1:power" }
String Player_PowerState "Power State: [%s] { channel="roku:roku_tv:mytv1:powerState" }
String Player_ActiveApp "Current App: [%s]" { channel="roku:roku_tv:mytv1:activeApp" }
String Player_ActiveAppName "Current App Name: [%s]" { channel="roku:roku_tv:mytv1:activeAppName" }
String Player_Button "Send Command to Roku" { channel="roku:roku_tv:mytv1:button" }
Player Player_Control "Control" { channel="roku:roku_tv:mytv1:control" }
String Player_PlayMode "Status: [%s]" { channel="roku:roku_tv:mytv1:playMode" }
Number:Time Player_TimeElapsed "Elapsed Time: [%d %unit%]" { channel="roku:roku_tv:mytv1:timeElapsed" }
Number:Time Player_TimeTotal "Total Time: [%d %unit%]" { channel="roku:roku_tv:mytv1:timeTotal" }
String Player_ActiveChannel "Current Channel: [%s]" { channel="roku:roku_tv:mytv1:activeChannel" }
String Player_SignalMode "Signal Mode: [%s]" { channel="roku:roku_tv:mytv1:signalMode" }
Number Player_SignalQuality "Signal Quality: [%d %%]" { channel="roku:roku_tv:mytv1:signalQuality" }
String Player_ChannelName "Channel Name: [%s]" { channel="roku:roku_tv:mytv1:channelName" }
String Player_ProgramTitle "Program Title: [%s]" { channel="roku:roku_tv:mytv1:programTitle" }
String Player_ProgramDescription "Program Description: [%s]" { channel="roku:roku_tv:mytv1:programDescription" }
String Player_ProgramRating "Program Rating: [%s]" { channel="roku:roku_tv:mytv1:programRating" }
Switch Player_Power "Power: [%s]" { channel="roku:roku_tv:mytv1:power" }
String Player_PowerState "Power State: [%s] { channel="roku:roku_tv:mytv1:powerState" }
String Player_ActiveApp "Current App: [%s]" { channel="roku:roku_tv:mytv1:activeApp" }
String Player_ActiveAppName "Current App Name: [%s]" { channel="roku:roku_tv:mytv1:activeAppName" }
String Player_Button "Send Command to Roku" { channel="roku:roku_tv:mytv1:button" }
Player Player_Control "Control" { channel="roku:roku_tv:mytv1:control" }
String Player_PlayMode "Status: [%s]" { channel="roku:roku_tv:mytv1:playMode" }
Number:Time Player_TimeElapsed "Elapsed Time: [%d %unit%]" { channel="roku:roku_tv:mytv1:timeElapsed" }
Number:Time Player_TimeTotal "Total Time: [%d %unit%]" { channel="roku:roku_tv:mytv1:timeTotal" }
DateTime Player_EndTime "End Time: [%1$tl:%1$tM %1$tp]" { channel="roku:roku_tv:mytv1:endTime" }
Dimmer Player_Progress "Progress [%.0f%%]" { channel="roku:roku_tv:mytv1:progress" }
String Player_ActiveChannel "Current Channel: [%s]" { channel="roku:roku_tv:mytv1:activeChannel" }
String Player_SignalMode "Signal Mode: [%s]" { channel="roku:roku_tv:mytv1:signalMode" }
Number Player_SignalQuality "Signal Quality: [%d %%]" { channel="roku:roku_tv:mytv1:signalQuality" }
String Player_ChannelName "Channel Name: [%s]" { channel="roku:roku_tv:mytv1:channelName" }
String Player_ProgramTitle "Program Title: [%s]" { channel="roku:roku_tv:mytv1:programTitle" }
String Player_ProgramDescription "Program Description: [%s]" { channel="roku:roku_tv:mytv1:programDescription" }
String Player_ProgramRating "Program Rating: [%s]" { channel="roku:roku_tv:mytv1:programRating" }
```
### `roku.sitemap` Example
@ -154,6 +161,8 @@ sitemap roku label="Roku" {
Text item=Player_PlayMode
Text item=Player_TimeElapsed icon="time"
Text item=Player_TimeTotal icon="time"
Text item=Player_EndTime icon="time"
Slider item=Player_Progress icon="time"
// The following items apply to Roku TVs only
Switch item=Player_Power
Text item=Player_PowerState

View File

@ -55,6 +55,8 @@ public class RokuBindingConstants {
public static final String PLAY_MODE = "playMode";
public static final String TIME_ELAPSED = "timeElapsed";
public static final String TIME_TOTAL = "timeTotal";
public static final String END_TIME = "endTime";
public static final String PROGRESS = "progress";
public static final String ACTIVE_CHANNEL = "activeChannel";
public static final String SIGNAL_MODE = "signalMode";
public static final String SIGNAL_QUALITY = "signalQuality";

View File

@ -14,6 +14,8 @@ package org.openhab.binding.roku.internal.handler;
import static org.openhab.binding.roku.internal.RokuBindingConstants.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@ -34,8 +36,10 @@ import org.openhab.binding.roku.internal.dto.DeviceInfo;
import org.openhab.binding.roku.internal.dto.Player;
import org.openhab.binding.roku.internal.dto.TvChannel;
import org.openhab.binding.roku.internal.dto.TvChannels.Channel;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.NextPreviousType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
@ -195,21 +199,32 @@ public class RokuHandler extends BaseThingHandler {
PLAY.equalsIgnoreCase(playerInfo.getState()) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
// Remove non-numeric from string, ie: ' ms'
String position = playerInfo.getPosition().replaceAll(NON_DIGIT_PATTERN, EMPTY);
if (!EMPTY.equals(position)) {
updateState(TIME_ELAPSED,
new QuantityType<>(Integer.parseInt(position) / 1000, API_SECONDS_UNIT));
final String positionStr = playerInfo.getPosition().replaceAll(NON_DIGIT_PATTERN, EMPTY);
int position = -1;
if (!EMPTY.equals(positionStr)) {
position = Integer.parseInt(positionStr) / 1000;
updateState(TIME_ELAPSED, new QuantityType<>(position, API_SECONDS_UNIT));
} else {
updateState(TIME_ELAPSED, UnDefType.UNDEF);
}
String duration = playerInfo.getDuration().replaceAll(NON_DIGIT_PATTERN, EMPTY);
if (!EMPTY.equals(duration)) {
updateState(TIME_TOTAL,
new QuantityType<>(Integer.parseInt(duration) / 1000, API_SECONDS_UNIT));
final String durationStr = playerInfo.getDuration().replaceAll(NON_DIGIT_PATTERN, EMPTY);
int duration = -1;
if (!EMPTY.equals(durationStr)) {
duration = Integer.parseInt(durationStr) / 1000;
updateState(TIME_TOTAL, new QuantityType<>(duration, API_SECONDS_UNIT));
} else {
updateState(TIME_TOTAL, UnDefType.UNDEF);
}
if (position >= 0 && duration > 0) {
updateState(END_TIME, new DateTimeType(Instant.now().plusSeconds(duration - position)));
updateState(PROGRESS,
new PercentType(BigDecimal.valueOf(Math.round(position / (double) duration * 100.0))));
} else {
updateState(END_TIME, UnDefType.UNDEF);
updateState(PROGRESS, UnDefType.UNDEF);
}
} catch (NumberFormatException e) {
logger.debug("Unable to parse playerInfo integer value. Exception: {}", e.getMessage());
} catch (RokuLimitedModeException e) {
@ -224,6 +239,8 @@ public class RokuHandler extends BaseThingHandler {
updateState(PLAY_MODE, UnDefType.UNDEF);
updateState(TIME_ELAPSED, UnDefType.UNDEF);
updateState(TIME_TOTAL, UnDefType.UNDEF);
updateState(END_TIME, UnDefType.UNDEF);
updateState(PROGRESS, UnDefType.UNDEF);
}
if (thingTypeUID.equals(THING_TYPE_ROKU_TV) && tvActive) {

View File

@ -80,6 +80,8 @@ channel-type.roku.channelName.label = Channel Name
channel-type.roku.channelName.description = The Name of the Channel Currently Selected
channel-type.roku.control.label = Control
channel-type.roku.control.description = Control playback e.g. Play/Pause/Next/Previous
channel-type.roku.endTime.label = End Time
channel-type.roku.endTime.description = The date/time when the currently playing media will end
channel-type.roku.playMode.label = Play Mode
channel-type.roku.playMode.description = The Current Playback Mode
channel-type.roku.powerState.label = Power State
@ -93,6 +95,8 @@ channel-type.roku.programRating.label = Program Rating
channel-type.roku.programRating.description = The TV Parental Guideline Rating of the Current TV Program
channel-type.roku.programTitle.label = Program Title
channel-type.roku.programTitle.description = The Name of the Current TV Program
channel-type.roku.progress.label = Media Progress
channel-type.roku.progress.description = The current progress of playing media
channel-type.roku.signalMode.label = Signal Mode
channel-type.roku.signalMode.description = The Signal Type of the Current TV Channel, ie: 1080i
channel-type.roku.signalQuality.label = Signal Quality

View File

@ -19,6 +19,8 @@
<channel id="playMode" typeId="playMode"/>
<channel id="timeElapsed" typeId="timeElapsed"/>
<channel id="timeTotal" typeId="timeTotal"/>
<channel id="endTime" typeId="endTime"/>
<channel id="progress" typeId="progress"/>
</channels>
<properties>
@ -28,7 +30,7 @@
<property name="Serial Number">unknown</property>
<property name="Device Id">unknown</property>
<property name="Software Version">unknown</property>
<property name="thingTypeVersion">1</property>
<property name="thingTypeVersion">2</property>
</properties>
<representation-property>uuid</representation-property>
@ -52,6 +54,8 @@
<channel id="playMode" typeId="playMode"/>
<channel id="timeElapsed" typeId="timeElapsed"/>
<channel id="timeTotal" typeId="timeTotal"/>
<channel id="endTime" typeId="endTime"/>
<channel id="progress" typeId="progress"/>
<channel id="activeChannel" typeId="activeChannel"/>
<channel id="signalMode" typeId="signalMode"/>
<channel id="signalQuality" typeId="signalQuality"/>
@ -69,7 +73,7 @@
<property name="Serial Number">unknown</property>
<property name="Device Id">unknown</property>
<property name="Software Version">unknown</property>
<property name="thingTypeVersion">1</property>
<property name="thingTypeVersion">2</property>
</properties>
<representation-property>uuid</representation-property>
@ -185,6 +189,24 @@
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="endTime">
<item-type>DateTime</item-type>
<label>End Time</label>
<description>The date/time when the currently playing media will end</description>
<category>Time</category>
<tags>
<tag>Status</tag>
<tag>Timestamp</tag>
</tags>
<state readOnly="true"/>
</channel-type>
<channel-type id="progress">
<item-type>Dimmer</item-type>
<label>Media Progress</label>
<description>The current progress of playing media</description>
</channel-type>
<channel-type id="activeChannel">
<item-type>String</item-type>
<label>Active Channel</label>

View File

@ -12,6 +12,15 @@
<type>roku:control</type>
</add-channel>
</instruction-set>
<instruction-set targetVersion="2">
<add-channel id="endTime">
<type>roku:endTime</type>
</add-channel>
<add-channel id="progress">
<type>roku:progress</type>
</add-channel>
</instruction-set>
</thing-type>
<thing-type uid="roku:roku_tv">
@ -29,6 +38,15 @@
<type>roku:control</type>
</add-channel>
</instruction-set>
<instruction-set targetVersion="2">
<add-channel id="endTime">
<type>roku:endTime</type>
</add-channel>
<add-channel id="progress">
<type>roku:progress</type>
</add-channel>
</instruction-set>
</thing-type>
</update:update-descriptions>

View File

@ -365,13 +365,19 @@ public class SpeedtestHandler extends BaseThingHandler {
isp = tmpCont.getIsp();
interfaceInternalIp = tmpCont.getInterface().getInternalIp();
interfaceExternalIp = tmpCont.getInterface().getExternalIp();
resultUrl = tmpCont.getResult().getUrl();
String url = String.valueOf(resultUrl) + ".png";
logger.debug("Downloading result image from: {}", url);
RawType image = HttpUtil.downloadImage(url);
if (image != null) {
resultImage = image;
if (tmpCont.getResult().isPersisted()) {
resultUrl = tmpCont.getResult().getUrl();
String url = String.valueOf(resultUrl) + ".png";
logger.debug("Downloading result image from: {}", url);
RawType image = HttpUtil.downloadImage(url);
if (image != null) {
resultImage = image;
} else {
resultImage = UnDefType.NULL;
}
} else {
logger.debug("Result image not persisted");
resultUrl = "";
resultImage = UnDefType.NULL;
}

View File

@ -263,6 +263,9 @@ public class ResultContainer {
@SerializedName("url")
@Expose
private String url;
@SerializedName("persisted")
@Expose
private boolean persisted;
public String getId() {
return id;
@ -279,6 +282,14 @@ public class ResultContainer {
public void setUrl(String url) {
this.url = url;
}
public boolean isPersisted() {
return persisted;
}
public void setPersisted(boolean persisted) {
this.persisted = persisted;
}
}
public class Server {

View File

@ -1,6 +1,18 @@
# Basic Profiles
This bundle provides a list of useful Profiles.
This bundle provides a list of useful Profiles:
| Profile | Description |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| [Generic Command Profile](#generic-command-profile) | Sends a Command towards the Item when an event is triggered |
| [Generic Toggle Switch Profile](#generic-toggle-switch-profile) | Toggles a Switch Item when an event is triggered |
| [Debounce (Counting) Profile](#debounce-counting-profile) | Counts and skip a number of State changes |
| [Debounce (Time) Profile](#debounce-time-profile) | Reduces the frequency of commands/state updates |
| [Invert / Negate Profile](#invert--negate-profile) | Inverts or negate a Command / State |
| [Round Profile](#round-profile) | Reduces the number of decimal places from input data |
| [Threshold Profile](#threshold-profile) | Translates numeric input data to `ON` or `OFF` based on a threshold value |
| [Time Range Command Profile](#time-range-command-profile) | An enhanced implementation of a follow profile which converts `OnOffType` to a `PercentType` |
| [State Filter Profile](#state-filter-profile) | Filters input data using arithmetic comparison conditions |
## Generic Command Profile