Compare commits

...

18 Commits

Author SHA1 Message Date
Gaël L'hopital
305dfc9b36
Merge 2bcfae7958 into 4e88f48a71 2025-01-09 14:46:15 +00:00
clinique
2bcfae7958 Fix headers
Signed-off-by: clinique <gael@lhopital.org>
2025-01-09 15:44:24 +01:00
clinique
111312abc7 Apply spotless
Signed-off-by: clinique <gael@lhopital.org>
2025-01-09 15:31:36 +01:00
gael@lhopital.org
e32eda1c08 Rebased
Some more code refactoring

Signed-off-by: Gaël L'hopital <gael@lhopital.org>
Signed-off-by: gael@lhopital.org <gael@lhopital.org>
2025-01-09 15:31:35 +01:00
gael@lhopital.org
da807f9633 Upgrading to 2025
Signed-off-by: gael@lhopital.org <gael@lhopital.org>
2025-01-09 15:31:33 +01:00
gael@lhopital.org
a3d3feec7f Reviewing connector logic
Signed-off-by: gael@lhopital.org <gael@lhopital.org>
2025-01-09 15:31:31 +01:00
clinique
7dd809b8ab Trying again to clean everything
Signed-off-by: clinique <gael@lhopital.org>
2025-01-09 15:31:30 +01:00
clinique
0959932148 Solving log errors
Signed-off-by: clinique <gael@lhopital.org>
2025-01-09 15:31:28 +01:00
clinique
3320dd39fc Apply spotless
Signed-off-by: clinique <gael@lhopital.org>
2025-01-09 15:31:27 +01:00
clinique
e12876ba11 Review the whole binding
Signed-off-by: clinique <gael@lhopital.org>
2025-01-09 15:31:25 +01:00
clinique
ed2a6d5700 Debugging
Signed-off-by: clinique <gael@lhopital.org>
2025-01-09 15:31:24 +01:00
Gaël L'hopital
c399be7a43 Some more code refactoring
Signed-off-by: Gaël L'hopital <gael@lhopital.org>
2025-01-09 15:31:22 +01:00
clinique
e4b432bb6b Ensure ressources are freed.
A bit of code revamp

Signed-off-by: clinique <gael@lhopital.org>
2025-01-09 15:31:20 +01:00
lsiepel
4e88f48a71
[tacmi] Fix SAT errors (#18046)
Signed-off-by: Leo Siepel <leosiepel@gmail.com>
2025-01-09 12:07:50 +01:00
Jacob Laursen
e69c44b85e
Disable another unstable test (#18069)
Related to #12474

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
2025-01-09 12:07:01 +01:00
Jacob Laursen
46d27b6fb5
Disable another unstable test (#18068)
Related to #12667

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
2025-01-09 12:06:30 +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
22 changed files with 565 additions and 395 deletions

View File

@ -1,4 +1,4 @@
/** /*
* Copyright (c) 2010-2025 Contributors to the openHAB project * Copyright (c) 2010-2025 Contributors to the openHAB project
* *
* See the NOTICE file(s) distributed with this work for additional * See the NOTICE file(s) distributed with this work for additional

View File

@ -1,4 +1,4 @@
/** /*
* Copyright (c) 2010-2025 Contributors to the openHAB project * Copyright (c) 2010-2025 Contributors to the openHAB project
* *
* See the NOTICE file(s) distributed with this work for additional * See the NOTICE file(s) distributed with this work for additional

View File

@ -58,8 +58,7 @@ public class Ipx800Actions implements ThingActions {
public void resetCounter( public void resetCounter(
@ActionInput(name = "counter", label = "Counter", required = true, description = "Id of the counter", type = "java.lang.Integer") Integer counter) { @ActionInput(name = "counter", label = "Counter", required = true, description = "Id of the counter", type = "java.lang.Integer") Integer counter) {
logger.debug("IPX800 action 'resetCounter' called"); logger.debug("IPX800 action 'resetCounter' called");
Ipx800v3Handler theHandler = this.handler; if (handler instanceof Ipx800v3Handler theHandler) {
if (theHandler != null) {
theHandler.resetCounter(counter); theHandler.resetCounter(counter);
} else { } else {
logger.warn("Method call resetCounter failed because IPX800 action service ThingHandler is null!"); logger.warn("Method call resetCounter failed because IPX800 action service ThingHandler is null!");
@ -70,8 +69,7 @@ public class Ipx800Actions implements ThingActions {
public void reset( public void reset(
@ActionInput(name = "placeholder", label = "Placeholder", required = false, description = "This parameter is not used", type = "java.lang.Integer") @Nullable Integer placeholder) { @ActionInput(name = "placeholder", label = "Placeholder", required = false, description = "This parameter is not used", type = "java.lang.Integer") @Nullable Integer placeholder) {
logger.debug("IPX800 action 'reset' called"); logger.debug("IPX800 action 'reset' called");
Ipx800v3Handler theHandler = this.handler; if (handler instanceof Ipx800v3Handler theHandler) {
if (theHandler != null) {
theHandler.reset(); theHandler.reset();
} else { } else {
logger.warn("Method call reset failed because IPX800 action service ThingHandler is null!"); logger.warn("Method call reset failed because IPX800 action service ThingHandler is null!");

View File

@ -18,13 +18,18 @@ import java.io.InputStreamReader;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.net.Socket; import java.net.Socket;
import java.net.SocketTimeoutException; import java.net.SocketTimeoutException;
import java.util.Optional; import java.net.UnknownHostException;
import java.util.Random;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.gce.internal.model.M2MMessageParser; import org.openhab.binding.gce.internal.model.M2MMessageParser;
import org.openhab.binding.gce.internal.model.PortDefinition;
import org.openhab.binding.gce.internal.model.StatusFile;
import org.openhab.binding.gce.internal.model.StatusFileAccessor;
import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.ThingUID;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.xml.sax.SAXException;
/** /**
* The {@link Ipx800DeviceConnector} is responsible for connecting, * The {@link Ipx800DeviceConnector} is responsible for connecting,
@ -35,156 +40,161 @@ import org.slf4j.LoggerFactory;
*/ */
@NonNullByDefault @NonNullByDefault
public class Ipx800DeviceConnector extends Thread { public class Ipx800DeviceConnector extends Thread {
private static final int DEFAULT_SOCKET_TIMEOUT_MS = 5000; private static final int DEFAULT_SOCKET_TIMEOUT_MS = 10000;
private static final int DEFAULT_RECONNECT_TIMEOUT_MS = 5000;
private static final int MAX_KEEPALIVE_FAILURE = 3; private static final int MAX_KEEPALIVE_FAILURE = 3;
private static final String ENDL = "\r\n";
private final Logger logger = LoggerFactory.getLogger(Ipx800DeviceConnector.class); private final Logger logger = LoggerFactory.getLogger(Ipx800DeviceConnector.class);
private final Random randomizer = new Random();
private final String hostname; private final M2MMessageParser parser;
private final int portNumber; private final StatusFileAccessor statusAccessor;
private final Ipx800EventListener listener;
private Optional<M2MMessageParser> messageParser = Optional.empty(); private final Socket socket;
private Optional<Socket> socket = Optional.empty(); private final BufferedReader input;
private Optional<BufferedReader> input = Optional.empty(); private final PrintWriter output;
private Optional<PrintWriter> output = Optional.empty();
private int failedKeepalive = 0; private int failedKeepalive = 0;
private boolean waitingKeepaliveResponse = false; private boolean waitingKeepaliveResponse = false;
private boolean interrupted = false;
public Ipx800DeviceConnector(String hostname, int portNumber, ThingUID uid) { public Ipx800DeviceConnector(String hostname, int portNumber, ThingUID uid, Ipx800EventListener listener)
throws UnknownHostException, IOException {
super("OH-binding-" + uid); super("OH-binding-" + uid);
this.hostname = hostname; this.listener = listener;
this.portNumber = portNumber;
setDaemon(true);
}
public synchronized void send(String message) {
output.ifPresentOrElse(out -> {
logger.debug("Sending '{}' to Ipx800", message);
out.write(message + ENDL);
out.flush();
}, () -> logger.warn("Trying to send '{}' while the output stream is closed.", message));
}
/**
* Connect to the ipx800
*
* @throws IOException
*/
private void connect() throws IOException {
disconnect();
logger.debug("Connecting to {}:{}...", hostname, portNumber); logger.debug("Connecting to {}:{}...", hostname, portNumber);
Socket socket = new Socket(hostname, portNumber); Socket socket = new Socket(hostname, portNumber);
socket.setSoTimeout(DEFAULT_SOCKET_TIMEOUT_MS); socket.setSoTimeout(DEFAULT_SOCKET_TIMEOUT_MS);
socket.getInputStream().skip(socket.getInputStream().available()); this.socket = socket;
this.socket = Optional.of(socket);
input = Optional.of(new BufferedReader(new InputStreamReader(socket.getInputStream()))); output = new PrintWriter(socket.getOutputStream(), true);
output = Optional.of(new PrintWriter(socket.getOutputStream(), true)); input = new BufferedReader(new InputStreamReader(socket.getInputStream()));
parser = new M2MMessageParser(listener);
statusAccessor = new StatusFileAccessor(hostname);
setDaemon(true);
} }
/** /**
* Disconnect the device *
* Stop the
* device thread
*/ */
private void disconnect() {
logger.debug("Disconnecting");
input.ifPresent(in -> {
try {
in.close();
} catch (IOException ignore) {
}
input = Optional.empty();
});
output.ifPresent(PrintWriter::close);
output = Optional.empty();
socket.ifPresent(client -> {
try {
client.close();
} catch (IOException ignore) {
}
socket = Optional.empty();
});
logger.debug("Disconnected");
}
/**
* Stop the device thread
*/
public void dispose() { public void dispose() {
interrupt(); interrupted = true;
disconnect(); }
public synchronized void send(String message) {
logger.debug("Sending '{}' to Ipx800", message);
output.println(message);
} }
/** /**
* Send an arbitrary keepalive command which cause the IPX to send an update. *
* If we don't receive the update maxKeepAliveFailure time, the connection is closed and reopened * Send a
* random keepalive
* command which
* cause the
* IPX to
* send an update.*
* If we don't
* receive the
* update maxKeepAliveFailure time,
* the connection
* is closed
*/ */
private void sendKeepalive() { private void sendKeepalive() {
output.ifPresent(out -> { PortDefinition pd = PortDefinition.values()[randomizer.nextInt(PortDefinition.AS_SET.size())];
if (waitingKeepaliveResponse) { String command = "%s%d".formatted(pd.m2mCommand, randomizer.nextInt(pd.quantity) + 1);
failedKeepalive++;
logger.debug("Sending keepalive, attempt {}", failedKeepalive); if (waitingKeepaliveResponse) {
} else { failedKeepalive++;
failedKeepalive = 0; logger.debug("Sending keepalive {}, attempt {}", command, failedKeepalive);
logger.debug("Sending keepalive"); } else {
} failedKeepalive = 0;
out.println("GetIn01"); logger.debug("Sending keepalive {}", command);
out.flush(); }
waitingKeepaliveResponse = true;
}); output.println(command);
parser.setExpectedResponse(command);
waitingKeepaliveResponse = true;
} }
@Override @Override
public void run() { public void run() {
try { while (!interrupted) {
waitingKeepaliveResponse = false; if (failedKeepalive > MAX_KEEPALIVE_FAILURE) {
failedKeepalive = 0; interrupted = true;
connect(); listener.errorOccurred(new IOException("Max keep alive attempts has been reached"));
while (!interrupted()) {
if (failedKeepalive > MAX_KEEPALIVE_FAILURE) {
throw new IOException("Max keep alive attempts has been reached");
}
input.ifPresent(in -> {
try {
String command = in.readLine();
waitingKeepaliveResponse = false;
messageParser.ifPresent(parser -> parser.unsolicitedUpdate(command));
} catch (IOException e) {
handleException(e);
}
});
} }
disconnect(); try {
} catch (IOException e) { String command = input.readLine();
handleException(e); waitingKeepaliveResponse = false;
} parser.unsolicitedUpdate(command);
try { } catch (SocketTimeoutException e) {
Thread.sleep(DEFAULT_RECONNECT_TIMEOUT_MS);
} catch (InterruptedException e) {
dispose();
}
}
private void handleException(Exception e) {
if (!interrupted()) {
if (e instanceof SocketTimeoutException) {
sendKeepalive(); sendKeepalive();
return; } catch (IOException e) {
} else if (e instanceof IOException) { interrupted = true;
logger.warn("Communication error: '{}'. Will retry in {} ms", e, DEFAULT_RECONNECT_TIMEOUT_MS); listener.errorOccurred(e);
}
}
if (output instanceof PrintWriter out) {
out.close();
}
if (input instanceof BufferedReader in) {
try {
in.close();
} catch (IOException e) {
logger.warn("Exception input stream: {}", e.getMessage());
}
}
if (socket instanceof Socket client) {
try {
logger.debug("Closing socket");
client.close();
} catch (IOException e) {
logger.warn("Exception closing socket: {}", e.getMessage());
} }
messageParser.ifPresent(parser -> parser.errorOccurred(e));
} }
} }
public void setParser(M2MMessageParser parser) { public StatusFile readStatusFile() throws SAXException, IOException {
this.messageParser = Optional.of(parser); return statusAccessor.read();
}
/**
*
* Set output
* of the
* device sending
* the corresponding command**
*
* @param targetPort
* @param targetValue
*/
public void setOutput(String targetPort, int targetValue, boolean pulse) {
logger.debug("Sending {} to {}", targetValue, targetPort);
String command = "Set%02d%s%s".formatted(Integer.parseInt(targetPort), targetValue, pulse ? "p" : "");
send(command);
}
/**
*
* Resets the
* counter value to 0**
*
* @param targetCounter
*/
public void resetCounter(int targetCounter) {
logger.debug("Resetting counter {} to 0", targetCounter);
send("ResetCount%d".formatted(targetCounter));
}
public void resetPLC() {
send("Reset");
} }
} }

View File

@ -14,30 +14,29 @@ package org.openhab.binding.gce.internal.handler;
import static org.openhab.binding.gce.internal.GCEBindingConstants.*; import static org.openhab.binding.gce.internal.GCEBindingConstants.*;
import java.io.IOException;
import java.net.UnknownHostException;
import java.time.Duration; import java.time.Duration;
import java.time.ZoneId; import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.gce.internal.action.Ipx800Actions; import org.openhab.binding.gce.internal.action.Ipx800Actions;
import org.openhab.binding.gce.internal.config.AnalogInputConfiguration; import org.openhab.binding.gce.internal.config.AnalogInputConfiguration;
import org.openhab.binding.gce.internal.config.DigitalInputConfiguration; import org.openhab.binding.gce.internal.config.DigitalInputConfiguration;
import org.openhab.binding.gce.internal.config.Ipx800Configuration; import org.openhab.binding.gce.internal.config.Ipx800Configuration;
import org.openhab.binding.gce.internal.config.RelayOutputConfiguration; import org.openhab.binding.gce.internal.config.RelayOutputConfiguration;
import org.openhab.binding.gce.internal.model.M2MMessageParser;
import org.openhab.binding.gce.internal.model.PortData; import org.openhab.binding.gce.internal.model.PortData;
import org.openhab.binding.gce.internal.model.PortDefinition; import org.openhab.binding.gce.internal.model.PortDefinition;
import org.openhab.binding.gce.internal.model.StatusFileInterpreter; import org.openhab.binding.gce.internal.model.StatusFile;
import org.openhab.binding.gce.internal.model.StatusFileInterpreter.StatusEntry;
import org.openhab.core.config.core.Configuration; import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.DecimalType;
@ -58,9 +57,9 @@ import org.openhab.core.thing.type.ChannelKind;
import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.Command; import org.openhab.core.types.Command;
import org.openhab.core.types.State; import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.xml.sax.SAXException;
/** /**
* The {@link Ipx800v3Handler} is responsible for handling commands, which are * The {@link Ipx800v3Handler} is responsible for handling commands, which are
@ -74,37 +73,13 @@ public class Ipx800v3Handler extends BaseThingHandler implements Ipx800EventList
private static final double ANALOG_SAMPLING = 0.000050354; private static final double ANALOG_SAMPLING = 0.000050354;
private final Logger logger = LoggerFactory.getLogger(Ipx800v3Handler.class); private final Logger logger = LoggerFactory.getLogger(Ipx800v3Handler.class);
private final Map<ChannelUID, PortData> portDatas = new HashMap<>();
private Optional<Ipx800DeviceConnector> connector = Optional.empty(); private @Nullable Ipx800DeviceConnector deviceConnector;
private Optional<M2MMessageParser> parser = Optional.empty(); private List<ScheduledFuture<?>> jobs = new ArrayList<>();
private Optional<ScheduledFuture<?>> refreshJob = Optional.empty();
private final Map<String, PortData> portDatas = new HashMap<>();
private class LongPressEvaluator implements Runnable {
private final ZonedDateTime referenceTime;
private final String port;
private final String eventChannelId;
public LongPressEvaluator(Channel channel, String port, PortData portData) {
this.referenceTime = portData.getTimestamp();
this.port = port;
this.eventChannelId = channel.getUID().getId() + PROPERTY_SEPARATOR + TRIGGER_CONTACT;
}
@Override
public void run() {
PortData currentData = portDatas.get(port);
if (currentData != null && currentData.getValue() == 1
&& referenceTime.equals(currentData.getTimestamp())) {
triggerChannel(eventChannelId, EVENT_LONG_PRESS);
}
}
}
public Ipx800v3Handler(Thing thing) { public Ipx800v3Handler(Thing thing) {
super(thing); super(thing);
logger.debug("Create an IPX800 Handler for thing '{}'", getThing().getUID());
} }
@Override @Override
@ -112,47 +87,76 @@ public class Ipx800v3Handler extends BaseThingHandler implements Ipx800EventList
logger.debug("Initializing IPX800 handler for uid '{}'", getThing().getUID()); logger.debug("Initializing IPX800 handler for uid '{}'", getThing().getUID());
Ipx800Configuration config = getConfigAs(Ipx800Configuration.class); Ipx800Configuration config = getConfigAs(Ipx800Configuration.class);
StatusFileInterpreter statusFile = new StatusFileInterpreter(config.hostname, this);
if (thing.getProperties().isEmpty()) { try {
updateProperties(Map.of(Thing.PROPERTY_VENDOR, "GCE Electronics", Thing.PROPERTY_FIRMWARE_VERSION, deviceConnector = new Ipx800DeviceConnector(config.hostname, config.portNumber, getThing().getUID(), this);
statusFile.getElement(StatusEntry.VERSION), Thing.PROPERTY_MAC_ADDRESS, updateStatus(ThingStatus.UNKNOWN);
statusFile.getElement(StatusEntry.CONFIG_MAC))); jobs.add(scheduler.scheduleWithFixedDelay(this::readStatusFile, 1500, config.pullInterval,
TimeUnit.MILLISECONDS));
} catch (UnknownHostException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
} catch (IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
} }
}
private void readStatusFile() {
if (deviceConnector instanceof Ipx800DeviceConnector connector) {
StatusFile status = null;
try {
status = connector.readStatusFile();
} catch (SAXException | IOException e) {
logger.warn("Unable to read status file for {}", thing.getUID());
}
if (Thread.State.NEW.equals(connector.getState())) {
setProperties(status);
updateChannels(status);
connector.start();
}
if (status instanceof StatusFile statusFile) {
PortDefinition.AS_SET.forEach(portDefinition -> statusFile.getPorts(portDefinition).forEach(
(portNum, value) -> dataReceived("%s%d".formatted(portDefinition.portName, portNum), value)));
}
}
}
private void updateChannels(@Nullable StatusFile status) {
List<Channel> channels = new ArrayList<>(getThing().getChannels()); List<Channel> channels = new ArrayList<>(getThing().getChannels());
PortDefinition.asStream().forEach(portDefinition -> { PortDefinition.AS_SET.forEach(portDefinition -> {
int nbElements = statusFile.getMaxNumberofNodeType(portDefinition); int nbElements = status != null ? status.getPorts(portDefinition).size() : portDefinition.quantity;
for (int i = 0; i < nbElements; i++) { for (int i = 0; i < nbElements; i++) {
ChannelUID portChannelUID = createChannels(portDefinition, i, channels); ChannelUID portChannelUID = createChannels(portDefinition, i, channels);
portDatas.put(portChannelUID.getId(), new PortData()); portDatas.put(portChannelUID, new PortData());
} }
}); });
updateThing(editThing().withChannels(channels).build()); updateThing(editThing().withChannels(channels).build());
}
connector = Optional.of(new Ipx800DeviceConnector(config.hostname, config.portNumber, getThing().getUID())); private void setProperties(@Nullable StatusFile status) {
parser = Optional.of(new M2MMessageParser(connector.get(), this)); Map<String, String> properties = new HashMap<>(thing.getProperties());
properties.put(Thing.PROPERTY_VENDOR, "GCE Electronics");
updateStatus(ThingStatus.UNKNOWN); if (status != null) {
properties.put(Thing.PROPERTY_FIRMWARE_VERSION, status.getVersion());
refreshJob = Optional.of( properties.put(Thing.PROPERTY_MAC_ADDRESS, status.getMac());
scheduler.scheduleWithFixedDelay(statusFile::read, 3000, config.pullInterval, TimeUnit.MILLISECONDS)); }
updateProperties(properties);
connector.get().start();
} }
@Override @Override
public void dispose() { public void dispose() {
refreshJob.ifPresent(job -> job.cancel(true)); jobs.forEach(job -> job.cancel(true));
refreshJob = Optional.empty(); jobs.clear();
connector.ifPresent(Ipx800DeviceConnector::dispose); if (deviceConnector instanceof Ipx800DeviceConnector connector) {
connector = Optional.empty(); connector.dispose();
deviceConnector = null;
parser = Optional.empty(); }
portDatas.values().stream().forEach(PortData::dispose); portDatas.values().stream().forEach(PortData::dispose);
portDatas.clear();
super.dispose(); super.dispose();
} }
@ -171,29 +175,25 @@ public class Ipx800v3Handler extends BaseThingHandler implements Ipx800EventList
ChannelUID mainChannelUID = new ChannelUID(groupUID, ndx); ChannelUID mainChannelUID = new ChannelUID(groupUID, ndx);
ChannelTypeUID channelType = new ChannelTypeUID(BINDING_ID, advancedChannelTypeName); ChannelTypeUID channelType = new ChannelTypeUID(BINDING_ID, advancedChannelTypeName);
switch (portDefinition) { switch (portDefinition) {
case ANALOG: case ANALOG -> {
addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.NUMBER) addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.NUMBER)
.withLabel("Analog Input " + ndx).withType(channelType), channels); .withLabel("Analog Input " + ndx).withType(channelType), channels);
addIfChannelAbsent( addIfChannelAbsent(
ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-voltage"), "Number:ElectricPotential") ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-voltage"), "Number:ElectricPotential")
.withType(new ChannelTypeUID(BINDING_ID, CHANNEL_VOLTAGE)).withLabel("Voltage " + ndx), .withType(new ChannelTypeUID(BINDING_ID, CHANNEL_VOLTAGE)).withLabel("Voltage " + ndx),
channels); channels);
break; }
case CONTACT: case CONTACT -> {
addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.CONTACT) addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.CONTACT)
.withLabel("Contact " + ndx).withType(channelType), channels); .withLabel("Contact " + ndx).withType(channelType), channels);
addIfChannelAbsent(ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-event"), null) addIfChannelAbsent(ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-event"), null)
.withType(new ChannelTypeUID(BINDING_ID, TRIGGER_CONTACT + (portIndex < 8 ? "" : "Advanced"))) .withType(new ChannelTypeUID(BINDING_ID, TRIGGER_CONTACT + (portIndex < 8 ? "" : "Advanced")))
.withLabel("Contact " + ndx + " Event").withKind(ChannelKind.TRIGGER), channels); .withLabel("Contact " + ndx + " Event").withKind(ChannelKind.TRIGGER), channels);
break; }
case COUNTER: case COUNTER -> addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.NUMBER)
addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.NUMBER) .withLabel("Counter " + ndx).withType(channelType), channels);
.withLabel("Counter " + ndx).withType(channelType), channels); case RELAY -> addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.SWITCH)
break; .withLabel("Relay " + ndx).withType(channelType), channels);
case RELAY:
addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.SWITCH)
.withLabel("Relay " + ndx).withType(channelType), channels);
break;
} }
addIfChannelAbsent(ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-duration"), "Number:Time") addIfChannelAbsent(ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-duration"), "Number:Time")
@ -209,8 +209,8 @@ public class Ipx800v3Handler extends BaseThingHandler implements Ipx800EventList
} }
private boolean ignoreCondition(double newValue, PortData portData, Configuration configuration, private boolean ignoreCondition(double newValue, PortData portData, Configuration configuration,
PortDefinition portDefinition, ZonedDateTime now) { PortDefinition portDefinition, Instant now) {
if (!portData.isInitializing()) { // Always accept if portData is not initialized if (portData.isInitialized()) { // Always accept if portData is not initialized
double prevValue = portData.getValue(); double prevValue = portData.getValue();
if (newValue == prevValue) { // Always reject if the value did not change if (newValue == prevValue) { // Always reject if the value did not change
return true; return true;
@ -231,68 +231,62 @@ public class Ipx800v3Handler extends BaseThingHandler implements Ipx800EventList
@Override @Override
public void dataReceived(String port, double value) { public void dataReceived(String port, double value) {
updateStatus(ThingStatus.ONLINE); updateStatus(ThingStatus.ONLINE);
Channel channel = thing.getChannel(PortDefinition.asChannelId(port)); if (thing.getChannel(PortDefinition.asChannelId(port)) instanceof Channel channel) {
if (channel != null) { ChannelUID channelUID = channel.getUID();
String channelId = channel.getUID().getId(); String channelId = channelUID.getId();
String groupId = channel.getUID().getGroupId();
PortData portData = portDatas.get(channelId); if (portDatas.get(channelUID) instanceof PortData portData
if (portData != null && groupId != null) { && channelUID.getGroupId() instanceof String groupId) {
ZonedDateTime now = ZonedDateTime.now(ZoneId.systemDefault()); Instant now = Instant.now();
long sinceLastChange = Duration.between(portData.getTimestamp(), now).toMillis();
Configuration configuration = channel.getConfiguration(); Configuration configuration = channel.getConfiguration();
PortDefinition portDefinition = PortDefinition.fromGroupId(groupId); PortDefinition portDefinition = PortDefinition.fromGroupId(groupId);
if (ignoreCondition(value, portData, configuration, portDefinition, now)) { if (ignoreCondition(value, portData, configuration, portDefinition, now)) {
logger.debug("Ignore condition met for port '{}' with data '{}'", port, value); logger.trace("Ignore condition met for port '{}' with data '{}'", port, value);
return; return;
} }
logger.debug("About to update port '{}' with data '{}'", port, value); logger.debug("About to update port '{}' with data '{}'", port, value);
State state = UnDefType.NULL; long sinceLastChange = Duration.between(portData.getTimestamp(), now).toMillis();
switch (portDefinition) { State state = switch (portDefinition) {
case COUNTER: case COUNTER -> new DecimalType(value);
state = new DecimalType(value); case RELAY -> OnOffType.from(value == 1);
break; case ANALOG -> {
case RELAY:
state = OnOffType.from(value == 1);
break;
case ANALOG:
state = new DecimalType(value);
updateIfLinked(channelId + PROPERTY_SEPARATOR + CHANNEL_VOLTAGE, updateIfLinked(channelId + PROPERTY_SEPARATOR + CHANNEL_VOLTAGE,
new QuantityType<>(value * ANALOG_SAMPLING, Units.VOLT)); new QuantityType<>(value * ANALOG_SAMPLING, Units.VOLT));
break; yield new DecimalType(value);
case CONTACT: }
DigitalInputConfiguration config = configuration.as(DigitalInputConfiguration.class); case CONTACT -> {
portData.cancelPulsing(); portData.cancelPulsing();
state = value == 1 ? OpenClosedType.CLOSED : OpenClosedType.OPEN; DigitalInputConfiguration config = configuration.as(DigitalInputConfiguration.class);
switch ((OpenClosedType) state) {
case CLOSED: if (value == 1) { // CLOSED
if (config.longPressTime != 0 && !portData.isInitializing()) { if (config.longPressTime != 0 && portData.isInitialized()) {
scheduler.schedule(new LongPressEvaluator(channel, port, portData), jobs.add(scheduler.schedule(() -> {
config.longPressTime, TimeUnit.MILLISECONDS); if (portData.getValue() == 1 && now.equals(portData.getTimestamp())) {
} else if (config.pulsePeriod != 0) { String eventChannelId = "%s-%s".formatted(channelUID.getId(), TRIGGER_CONTACT);
portData.setPulsing(scheduler.scheduleWithFixedDelay(() -> { triggerChannel(eventChannelId, EVENT_LONG_PRESS);
triggerPushButtonChannel(channel, EVENT_PULSE);
}, config.pulsePeriod, config.pulsePeriod, TimeUnit.MILLISECONDS));
if (config.pulseTimeout != 0) {
scheduler.schedule(portData::cancelPulsing, config.pulseTimeout,
TimeUnit.MILLISECONDS);
} }
}, config.longPressTime, TimeUnit.MILLISECONDS));
} else if (config.pulsePeriod != 0) {
portData.setPulsing(scheduler.scheduleWithFixedDelay(() -> {
triggerPushButtonChannel(channel, EVENT_PULSE);
}, config.pulsePeriod, config.pulsePeriod, TimeUnit.MILLISECONDS));
if (config.pulseTimeout != 0) {
portData.setPulseCanceler(scheduler.schedule(portData::cancelPulsing,
config.pulseTimeout, TimeUnit.MILLISECONDS));
} }
break; }
case OPEN: } else if (portData.isInitialized() && sinceLastChange < config.longPressTime) {
if (!portData.isInitializing() && config.longPressTime != 0 triggerPushButtonChannel(channel, EVENT_SHORT_PRESS);
&& sinceLastChange < config.longPressTime) {
triggerPushButtonChannel(channel, EVENT_SHORT_PRESS);
}
break;
} }
if (!portData.isInitializing()) { if (portData.isInitialized()) {
triggerPushButtonChannel(channel, value == 1 ? EVENT_PRESSED : EVENT_RELEASED); triggerPushButtonChannel(channel, value == 1 ? EVENT_PRESSED : EVENT_RELEASED);
} }
break; yield value == 1 ? OpenClosedType.CLOSED : OpenClosedType.OPEN;
} }
};
updateIfLinked(channelId, state); updateIfLinked(channelId, state);
if (!portData.isInitializing()) { if (portData.isInitialized()) {
updateIfLinked(channelId + PROPERTY_SEPARATOR + CHANNEL_LAST_STATE_DURATION, updateIfLinked(channelId + PROPERTY_SEPARATOR + CHANNEL_LAST_STATE_DURATION,
new QuantityType<>(sinceLastChange / 1000, Units.SECOND)); new QuantityType<>(sinceLastChange / 1000, Units.SECOND));
} }
@ -320,20 +314,18 @@ public class Ipx800v3Handler extends BaseThingHandler implements Ipx800EventList
public void handleCommand(ChannelUID channelUID, Command command) { public void handleCommand(ChannelUID channelUID, Command command) {
logger.debug("Received channel: {}, command: {}", channelUID, command); logger.debug("Received channel: {}, command: {}", channelUID, command);
Channel channel = thing.getChannel(channelUID.getId()); if (thing.getChannel(channelUID.getId()) instanceof Channel channel
String groupId = channelUID.getGroupId(); && channelUID.getGroupId() instanceof String groupId //
&& command instanceof OnOffType onOffCommand //
if (channel == null || groupId == null) { && isValidPortId(channelUID) //
return; && PortDefinition.RELAY.equals(PortDefinition.fromGroupId(groupId))
} && deviceConnector instanceof Ipx800DeviceConnector connector) {
if (command instanceof OnOffType onOffCommand && isValidPortId(channelUID)
&& PortDefinition.fromGroupId(groupId) == PortDefinition.RELAY) {
RelayOutputConfiguration config = channel.getConfiguration().as(RelayOutputConfiguration.class); RelayOutputConfiguration config = channel.getConfiguration().as(RelayOutputConfiguration.class);
String id = channelUID.getIdWithoutGroup(); connector.setOutput(channelUID.getIdWithoutGroup(), OnOffType.ON.equals(onOffCommand) ? 1 : 0,
parser.ifPresent(p -> p.setOutput(id, onOffCommand == OnOffType.ON ? 1 : 0, config.pulse)); config.pulse);
return; } else {
logger.debug("Can not handle command '{}' on channel '{}'", command, channelUID);
} }
logger.debug("Can not handle command '{}' on channel '{}'", command, channelUID);
} }
private boolean isValidPortId(ChannelUID channelUID) { private boolean isValidPortId(ChannelUID channelUID) {
@ -341,11 +333,15 @@ public class Ipx800v3Handler extends BaseThingHandler implements Ipx800EventList
} }
public void resetCounter(int counter) { public void resetCounter(int counter) {
parser.ifPresent(p -> p.resetCounter(counter)); if (deviceConnector instanceof Ipx800DeviceConnector connector) {
connector.resetCounter(counter);
}
} }
public void reset() { public void reset() {
parser.ifPresent(M2MMessageParser::resetPLC); if (deviceConnector instanceof Ipx800DeviceConnector connector) {
connector.resetPLC();
}
} }
@Override @Override

View File

@ -15,7 +15,6 @@ package org.openhab.binding.gce.internal.model;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.gce.internal.handler.Ipx800DeviceConnector;
import org.openhab.binding.gce.internal.handler.Ipx800EventListener; import org.openhab.binding.gce.internal.handler.Ipx800EventListener;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -33,23 +32,19 @@ public class M2MMessageParser {
.compile("I=" + IO_DESCRIPTOR + "&O=" + IO_DESCRIPTOR + "&([AC]\\d{1,2}=\\d+&)*[^I]*"); .compile("I=" + IO_DESCRIPTOR + "&O=" + IO_DESCRIPTOR + "&([AC]\\d{1,2}=\\d+&)*[^I]*");
private final Logger logger = LoggerFactory.getLogger(M2MMessageParser.class); private final Logger logger = LoggerFactory.getLogger(M2MMessageParser.class);
private final Ipx800DeviceConnector connector;
private final Ipx800EventListener listener; private final Ipx800EventListener listener;
private String expectedResponse = ""; private String expectedResponse = "";
public M2MMessageParser(Ipx800DeviceConnector connector, Ipx800EventListener listener) { public M2MMessageParser(Ipx800EventListener listener) {
this.connector = connector;
this.listener = listener; this.listener = listener;
connector.setParser(this);
} }
/**
*
* @param data
*/
public void unsolicitedUpdate(String data) { public void unsolicitedUpdate(String data) {
if (IO_PATTERN.matcher(data).matches()) { if ("OK".equals(data)) { // If OK, do nothing special
} else if ("? Bad command".equals(data)) {
logger.warn("{}", data);
} else if (IO_PATTERN.matcher(data).matches()) {
PortDefinition portDefinition = PortDefinition.fromM2MCommand(expectedResponse); PortDefinition portDefinition = PortDefinition.fromM2MCommand(expectedResponse);
decodeDataLine(portDefinition, data); decodeDataLine(portDefinition, data);
} else if (VALIDATION_PATTERN.matcher(data).matches()) { } else if (VALIDATION_PATTERN.matcher(data).matches()) {
@ -67,65 +62,36 @@ public class M2MMessageParser {
portNumShift = 0; // Align counters on 1 based array portNumShift = 0; // Align counters on 1 based array
case ANALOG: { case ANALOG: {
int portNumber = Integer.parseInt(statusPart[0].substring(1)) + portNumShift; int portNumber = Integer.parseInt(statusPart[0].substring(1)) + portNumShift;
setStatus(portDefinition.getPortName() + portNumber, Double.parseDouble(statusPart[1])); setStatus(portDefinition.portName + portNumber, Double.parseDouble(statusPart[1]));
} }
} }
} }
} else if (!expectedResponse.isEmpty()) { } else if (!expectedResponse.isEmpty()) {
setStatus(expectedResponse, Double.parseDouble(data)); setStatus(expectedResponse, Double.parseDouble(data));
} else {
logger.warn("Unable to handle data received: {}", data);
} }
expectedResponse = ""; expectedResponse = "";
} }
private void decodeDataLine(PortDefinition portDefinition, String data) { private void decodeDataLine(PortDefinition portDefinition, String data) {
for (int count = 0; count < data.length(); count++) { for (int count = 0; count < data.length(); count++) {
setStatus(portDefinition.getPortName() + (count + 1), (double) data.charAt(count) - '0'); setStatus(portDefinition.portName + (count + 1), (double) data.charAt(count) - '0');
} }
} }
private void setStatus(String port, double value) { private void setStatus(String port, double value) {
logger.debug("Received {} : {}", port, value); logger.debug("Received {} on port {}", value, port);
listener.dataReceived(port, value); listener.dataReceived(port, value);
} }
public void setExpectedResponse(String expectedResponse) { public void setExpectedResponse(String expectedResponse) {
if (expectedResponse.endsWith("s")) { // GetInputs or GetOutputs if (expectedResponse.endsWith("s")) { // GetInputs or GetOutputs
this.expectedResponse = expectedResponse; this.expectedResponse = expectedResponse;
} else { // GetAnx or GetCountx return;
PortDefinition portType = PortDefinition.fromM2MCommand(expectedResponse);
this.expectedResponse = expectedResponse.replaceAll(portType.getM2mCommand(), portType.getPortName());
} }
} // GetAnx or GetCountx
PortDefinition portType = PortDefinition.fromM2MCommand(expectedResponse);
/** this.expectedResponse = expectedResponse.replaceAll(portType.m2mCommand, portType.portName);
* Set output of the device sending the corresponding command
*
* @param targetPort
* @param targetValue
*/
public void setOutput(String targetPort, int targetValue, boolean pulse) {
logger.debug("Sending {} to {}", targetValue, targetPort);
String command = String.format("Set%02d%s%s", Integer.parseInt(targetPort), targetValue, pulse ? "p" : "");
connector.send(command);
}
/**
* Resets the counter value to 0
*
* @param targetCounter
*/
public void resetCounter(int targetCounter) {
logger.debug("Resetting counter {} to 0", targetCounter);
connector.send(String.format("ResetCount%d", targetCounter));
}
public void errorOccurred(Exception e) {
logger.warn("Error received from connector : {}", e.getMessage());
listener.errorOccurred(e);
}
public void resetPLC() {
connector.send("Reset");
} }
} }

View File

@ -12,11 +12,11 @@
*/ */
package org.openhab.binding.gce.internal.model; package org.openhab.binding.gce.internal.model;
import java.time.ZonedDateTime; import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledFuture;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/** /**
* The {@link PortData} is responsible for holding data regarding current status of a port. * The {@link PortData} is responsible for holding data regarding current status of a port.
@ -26,19 +26,31 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
@NonNullByDefault @NonNullByDefault
public class PortData { public class PortData {
private double value = -1; private double value = -1;
private ZonedDateTime timestamp = ZonedDateTime.now(); private Instant timestamp = Instant.now();
private Optional<ScheduledFuture<?>> pulsing = Optional.empty(); private @Nullable ScheduledFuture<?> pulsing;
private @Nullable ScheduledFuture<?> pulseCanceler;
public void cancelPulsing() { public void cancelPulsing() {
pulsing.ifPresent(pulse -> pulse.cancel(true)); if (pulsing instanceof ScheduledFuture job) {
pulsing = Optional.empty(); job.cancel(true);
pulsing = null;
}
cancelCanceler();
}
public void cancelCanceler() {
if (pulseCanceler instanceof ScheduledFuture job) {
job.cancel(true);
pulseCanceler = null;
}
} }
public void dispose() { public void dispose() {
cancelPulsing(); cancelPulsing();
cancelCanceler();
} }
public void setData(double value, ZonedDateTime timestamp) { public void setData(double value, Instant timestamp) {
this.value = value; this.value = value;
this.timestamp = timestamp; this.timestamp = timestamp;
} }
@ -47,15 +59,20 @@ public class PortData {
return value; return value;
} }
public ZonedDateTime getTimestamp() { public Instant getTimestamp() {
return timestamp; return timestamp;
} }
public void setPulsing(ScheduledFuture<?> pulsing) { public void setPulsing(ScheduledFuture<?> pulsing) {
this.pulsing = Optional.of(pulsing); cancelPulsing();
this.pulsing = pulsing;
} }
public boolean isInitializing() { public boolean isInitialized() {
return value == -1; return value != -1;
}
public void setPulseCanceler(ScheduledFuture<?> pulseCanceler) {
this.pulseCanceler = pulseCanceler;
} }
} }

View File

@ -12,7 +12,7 @@
*/ */
package org.openhab.binding.gce.internal.model; package org.openhab.binding.gce.internal.model;
import java.util.stream.Stream; import java.util.EnumSet;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
@ -29,25 +29,19 @@ public enum PortDefinition {
RELAY("led", "O", "GetOut", 8), RELAY("led", "O", "GetOut", 8),
CONTACT("btn", "I", "GetIn", 8); CONTACT("btn", "I", "GetIn", 8);
private final String nodeName; // Name used in the status xml file public final String nodeName; // Name used in the status xml file
private final String portName; // Name used by the M2M protocol public final String portName; // Name used by the M2M protocol
private final String m2mCommand; // associated M2M command public final String m2mCommand; // associated M2M command
private final int quantity; // base number of ports public final int quantity; // base number of ports
PortDefinition(String nodeName, String portName, String m2mCommand, int quantity) { private PortDefinition(String nodeName, String portName, String m2mCommand, int quantity) {
this.nodeName = nodeName; this.nodeName = nodeName;
this.portName = portName; this.portName = portName;
this.m2mCommand = m2mCommand; this.m2mCommand = m2mCommand;
this.quantity = quantity; this.quantity = quantity;
} }
public String getNodeName() { public static final EnumSet<PortDefinition> AS_SET = EnumSet.allOf(PortDefinition.class);
return nodeName;
}
public String getPortName() {
return portName;
}
@Override @Override
public String toString() { public String toString() {
@ -58,20 +52,12 @@ public enum PortDefinition {
return id >= quantity; return id >= quantity;
} }
public String getM2mCommand() {
return m2mCommand;
}
public static Stream<PortDefinition> asStream() {
return Stream.of(PortDefinition.values());
}
public static PortDefinition fromM2MCommand(String m2mCommand) { public static PortDefinition fromM2MCommand(String m2mCommand) {
return asStream().filter(v -> m2mCommand.startsWith(v.m2mCommand)).findFirst().get(); return AS_SET.stream().filter(v -> m2mCommand.startsWith(v.m2mCommand)).findFirst().get();
} }
public static PortDefinition fromPortName(String portName) { public static PortDefinition fromPortName(String portName) {
return asStream().filter(v -> portName.startsWith(v.portName)).findFirst().get(); return AS_SET.stream().filter(v -> portName.startsWith(v.portName)).findFirst().get();
} }
public static PortDefinition fromGroupId(String groupId) { public static PortDefinition fromGroupId(String groupId) {
@ -80,7 +66,7 @@ public enum PortDefinition {
public static String asChannelId(String portDefinition) { public static String asChannelId(String portDefinition) {
String portKind = portDefinition.substring(0, 1); String portKind = portDefinition.substring(0, 1);
PortDefinition result = asStream().filter(v -> v.portName.startsWith(portKind)).findFirst().get(); PortDefinition result = AS_SET.stream().filter(v -> v.portName.equals(portKind)).findFirst().get();
return result.toString() + "#" + portDefinition.substring(1); return "%s#%s".formatted(result.toString(), portDefinition.substring(1));
} }
} }

View File

@ -0,0 +1,67 @@
/*
* 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.gce.internal.model;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.IntStream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
/**
* This class takes care of interpreting the status.xml file
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class StatusFile {
private final Logger logger = LoggerFactory.getLogger(StatusFile.class);
private final Element root;
private final NodeList childs;
public StatusFile(Document doc) {
this.root = doc.getDocumentElement();
root.normalize();
this.childs = root.getChildNodes();
}
public String getMac() {
return root.getElementsByTagName("config_mac").item(0).getTextContent();
}
public String getVersion() {
return root.getElementsByTagName("version").item(0).getTextContent();
}
public Map<Integer, Double> getPorts(PortDefinition portDefinition) {
Map<Integer, Double> result = new HashMap<>();
String searched = portDefinition.nodeName;
IntStream.range(0, childs.getLength()).boxed().map(childs::item)
.filter(node -> node.getNodeName().startsWith(searched)).forEach(node -> {
try {
result.put(Integer.parseInt(node.getNodeName().replace(searched, "")) + 1,
Double.parseDouble(node.getTextContent().replace("dn", "1").replace("up", "0")));
} catch (NumberFormatException e) {
logger.warn("{}", e.getMessage());
}
});
return result;
}
}

View File

@ -0,0 +1,55 @@
/*
* 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.gce.internal.model;
import java.io.IOException;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.xml.sax.SAXException;
/**
* This class takes care of providing the IPX status file
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class StatusFileAccessor {
private static final String URL_TEMPLATE = "http://%s/globalstatus.xml";
private final DocumentBuilder builder;
private final String url;
public StatusFileAccessor(String hostname) {
this.url = URL_TEMPLATE.formatted(hostname);
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
// see https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
try {
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
builder = factory.newDocumentBuilder();
} catch (ParserConfigurationException e) {
throw new IllegalArgumentException("Error initializing StatusFileAccessor", e);
}
}
public StatusFile read() throws SAXException, IOException {
return new StatusFile(builder.parse(url));
}
}

View File

@ -19,9 +19,9 @@ import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import javax.ws.rs.HttpMethod;
import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.ParserConfigurationException;
@ -78,7 +78,7 @@ public class StatusFileInterpreter {
public void read() { public void read() {
try { try {
String statusPage = HttpUtil.executeUrl("GET", url, 5000); String statusPage = HttpUtil.executeUrl(HttpMethod.GET, url, 5000);
InputStream inputStream = new ByteArrayInputStream(statusPage.getBytes()); InputStream inputStream = new ByteArrayInputStream(statusPage.getBytes());
Document document = builder.parse(inputStream); Document document = builder.parse(inputStream);
document.getDocumentElement().normalize(); document.getDocumentElement().normalize();
@ -92,13 +92,13 @@ public class StatusFileInterpreter {
private void pushDatas() { private void pushDatas() {
getRoot().ifPresent(root -> { getRoot().ifPresent(root -> {
PortDefinition.asStream().forEach(portDefinition -> { PortDefinition.AS_SET.forEach(portDefinition -> {
List<Node> xmlNodes = getMatchingNodes(root.getChildNodes(), portDefinition.getNodeName()); List<Node> xmlNodes = getMatchingNodes(root.getChildNodes(), portDefinition.nodeName);
xmlNodes.forEach(xmlNode -> { xmlNodes.forEach(xmlNode -> {
String sPortNum = xmlNode.getNodeName().replace(portDefinition.getNodeName(), ""); String sPortNum = xmlNode.getNodeName().replace(portDefinition.nodeName, "");
int portNum = Integer.parseInt(sPortNum) + 1; int portNum = Integer.parseInt(sPortNum) + 1;
double value = Double.parseDouble(xmlNode.getTextContent().replace("dn", "1").replace("up", "0")); double value = Double.parseDouble(xmlNode.getTextContent().replace("dn", "1").replace("up", "0"));
listener.dataReceived(String.format("%s%d", portDefinition.getPortName(), portNum), value); listener.dataReceived("%s%d".formatted(portDefinition.portName, portNum), value);
}); });
}); });
}); });
@ -113,12 +113,12 @@ public class StatusFileInterpreter {
private List<Node> getMatchingNodes(NodeList nodeList, String criteria) { private List<Node> getMatchingNodes(NodeList nodeList, String criteria) {
return IntStream.range(0, nodeList.getLength()).boxed().map(nodeList::item) return IntStream.range(0, nodeList.getLength()).boxed().map(nodeList::item)
.filter(node -> node.getNodeName().startsWith(criteria)).sorted(Comparator.comparing(Node::getNodeName)) .filter(node -> node.getNodeName().startsWith(criteria)).sorted(Comparator.comparing(Node::getNodeName))
.collect(Collectors.toList()); .toList();
} }
public int getMaxNumberofNodeType(PortDefinition portDefinition) { public int getMaxNumberofNodeType(PortDefinition portDefinition) {
return getRoot().map(root -> getMatchingNodes(root.getChildNodes(), portDefinition.getNodeName()).size()) return Objects.requireNonNull(getRoot()
.orElse(0); .map(root -> getMatchingNodes(root.getChildNodes(), portDefinition.nodeName).size()).orElse(0));
} }
private Optional<Element> getRoot() { private Optional<Element> getRoot() {

View File

@ -46,6 +46,8 @@ The following channels are available:
| playMode | String | The current playback mode ie: stop, play, pause (ReadOnly). | | 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). | | 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. | | 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. | | 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). | | 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). | | 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: 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 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:** **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 ```java
// Roku streaming media player items: // Roku streaming media player items:
String Player_ActiveApp "Current App: [%s]" { channel="roku:roku_player:myplayer1:activeApp" } 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_ActiveAppName "Current App Name: [%s]" { channel="roku:roku_player:myplayer1:activeAppName" }
String Player_Button "Send Command to Roku" { channel="roku:roku_player:myplayer1:button" } String Player_Button "Send Command to Roku" { channel="roku:roku_player:myplayer1:button" }
Player Player_Control "Control" { channel="roku:roku_player:myplayer1:control" } Player Player_Control "Control" { channel="roku:roku_player:myplayer1:control" }
String Player_PlayMode "Status: [%s]" { channel="roku:roku_player:myplayer1:playMode" } 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_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" } 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: // Roku TV items:
Switch Player_Power "Power: [%s]" { channel="roku:roku_tv:mytv1:power" } 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_PowerState "Power State: [%s] { channel="roku:roku_tv:mytv1:powerState" }
String Player_ActiveApp "Current App: [%s]" { channel="roku:roku_tv:mytv1:activeApp" } 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_ActiveAppName "Current App Name: [%s]" { channel="roku:roku_tv:mytv1:activeAppName" }
String Player_Button "Send Command to Roku" { channel="roku:roku_tv:mytv1:button" } String Player_Button "Send Command to Roku" { channel="roku:roku_tv:mytv1:button" }
Player Player_Control "Control" { channel="roku:roku_tv:mytv1:control" } Player Player_Control "Control" { channel="roku:roku_tv:mytv1:control" }
String Player_PlayMode "Status: [%s]" { channel="roku:roku_tv:mytv1:playMode" } 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_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" } 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" } DateTime Player_EndTime "End Time: [%1$tl:%1$tM %1$tp]" { channel="roku:roku_tv:mytv1:endTime" }
String Player_SignalMode "Signal Mode: [%s]" { channel="roku:roku_tv:mytv1:signalMode" } Dimmer Player_Progress "Progress [%.0f%%]" { channel="roku:roku_tv:mytv1:progress" }
Number Player_SignalQuality "Signal Quality: [%d %%]" { channel="roku:roku_tv:mytv1:signalQuality" } String Player_ActiveChannel "Current Channel: [%s]" { channel="roku:roku_tv:mytv1:activeChannel" }
String Player_ChannelName "Channel Name: [%s]" { channel="roku:roku_tv:mytv1:channelName" } String Player_SignalMode "Signal Mode: [%s]" { channel="roku:roku_tv:mytv1:signalMode" }
String Player_ProgramTitle "Program Title: [%s]" { channel="roku:roku_tv:mytv1:programTitle" } Number Player_SignalQuality "Signal Quality: [%d %%]" { channel="roku:roku_tv:mytv1:signalQuality" }
String Player_ProgramDescription "Program Description: [%s]" { channel="roku:roku_tv:mytv1:programDescription" } String Player_ChannelName "Channel Name: [%s]" { channel="roku:roku_tv:mytv1:channelName" }
String Player_ProgramRating "Program Rating: [%s]" { channel="roku:roku_tv:mytv1:programRating" } 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 ### `roku.sitemap` Example
@ -154,6 +161,8 @@ sitemap roku label="Roku" {
Text item=Player_PlayMode Text item=Player_PlayMode
Text item=Player_TimeElapsed icon="time" Text item=Player_TimeElapsed icon="time"
Text item=Player_TimeTotal 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 // The following items apply to Roku TVs only
Switch item=Player_Power Switch item=Player_Power
Text item=Player_PowerState Text item=Player_PowerState

View File

@ -55,6 +55,8 @@ public class RokuBindingConstants {
public static final String PLAY_MODE = "playMode"; public static final String PLAY_MODE = "playMode";
public static final String TIME_ELAPSED = "timeElapsed"; public static final String TIME_ELAPSED = "timeElapsed";
public static final String TIME_TOTAL = "timeTotal"; 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 ACTIVE_CHANNEL = "activeChannel";
public static final String SIGNAL_MODE = "signalMode"; public static final String SIGNAL_MODE = "signalMode";
public static final String SIGNAL_QUALITY = "signalQuality"; 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 static org.openhab.binding.roku.internal.RokuBindingConstants.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; 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.Player;
import org.openhab.binding.roku.internal.dto.TvChannel; import org.openhab.binding.roku.internal.dto.TvChannel;
import org.openhab.binding.roku.internal.dto.TvChannels.Channel; 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.NextPreviousType;
import org.openhab.core.library.types.OnOffType; 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.PlayPauseType;
import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType; import org.openhab.core.library.types.StringType;
@ -195,21 +199,32 @@ public class RokuHandler extends BaseThingHandler {
PLAY.equalsIgnoreCase(playerInfo.getState()) ? PlayPauseType.PLAY : PlayPauseType.PAUSE); PLAY.equalsIgnoreCase(playerInfo.getState()) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
// Remove non-numeric from string, ie: ' ms' // Remove non-numeric from string, ie: ' ms'
String position = playerInfo.getPosition().replaceAll(NON_DIGIT_PATTERN, EMPTY); final String positionStr = playerInfo.getPosition().replaceAll(NON_DIGIT_PATTERN, EMPTY);
if (!EMPTY.equals(position)) { int position = -1;
updateState(TIME_ELAPSED, if (!EMPTY.equals(positionStr)) {
new QuantityType<>(Integer.parseInt(position) / 1000, API_SECONDS_UNIT)); position = Integer.parseInt(positionStr) / 1000;
updateState(TIME_ELAPSED, new QuantityType<>(position, API_SECONDS_UNIT));
} else { } else {
updateState(TIME_ELAPSED, UnDefType.UNDEF); updateState(TIME_ELAPSED, UnDefType.UNDEF);
} }
String duration = playerInfo.getDuration().replaceAll(NON_DIGIT_PATTERN, EMPTY); final String durationStr = playerInfo.getDuration().replaceAll(NON_DIGIT_PATTERN, EMPTY);
if (!EMPTY.equals(duration)) { int duration = -1;
updateState(TIME_TOTAL, if (!EMPTY.equals(durationStr)) {
new QuantityType<>(Integer.parseInt(duration) / 1000, API_SECONDS_UNIT)); duration = Integer.parseInt(durationStr) / 1000;
updateState(TIME_TOTAL, new QuantityType<>(duration, API_SECONDS_UNIT));
} else { } else {
updateState(TIME_TOTAL, UnDefType.UNDEF); 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) { } catch (NumberFormatException e) {
logger.debug("Unable to parse playerInfo integer value. Exception: {}", e.getMessage()); logger.debug("Unable to parse playerInfo integer value. Exception: {}", e.getMessage());
} catch (RokuLimitedModeException e) { } catch (RokuLimitedModeException e) {
@ -224,6 +239,8 @@ public class RokuHandler extends BaseThingHandler {
updateState(PLAY_MODE, UnDefType.UNDEF); updateState(PLAY_MODE, UnDefType.UNDEF);
updateState(TIME_ELAPSED, UnDefType.UNDEF); updateState(TIME_ELAPSED, UnDefType.UNDEF);
updateState(TIME_TOTAL, UnDefType.UNDEF); updateState(TIME_TOTAL, UnDefType.UNDEF);
updateState(END_TIME, UnDefType.UNDEF);
updateState(PROGRESS, UnDefType.UNDEF);
} }
if (thingTypeUID.equals(THING_TYPE_ROKU_TV) && tvActive) { 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.channelName.description = The Name of the Channel Currently Selected
channel-type.roku.control.label = Control channel-type.roku.control.label = Control
channel-type.roku.control.description = Control playback e.g. Play/Pause/Next/Previous 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.label = Play Mode
channel-type.roku.playMode.description = The Current Playback Mode channel-type.roku.playMode.description = The Current Playback Mode
channel-type.roku.powerState.label = Power State 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.programRating.description = The TV Parental Guideline Rating of the Current TV Program
channel-type.roku.programTitle.label = Program Title channel-type.roku.programTitle.label = Program Title
channel-type.roku.programTitle.description = The Name of the Current TV Program 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.label = Signal Mode
channel-type.roku.signalMode.description = The Signal Type of the Current TV Channel, ie: 1080i channel-type.roku.signalMode.description = The Signal Type of the Current TV Channel, ie: 1080i
channel-type.roku.signalQuality.label = Signal Quality channel-type.roku.signalQuality.label = Signal Quality

View File

@ -19,6 +19,8 @@
<channel id="playMode" typeId="playMode"/> <channel id="playMode" typeId="playMode"/>
<channel id="timeElapsed" typeId="timeElapsed"/> <channel id="timeElapsed" typeId="timeElapsed"/>
<channel id="timeTotal" typeId="timeTotal"/> <channel id="timeTotal" typeId="timeTotal"/>
<channel id="endTime" typeId="endTime"/>
<channel id="progress" typeId="progress"/>
</channels> </channels>
<properties> <properties>
@ -28,7 +30,7 @@
<property name="Serial Number">unknown</property> <property name="Serial Number">unknown</property>
<property name="Device Id">unknown</property> <property name="Device Id">unknown</property>
<property name="Software Version">unknown</property> <property name="Software Version">unknown</property>
<property name="thingTypeVersion">1</property> <property name="thingTypeVersion">2</property>
</properties> </properties>
<representation-property>uuid</representation-property> <representation-property>uuid</representation-property>
@ -52,6 +54,8 @@
<channel id="playMode" typeId="playMode"/> <channel id="playMode" typeId="playMode"/>
<channel id="timeElapsed" typeId="timeElapsed"/> <channel id="timeElapsed" typeId="timeElapsed"/>
<channel id="timeTotal" typeId="timeTotal"/> <channel id="timeTotal" typeId="timeTotal"/>
<channel id="endTime" typeId="endTime"/>
<channel id="progress" typeId="progress"/>
<channel id="activeChannel" typeId="activeChannel"/> <channel id="activeChannel" typeId="activeChannel"/>
<channel id="signalMode" typeId="signalMode"/> <channel id="signalMode" typeId="signalMode"/>
<channel id="signalQuality" typeId="signalQuality"/> <channel id="signalQuality" typeId="signalQuality"/>
@ -69,7 +73,7 @@
<property name="Serial Number">unknown</property> <property name="Serial Number">unknown</property>
<property name="Device Id">unknown</property> <property name="Device Id">unknown</property>
<property name="Software Version">unknown</property> <property name="Software Version">unknown</property>
<property name="thingTypeVersion">1</property> <property name="thingTypeVersion">2</property>
</properties> </properties>
<representation-property>uuid</representation-property> <representation-property>uuid</representation-property>
@ -185,6 +189,24 @@
<state readOnly="true" pattern="%d %unit%"/> <state readOnly="true" pattern="%d %unit%"/>
</channel-type> </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"> <channel-type id="activeChannel">
<item-type>String</item-type> <item-type>String</item-type>
<label>Active Channel</label> <label>Active Channel</label>

View File

@ -12,6 +12,15 @@
<type>roku:control</type> <type>roku:control</type>
</add-channel> </add-channel>
</instruction-set> </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>
<thing-type uid="roku:roku_tv"> <thing-type uid="roku:roku_tv">
@ -29,6 +38,15 @@
<type>roku:control</type> <type>roku:control</type>
</add-channel> </add-channel>
</instruction-set> </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>
</update:update-descriptions> </update:update-descriptions>

View File

@ -185,6 +185,7 @@ public class ApiPageParser extends AbstractSimpleMarkupHandler {
} else if ("durchsichtig".equals(classFlag)) { // link } else if ("durchsichtig".equals(classFlag)) { // link
this.fieldType = FieldType.IGNORE; this.fieldType = FieldType.IGNORE;
} else if ("bord".equals(classFlag)) { // special button style - not of our interest... } else if ("bord".equals(classFlag)) { // special button style - not of our interest...
continue;
} else { } else {
logger.debug("Unhanndled class in {}:{}:{}: '{}' ", id, line, col, classFlag); logger.debug("Unhanndled class in {}:{}:{}: '{}' ", id, line, col, classFlag);
} }
@ -192,7 +193,7 @@ public class ApiPageParser extends AbstractSimpleMarkupHandler {
} }
} else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
&& "span".equals(elementName)) { && "span".equals(elementName)) {
// ignored... return; // ignored...
} else { } else {
logger.debug("Unexpected OpenElement in {}:{}: {} [{}]", line, col, elementName, attributes); logger.debug("Unexpected OpenElement in {}:{}: {} [{}]", line, col, elementName, attributes);
} }
@ -245,14 +246,14 @@ public class ApiPageParser extends AbstractSimpleMarkupHandler {
getApiPageEntry(id, line, col, shortName, description, this.buttonValue); getApiPageEntry(id, line, col, shortName, description, this.buttonValue);
} }
} else if (this.fieldType == FieldType.IGNORE) { } else if (this.fieldType == FieldType.IGNORE) {
// ignore return; // ignore
} else { } else {
logger.debug("Unhandled setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, sb); logger.debug("Unhandled setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, sb);
} }
} }
} else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
&& "span".equals(elementName)) { && "span".equals(elementName)) {
// ignored... return;// ignored...
} else { } else {
logger.debug("Unexpected CloseElement in {}:{}: {}", line, col, elementName); logger.debug("Unexpected CloseElement in {}:{}: {}", line, col, elementName);
} }
@ -307,7 +308,7 @@ public class ApiPageParser extends AbstractSimpleMarkupHandler {
} }
} else if (this.parserState == ParserState.INIT && ((len == 1 && buffer[offset] == '\n') } else if (this.parserState == ParserState.INIT && ((len == 1 && buffer[offset] == '\n')
|| (len == 2 && buffer[offset] == '\r' && buffer[offset + 1] == '\n'))) { || (len == 2 && buffer[offset] == '\r' && buffer[offset + 1] == '\n'))) {
// single newline - ignore/drop it... return; // single newline - ignore/drop it...
} else { } else {
String msg = new String(buffer, offset, len).replace("\n", "\\n").replace("\r", "\\r"); String msg = new String(buffer, offset, len).replace("\n", "\\n").replace("\r", "\\r");
logger.debug("Unexpected Text {}:{}: ParserState: {} ({}) `{}`", line, col, parserState, len, msg); logger.debug("Unexpected Text {}:{}: ParserState: {} ({}) `{}`", line, col, parserState, len, msg);
@ -400,9 +401,9 @@ public class ApiPageParser extends AbstractSimpleMarkupHandler {
// failed to get unit... // failed to get unit...
if ("Imp".equals(unitStr) || "€$".contains(unitStr)) { if ("Imp".equals(unitStr) || "€$".contains(unitStr)) {
// special case // special case
unitData = taCmiSchemaHandler.SPECIAL_MARKER; unitData = TACmiSchemaHandler.SPECIAL_MARKER;
} else { } else {
unitData = taCmiSchemaHandler.NULL_MARKER; unitData = TACmiSchemaHandler.NULL_MARKER;
logger.warn( logger.warn(
"Unhandled UoM '{}' - seen on channel {} '{}'; Message from QuantityType: {}", "Unhandled UoM '{}' - seen on channel {} '{}'; Message from QuantityType: {}",
valParts[1], shortName, description, iae.getMessage()); valParts[1], shortName, description, iae.getMessage());
@ -410,12 +411,12 @@ public class ApiPageParser extends AbstractSimpleMarkupHandler {
} }
taCmiSchemaHandler.unitsCache.put(unitStr, unitData); taCmiSchemaHandler.unitsCache.put(unitStr, unitData);
} }
if (unitData == taCmiSchemaHandler.NULL_MARKER) { if (unitData == TACmiSchemaHandler.NULL_MARKER) {
// no UoM mappable - just send value // no UoM mappable - just send value
channelType = "Number"; channelType = "Number";
unit = null; unit = null;
state = new DecimalType(bd); state = new DecimalType(bd);
} else if (unitData == taCmiSchemaHandler.SPECIAL_MARKER) { } else if (unitData == TACmiSchemaHandler.SPECIAL_MARKER) {
// special handling for unknown UoM // special handling for unknown UoM
if ("Imp".equals(unitStr)) { // Number of Pulses if ("Imp".equals(unitStr)) { // Number of Pulses
// impulses - no idea how to map this to something useful here? // impulses - no idea how to map this to something useful here?

View File

@ -102,7 +102,7 @@ public class ChangerX2Parser extends AbstractSimpleMarkupHandler {
this.optionFieldName = attributes == null ? null : attributes.get("name"); this.optionFieldName = attributes == null ? null : attributes.get("name");
} else if ((this.parserState == ParserState.INIT || this.parserState == ParserState.INPUT) } else if ((this.parserState == ParserState.INIT || this.parserState == ParserState.INPUT)
&& "br".equals(elementName)) { && "br".equals(elementName)) {
// ignored return; // ignored
} else if ((this.parserState == ParserState.INIT || this.parserState == ParserState.INPUT) } else if ((this.parserState == ParserState.INIT || this.parserState == ParserState.INPUT)
&& "input".equals(elementName) && "changeto".equals(id)) { && "input".equals(elementName) && "changeto".equals(id)) {
this.parserState = ParserState.INPUT_DATA; this.parserState = ParserState.INPUT_DATA;
@ -171,7 +171,6 @@ public class ChangerX2Parser extends AbstractSimpleMarkupHandler {
} }
this.options.put(ChangerX2Entry.TIME_PERIOD_PARTS, timeParts); this.options.put(ChangerX2Entry.TIME_PERIOD_PARTS, timeParts);
} else { } else {
logger.warn("Error parsing options for {}: Unhandled input field in {}:{}: {}", channelName, line, logger.warn("Error parsing options for {}: Unhandled input field in {}:{}: {}", channelName, line,
col, attributes); col, attributes);
} }
@ -218,7 +217,7 @@ public class ChangerX2Parser extends AbstractSimpleMarkupHandler {
} }
} }
} else if (this.parserState == ParserState.INPUT && "span".equals(elementName)) { } else if (this.parserState == ParserState.INPUT && "span".equals(elementName)) {
// span's are ignored... return; // span's are ignored...
} else { } else {
logger.debug("Error parsing options for {}: Unexpected CloseElement in {}:{}: {}", channelName, line, col, logger.debug("Error parsing options for {}: Unexpected CloseElement in {}:{}: {}", channelName, line, col,
elementName); elementName);
@ -275,10 +274,11 @@ public class ChangerX2Parser extends AbstractSimpleMarkupHandler {
sb.append(buffer, offset, len); sb.append(buffer, offset, len);
} }
} else if (this.parserState == ParserState.INIT && len == 1 && buffer[offset] == '\n') { } else if (this.parserState == ParserState.INIT && len == 1 && buffer[offset] == '\n') {
// single newline - ignore/drop it... return; // single newline - ignore/drop it...
} else if (this.parserState == ParserState.INPUT) { } else if (this.parserState == ParserState.INPUT) {
// this is a label next to the value input field - we currently have no use for it so // this is a label next to the value input field - we currently have no use for it so
// it's dropped... // it's dropped...
return;
} else { } else {
logger.debug("Error parsing options for {}: Unexpected Text {}:{}: (ctx: {} len: {}) '{}' ", logger.debug("Error parsing options for {}: Unexpected Text {}:{}: (ctx: {} len: {}) '{}' ",
this.channelName, line, col, this.parserState, len, new String(buffer, offset, len)); this.channelName, line, col, this.parserState, len, new String(buffer, offset, len));

View File

@ -90,9 +90,9 @@ public class TACmiSchemaHandler extends BaseThingHandler {
// this is the units lookup cache. // this is the units lookup cache.
protected final Map<String, UnitAndType> unitsCache = new ConcurrentHashMap<>(); protected final Map<String, UnitAndType> unitsCache = new ConcurrentHashMap<>();
// marks an entry with known un-resolveable unit // marks an entry with known un-resolveable unit
protected final UnitAndType NULL_MARKER = new UnitAndType(Units.ONE, ""); protected static final UnitAndType NULL_MARKER = new UnitAndType(Units.ONE, "");
// marks an entry with special handling - i.e. 'Imp' // marks an entry with special handling - i.e. 'Imp'
protected final UnitAndType SPECIAL_MARKER = new UnitAndType(Units.ONE, "s"); protected static final UnitAndType SPECIAL_MARKER = new UnitAndType(Units.ONE, "s");
public TACmiSchemaHandler(final Thing thing, final HttpClient httpClient, public TACmiSchemaHandler(final Thing thing, final HttpClient httpClient,
final TACmiChannelTypeProvider channelTypeProvider) { final TACmiChannelTypeProvider channelTypeProvider) {

View File

@ -165,6 +165,7 @@ public class HomieImplementationTest extends MqttOSGiTest {
"Connection " + homieConnection.getClientId() + " not retrieving all topics "); "Connection " + homieConnection.getClientId() + " not retrieving all topics ");
} }
@Disabled("https://github.com/openhab/openhab-addons/issues/12667")
@Test @Test
public void retrieveOneAttribute() throws Exception { public void retrieveOneAttribute() throws Exception {
WaitForTopicValue watcher = new WaitForTopicValue(homieConnection, DEVICE_TOPIC + "/$homie"); WaitForTopicValue watcher = new WaitForTopicValue(homieConnection, DEVICE_TOPIC + "/$homie");

View File

@ -107,6 +107,7 @@ public class WemoMakerHandlerOSGiTest extends GenericWemoOSGiTest {
} }
@Test @Test
@Disabled("https://github.com/openhab/openhab-addons/issues/12474")
public void assertThatThingHandlesREFRESHCommand() public void assertThatThingHandlesREFRESHCommand()
throws MalformedURLException, URISyntaxException, ValidationException, IOException { throws MalformedURLException, URISyntaxException, ValidationException, IOException {
Command command = RefreshType.REFRESH; Command command = RefreshType.REFRESH;