diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/action/Ipx800Actions.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/action/Ipx800Actions.java index 9ae9a97d8037c..268b3ce1fdf6a 100644 --- a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/action/Ipx800Actions.java +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/action/Ipx800Actions.java @@ -58,8 +58,7 @@ public void setThingHandler(@Nullable ThingHandler handler) { public void resetCounter( @ActionInput(name = "counter", label = "Counter", required = true, description = "Id of the counter", type = "java.lang.Integer") Integer counter) { logger.debug("IPX800 action 'resetCounter' called"); - Ipx800v3Handler theHandler = this.handler; - if (theHandler != null) { + if (handler instanceof Ipx800v3Handler theHandler) { theHandler.resetCounter(counter); } else { logger.warn("Method call resetCounter failed because IPX800 action service ThingHandler is null!"); @@ -70,8 +69,7 @@ public void resetCounter( public void reset( @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"); - Ipx800v3Handler theHandler = this.handler; - if (theHandler != null) { + if (handler instanceof Ipx800v3Handler theHandler) { theHandler.reset(); } else { logger.warn("Method call reset failed because IPX800 action service ThingHandler is null!"); diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800DeviceConnector.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800DeviceConnector.java index 965347d440b6b..e3779f2e0319b 100644 --- a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800DeviceConnector.java +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800DeviceConnector.java @@ -18,13 +18,18 @@ import java.io.PrintWriter; import java.net.Socket; import java.net.SocketTimeoutException; -import java.util.Optional; +import java.net.UnknownHostException; +import java.util.Random; import org.eclipse.jdt.annotation.NonNullByDefault; 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.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.xml.sax.SAXException; /** * The {@link Ipx800DeviceConnector} is responsible for connecting, @@ -35,156 +40,141 @@ */ @NonNullByDefault public class Ipx800DeviceConnector extends Thread { - private static final int DEFAULT_SOCKET_TIMEOUT_MS = 5000; - private static final int DEFAULT_RECONNECT_TIMEOUT_MS = 5000; + private static final int DEFAULT_SOCKET_TIMEOUT_MS = 10000; 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 Random randomizer = new Random(); - private final String hostname; - private final int portNumber; - - private Optional messageParser = Optional.empty(); - private Optional socket = Optional.empty(); - private Optional input = Optional.empty(); - private Optional output = Optional.empty(); + private final M2MMessageParser parser; + private final StatusFileAccessor statusAccessor; + private final Ipx800EventListener listener; + private final Socket socket; + private final BufferedReader input; + private final PrintWriter output; private int failedKeepalive = 0; 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); - this.hostname = hostname; - this.portNumber = portNumber; + this.listener = listener; + + logger.debug("Connecting to {}:{}...", hostname, portNumber); + Socket socket = new Socket(hostname, portNumber); + socket.setSoTimeout(DEFAULT_SOCKET_TIMEOUT_MS); + this.socket = socket; + + output = new PrintWriter(socket.getOutputStream(), true); + input = new BufferedReader(new InputStreamReader(socket.getInputStream())); + parser = new M2MMessageParser(listener); + statusAccessor = new StatusFileAccessor(hostname); setDaemon(true); } + /** + * Stop the device thread + */ + public void dispose() { + interrupted = 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)); + logger.debug("Sending '{}' to Ipx800", message); + output.println(message); } /** - * Connect to the ipx800 - * - * @throws IOException + * 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 connect() throws IOException { - disconnect(); + private void sendKeepalive() { + PortDefinition pd = PortDefinition.values()[randomizer.nextInt(PortDefinition.AS_SET.size())]; + String command = "%s%d".formatted(pd.m2mCommand, randomizer.nextInt(pd.quantity) + 1); - logger.debug("Connecting to {}:{}...", hostname, portNumber); - Socket socket = new Socket(hostname, portNumber); - socket.setSoTimeout(DEFAULT_SOCKET_TIMEOUT_MS); - socket.getInputStream().skip(socket.getInputStream().available()); - this.socket = Optional.of(socket); + if (waitingKeepaliveResponse) { + failedKeepalive++; + logger.debug("Sending keepalive {}, attempt {}", command, failedKeepalive); + } else { + failedKeepalive = 0; + logger.debug("Sending keepalive {}", command); + } + + output.println(command); + parser.setExpectedResponse(command); - input = Optional.of(new BufferedReader(new InputStreamReader(socket.getInputStream()))); - output = Optional.of(new PrintWriter(socket.getOutputStream(), true)); + waitingKeepaliveResponse = true; } - /** - * Disconnect the device - */ - private void disconnect() { - logger.debug("Disconnecting"); + @Override + public void run() { + while (!interrupted) { + if (failedKeepalive > MAX_KEEPALIVE_FAILURE) { + interrupted = true; + listener.errorOccurred(new IOException("Max keep alive attempts has been reached")); + } + try { + String command = input.readLine(); + waitingKeepaliveResponse = false; + parser.unsolicitedUpdate(command); + } catch (SocketTimeoutException e) { + sendKeepalive(); + } catch (IOException e) { + interrupted = true; + listener.errorOccurred(e); + } + } + if (output instanceof PrintWriter out) { + out.close(); + } - input.ifPresent(in -> { + if (input instanceof BufferedReader in) { try { in.close(); - } catch (IOException ignore) { + } catch (IOException e) { + logger.warn("Exception input stream: {}", e.getMessage()); } - input = Optional.empty(); - }); - - output.ifPresent(PrintWriter::close); - output = Optional.empty(); + } - socket.ifPresent(client -> { + if (socket instanceof Socket client) { try { + logger.debug("Closing socket"); client.close(); - } catch (IOException ignore) { + } catch (IOException e) { + logger.warn("Exception closing socket: {}", e.getMessage()); } - socket = Optional.empty(); - }); + } + } - logger.debug("Disconnected"); + public StatusFile readStatusFile() throws SAXException, IOException { + return statusAccessor.read(); } /** - * Stop the device thread + * Set output of the device sending the corresponding command + * + * @param targetPort + * @param targetValue */ - public void dispose() { - interrupt(); - disconnect(); + 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); } /** - * 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 + * Resets the counter value to 0 + * + * @param targetCounter */ - private void sendKeepalive() { - output.ifPresent(out -> { - if (waitingKeepaliveResponse) { - failedKeepalive++; - logger.debug("Sending keepalive, attempt {}", failedKeepalive); - } else { - failedKeepalive = 0; - logger.debug("Sending keepalive"); - } - out.println("GetIn01"); - out.flush(); - waitingKeepaliveResponse = true; - }); - } - - @Override - public void run() { - try { - waitingKeepaliveResponse = false; - failedKeepalive = 0; - connect(); - 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(); - } catch (IOException e) { - handleException(e); - } - try { - Thread.sleep(DEFAULT_RECONNECT_TIMEOUT_MS); - } catch (InterruptedException e) { - dispose(); - } - } - - private void handleException(Exception e) { - if (!interrupted()) { - if (e instanceof SocketTimeoutException) { - sendKeepalive(); - return; - } else if (e instanceof IOException) { - logger.warn("Communication error: '{}'. Will retry in {} ms", e, DEFAULT_RECONNECT_TIMEOUT_MS); - } - messageParser.ifPresent(parser -> parser.errorOccurred(e)); - } + public void resetCounter(int targetCounter) { + logger.debug("Resetting counter {} to 0", targetCounter); + send("ResetCount%d".formatted(targetCounter)); } - public void setParser(M2MMessageParser parser) { - this.messageParser = Optional.of(parser); + public void resetPLC() { + send("Reset"); } } diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800v3Handler.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800v3Handler.java index d69902345f4f6..4ee32a43bb0ac 100644 --- a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800v3Handler.java +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800v3Handler.java @@ -14,30 +14,29 @@ import static org.openhab.binding.gce.internal.GCEBindingConstants.*; +import java.io.IOException; +import java.net.UnknownHostException; import java.time.Duration; -import java.time.ZoneId; -import java.time.ZonedDateTime; +import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.gce.internal.action.Ipx800Actions; import org.openhab.binding.gce.internal.config.AnalogInputConfiguration; import org.openhab.binding.gce.internal.config.DigitalInputConfiguration; import org.openhab.binding.gce.internal.config.Ipx800Configuration; 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.PortDefinition; -import org.openhab.binding.gce.internal.model.StatusFileInterpreter; -import org.openhab.binding.gce.internal.model.StatusFileInterpreter.StatusEntry; +import org.openhab.binding.gce.internal.model.StatusFile; import org.openhab.core.config.core.Configuration; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.DecimalType; @@ -58,9 +57,9 @@ import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.types.Command; import org.openhab.core.types.State; -import org.openhab.core.types.UnDefType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.xml.sax.SAXException; /** * 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 final Logger logger = LoggerFactory.getLogger(Ipx800v3Handler.class); + private final Map portDatas = new HashMap<>(); - private Optional connector = Optional.empty(); - private Optional parser = Optional.empty(); - private Optional> refreshJob = Optional.empty(); - - private final Map 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); - } - } - } + private @Nullable Ipx800DeviceConnector deviceConnector; + private List> jobs = new ArrayList<>(); public Ipx800v3Handler(Thing thing) { super(thing); - logger.debug("Create an IPX800 Handler for thing '{}'", getThing().getUID()); } @Override @@ -112,47 +87,76 @@ public void initialize() { logger.debug("Initializing IPX800 handler for uid '{}'", getThing().getUID()); Ipx800Configuration config = getConfigAs(Ipx800Configuration.class); - StatusFileInterpreter statusFile = new StatusFileInterpreter(config.hostname, this); - if (thing.getProperties().isEmpty()) { - updateProperties(Map.of(Thing.PROPERTY_VENDOR, "GCE Electronics", Thing.PROPERTY_FIRMWARE_VERSION, - statusFile.getElement(StatusEntry.VERSION), Thing.PROPERTY_MAC_ADDRESS, - statusFile.getElement(StatusEntry.CONFIG_MAC))); + try { + deviceConnector = new Ipx800DeviceConnector(config.hostname, config.portNumber, getThing().getUID(), this); + updateStatus(ThingStatus.UNKNOWN); + 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 channels = new ArrayList<>(getThing().getChannels()); - PortDefinition.asStream().forEach(portDefinition -> { - int nbElements = statusFile.getMaxNumberofNodeType(portDefinition); + PortDefinition.AS_SET.forEach(portDefinition -> { + int nbElements = status != null ? status.getPorts(portDefinition).size() : portDefinition.quantity; for (int i = 0; i < nbElements; i++) { ChannelUID portChannelUID = createChannels(portDefinition, i, channels); - portDatas.put(portChannelUID.getId(), new PortData()); + portDatas.put(portChannelUID, new PortData()); } }); - updateThing(editThing().withChannels(channels).build()); + } - connector = Optional.of(new Ipx800DeviceConnector(config.hostname, config.portNumber, getThing().getUID())); - parser = Optional.of(new M2MMessageParser(connector.get(), this)); - - updateStatus(ThingStatus.UNKNOWN); - - refreshJob = Optional.of( - scheduler.scheduleWithFixedDelay(statusFile::read, 3000, config.pullInterval, TimeUnit.MILLISECONDS)); - - connector.get().start(); + private void setProperties(@Nullable StatusFile status) { + Map properties = new HashMap<>(thing.getProperties()); + properties.put(Thing.PROPERTY_VENDOR, "GCE Electronics"); + if (status != null) { + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, status.getVersion()); + properties.put(Thing.PROPERTY_MAC_ADDRESS, status.getMac()); + } + updateProperties(properties); } @Override public void dispose() { - refreshJob.ifPresent(job -> job.cancel(true)); - refreshJob = Optional.empty(); - - connector.ifPresent(Ipx800DeviceConnector::dispose); - connector = Optional.empty(); + jobs.forEach(job -> job.cancel(true)); + jobs.clear(); - parser = Optional.empty(); + if (deviceConnector instanceof Ipx800DeviceConnector connector) { + connector.dispose(); + deviceConnector = null; + } portDatas.values().stream().forEach(PortData::dispose); + portDatas.clear(); + super.dispose(); } @@ -171,29 +175,25 @@ private ChannelUID createChannels(PortDefinition portDefinition, int portIndex, ChannelUID mainChannelUID = new ChannelUID(groupUID, ndx); ChannelTypeUID channelType = new ChannelTypeUID(BINDING_ID, advancedChannelTypeName); switch (portDefinition) { - case ANALOG: + case ANALOG -> { addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.NUMBER) .withLabel("Analog Input " + ndx).withType(channelType), channels); addIfChannelAbsent( ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-voltage"), "Number:ElectricPotential") .withType(new ChannelTypeUID(BINDING_ID, CHANNEL_VOLTAGE)).withLabel("Voltage " + ndx), channels); - break; - case CONTACT: + } + case CONTACT -> { addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.CONTACT) .withLabel("Contact " + ndx).withType(channelType), channels); addIfChannelAbsent(ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-event"), null) .withType(new ChannelTypeUID(BINDING_ID, TRIGGER_CONTACT + (portIndex < 8 ? "" : "Advanced"))) .withLabel("Contact " + ndx + " Event").withKind(ChannelKind.TRIGGER), channels); - break; - case COUNTER: - addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.NUMBER) - .withLabel("Counter " + ndx).withType(channelType), channels); - break; - case RELAY: - addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.SWITCH) - .withLabel("Relay " + ndx).withType(channelType), channels); - break; + } + case COUNTER -> addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.NUMBER) + .withLabel("Counter " + ndx).withType(channelType), channels); + case RELAY -> addIfChannelAbsent(ChannelBuilder.create(mainChannelUID, CoreItemFactory.SWITCH) + .withLabel("Relay " + ndx).withType(channelType), channels); } addIfChannelAbsent(ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-duration"), "Number:Time") @@ -209,8 +209,8 @@ public void errorOccurred(Exception e) { } private boolean ignoreCondition(double newValue, PortData portData, Configuration configuration, - PortDefinition portDefinition, ZonedDateTime now) { - if (!portData.isInitializing()) { // Always accept if portData is not initialized + PortDefinition portDefinition, Instant now) { + if (portData.isInitialized()) { // Always accept if portData is not initialized double prevValue = portData.getValue(); if (newValue == prevValue) { // Always reject if the value did not change return true; @@ -231,68 +231,62 @@ private boolean ignoreCondition(double newValue, PortData portData, Configuratio @Override public void dataReceived(String port, double value) { updateStatus(ThingStatus.ONLINE); - Channel channel = thing.getChannel(PortDefinition.asChannelId(port)); - if (channel != null) { - String channelId = channel.getUID().getId(); - String groupId = channel.getUID().getGroupId(); - PortData portData = portDatas.get(channelId); - if (portData != null && groupId != null) { - ZonedDateTime now = ZonedDateTime.now(ZoneId.systemDefault()); - long sinceLastChange = Duration.between(portData.getTimestamp(), now).toMillis(); + if (thing.getChannel(PortDefinition.asChannelId(port)) instanceof Channel channel) { + ChannelUID channelUID = channel.getUID(); + String channelId = channelUID.getId(); + + if (portDatas.get(channelUID) instanceof PortData portData + && channelUID.getGroupId() instanceof String groupId) { + Instant now = Instant.now(); Configuration configuration = channel.getConfiguration(); PortDefinition portDefinition = PortDefinition.fromGroupId(groupId); 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; } logger.debug("About to update port '{}' with data '{}'", port, value); - State state = UnDefType.NULL; - switch (portDefinition) { - case COUNTER: - state = new DecimalType(value); - break; - case RELAY: - state = OnOffType.from(value == 1); - break; - case ANALOG: - state = new DecimalType(value); + long sinceLastChange = Duration.between(portData.getTimestamp(), now).toMillis(); + State state = switch (portDefinition) { + case COUNTER -> new DecimalType(value); + case RELAY -> OnOffType.from(value == 1); + case ANALOG -> { updateIfLinked(channelId + PROPERTY_SEPARATOR + CHANNEL_VOLTAGE, new QuantityType<>(value * ANALOG_SAMPLING, Units.VOLT)); - break; - case CONTACT: - DigitalInputConfiguration config = configuration.as(DigitalInputConfiguration.class); + yield new DecimalType(value); + } + case CONTACT -> { portData.cancelPulsing(); - state = value == 1 ? OpenClosedType.CLOSED : OpenClosedType.OPEN; - switch ((OpenClosedType) state) { - case CLOSED: - if (config.longPressTime != 0 && !portData.isInitializing()) { - scheduler.schedule(new LongPressEvaluator(channel, port, portData), - 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) { - scheduler.schedule(portData::cancelPulsing, config.pulseTimeout, - TimeUnit.MILLISECONDS); + DigitalInputConfiguration config = configuration.as(DigitalInputConfiguration.class); + + if (value == 1) { // CLOSED + if (config.longPressTime != 0 && portData.isInitialized()) { + jobs.add(scheduler.schedule(() -> { + if (portData.getValue() == 1 && now.equals(portData.getTimestamp())) { + String eventChannelId = "%s-%s".formatted(channelUID.getId(), TRIGGER_CONTACT); + triggerChannel(eventChannelId, EVENT_LONG_PRESS); } + }, 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: - if (!portData.isInitializing() && config.longPressTime != 0 - && sinceLastChange < config.longPressTime) { - triggerPushButtonChannel(channel, EVENT_SHORT_PRESS); - } - break; + } + } else if (portData.isInitialized() && sinceLastChange < config.longPressTime) { + triggerPushButtonChannel(channel, EVENT_SHORT_PRESS); } - if (!portData.isInitializing()) { + if (portData.isInitialized()) { triggerPushButtonChannel(channel, value == 1 ? EVENT_PRESSED : EVENT_RELEASED); } - break; - } + yield value == 1 ? OpenClosedType.CLOSED : OpenClosedType.OPEN; + } + }; updateIfLinked(channelId, state); - if (!portData.isInitializing()) { + if (portData.isInitialized()) { updateIfLinked(channelId + PROPERTY_SEPARATOR + CHANNEL_LAST_STATE_DURATION, new QuantityType<>(sinceLastChange / 1000, Units.SECOND)); } @@ -320,20 +314,18 @@ protected void triggerPushButtonChannel(Channel channel, String event) { public void handleCommand(ChannelUID channelUID, Command command) { logger.debug("Received channel: {}, command: {}", channelUID, command); - Channel channel = thing.getChannel(channelUID.getId()); - String groupId = channelUID.getGroupId(); - - if (channel == null || groupId == null) { - return; - } - if (command instanceof OnOffType onOffCommand && isValidPortId(channelUID) - && PortDefinition.fromGroupId(groupId) == PortDefinition.RELAY) { + if (thing.getChannel(channelUID.getId()) instanceof Channel channel + && channelUID.getGroupId() instanceof String groupId // + && command instanceof OnOffType onOffCommand // + && isValidPortId(channelUID) // + && PortDefinition.RELAY.equals(PortDefinition.fromGroupId(groupId)) + && deviceConnector instanceof Ipx800DeviceConnector connector) { RelayOutputConfiguration config = channel.getConfiguration().as(RelayOutputConfiguration.class); - String id = channelUID.getIdWithoutGroup(); - parser.ifPresent(p -> p.setOutput(id, onOffCommand == OnOffType.ON ? 1 : 0, config.pulse)); - return; + connector.setOutput(channelUID.getIdWithoutGroup(), OnOffType.ON.equals(onOffCommand) ? 1 : 0, + config.pulse); + } 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) { @@ -341,11 +333,15 @@ private boolean isValidPortId(ChannelUID channelUID) { } public void resetCounter(int counter) { - parser.ifPresent(p -> p.resetCounter(counter)); + if (deviceConnector instanceof Ipx800DeviceConnector connector) { + connector.resetCounter(counter); + } } public void reset() { - parser.ifPresent(M2MMessageParser::resetPLC); + if (deviceConnector instanceof Ipx800DeviceConnector connector) { + connector.resetPLC(); + } } @Override diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/M2MMessageParser.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/M2MMessageParser.java index 1b4d97d81067a..959141d3f208d 100644 --- a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/M2MMessageParser.java +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/M2MMessageParser.java @@ -15,7 +15,6 @@ import java.util.regex.Pattern; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.gce.internal.handler.Ipx800DeviceConnector; import org.openhab.binding.gce.internal.handler.Ipx800EventListener; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,23 +32,19 @@ public class M2MMessageParser { .compile("I=" + IO_DESCRIPTOR + "&O=" + IO_DESCRIPTOR + "&([AC]\\d{1,2}=\\d+&)*[^I]*"); private final Logger logger = LoggerFactory.getLogger(M2MMessageParser.class); - private final Ipx800DeviceConnector connector; private final Ipx800EventListener listener; private String expectedResponse = ""; - public M2MMessageParser(Ipx800DeviceConnector connector, Ipx800EventListener listener) { - this.connector = connector; + public M2MMessageParser(Ipx800EventListener listener) { this.listener = listener; - connector.setParser(this); } - /** - * - * @param 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); decodeDataLine(portDefinition, data); } else if (VALIDATION_PATTERN.matcher(data).matches()) { @@ -67,65 +62,36 @@ public void unsolicitedUpdate(String data) { portNumShift = 0; // Align counters on 1 based array case ANALOG: { 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()) { setStatus(expectedResponse, Double.parseDouble(data)); + } else { + logger.warn("Unable to handle data received: {}", data); } - expectedResponse = ""; } private void decodeDataLine(PortDefinition portDefinition, String data) { 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) { - logger.debug("Received {} : {}", port, value); + logger.debug("Received {} on port {}", value, port); listener.dataReceived(port, value); } public void setExpectedResponse(String expectedResponse) { if (expectedResponse.endsWith("s")) { // GetInputs or GetOutputs this.expectedResponse = expectedResponse; - } else { // GetAnx or GetCountx - PortDefinition portType = PortDefinition.fromM2MCommand(expectedResponse); - this.expectedResponse = expectedResponse.replaceAll(portType.getM2mCommand(), portType.getPortName()); + return; } - } - - /** - * 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"); + // GetAnx or GetCountx + PortDefinition portType = PortDefinition.fromM2MCommand(expectedResponse); + this.expectedResponse = expectedResponse.replaceAll(portType.m2mCommand, portType.portName); } } diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortData.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortData.java index 8e21165bdfe0f..94ac768e04c81 100644 --- a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortData.java +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortData.java @@ -12,11 +12,11 @@ */ package org.openhab.binding.gce.internal.model; -import java.time.ZonedDateTime; -import java.util.Optional; +import java.time.Instant; import java.util.concurrent.ScheduledFuture; 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. @@ -26,19 +26,31 @@ @NonNullByDefault public class PortData { private double value = -1; - private ZonedDateTime timestamp = ZonedDateTime.now(); - private Optional> pulsing = Optional.empty(); + private Instant timestamp = Instant.now(); + private @Nullable ScheduledFuture pulsing; + private @Nullable ScheduledFuture pulseCanceler; public void cancelPulsing() { - pulsing.ifPresent(pulse -> pulse.cancel(true)); - pulsing = Optional.empty(); + if (pulsing instanceof ScheduledFuture job) { + job.cancel(true); + pulsing = null; + } + cancelCanceler(); + } + + public void cancelCanceler() { + if (pulseCanceler instanceof ScheduledFuture job) { + job.cancel(true); + pulseCanceler = null; + } } public void dispose() { cancelPulsing(); + cancelCanceler(); } - public void setData(double value, ZonedDateTime timestamp) { + public void setData(double value, Instant timestamp) { this.value = value; this.timestamp = timestamp; } @@ -47,15 +59,20 @@ public double getValue() { return value; } - public ZonedDateTime getTimestamp() { + public Instant getTimestamp() { return timestamp; } public void setPulsing(ScheduledFuture pulsing) { - this.pulsing = Optional.of(pulsing); + cancelPulsing(); + this.pulsing = pulsing; + } + + public boolean isInitialized() { + return value != -1; } - public boolean isInitializing() { - return value == -1; + public void setPulseCanceler(ScheduledFuture pulseCanceler) { + this.pulseCanceler = pulseCanceler; } } diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortDefinition.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortDefinition.java index f6a2cba01be69..1362107f04917 100644 --- a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortDefinition.java +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortDefinition.java @@ -12,7 +12,7 @@ */ package org.openhab.binding.gce.internal.model; -import java.util.stream.Stream; +import java.util.EnumSet; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -29,25 +29,19 @@ public enum PortDefinition { RELAY("led", "O", "GetOut", 8), CONTACT("btn", "I", "GetIn", 8); - private final String nodeName; // Name used in the status xml file - private final String portName; // Name used by the M2M protocol - private final String m2mCommand; // associated M2M command - private final int quantity; // base number of ports + public final String nodeName; // Name used in the status xml file + public final String portName; // Name used by the M2M protocol + public final String m2mCommand; // associated M2M command + 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.portName = portName; this.m2mCommand = m2mCommand; this.quantity = quantity; } - public String getNodeName() { - return nodeName; - } - - public String getPortName() { - return portName; - } + public static final EnumSet AS_SET = EnumSet.allOf(PortDefinition.class); @Override public String toString() { @@ -58,20 +52,12 @@ public boolean isAdvanced(int id) { return id >= quantity; } - public String getM2mCommand() { - return m2mCommand; - } - - public static Stream asStream() { - return Stream.of(PortDefinition.values()); - } - 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) { - 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) { @@ -80,7 +66,7 @@ public static PortDefinition fromGroupId(String groupId) { public static String asChannelId(String portDefinition) { String portKind = portDefinition.substring(0, 1); - PortDefinition result = asStream().filter(v -> v.portName.startsWith(portKind)).findFirst().get(); - return result.toString() + "#" + portDefinition.substring(1); + PortDefinition result = AS_SET.stream().filter(v -> v.portName.equals(portKind)).findFirst().get(); + return "%s#%s".formatted(result.toString(), portDefinition.substring(1)); } } diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFile.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFile.java new file mode 100644 index 0000000000000..27b148b7b714e --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFile.java @@ -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 getPorts(PortDefinition portDefinition) { + Map 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; + } +} diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFileAccessor.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFileAccessor.java new file mode 100644 index 0000000000000..794e655cec57c --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFileAccessor.java @@ -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)); + } +} diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFileInterpreter.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFileInterpreter.java index 652dc82bca7f3..593a1b460e364 100644 --- a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFileInterpreter.java +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFileInterpreter.java @@ -19,9 +19,9 @@ import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.stream.Collectors; import java.util.stream.IntStream; +import javax.ws.rs.HttpMethod; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -78,7 +78,7 @@ public StatusFileInterpreter(String hostname, Ipx800EventListener listener) { public void read() { try { - String statusPage = HttpUtil.executeUrl("GET", url, 5000); + String statusPage = HttpUtil.executeUrl(HttpMethod.GET, url, 5000); InputStream inputStream = new ByteArrayInputStream(statusPage.getBytes()); Document document = builder.parse(inputStream); document.getDocumentElement().normalize(); @@ -92,13 +92,13 @@ public void read() { private void pushDatas() { getRoot().ifPresent(root -> { - PortDefinition.asStream().forEach(portDefinition -> { - List xmlNodes = getMatchingNodes(root.getChildNodes(), portDefinition.getNodeName()); + PortDefinition.AS_SET.forEach(portDefinition -> { + List xmlNodes = getMatchingNodes(root.getChildNodes(), portDefinition.nodeName); xmlNodes.forEach(xmlNode -> { - String sPortNum = xmlNode.getNodeName().replace(portDefinition.getNodeName(), ""); + String sPortNum = xmlNode.getNodeName().replace(portDefinition.nodeName, ""); int portNum = Integer.parseInt(sPortNum) + 1; 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 String getElement(StatusEntry entry) { private List getMatchingNodes(NodeList nodeList, String criteria) { return IntStream.range(0, nodeList.getLength()).boxed().map(nodeList::item) .filter(node -> node.getNodeName().startsWith(criteria)).sorted(Comparator.comparing(Node::getNodeName)) - .collect(Collectors.toList()); + .toList(); } public int getMaxNumberofNodeType(PortDefinition portDefinition) { - return getRoot().map(root -> getMatchingNodes(root.getChildNodes(), portDefinition.getNodeName()).size()) - .orElse(0); + return Objects.requireNonNull(getRoot() + .map(root -> getMatchingNodes(root.getChildNodes(), portDefinition.nodeName).size()).orElse(0)); } private Optional getRoot() {