diff --git a/CODEOWNERS b/CODEOWNERS index d4e47dd271368..42b05f85432cb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -70,6 +70,7 @@ /bundles/org.openhab.binding.fsinternetradio/ @paphko /bundles/org.openhab.binding.ftpupload/ @paulianttila /bundles/org.openhab.binding.gardena/ @gerrieg +/bundles/org.openhab.binding.gce/ @clinique /bundles/org.openhab.binding.globalcache/ @mhilbush /bundles/org.openhab.binding.gpstracker/ @gbicskei /bundles/org.openhab.binding.gree/ @markus7017 diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index d3641115fc4a5..b3f63fd758cdb 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -339,6 +339,11 @@ org.openhab.binding.gardena ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.gce + ${project.version} + org.openhab.addons.bundles org.openhab.binding.goecharger diff --git a/bundles/org.openhab.binding.gce/.classpath b/bundles/org.openhab.binding.gce/.classpath new file mode 100644 index 0000000000000..a5d95095ccaaf --- /dev/null +++ b/bundles/org.openhab.binding.gce/.classpath @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.gce/.project b/bundles/org.openhab.binding.gce/.project new file mode 100644 index 0000000000000..6cf1cf4a2660c --- /dev/null +++ b/bundles/org.openhab.binding.gce/.project @@ -0,0 +1,23 @@ + + + org.openhab.binding.gce + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + diff --git a/bundles/org.openhab.binding.gce/NOTICE b/bundles/org.openhab.binding.gce/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.gce/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.gce/README.md b/bundles/org.openhab.binding.gce/README.md new file mode 100644 index 0000000000000..0e57097f1cee7 --- /dev/null +++ b/bundles/org.openhab.binding.gce/README.md @@ -0,0 +1,192 @@ +# GCE Binding + +This binding aims to handle various GCE Electronics equipments. +IPX800 is a 8 relay webserver from gce-electronics with a lot of possibilities: + +* 8 Digital Input +* 8 Relay (250V / 10A / channel) +* 4 Analog Input +* 8 Counters +* Ability to cascade up to 3 extensions for a total of 32 inputs / 32 relay + +Each IPX800 connected to openHAB must be configured with the setting 'Send data on status changed' on the website in M2M > TCP client. + +To make it simple, IPX800 is a simple device that drives output and retrieves input. +On input we generally connect push buttons (for instance house switchs), on ouputs we can connect light bulbs for instance. + +Features of the binding: + + * Multi ipx support + * Direct TCP connection + * Auto reconnect + * Simple clic/Long press + * Pulse mode support + +## Binding Configuration + +There is no configuration at binding level. + + +## Thing Configuration + +The IPX800v3 (ID : 'ipx800v3') accepts the following configuration parameters : + +| Property | Default | Required | Description | +|---------------------|---------|----------|-----------------------------| +| hostname | | Yes | IP address or hostname. | +| portNumber | 9870 | No | TCP client connection port. | +| pullInterval* | 5000 | No | Refresh interval (in ms) | + +The binding will query periodically the 'globalstatus.xml' page of the IPX to get fresh informations. +This is especially usefull for Analog inputs and Counter as modification of these values on PLC side does not trigger any M2M message. + +The thing provides four groups of channels. + +### Digital Inputs + +This represents the inputs of the PLC. Each can be open or closed. +They are usually commuted by physical devices like pushbuttons, magnets... + +#### Digital Input Channels (contacts) + +Each input will have these associated channels: + +| Group | Channel Name | Item Type | R/W | Description | +|----------|------------------------|-------------|-----|-----------------------------------------------------------------------------| +| contact | `portnumber` | Contact | R | Status of the actual port (OPEN, CLOSED) | +| contact | `portnumber`-duration | Number:Time | R | Updated when the port status changes to the duration of the previous state. | + +Associated events: + +| Channel Type ID | Options | Description | Conf Dependency | +|--------------------|-------------------|--------------------------------------------------|-----------------| +| `portnumber`-event | | Triggered on or after a port status change | | +| | PRESSED | Triggered when state changes from OPEN to CLOSED | | +| | RELEASED | Triggered when state changes from CLOSED to OPEN | | +| | LONG_PRESS | Triggered when RELEASED after a long period | longPressTime | +| | SHORT_PRESS | Triggered when RELEASED before a long period | longPressTime | +| | PULSE | Triggered during CLOSED state | pulsePeriod | + +#### Configuration + +| Property | Default | Unit | Description | +|-----------------|---------|------|---------------------------------------------------------------------------------| +| debouncePeriod | 0(*) | ms | Debounce time (ignores flappling within this time). No debounce is done if '0'. | +| longPressTime | 0(*) | ms | Delay before triggering long press event. Ignored if '0'. | +| pulsePeriod | 0(*) | ms | Period of pulse event triggering while the entry is closed. Ignored if '0'. | +| pulseTimeout | 0(*) | ms | Period of time after pulsing will be stopped. None if '0'. | + +* Values below 100ms should be avoided as the JVM could skip them and proceed in the same time slice. + + +### Digital Outputs Channels (relays) + +Each output will have these associated channels: + +| Group | Channel Name | Item Type | R/W | Description | +|----------|------------------------|-------------|-----|-----------------------------------------------------------------------------| +| relay | `portnumber` | Switch | R/W | Status of the actual port (ON, OFF) | +| relay | `portnumber`-duration | Number:Time | R | Updated when the port status changes to the duration of the previous state. | + +#### Configuration + +| Property | Default | Description | +|-----------------|---------|--------------------------------------------------------------------------| +| pulse | false | If set, the output will be in pulse mode, releasing it after the contact | + +### Counters Channels + +Each counter will have these associated channels: + +| Group | Channel Name | Item Type | R/W | Description | +|----------|--------------------------|-------------|-----|--------------------------------------------------------------------------------| +| counter | `counternumber` | Number | R | Actual value of the counter | +| counter | `counternumber`-duration | Number:Time | R | Updated when the counter status changes to the duration of the previous state. | + +#### Configuration + +This channel has no configuration setting. + +### Analog Inputs Channels + +Each analog port will have these associated channels: + +| Group | Channel Name | Item Type | R/W | Description | +|--------|-----------------------|--------------------------|-----|-----------------------------------------------------------------------------| +| analog | `portnumber` | Number | R | Value of the port. | +| analog | `portnumber`-duration | Number:Time | R | Updated when the port status changes to the duration of the previous state. | +| analog | `portnumber`-voltage | Number:ElectricPotential | R | Electrical equivalency of the analogic value | + +#### Configuration + +| Property | Default | Description | +|------------|---------|-------------------------------------------------------------------------------------| +| hysteresis | 0 | If set, the channel will ignore status if change (+ or -) is less than hysteresis/2 | + + +## Rule Actions + +Multiple actions are supported by this binding. In classic rules these are accessible as shown in the example below: + +Getting ipxActions variable in scripts + +``` + val ipxActions = getActions("gce","gce:ipx800v3:43cc8d07") + if(null === ipxActions) { + logInfo("actions", "ipxActions not found, check thing ID") + return + } else { + // do something with sunActions + } +``` + +### resetCounter(counterId) + +Resets the value of the given counter to 0. + +* `counterId` (Integer) - id of the counter. + + +### reset(placeholder) + +Restarts the PLC. + +* `placeholder` (Integer) - This parameter is not used (can be null). + +## Example + +### Things + +ipx800.things + +```java + +Thing gce:ipx800v3:ipx "IPX800" @ "diningroom" [hostname="192.168.0.144", portNumber=9870] { + Channels: + Type contact : contact#1 [ // Aimant Détection porte de garage ouverte + debouncePeriod=2500, + pulsePeriod=1000, + pulseTimeout=60000 + ] + Type contact : contact#2 [ // Aimant Détection porte de garage fermée + debouncePeriod=2500 + ] + Type relay : relay#8 [ // Actionneur porte de garage + pulse=true + ] +} +``` + +ipx800.items + +```java + +Group gIPXInputs "Inputs" +Contact input1 "Porte garage ouverte [%s]" (gIPXInputs) {channel="gce:ipx800v3:ipx:contact#1"} +Contact input2 "Porte garage fermée [%s]" (gIPXInputs) {channel="gce:ipx800v3:ipx:contact#2"} + +Group gIPXOutputs "Outputs" +Switch output3 "Chaudière" (gIPXOutputs) {channel="gce:ipx800v3:ipx:relay#3"} +Switch output4 "Lumière Porche" (gIPXOutputs) {channel="gce:ipx800v3:ipx:relay#4"} + +``` diff --git a/bundles/org.openhab.binding.gce/pom.xml b/bundles/org.openhab.binding.gce/pom.xml new file mode 100644 index 0000000000000..f92601cdc09dd --- /dev/null +++ b/bundles/org.openhab.binding.gce/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 2.5.9-SNAPSHOT + + + org.openhab.binding.gce + + openHAB Add-ons :: Bundles :: GCE Binding + + diff --git a/bundles/org.openhab.binding.gce/src/main/feature/feature.xml b/bundles/org.openhab.binding.gce/src/main/feature/feature.xml new file mode 100644 index 0000000000000..57f2cb8f071cc --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.gce/${project.version} + + diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/GCEBindingConstants.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/GCEBindingConstants.java new file mode 100644 index 0000000000000..e23b1dbd8ac98 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/GCEBindingConstants.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2010-2020 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; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.core.thing.ThingTypeUID; + +/** + * The {@link GCEBindingConstants} class defines common constants, which are used + * across the whole binding. + * + * @author Gaël L'hopital - Initial Contribution + */ +@NonNullByDefault +public class GCEBindingConstants { + + public static final String BINDING_ID = "gce"; + + // Bridge Type UID + public static final ThingTypeUID IPXV3_THING_TYPE = new ThingTypeUID(BINDING_ID, "ipx800v3"); + + public static final String CHANNEL_LAST_STATE_DURATION = "duration"; + public static final String CHANNEL_VOLTAGE = "voltage"; + public static final String TRIGGER_CONTACT = "contact-trigger"; + + public static final String EVENT_PRESSED = "PRESSED"; + public static final String EVENT_RELEASED = "RELEASED"; + public static final String EVENT_SHORT_PRESS = "SHORT_PRESS"; + public static final String EVENT_LONG_PRESS = "LONG_PRESS"; + public static final String EVENT_PULSE = "PULSE"; + + // Adressable thing +} diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/GCEHandlerFactory.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/GCEHandlerFactory.java new file mode 100644 index 0000000000000..e637d8b76c620 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/GCEHandlerFactory.java @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2010-2020 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; + +import static org.openhab.binding.gce.internal.GCEBindingConstants.IPXV3_THING_TYPE; + +import java.util.Collections; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.thing.Thing; +import org.eclipse.smarthome.core.thing.ThingTypeUID; +import org.eclipse.smarthome.core.thing.binding.BaseThingHandlerFactory; +import org.eclipse.smarthome.core.thing.binding.ThingHandler; +import org.eclipse.smarthome.core.thing.binding.ThingHandlerFactory; +import org.openhab.binding.gce.internal.handler.Ipx800v3Handler; +import org.osgi.service.component.annotations.Component; + +/** + * The {@link GCEHandlerFactory} is responsible for creating things and + * thing handlers. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +@Component(service = ThingHandlerFactory.class, configurationPid = "binding.gce") +public class GCEHandlerFactory extends BaseThingHandlerFactory { + private static final Set SUPPORTED_THING_TYPES_UIDS = Collections.singleton(IPXV3_THING_TYPE); + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + return IPXV3_THING_TYPE.equals(thingTypeUID) ? new Ipx800v3Handler(thing) : null; + } +} diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/action/IIpx800Actions.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/action/IIpx800Actions.java new file mode 100644 index 0000000000000..03cd5d15edc8f --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/action/IIpx800Actions.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2020 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.action; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link IIpx800Actions} defines the interface for all thing actions supported by the binding. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public interface IIpx800Actions { + public void resetCounter(Integer counter); + + public void reset(@Nullable Integer placeholder); +} 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 new file mode 100644 index 0000000000000..cfc07b3ee9697 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/action/Ipx800Actions.java @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2010-2020 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.action; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.thing.binding.ThingActions; +import org.eclipse.smarthome.core.thing.binding.ThingActionsScope; +import org.eclipse.smarthome.core.thing.binding.ThingHandler; +import org.openhab.binding.gce.internal.handler.Ipx800v3Handler; +import org.openhab.core.automation.annotation.ActionInput; +import org.openhab.core.automation.annotation.RuleAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {Ipx800Actions } defines rule actions for the GCE binding. + *

+ * Note:The static method invokeMethodOf handles the case where + * the test actions instanceof Ipx800Actions fails. This test can fail + * due to an issue in openHAB core v2.5.0 where the {@link Ipx800Actions} class + * can be loaded by a different classloader than the actions instance. + * + * @author Gaël L'hopital - Initial contribution + */ +@ThingActionsScope(name = "gce") +@NonNullByDefault +public class Ipx800Actions implements ThingActions, IIpx800Actions { + private final Logger logger = LoggerFactory.getLogger(Ipx800Actions.class); + + protected @Nullable Ipx800v3Handler handler; + + public Ipx800Actions() { + logger.debug("IPX800 actions service instanciated"); + } + + @Override + public void setThingHandler(@Nullable ThingHandler handler) { + if (handler instanceof Ipx800v3Handler) { + this.handler = (Ipx800v3Handler) handler; + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return this.handler; + } + + @Override + @RuleAction(label = "GCE : Reset counter", description = "Resets to 0 value of a given counter") + 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) { + theHandler.resetCounter(counter); + } else { + logger.warn("Method call resetCounter failed because IPX800 action service ThingHandler is null!"); + } + } + + @Override + @RuleAction(label = "GCE : Reset PLC", description = "Restarts the IPX800") + 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) { + theHandler.reset(); + } else { + logger.warn("Method call reset failed because IPX800 action service ThingHandler is null!"); + } + } + + public static void resetCounter(@Nullable ThingActions actions, Integer counter) { + invokeMethodOf(actions).resetCounter(counter); + } + + public static void reset(@Nullable ThingActions actions, @Nullable Integer placeholder) { + invokeMethodOf(actions).reset(placeholder); + } + + private static IIpx800Actions invokeMethodOf(@Nullable ThingActions actions) { + if (actions == null) { + throw new IllegalArgumentException("actions cannot be null"); + } + if (actions.getClass().getName().equals(Ipx800Actions.class.getName())) { + if (actions instanceof IIpx800Actions) { + return (IIpx800Actions) actions; + } else { + return (IIpx800Actions) Proxy.newProxyInstance(IIpx800Actions.class.getClassLoader(), + new Class[] { IIpx800Actions.class }, (Object proxy, Method method, Object[] args) -> { + Method m = actions.getClass().getDeclaredMethod(method.getName(), + method.getParameterTypes()); + return m.invoke(actions, args); + }); + } + } + throw new IllegalArgumentException("Actions is not an instance of Ipx800Actions"); + } +} diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/AnalogInputConfiguration.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/AnalogInputConfiguration.java new file mode 100644 index 0000000000000..aafab96a0872f --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/AnalogInputConfiguration.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2010-2020 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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AnalogInputConfiguration} class holds configuration informations of + * an ipx800 Analog Input port. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class AnalogInputConfiguration { + public long hysteresis; +} diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/DigitalInputConfiguration.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/DigitalInputConfiguration.java new file mode 100644 index 0000000000000..6394d447cd2ee --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/DigitalInputConfiguration.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2010-2020 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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.config.core.Configuration; + +/** + * The {@link DigitalInputConfiguration} class holds configuration informations of + * an ipx800 Digital Input port. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class DigitalInputConfiguration extends Configuration { + public long debouncePeriod = 0; + public long longPressTime = 0; + public long pulsePeriod = 0; + public long pulseTimeout = 0; +} diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/Ipx800Configuration.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/Ipx800Configuration.java new file mode 100644 index 0000000000000..aae10b3bb3279 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/Ipx800Configuration.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2020 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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link Ipx800Configuration} class holds configuration informations of + * the ipx800v3 thing. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class Ipx800Configuration { + public String hostname = ""; + public int portNumber = 9870; + public int pullInterval = 5000; +} diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/RelayOutputConfiguration.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/RelayOutputConfiguration.java new file mode 100644 index 0000000000000..77cc5a3b8f67e --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/config/RelayOutputConfiguration.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2010-2020 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.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.config.core.Configuration; + +/** + * The {@link RelayOutputConfiguration} class holds configuration informations of + * an ipx800 relay output. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class RelayOutputConfiguration extends Configuration { + public boolean pulse; +} 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 new file mode 100644 index 0000000000000..771ee566f3aa9 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800DeviceConnector.java @@ -0,0 +1,185 @@ +/** + * Copyright (c) 2010-2020 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.handler; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.Socket; +import java.net.SocketTimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.thing.ThingUID; +import org.openhab.binding.gce.internal.model.M2MMessageParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link Ipx800DeviceConnector} is responsible for connecting, + * reading, writing and disconnecting from the Ipx800. + * + * @author Seebag - Initial Contribution + * @author Gaël L'hopital - Ported and adapted for OH2 + */ +@NonNullByDefault +public class Ipx800DeviceConnector extends Thread { + private final Logger logger = LoggerFactory.getLogger(Ipx800DeviceConnector.class); + private static final int DEFAULT_SOCKET_TIMEOUT_MS = 5000; + private static final int DEFAULT_RECONNECT_TIMEOUT_MS = 5000; + private static final int MAX_KEEPALIVE_FAILURE = 3; + private static final String ENDL = "\r\n"; + + private final String hostname; + public final int portNumber; + private @Nullable M2MMessageParser parser; + + private @NonNullByDefault({}) Socket client; + private @NonNullByDefault({}) BufferedReader in; + private @NonNullByDefault({}) PrintWriter out; + + private int failedKeepalive = 0; + private boolean waitingKeepaliveResponse = false; + + public Ipx800DeviceConnector(String hostname, int portNumber, ThingUID uid) { + super("OH-binding-" + uid); + this.hostname = hostname; + this.portNumber = portNumber; + setDaemon(true); + } + + public synchronized void send(String message) { + logger.debug("Sending '{}' to Ipx800", message); + out.write(message + ENDL); + out.flush(); + } + + /** + * Connect to the ipx800 + * + * @throws IOException + */ + private void connect() throws IOException { + disconnect(); + logger.debug("Connecting {}:{}...", hostname, portNumber); + client = new Socket(hostname, portNumber); + client.setSoTimeout(DEFAULT_SOCKET_TIMEOUT_MS); + client.getInputStream().skip(client.getInputStream().available()); + in = new BufferedReader(new InputStreamReader(client.getInputStream())); + out = new PrintWriter(client.getOutputStream(), true); + } + + /** + * Disconnect the device + */ + private void disconnect() { + logger.debug("Disconnecting"); + + if (in != null) { + try { + in.close(); + } catch (IOException ignore) { + } + this.in = null; + } + if (out != null) { + out.close(); + this.out = null; + } + if (client != null) { + try { + client.close(); + } catch (IOException ignore) { + } + this.client = null; + } + logger.debug("Disconnected"); + } + + /** + * Stop the device thread + */ + public void destroyAndExit() { + interrupt(); + disconnect(); + } + + /** + * 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 + */ + private void sendKeepalive() { + if (out != null) { + 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"); + } + try { + String command = in.readLine(); + waitingKeepaliveResponse = false; + if (parser != null) { + parser.unsolicitedUpdate(command); + } + } catch (SocketTimeoutException e) { + handleException(e); + } + } + disconnect(); + } catch (IOException e) { + handleException(e); + } + try { + Thread.sleep(DEFAULT_RECONNECT_TIMEOUT_MS); + } catch (InterruptedException e) { + destroyAndExit(); + } + } + + 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); + } + if (parser != null) { + parser.errorOccurred(e); + } + } + } + + public void setParser(M2MMessageParser parser) { + this.parser = parser; + } +} diff --git a/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800EventListener.java b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800EventListener.java new file mode 100644 index 0000000000000..efd2292ca3c59 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800EventListener.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2010-2020 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.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * This interface defines interface to receive data from IPX800 controller. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public interface Ipx800EventListener { + + /** + * Procedure for receive data from IPX800 controller. + * + * @param port Port (kind and number) receiving update + * @param value value updated + */ + void dataReceived(String port, double value); + + /** + * Procedure for receiving information fatal error. + * + * @param e Error occurred. + */ + void errorOccurred(Exception e); +} 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 new file mode 100644 index 0000000000000..317a2ec088963 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/handler/Ipx800v3Handler.java @@ -0,0 +1,392 @@ +/** + * Copyright (c) 2010-2020 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.handler; + +import static org.openhab.binding.gce.internal.GCEBindingConstants.*; + +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.config.core.Configuration; +import org.eclipse.smarthome.core.library.CoreItemFactory; +import org.eclipse.smarthome.core.library.types.DecimalType; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.library.types.OpenClosedType; +import org.eclipse.smarthome.core.library.types.QuantityType; +import org.eclipse.smarthome.core.library.unit.SmartHomeUnits; +import org.eclipse.smarthome.core.thing.Channel; +import org.eclipse.smarthome.core.thing.ChannelGroupUID; +import org.eclipse.smarthome.core.thing.ChannelUID; +import org.eclipse.smarthome.core.thing.Thing; +import org.eclipse.smarthome.core.thing.ThingStatus; +import org.eclipse.smarthome.core.thing.ThingStatusDetail; +import org.eclipse.smarthome.core.thing.binding.BaseThingHandler; +import org.eclipse.smarthome.core.thing.binding.ThingHandlerService; +import org.eclipse.smarthome.core.thing.binding.builder.ChannelBuilder; +import org.eclipse.smarthome.core.thing.binding.builder.ThingBuilder; +import org.eclipse.smarthome.core.thing.type.ChannelKind; +import org.eclipse.smarthome.core.thing.type.ChannelTypeUID; +import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.core.types.State; +import org.eclipse.smarthome.core.types.UnDefType; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link Ipx800v3Handler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class Ipx800v3Handler extends BaseThingHandler implements Ipx800EventListener { + private static final String PROPERTY_SEPARATOR = "-"; + private static final double ANALOG_SAMPLING = 0.000050354; + + private final Logger logger = LoggerFactory.getLogger(Ipx800v3Handler.class); + + private @NonNullByDefault({}) Ipx800Configuration configuration; + private @NonNullByDefault({}) Ipx800DeviceConnector connector; + private @Nullable M2MMessageParser parser; + private @NonNullByDefault({}) StatusFileInterpreter statusFile; + private @Nullable ScheduledFuture refreshJob; + + 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 && currentData.getTimestamp() == referenceTime) { + triggerChannel(eventChannelId, EVENT_LONG_PRESS); + } + } + } + + public Ipx800v3Handler(Thing thing) { + super(thing); + logger.debug("Create a IPX800 Handler for thing '{}'", getThing().getUID()); + } + + @Override + public void initialize() { + + configuration = getConfigAs(Ipx800Configuration.class); + + logger.debug("Initializing IPX800 handler for uid '{}'", getThing().getUID()); + + statusFile = new StatusFileInterpreter(configuration.hostname, this); + + if (thing.getProperties().isEmpty()) { + discoverAttributes(); + } + + connector = new Ipx800DeviceConnector(configuration.hostname, configuration.portNumber, getThing().getUID()); + parser = new M2MMessageParser(connector, this); + + updateStatus(ThingStatus.UNKNOWN); + + refreshJob = scheduler.scheduleWithFixedDelay(statusFile::read, 3000, configuration.pullInterval, + TimeUnit.MILLISECONDS); + + connector.start(); + } + + @Override + public void dispose() { + if (refreshJob != null) { + refreshJob.cancel(true); + refreshJob = null; + } + + if (connector != null) { + connector.destroyAndExit(); + } + parser = null; + + portDatas.values().stream().forEach(portData -> { + if (portData != null) { + portData.destroy(); + } + }); + super.dispose(); + } + + protected void discoverAttributes() { + final Map properties = new HashMap<>(); + + properties.put(Thing.PROPERTY_VENDOR, "GCE Electronics"); + properties.put(Thing.PROPERTY_FIRMWARE_VERSION, statusFile.getElement(StatusEntry.VERSION)); + properties.put(Thing.PROPERTY_MAC_ADDRESS, statusFile.getElement(StatusEntry.CONFIG_MAC)); + updateProperties(properties); + + ThingBuilder thingBuilder = editThing(); + List channels = new ArrayList<>(getThing().getChannels()); + + PortDefinition.asStream().forEach(portDefinition -> { + int nbElements = statusFile.getMaxNumberofNodeType(portDefinition); + for (int i = 0; i < nbElements; i++) { + createChannels(portDefinition, i, channels); + } + }); + + thingBuilder.withChannels(channels); + updateThing(thingBuilder.build()); + } + + private void createChannels(PortDefinition portDefinition, int portIndex, List channels) { + String ndx = Integer.toString(portIndex + 1); + String advancedChannelTypeName = portDefinition.toString() + + (portDefinition.isAdvanced(portIndex) ? "Advanced" : ""); + ChannelGroupUID groupUID = new ChannelGroupUID(thing.getUID(), portDefinition.toString()); + ChannelUID mainChannelUID = new ChannelUID(groupUID, ndx); + ChannelTypeUID channelType = new ChannelTypeUID(BINDING_ID, advancedChannelTypeName); + switch (portDefinition) { + case ANALOG: + channels.add(ChannelBuilder.create(mainChannelUID, CoreItemFactory.NUMBER) + .withLabel("Analog Input " + ndx).withType(channelType).build()); + channels.add(ChannelBuilder + .create(new ChannelUID(groupUID, ndx + "-voltage"), "Number:ElectricPotential") + .withLabel("Voltage " + ndx).withType(new ChannelTypeUID(BINDING_ID, CHANNEL_VOLTAGE)).build()); + break; + case CONTACT: + channels.add(ChannelBuilder.create(mainChannelUID, CoreItemFactory.CONTACT).withLabel("Contact " + ndx) + .withType(channelType).build()); + channels.add(ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-event"), null) + .withLabel("Contact " + ndx + " Event").withKind(ChannelKind.TRIGGER) + .withType(new ChannelTypeUID(BINDING_ID, TRIGGER_CONTACT + (portIndex < 8 ? "" : "Advanced"))) + .build()); + break; + case COUNTER: + channels.add(ChannelBuilder.create(mainChannelUID, CoreItemFactory.NUMBER).withLabel("Counter " + ndx) + .withType(channelType).build()); + break; + case RELAY: + channels.add(ChannelBuilder.create(mainChannelUID, CoreItemFactory.SWITCH).withLabel("Relay " + ndx) + .withType(channelType).build()); + break; + } + channels.add(ChannelBuilder.create(new ChannelUID(groupUID, ndx + "-duration"), "Number:Time") + .withLabel("Previous state duration " + ndx) + .withType(new ChannelTypeUID(BINDING_ID, CHANNEL_LAST_STATE_DURATION)).build()); + } + + @Override + public void errorOccurred(Exception e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + + private boolean ignoreCondition(double newValue, PortData portData, Configuration configuration, + PortDefinition portDefinition, ZonedDateTime now) { + if (!portData.isInitializing()) { // Always accept if portData is not initialized + double prevValue = portData.getValue(); + if (newValue == prevValue) { // Always reject if the value did not change + return true; + } + if (portDefinition == PortDefinition.ANALOG) { // For analog values, check histeresis + AnalogInputConfiguration config = configuration.as(AnalogInputConfiguration.class); + long hysteresis = config.hysteresis / 2; + if (newValue <= prevValue + hysteresis && newValue >= prevValue - hysteresis) { + return true; + } + } + if (portDefinition == PortDefinition.CONTACT) { // For contact values, check debounce + DigitalInputConfiguration config = configuration.as(DigitalInputConfiguration.class); + if (config.debouncePeriod != 0 + && now.isBefore(portData.getTimestamp().plus(config.debouncePeriod, ChronoUnit.MILLIS))) { + return true; + } + } + } + return false; + } + + @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(); + 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); + return; + } + logger.debug("About to update port '{}' with data '{}'", port, value); + State state = UnDefType.UNDEF; + switch (portDefinition) { + case COUNTER: + state = new DecimalType(value); + break; + case RELAY: + state = value == 1 ? OnOffType.ON : OnOffType.OFF; + break; + case ANALOG: + state = new DecimalType(value); + updateState(channelId + PROPERTY_SEPARATOR + CHANNEL_VOLTAGE, + new QuantityType<>(value * ANALOG_SAMPLING, SmartHomeUnits.VOLT)); + break; + case CONTACT: + DigitalInputConfiguration config = configuration.as(DigitalInputConfiguration.class); + 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); + } + } + break; + case OPEN: + if (!portData.isInitializing() && config.longPressTime != 0 + && sinceLastChange < config.longPressTime) { + triggerPushButtonChannel(channel, EVENT_SHORT_PRESS); + } + break; + } + if (!portData.isInitializing()) { + triggerPushButtonChannel(channel, value == 1 ? EVENT_PRESSED : EVENT_RELEASED); + } + break; + } + + updateState(channelId, state); + if (!portData.isInitializing()) { + updateState(channelId + PROPERTY_SEPARATOR + CHANNEL_LAST_STATE_DURATION, + new QuantityType<>(sinceLastChange / 1000, SmartHomeUnits.SECOND)); + } + portData.setData(value, now); + } else { + logger.debug("Received data '{}' for not configured port '{}'", value, port); + } + } else { + logger.debug("Received data '{}' for not configured channel '{}'", value, port); + } + } + + protected void triggerPushButtonChannel(Channel channel, String event) { + logger.debug("Triggering event '{}' on channel '{}'", event, channel.getUID()); + triggerChannel(channel.getUID().getId() + PROPERTY_SEPARATOR + TRIGGER_CONTACT, event); + } + + @Override + 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 && isValidPortId(channelUID) + && PortDefinition.fromGroupId(groupId) == PortDefinition.RELAY) { + RelayOutputConfiguration config = channel.getConfiguration().as(RelayOutputConfiguration.class); + String id = channelUID.getIdWithoutGroup(); + if (parser != null) { + parser.setOutput(id, (OnOffType) command == OnOffType.ON ? 1 : 0, config.pulse); + } + return; + } + logger.debug("Can not handle command '{}' on channel '{}'", command, channelUID); + } + + @Override + public void channelLinked(ChannelUID channelUID) { + logger.debug("channelLinked: {}", channelUID); + final String channelId = channelUID.getId(); + if (isValidPortId(channelUID)) { + Channel channel = thing.getChannel(channelUID); + if (channel != null) { + PortData data = new PortData(); + portDatas.put(channelId, data); + } + } + } + + private boolean isValidPortId(ChannelUID channelUID) { + return channelUID.getIdWithoutGroup().chars().allMatch(Character::isDigit); + } + + @Override + public void channelUnlinked(ChannelUID channelUID) { + super.channelUnlinked(channelUID); + PortData portData = portDatas.remove(channelUID.getId()); + if (portData != null) { + portData.destroy(); + } + } + + public void resetCounter(int counter) { + if (parser != null) { + parser.resetCounter(counter); + } + } + + public void reset() { + if (parser != null) { + parser.resetPLC(); + } + } + + @Override + public Collection> getServices() { + return Collections.singletonList(Ipx800Actions.class); + } +} 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 new file mode 100644 index 0000000000000..89a0c378288f3 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/M2MMessageParser.java @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2010-2020 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.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.gce.internal.handler.Ipx800DeviceConnector; +import org.openhab.binding.gce.internal.handler.Ipx800EventListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class handles message translation to and from the IPX. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class M2MMessageParser { + private static final String IO_DESCRIPTOR = "(\\d{32})"; + private static final Pattern IO_PATTERN = Pattern.compile(IO_DESCRIPTOR); + private static final Pattern VALIDATION_PATTERN = Pattern + .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 @Nullable Ipx800EventListener listener; + + private String expectedResponse = ""; + + public M2MMessageParser(Ipx800DeviceConnector connector, @Nullable Ipx800EventListener listener) { + this.connector = connector; + this.listener = listener; + connector.setParser(this); + } + + /** + * + * @param data + */ + public void unsolicitedUpdate(String data) { + if (IO_PATTERN.matcher(data).matches()) { + PortDefinition portDefinition = PortDefinition.fromM2MCommand(expectedResponse); + decodeDataLine(portDefinition, data); + } else if (VALIDATION_PATTERN.matcher(data).matches()) { + for (String status : data.split("&")) { + String statusPart[] = status.split("="); + int portNumShift = 1; + PortDefinition portDefinition = PortDefinition.fromPortName(statusPart[0].substring(0, 1)); + switch (portDefinition) { + case CONTACT: + case RELAY: { + decodeDataLine(portDefinition, statusPart[1]); + break; + } + case COUNTER: + 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])); + } + } + } + } else if (!expectedResponse.isEmpty()) { + setStatus(expectedResponse, Double.parseDouble(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'); + } + } + + private void setStatus(String port, double value) { + logger.debug("Received {} : {}", port, value); + if (listener != null) { + 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()); + } + } + + /** + * 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()); + if (listener != null) { + listener.errorOccurred(e); + } + } + + public void resetPLC() { + connector.send("Reset"); + } +} 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 new file mode 100644 index 0000000000000..4b4f923705aaa --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortData.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2010-2020 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.time.ZonedDateTime; +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. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class PortData { + private double value = -1; + private ZonedDateTime timestamp = ZonedDateTime.now(); + private @Nullable ScheduledFuture pulsing; + + public void cancelPulsing() { + if (pulsing != null) { + pulsing.cancel(true); + } + pulsing = null; + } + + public void destroy() { + cancelPulsing(); + } + + public void setData(double value, ZonedDateTime timestamp) { + this.value = value; + this.timestamp = timestamp; + } + + public double getValue() { + return value; + } + + public ZonedDateTime getTimestamp() { + return timestamp; + } + + public void setPulsing(ScheduledFuture pulsing) { + this.pulsing = pulsing; + } + + public boolean isInitializing() { + return value == -1; + } +} 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 new file mode 100644 index 0000000000000..1d9286dababc7 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/PortDefinition.java @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2010-2020 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.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link PortDefinition} enum defines and handle port + * definition constants + * + * @author Gaël L'hopital - Initial Contribution + */ +@NonNullByDefault +public enum PortDefinition { + COUNTER("count", "C", "GetCount", 8), + ANALOG("analog", "A", "GetAn", 4), + 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 + + 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; + } + + @Override + public String toString() { + return name().toLowerCase(); + } + + 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(); + } + + public static PortDefinition fromPortName(String portName) { + return asStream().filter(v -> portName.startsWith(v.portName)).findFirst().get(); + } + + public static PortDefinition fromGroupId(String groupId) { + return valueOf(groupId.toUpperCase()); + } + + 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); + } +} 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 new file mode 100644 index 0000000000000..07a5aa7032ab1 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/java/org/openhab/binding/gce/internal/model/StatusFileInterpreter.java @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2010-2020 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.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.io.net.http.HttpUtil; +import org.openhab.binding.gce.internal.handler.Ipx800EventListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** + * This class takes care of interpreting the status.xml file + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class StatusFileInterpreter { + private static final String URL_TEMPLATE = "http://%s/globalstatus.xml"; + private final Logger logger = LoggerFactory.getLogger(StatusFileInterpreter.class); + private final String hostname; + private @Nullable Document doc; + private final Ipx800EventListener listener; + + public static enum StatusEntry { + VERSION, + CONFIG_MAC; + } + + public StatusFileInterpreter(String hostname, Ipx800EventListener listener) { + this.hostname = hostname; + this.listener = listener; + } + + public void read() { + try { + DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder(); + String statusPage = HttpUtil.executeUrl("GET", String.format(URL_TEMPLATE, hostname), 5000); + InputStream inputStream = new ByteArrayInputStream(statusPage.getBytes()); + Document document = builder.parse(inputStream); + document.getDocumentElement().normalize(); + doc = document; + pushDatas(); + inputStream.close(); + } catch (IOException | SAXException | ParserConfigurationException e) { + logger.warn("Unable to read IPX800 status page : {}", e.getMessage()); + doc = null; + } + } + + private void pushDatas() { + Element root = getRoot(); + if (root != null) { + PortDefinition.asStream().forEach(portDefinition -> { + List xmlNodes = getMatchingNodes(root.getChildNodes(), portDefinition.getNodeName()); + xmlNodes.forEach(xmlNode -> { + String sPortNum = xmlNode.getNodeName().replace(portDefinition.getNodeName(), ""); + 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); + }); + }); + } + } + + public String getElement(StatusEntry entry) { + Element root = getRoot(); + if (root != null) { + return root.getElementsByTagName(entry.name().toLowerCase()).item(0).getTextContent(); + } else { + return ""; + } + } + + 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(o -> o.getNodeName())).collect(Collectors.toList()); + } + + public int getMaxNumberofNodeType(PortDefinition portDefinition) { + Element root = getRoot(); + if (root != null) { + List filteredNodes = getMatchingNodes(root.getChildNodes(), portDefinition.getNodeName()); + return filteredNodes.size(); + } + return 0; + } + + private @Nullable Element getRoot() { + if (doc == null) { + read(); + } + if (doc != null) { + return doc.getDocumentElement(); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/binding/binding.xml b/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/binding/binding.xml new file mode 100644 index 0000000000000..0fcd7050f5980 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/binding/binding.xml @@ -0,0 +1,10 @@ + + + + GCE Electronics Binding + Provides access to IPX800 PLC build by GCE. + Gaël L'hopital + + diff --git a/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/config/gceConfig.xml b/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/config/gceConfig.xml new file mode 100644 index 0000000000000..4f3b11b4afefd --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/config/gceConfig.xml @@ -0,0 +1,63 @@ + + + + + + network-address + + Network/IP address of the IPX800 without http(s) prefix. + + + + TCP client connection port. + 9870 + true + + + + Delay for pulling Analog and Counters info (in milliseconds). + 5000 + + + + + + + 0 + + + + Long press time in milliseconds. + 0 + + + + Pulse period in milliseconds. + 0 + + + + Maximum period for sending pulses in milliseconds. + 0 + + + + + + + false + + + + + + + 0 + + + + diff --git a/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/thing/channels.xml b/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/thing/channels.xml new file mode 100644 index 0000000000000..4821a254463fa --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/thing/channels.xml @@ -0,0 +1,95 @@ + + + + + Contact + + Contact + + + + + + Contact + + Contact + + + + + + Switch + + + + + + Switch + + + + + + Number + + + + + + Number + + + + + + + Number + + + + + + Number:Time + + Duration of previous state before state change. + + + + + trigger + + + + + + + + + + + + + + trigger + + + + + + + + + + + + + + Number:ElectricPotential + + Voltage + + + + diff --git a/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/thing/ipx800v3.xml b/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/thing/ipx800v3.xml new file mode 100644 index 0000000000000..21fecb448f505 --- /dev/null +++ b/bundles/org.openhab.binding.gce/src/main/resources/ESH-INF/thing/ipx800v3.xml @@ -0,0 +1,36 @@ + + + + + + The GCE IPX800v3 device + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 18e000ff456d1..7c97447912bc1 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -103,6 +103,7 @@ org.openhab.binding.fsinternetradio org.openhab.binding.ftpupload org.openhab.binding.gardena + org.openhab.binding.gce org.openhab.binding.goecharger org.openhab.binding.globalcache org.openhab.binding.gpstracker