diff --git a/CODEOWNERS b/CODEOWNERS index f6a3d79739bd6..35e05ed924608 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -217,6 +217,7 @@ /bundles/org.openhab.binding.mpd/ @stefanroellin /bundles/org.openhab.binding.synopanalyzer/ @clinique /bundles/org.openhab.binding.systeminfo/ @svilenvul +/bundles/org.openhab.binding.tacmi/ @twendt @Wolfgang1966 @marvkis /bundles/org.openhab.binding.tado/ @dfrommi /bundles/org.openhab.binding.tankerkoenig/ @dolic @JueBag /bundles/org.openhab.binding.telegram/ @ZzetT diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index d3786db8ddfd4..48beca13eb0cb 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1076,6 +1076,11 @@ org.openhab.binding.systeminfo ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.tacmi + ${project.version} + org.openhab.addons.bundles org.openhab.binding.tado diff --git a/bundles/org.openhab.binding.tacmi/.classpath b/bundles/org.openhab.binding.tacmi/.classpath new file mode 100644 index 0000000000000..39abf1c5e9102 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/.classpath @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.tacmi/.project b/bundles/org.openhab.binding.tacmi/.project new file mode 100644 index 0000000000000..edf721d9dbfd1 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/.project @@ -0,0 +1,23 @@ + + + org.openhab.binding.tacmi + + + + + + 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.tacmi/NOTICE b/bundles/org.openhab.binding.tacmi/NOTICE new file mode 100644 index 0000000000000..59b4d27a4ab2f --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/NOTICE @@ -0,0 +1,20 @@ +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 + +== Third-party Content + +attoparser +* License: Apache 2.0 +* Project: https://www.attoparser.org +* Source: https://github.com/attoparser/attoparser diff --git a/bundles/org.openhab.binding.tacmi/README.md b/bundles/org.openhab.binding.tacmi/README.md new file mode 100644 index 0000000000000..65fc7391dc2b2 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/README.md @@ -0,0 +1,264 @@ +# TA C.M.I. Binding + +This binding makes use of the CAN over Ethernet feature of the C.M.I. from Technische Alternative. +Since I only have the new UVR16x2, it has only been tested with this controller. + +The binding supports two ways to interact with the C.M.I. and all devices connected to the C.M.I. via the CAN bus. +These modes are: + + +Via a "Schema API Page" + * Read values from output nodes + * Change values for controllable nodes + +CoE (CAN over Ethernet) Connection + * Receive data from analog CAN-outputs defined in TAPPS2 + * Receive data from digital CAN-outputs defined in TAPPS2 + * Send ON/OFF to digital CAN-inputs defined in TAPPS2 + * Send numeric values to analog CAN-inputs defined in TAPPS2 + + +Depending on what you want to achieve, either the "Schema API Page" or the CoE way might be better. +As rough guidance: Anything you want to provide to the TA equipment it has to work / operate with the CoE might be better. +If you plan things mainly for user interaction the "Schema API Page" might be better. + + +## Prerequisites + +### Setting up the "Schema API Page" + +The "Schema API page" is a special schema page created via TA's *TA-Designer* application available as download on their web site. +This page just needs to exist and be deployed on the C.M.I. but it dosn't need to be linked by the other schema pages you are using to control your TA installation. + +All objects from this special 'API' page are automatically mapped as channels of this thing, so the labels of the objects on this page have to follow a specific schema. + +When adding objects to this page, the schema for the Object's *Pre-Text* field has to follow the schema ` : `. + +Maybe this screenshot shows it best: + +![screenshot-channel-object-details](doc/images/channel-object-details.png) + +The Text from the *Pre-Text* will be used to define the channel. +The first word *tempCollector* (highlighted in the screenshot) will be used as channel name, so it has to be unique. +Everything else till the final *:* will be used as channel description. +Be sure to have at least 2 words in the *Pre-Text* as we need both - the channel name and a description. +The binding will log an error otherwise. +Also keep in mind: for the channel name we have to adhere to the openHAB channel name conventions - so just use letters and numbers without any special sings here. +The type of the channel will be automatically determined by the type of the object added. +Also don't forget the final colon - this is the separator between the label and the value. +Without the colon the parser couldn't build up a working channel for this value. + +The first sample is a sensor reading, but also the 'operation mode' of a heating circuit could be added: + +![screenshot-sample-with-heating-circuit](doc/images/sample-with-heating-circuit.png) + +In this screenshot you also see the schema page id - the parenthesized number on the bottom page overview, in this sample 4. + +### CoE Configuration + +#### Configure CAN outputs in TAPPS2 + +You need to configure CAN outputs in your Functional data on the UVR16x2. +This can be done by using the TAPPS2 application from TA. Follow the user guide on how to do this. + +#### Configure your CMI for CoE + +Now follow the User Guide of the CMI on how to setup CAN over Ethernet (COE). +Here you will map your outputs that you configured in the previous step. +This can be accomplished via the GUI on the CMI or via the coe.csv file. +As the target device you need to put the IP of your openHAB server. +Don’t forget to reboot the CMI after you uploaded the coe.csv file. + +## Supported Bridge and Things + +* TA C.M.I. schema API connection - Thing + +This thing reflecting one of our 'schema API page' as defined in the prerequisites. +This thing doesn't need the bridge. +Multiple of these pages on different C.M.I.'s could be defined within a openHAB instance. + +* TA C.M.I. CoE Bridge + +In order to get the CAN over Ethernet (COE) envionment working a `coe-bridge` has to be created. +The bridge itself opens the UDP port 5441 for communication with the C.M.I. devices. +The bridge could be used for multiple C.M.I. devices. + +* TA C.M.I. CoE Connection - Thing + +This thing reflects a connection to a node behind a specific C.M.I.. +This node could be every CAN-Capable device from TA which allows to define an CAN-Input. + +## Discovery + +Autodiscovering is not supported. We have to define the things manually. + +## Thing Configuration + +### TA C.M.I. schema API connection + +The _TA C.M.I. Schema API Connection_ has to be manually configured. + +The thing has the following configuration parameters: + +| Parameter Label | Parameter ID | Description | Accepted values | +|-------------------------|--------------|---------------------------------------------------------------------------------------------------------------|------------------------| +| C.M.I. IP Address | host | Host name or IP address of the C.M.I | host name or ip | +| Username | username | Username for authentication on the C.M.I. | string with username | +| Password | password | Password for authentication on the C.M.I. | string with password | +| API Schema ID | schemaId | ID of the schema API page | 1-256 | +| Poll Interval | pollInterval | Poll interval (in seconds) how often to poll the API Page | 1-300; default 10 | + +This thing doesn't need a bridge. Multiple of these things for different C.M.I.'s could be defined within a openHAB instance. + +### TA C.M.I. CoE Connection + +The _TA C.M.I. CoE Connection_ has to be manually configured. + +This thing reflects a connection to a node behind a specific C.M.I.. This node could be every CAN-Capable device from TA which allows to define an CAN-Input. + +| Parameter Label | Parameter ID | Description | Accepted values | +|-------------------------|-----------------|---------------------------------------------------------------------------------------------------------------|------------------------| +| C.M.I. IP Address | host | Host name or IP address of the C.M.I | host name or ip | +| Node | node | The CoE / CAN Node number openHAB should represent | 1-64 | + +The thing has no channels by default - they have to be added manually matching the configured inputs / outputs for the related CAN Node. Digital and Analog channels are supported. Please read TA's documentation related to the CAN-protocol - multiple analog (4) and digital (16) channels are combined so please be aware of this design limitation. + +## Channels + +### TA C.M.I. schema API connection + +The channels provided by this thing depends on the configuration of the "schema API page". +All the channels are dynamically created to match it. +Also when the API Page is updated, the channels are also updated during the next refresh. + +### TA C.M.I. CoE Connection + +Some comments on the CoE Connection and channel configuration: +As you might already have taken notice when studying the TA's manual, there are always a multiple CoE-values updated within a single CoE-message. +This is a design decision made by TA. +But this also means for CoE-Messages from openHAB to TA C.M.I. we have to send multiple values at once. +But due to OH's design there is no default restore of previous values out of the box. +So after OH startup the _output thing channels_ are either initialized with it's configured default value or flagged as 'unknown' until the first update on the channel happens. +You could either use some 'illegal' value as initial value and use _CoE Value Validation_ on the TA side to detect invalid values. +An other option would be to use only every 4th analog and 16th digital channel if you only need a few channels. +Additionally you could use [OH's persistence service](https://www.openhab.org/docs/configuration/persistence.html#restoring-item-states-on-restart) and it's option to [restore the item states](https://www.openhab.org/docs/configuration/persistence.html#restoring-item-states-on-restart) during OH startup. +As this only restores the item states you have to write a rule issuing _postUpdates_ on the items with the item's current value so the channel for the binding is updated. + +Supported channels for the CoE connection are: + +| Channel | Type | Description | +|-----------------|-------------|----------------------------------------------------------------------| +| coe-digital-in | Switch (RO) | Digital input channel for digital state data received from the node | +| coe-digital-out | Switch | Digital output channel for digital state data sent to the node | +| coe-analog-in | Number (RO) | Analog input channel for numeric values received from the node | +| coe-analog-out | Number | Analog output channel for numeric values sent to the node | + +Each channel has it's own set of configuration parameters. +Here a list of possible parameters: + +Channel's `coe-digital-in` and `coe-analog-in`: + +| Parameter Label | Parameter ID | Description | Accepted values | +|-------------------------|--------------|---------------------------------------------------------------------------------------------------------------|------------------------| +| Output | output | C.M.I. Network Output | 1-64 | + +Channel `coe-digital-out`: + +| Parameter Label | Parameter ID | Description | Accepted values | +|-------------------------|--------------|---------------------------------------------------------------------------------------------------------------|-------------------------| +| Output | output | C.M.I. Network Output | 1-64 | +| Initial Value | initialValue | Initial value to set after startup (optional, defaults to uninitialized) | true (on) / false (off) | + +Channel `coe-analog-out`: + +| Parameter Label | Parameter ID | Description | Accepted values | +|-------------------------|--------------|---------------------------------------------------------------------------------------------------------------|-------------------------| +| Output | output | C.M.I. Network Output | 1-64 | +| Measurement Type | type | Measurement type for this channel (see table below) | 0-21 | +| Initial Value | initialValue | Initial value to set after startup (optional, defaults to uninitialized) | floating point numeric | + +The binding supports all 21 measure types that exist according to the TA documentation. +Unfortunately, the documentation is not consistent here, so most of the types are supported only by generic names. +The known measure types are: + +| id | type | description | +|--------|---------------|-----------------------------------------------| +| 1 | Temperature | Tempeature value. Value is multiplied by 0.1 | +| 2 | Unknown2 | | +| 3 | Unknown3 | | +| 4 | Seconds | | +| 5...9 | Unknown5..9 | | +| 10 | Kilowatt | | +| 11 | Kilowatthours | | +| 12 | Megawatthours | | +| 13..21 | Unknown | | + + +## Full Example + +As there is no common configuration as everything depends on the configuration of the TA devices. +So we just can provide some samples providing the basics so you can build the configuration matching your system. + +Example of a _.thing_ file: + +``` +Thing tacmi:cmiSchema:apiLab "CMIApiPage"@"lab" [ host="192.168.178.33", username="user", password="secret", schemaId=4 ] +Bridge tacmi:coe-bridge:coe-bridge "TA C.M.I. Bridge" +{ + + Thing cmi cmiTest "Test-CMI"@"lab" [ host="192.168.178.33", node=54 ] { + Channels: + Type coe-digital-in : digitalInput1 "Digital input 1" [ output=1 ] + Type coe-digital-out : digitalOutput1 "Digital output 1" [ output=1, initialValue=true] + Type coe-analog-in : analogInput1 "Analog input 1" [ output=1 ] + Type coe-analog-out : analogOutput1 "Analog output 1" [ output=1, type=1, initialValue=22 ] + } + +} +``` + +Sample _.items_-File: + +``` +# APIPage-items +Number TACMI_Api_tempCollector "Collector temp [%.1f °C]" {channel="tacmi:cmiSchema:apiLab:tempCollector"} +String TACMI_Api_hc1OperationMode "Heating Curcuit 1 Operation Mode [%s]" {channel="tacmi:cmiSchema:apiLab:hc1OperationMode"} + + +# COE-items +Number TACMI_Analog_In_1 "TA input value 1 [%.1f]" {channel="tacmi:cmi:coe-bridge:cmiTest:analogInput1"} +Number TACMI_Analog_Out_1 "TA output value 1 [%.1f]" {channel="tacmi:cmi:coe-bridge:cmiTest:analogOutput1"} +Switch TACMI_Digital_In_1 "TA input switch 1 [%s]" {channel="tacmi:cmi:coe-bridge:cmiTest:digitalInput1"} +Switch TACMI_Digital_Out_1 "TA output switch 1 [%s]" {channel="tacmi:cmi:coe-bridge:cmiTest:digitalOutput1"} +``` + +Sample _.sitemap_ snipplet + +``` +sitemap heatingTA label="heatingTA" +{ + Text item=TACMI_Api_tempCollector + Switch item=TACMI_Api_hc1OperationMode mappings=["Zeit/Auto"="Auto", "Normal"="Operating", "Abgesenkt"="lowered", "Standby/Frostschutz"="Standby"] + + Text item=TACMI_Analog_In_1 + Setpoint item=TACMI_Analog_Out_1 step=5 minValue=15 maxValue=45 + Switch item=TACMI_Digital_In_1 + Switch item=TACMI_Digital_Out_1 +} +``` + +## Some additional hints and comments + +Some additional hints and comments: + +You might already have noticed that some state information is in German. +As I have set the `Accept-Language`-Http-Header to `en` for all request and found no other way setting a language for the schema pages I assume it is a lack of internationalization in the C.M.I. +You could circumvent this by creating map files to map things properly to your language. + +If you want to see the possible options of a multi-state field you could open the *schema API page* with your web browser and click on the object. +A Popup with an option field will be shown showing all possible options, like in this screenshot: + +![screenshot-operation-mode-values](doc/images/operation-mode-values.png) + +Please be also aware that there are field having more 'state' values than options, i.E. a manual output override: It has 'Auto/On', 'Auto/Off', 'Manual/On', 'Manual/Off' as state, but only 'Auto', 'Manual/On' and 'Manual/Off' as updateable option. +You only set it to 'Auto' and the extension On/Off is added depending on the system's current state. diff --git a/bundles/org.openhab.binding.tacmi/doc/images/channel-object-details.png b/bundles/org.openhab.binding.tacmi/doc/images/channel-object-details.png new file mode 100644 index 0000000000000..b98783c9c205e Binary files /dev/null and b/bundles/org.openhab.binding.tacmi/doc/images/channel-object-details.png differ diff --git a/bundles/org.openhab.binding.tacmi/doc/images/operation-mode-values.png b/bundles/org.openhab.binding.tacmi/doc/images/operation-mode-values.png new file mode 100644 index 0000000000000..5628af4209375 Binary files /dev/null and b/bundles/org.openhab.binding.tacmi/doc/images/operation-mode-values.png differ diff --git a/bundles/org.openhab.binding.tacmi/doc/images/sample-with-heating-circuit.png b/bundles/org.openhab.binding.tacmi/doc/images/sample-with-heating-circuit.png new file mode 100644 index 0000000000000..fdd6fab40f958 Binary files /dev/null and b/bundles/org.openhab.binding.tacmi/doc/images/sample-with-heating-circuit.png differ diff --git a/bundles/org.openhab.binding.tacmi/pom.xml b/bundles/org.openhab.binding.tacmi/pom.xml new file mode 100644 index 0000000000000..aa3ce0d7c5050 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/pom.xml @@ -0,0 +1,25 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 2.5.9-SNAPSHOT + + + org.openhab.binding.tacmi + + openHAB Add-ons :: Bundles :: TA C.M.I. Binding + + + + org.attoparser + attoparser + 2.0.5.RELEASE + compile + + + diff --git a/bundles/org.openhab.binding.tacmi/src/main/feature/feature.xml b/bundles/org.openhab.binding.tacmi/src/main/feature/feature.xml new file mode 100644 index 0000000000000..b7a38c94d4f59 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/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.tacmi/${project.version} + + diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiBindingConstants.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiBindingConstants.java new file mode 100644 index 0000000000000..d8b71ef31bf3b --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiBindingConstants.java @@ -0,0 +1,55 @@ +/** + * 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.tacmi.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.core.thing.ThingTypeUID; +import org.eclipse.smarthome.core.thing.type.ChannelTypeUID; + +/** + * The {@link TACmiBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class TACmiBindingConstants { + + public static final String BINDING_ID = "tacmi"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_CMI = new ThingTypeUID(BINDING_ID, "cmi"); + public static final ThingTypeUID THING_TYPE_COE_BRIDGE = new ThingTypeUID(BINDING_ID, "coe-bridge"); + public static final ThingTypeUID THING_TYPE_CMI_SCHEMA = new ThingTypeUID(BINDING_ID, "cmiSchema"); + + public static final ChannelTypeUID CHANNEL_TYPE_COE_DIGITAL_IN_UID = new ChannelTypeUID(BINDING_ID, + "coe-digital-in"); + public static final ChannelTypeUID CHANNEL_TYPE_COE_ANALOG_IN_UID = new ChannelTypeUID(BINDING_ID, "coe-analog-in"); + + public static final ChannelTypeUID CHANNEL_TYPE_COE_DIGITAL_OUT_UID = new ChannelTypeUID(BINDING_ID, + "coe-digital-out"); + public static final ChannelTypeUID CHANNEL_TYPE_COE_ANALOG_OUT_UID = new ChannelTypeUID(BINDING_ID, + "coe-analog-out"); + + public static final ChannelTypeUID CHANNEL_TYPE_SCHEME_SWITCH_RO_UID = new ChannelTypeUID(BINDING_ID, + "schema-switch-ro"); + public static final ChannelTypeUID CHANNEL_TYPE_SCHEME_SWITCH_RW_UID = new ChannelTypeUID(BINDING_ID, + "schema-switch-rw"); + public static final ChannelTypeUID CHANNEL_TYPE_SCHEME_NUMERIC_RO_UID = new ChannelTypeUID(BINDING_ID, + "schema-numeric-ro"); + public static final ChannelTypeUID CHANNEL_TYPE_SCHEME_STATE_RO_UID = new ChannelTypeUID(BINDING_ID, + "schema-state-ro"); + + // Channel specific configuration items + public final static String CHANNEL_CONFIG_OUTPUT = "output"; +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiChannelTypeProvider.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiChannelTypeProvider.java new file mode 100644 index 0000000000000..7aacbd3553d3c --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiChannelTypeProvider.java @@ -0,0 +1,56 @@ +/** + * 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.tacmi.internal; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.thing.type.ChannelType; +import org.eclipse.smarthome.core.thing.type.ChannelTypeProvider; +import org.eclipse.smarthome.core.thing.type.ChannelTypeUID; +import org.osgi.service.component.annotations.Component; + +/** + * Provides all ChannelTypes for the schema binding... + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +@Component(service = { TACmiChannelTypeProvider.class, ChannelTypeProvider.class }) +public class TACmiChannelTypeProvider implements ChannelTypeProvider { + + private final Map channelTypesByUID = new HashMap<>(); + + @Override + public Collection getChannelTypes(@Nullable Locale locale) { + return Collections.unmodifiableCollection(channelTypesByUID.values()); + } + + @Override + public @Nullable ChannelType getChannelType(ChannelTypeUID channelTypeUID, @Nullable Locale locale) { + return channelTypesByUID.get(channelTypeUID); + } + + public ChannelType getInternalChannelType(ChannelTypeUID channelTypeUID) { + return channelTypesByUID.get(channelTypeUID); + } + + public void addChannelType(ChannelType channelType) { + channelTypesByUID.put(channelType.getUID(), channelType); + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiHandlerFactory.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiHandlerFactory.java new file mode 100644 index 0000000000000..1f17d06221711 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiHandlerFactory.java @@ -0,0 +1,81 @@ +/** + * 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.tacmi.internal; + +import static org.openhab.binding.tacmi.internal.TACmiBindingConstants.*; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.smarthome.core.thing.Bridge; +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.eclipse.smarthome.io.net.http.HttpClientFactory; +import org.openhab.binding.tacmi.internal.coe.TACmiCoEBridgeHandler; +import org.openhab.binding.tacmi.internal.coe.TACmiHandler; +import org.openhab.binding.tacmi.internal.schema.TACmiSchemaHandler; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link TACmiHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.tacmi", service = ThingHandlerFactory.class) +public class TACmiHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Collections.unmodifiableSet( + Stream.of(THING_TYPE_CMI, THING_TYPE_COE_BRIDGE, THING_TYPE_CMI_SCHEMA).collect(Collectors.toSet())); + + private final HttpClient httpClient; + private final TACmiChannelTypeProvider channelTypeProvider; + + @Activate + public TACmiHandlerFactory(@Reference HttpClientFactory httpClientFactory, + @Reference TACmiChannelTypeProvider channelTypeProvider) { + this.httpClient = httpClientFactory.getCommonHttpClient(); + this.channelTypeProvider = channelTypeProvider; + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_CMI.equals(thingTypeUID)) { + return new TACmiHandler(thing); + } else if (THING_TYPE_COE_BRIDGE.equals(thingTypeUID)) { + return new TACmiCoEBridgeHandler((Bridge) thing); + } else if (THING_TYPE_CMI_SCHEMA.equals(thingTypeUID)) { + return new TACmiSchemaHandler(thing, httpClient, channelTypeProvider); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiMeasureType.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiMeasureType.java new file mode 100644 index 0000000000000..f2f08a4365479 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/TACmiMeasureType.java @@ -0,0 +1,85 @@ +/** + * 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.tacmi.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This enum holds all the different measures and states available to be + * retrieved by the TACmi binding, including the scale factors needed to convert the received values to the real + * numbers. + * + * @author Timo Wendt - Initial contribution + * @author Wolfgang Klimt - improvements + * @author Christian Niessner - Ported to OpenHAB2 + */ +@NonNullByDefault +public enum TACmiMeasureType { + NONE(0, 1), + TEMPERATURE(1, 10), + UNKNOWN2(2, 1), + UNKNOWN3(3, 1), + SECONDS(4, 1), + UNKNOWN5(5, 1), + UNKNOWN6(6, 1), + UNKNOWN7(7, 1), + UNKNOWN8(8, 1), + UNKNOWN9(9, 1), + KILOWATT(10, 100), + KILOWATTHOURS(11, 10), + MEGAWATTHOURS(12, 1), + UNKNOWN13(13, 1), + UNKNOWN14(14, 1), + UNKNOWN15(15, 1), + UNKNOWN16(16, 1), + UNKNOWN17(17, 1), + UNKNOWN18(18, 1), + UNKNOWN19(19, 1), + UNKNOWN20(20, 1), + UNKNOWN21(21, 1), + + UNSUPPORTED(-1, 1); + + private final int typeval; + private final int offset; + + private static final Logger logger = LoggerFactory.getLogger(TACmiMeasureType.class); + + private TACmiMeasureType(int typeval, int offset) { + this.typeval = typeval; + this.offset = offset; + } + + public int getTypeValue() { + return typeval; + } + + public int getOffset() { + return offset; + } + + /** + * Return measure type for a specific int value + */ + public static TACmiMeasureType fromInt(int type) { + for (TACmiMeasureType mtype : TACmiMeasureType.values()) { + if (mtype.getTypeValue() == type) { + return mtype; + } + } + logger.debug("Received unexpected measure type {}", type); + return TACmiMeasureType.UNSUPPORTED; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/PodData.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/PodData.java new file mode 100644 index 0000000000000..134eb7728a802 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/PodData.java @@ -0,0 +1,38 @@ +/** + * 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.tacmi.internal.coe; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.tacmi.internal.message.Message; +import org.openhab.binding.tacmi.internal.message.MessageType; + +/** + * This class carries all relevant data for the POD + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class PodData { + protected final byte podId; + protected final MessageType messageType; + protected @Nullable Message message; + + /** + * Create new AnalogValue with specified value and type + */ + public PodData(PodIdentifier pi, byte node) { + this.podId = pi.podId; + this.messageType = pi.messageType; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/PodDataOutgoing.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/PodDataOutgoing.java new file mode 100644 index 0000000000000..16bffcc77e33b --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/PodDataOutgoing.java @@ -0,0 +1,85 @@ +/** + * 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.tacmi.internal.coe; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.smarthome.core.thing.ChannelUID; +import org.openhab.binding.tacmi.internal.message.AnalogMessage; +import org.openhab.binding.tacmi.internal.message.DigitalMessage; +import org.openhab.binding.tacmi.internal.message.MessageType; + +/** + * This class carries all relevant data for the POD + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class PodDataOutgoing extends PodData { + + protected long lastSent; + protected final ChannelUID[] channeUIDs; + protected final boolean[] initialized; + private boolean allValuesInitialized; + + /** + * Create new AnalogValue with specified value and type + */ + public PodDataOutgoing(PodIdentifier pi, byte node) { + super(pi, node); + boolean analog = pi.messageType == MessageType.ANALOG; + int valueCount = analog ? 4 : 16; + this.channeUIDs = new ChannelUID[valueCount]; + this.initialized = new boolean[valueCount]; + this.allValuesInitialized = false; + this.message = analog ? new AnalogMessage(node, pi.podId) : new DigitalMessage(node, pi.podId); + this.lastSent = System.currentTimeMillis(); + } + + /** + * checks if all (in use) values have been set to a value - used to prevent sending of unintended values via CoE + */ + public boolean isAllValuesInitialized() { + if (this.allValuesInitialized) { + return true; + } + boolean allInitialized = true; + for (int idx = 0; idx < this.initialized.length; idx++) { + if (this.channeUIDs[idx] != null && !this.initialized[idx]) { + return false; + } + } + if (!allInitialized) { + return false; + } + this.allValuesInitialized = true; + return true; + } + + public String getUninitializedChannelNames() { + if (this.allValuesInitialized) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (int idx = 0; idx < this.initialized.length; idx++) { + ChannelUID ct = this.channeUIDs[idx]; + if (ct != null && !this.initialized[idx]) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(ct.getId()); + } + } + return sb.toString(); + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/PodIdentifier.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/PodIdentifier.java new file mode 100644 index 0000000000000..3a718a160df2e --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/PodIdentifier.java @@ -0,0 +1,67 @@ +/** + * 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.tacmi.internal.coe; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.tacmi.internal.message.MessageType; + +/** + * This class defines a key for POD identification + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public final class PodIdentifier { + public final MessageType messageType; + public final byte podId; + public final boolean outgoing; + + /** + * Create new AnalogValue with specified value and type + */ + public PodIdentifier(MessageType messageType, byte podId, boolean outgoing) { + this.messageType = messageType; + if (podId < 0) { + throw new ArrayIndexOutOfBoundsException(podId); + } + switch (messageType) { + case ANALOG: + if (podId < 1 || podId > 8) { + throw new ArrayIndexOutOfBoundsException(podId); + } + break; + case DIGITAL: + if (podId != 0 && podId != 9) { + throw new ArrayIndexOutOfBoundsException(podId); + } + break; + } + this.podId = podId; + this.outgoing = outgoing; + } + + @Override + public int hashCode() { + return (this.messageType.ordinal() << 8) | podId | (outgoing ? 0x10000 : 0); + } + + @Override + public boolean equals(@Nullable Object o) { + if (!(o instanceof PodIdentifier)) { + return false; + } + PodIdentifier po = (PodIdentifier) o; + return this.messageType == po.messageType && this.podId == po.podId && this.outgoing == po.outgoing; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiChannelConfiguration.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiChannelConfiguration.java new file mode 100644 index 0000000000000..3b5b1edf46c1a --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiChannelConfiguration.java @@ -0,0 +1,48 @@ +/** + * 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.tacmi.internal.coe; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link TACmiConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class TACmiChannelConfiguration { + + /** + * chnanel / output id + */ + public int output; + + // required for MAP operations... + @Override + public int hashCode() { + return output; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || !other.getClass().equals(TACmiChannelConfiguration.class)) { + return false; + } + TACmiChannelConfiguration o = (TACmiChannelConfiguration) other; + return this.output == o.output; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiChannelConfigurationAnalog.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiChannelConfigurationAnalog.java new file mode 100644 index 0000000000000..215ae65278018 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiChannelConfigurationAnalog.java @@ -0,0 +1,57 @@ +/** + * 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.tacmi.internal.coe; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link TACmiConfiguration} class contains fields mapping thing + * configuration parameters. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class TACmiChannelConfigurationAnalog extends TACmiChannelConfiguration { + + /** + * measurement type + */ + public int type; + + /** + * initial value + */ + public @Nullable Double initialValue; + + // required for MAP operations... + @Override + public int hashCode() { + Double iv = initialValue; + return 31 * output * type * (iv == null ? 1 : iv.hashCode()); + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || !other.getClass().equals(TACmiChannelConfigurationAnalog.class)) { + return false; + } + TACmiChannelConfigurationAnalog o = (TACmiChannelConfigurationAnalog) other; + return this.output == o.output && this.type == o.type && Objects.equals(this.initialValue, o.initialValue); + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiChannelConfigurationDigital.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiChannelConfigurationDigital.java new file mode 100644 index 0000000000000..cc57e6cae517a --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiChannelConfigurationDigital.java @@ -0,0 +1,52 @@ +/** + * 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.tacmi.internal.coe; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link TACmiConfiguration} class contains fields mapping thing + * configuration parameters. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class TACmiChannelConfigurationDigital extends TACmiChannelConfiguration { + + /** + * initial value + */ + public @Nullable Boolean initialValue; + + // required for MAP operations... + @Override + public int hashCode() { + Boolean iv = initialValue; + return 31 * output * (iv == null ? 1 : iv.booleanValue() ? 9 : 3); + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || !other.getClass().equals(TACmiChannelConfigurationDigital.class)) { + return false; + } + TACmiChannelConfigurationDigital o = (TACmiChannelConfigurationDigital) other; + return this.output == o.output && Objects.equals(this.initialValue, o.initialValue); + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiCoEBridgeHandler.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiCoEBridgeHandler.java new file mode 100644 index 0000000000000..8880f9c796a06 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiCoEBridgeHandler.java @@ -0,0 +1,248 @@ +/** + * 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.tacmi.internal.coe; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.util.Collection; +import java.util.HashSet; +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.core.thing.Bridge; +import org.eclipse.smarthome.core.thing.ChannelUID; +import org.eclipse.smarthome.core.thing.ThingStatus; +import org.eclipse.smarthome.core.thing.ThingStatusDetail; +import org.eclipse.smarthome.core.thing.binding.BaseBridgeHandler; +import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.core.types.RefreshType; +import org.openhab.binding.tacmi.internal.message.AnalogMessage; +import org.openhab.binding.tacmi.internal.message.DigitalMessage; +import org.openhab.binding.tacmi.internal.message.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link TACmiCoEBridgeHandler} is the handler for a smarthomatic Bridge and + * connects it to the framework. All {@link TACmiHandler}s use the + * {@link TACmiCoEBridgeHandler} to execute the actual commands. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class TACmiCoEBridgeHandler extends BaseBridgeHandler { + + private final Logger logger = LoggerFactory.getLogger(TACmiCoEBridgeHandler.class); + + /** + * Port the C.M.I. uses for COE-Communication - this cannot be changed. + */ + private static final int COE_PORT = 5441; + + /** + * Connection socket + */ + private @Nullable DatagramSocket coeSocket = null; + + private @Nullable ReceiveThread receiveThread; + + private @Nullable ScheduledFuture timeoutCheckFuture; + + private final Collection registeredCMIs = new HashSet<>(); + + public TACmiCoEBridgeHandler(final Bridge br) { + super(br); + } + + /** + * Thread which receives all data from the bridge. + */ + private class ReceiveThread extends Thread { + private final Logger logger = LoggerFactory.getLogger(ReceiveThread.class); + + ReceiveThread(String threadName) { + super(threadName); + } + + @Override + public void run() { + final DatagramSocket coeSocket = TACmiCoEBridgeHandler.this.coeSocket; + if (coeSocket == null) { + logger.warn("coeSocket is NULL - Reader disabled!"); + return; + } + while (!isInterrupted()) { + final byte[] receiveData = new byte[14]; + + try { + final DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length); + try { + coeSocket.receive(receivePacket); + } catch (final SocketTimeoutException te) { + logger.trace("Receive timeout on CoE socket, retrying ..."); + continue; + } + + final byte[] data = receivePacket.getData(); + Message message; + if (data[1] > 0 && data[1] < 9) { + message = new AnalogMessage(data); + } else if (data[1] == 0 || data[1] == 9) { + message = new DigitalMessage(data); + } else { + logger.debug("Invalid message received"); + continue; + } + logger.debug("{}", message.toString()); + + final InetAddress remoteAddress = receivePacket.getAddress(); + final int node = message.canNode; + boolean found = false; + for (final TACmiHandler cmi : registeredCMIs) { + if (cmi.isFor(remoteAddress, node)) { + cmi.handleCoE(message); + found = true; + } + } + if (!found) { + logger.debug("Received CoE-Packet from {} Node {} and we don't have a Thing for!", + remoteAddress, node); + } + } catch (final IOException e) { + if (isInterrupted()) { + return; + } + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "Error processing data: " + e.getMessage()); + + } catch (RuntimeException e) { + // we catch runtime exceptions here to prevent the receiving thread to stop accidentally if + // something like a IllegalStateException or NumberFormatExceptions are thrown. This indicates a bug + // or a situation / setup I'm not thinking of ;) + if (isInterrupted()) { + return; + } + logger.error("Error processing data: {}", e.getMessage(), e); + } + } + } + } + + /** + * Periodically check for timeouts on the registered / active CoE channels + */ + private void checkForTimeouts() { + for (final TACmiHandler cmi : registeredCMIs) { + cmi.checkForTimeout(); + } + } + + @Override + public void initialize() { + try { + final DatagramSocket coeSocket = new DatagramSocket(COE_PORT); + coeSocket.setBroadcast(true); + coeSocket.setSoTimeout(330000); // 300 sec is default resent-time; so we wait 330 secs + this.coeSocket = coeSocket; + } catch (final SocketException e) { + // logged by framework via updateStatus + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Failed to create UDP-Socket for C.M.I. CoE bridge. Reason: " + e.getMessage()); + return; + } + + ReceiveThread reciveThreadNN = new ReceiveThread("OH-binding-" + getThing().getUID().getAsString()); + reciveThreadNN.setDaemon(true); + reciveThreadNN.start(); + this.receiveThread = reciveThreadNN; + + ScheduledFuture timeoutCheckFuture = this.timeoutCheckFuture; + if (timeoutCheckFuture == null || timeoutCheckFuture.isCancelled()) { + this.timeoutCheckFuture = scheduler.scheduleWithFixedDelay(this::checkForTimeouts, 1, 1, TimeUnit.SECONDS); + } + + updateStatus(ThingStatus.ONLINE); + } + + public void sendData(final byte[] pkt, final @Nullable InetAddress cmiAddress) throws IOException { + final DatagramPacket packet = new DatagramPacket(pkt, pkt.length, cmiAddress, COE_PORT); + @Nullable + DatagramSocket sock = this.coeSocket; + if (sock == null) { + throw new IOException("Socket is closed!"); + } + sock.send(packet); + } + + @Override + public void handleCommand(final ChannelUID channelUID, final Command command) { + if (command instanceof RefreshType) { + // just forward it to the registered handlers... + for (final TACmiHandler cmi : registeredCMIs) { + cmi.handleCommand(channelUID, command); + } + } else { + logger.debug("No bridge commands defined."); + } + } + + protected void registerCMI(final TACmiHandler handler) { + this.registeredCMIs.add(handler); + } + + protected void unregisterCMI(final TACmiHandler handler) { + this.registeredCMIs.remove(handler); + } + + @Override + public void dispose() { + // clean up the timeout check + ScheduledFuture timeoutCheckFuture = this.timeoutCheckFuture; + if (timeoutCheckFuture != null) { + timeoutCheckFuture.cancel(true); + this.timeoutCheckFuture = null; + } + + // clean up the receive thread + ReceiveThread receiveThread = this.receiveThread; + if (receiveThread != null) { + receiveThread.interrupt(); // just interrupt it so when the socketException throws it's flagged as + // interrupted. + } + + @Nullable + DatagramSocket sock = this.coeSocket; + if (sock != null && !sock.isClosed()) { + sock.close(); + this.coeSocket = null; + } + if (receiveThread != null) { + receiveThread.interrupt(); + try { + // it should join quite quick as we already closed the socket which should have the receiver thread + // caused to stop. + receiveThread.join(250); + } catch (final InterruptedException e) { + logger.debug("Unexpected interrupt in receiveThread.join(): {}", e.getMessage(), e); + } + this.receiveThread = null; + } + super.dispose(); + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiConfiguration.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiConfiguration.java new file mode 100644 index 0000000000000..387d4c27fb08c --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiConfiguration.java @@ -0,0 +1,35 @@ +/** + * 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.tacmi.internal.coe; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link TACmiConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class TACmiConfiguration { + + /** + * host address of the C.M.I. + */ + public @Nullable String host; + + /** + * CoE / CAN node ID we are representing + */ + public int node; +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiHandler.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiHandler.java new file mode 100644 index 0000000000000..f0615e047914c --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/coe/TACmiHandler.java @@ -0,0 +1,405 @@ +/** + * 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.tacmi.internal.coe; + +import static org.openhab.binding.tacmi.internal.TACmiBindingConstants.*; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.library.types.DecimalType; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.thing.Bridge; +import org.eclipse.smarthome.core.thing.Channel; +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.type.ChannelTypeUID; +import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.core.types.RefreshType; +import org.eclipse.smarthome.core.types.State; +import org.openhab.binding.tacmi.internal.TACmiBindingConstants; +import org.openhab.binding.tacmi.internal.TACmiMeasureType; +import org.openhab.binding.tacmi.internal.message.AnalogMessage; +import org.openhab.binding.tacmi.internal.message.AnalogValue; +import org.openhab.binding.tacmi.internal.message.DigitalMessage; +import org.openhab.binding.tacmi.internal.message.Message; +import org.openhab.binding.tacmi.internal.message.MessageType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link TACmiHandler} is responsible for handling commands, which are sent + * to one of the channels. + * + * @author Timo Wendt - Initial contribution + * @author Christian Niessner - Ported to OpenHAB2 + */ +@NonNullByDefault +public class TACmiHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(TACmiHandler.class); + + private final Map<@Nullable PodIdentifier, @Nullable PodData> podDatas = new HashMap<>(); + private final Map<@Nullable ChannelUID, @Nullable TACmiChannelConfiguration> channelConfigByUID = new HashMap<>(); + + private @Nullable TACmiCoEBridgeHandler bridge; + private long lastMessageRecvTS; // last received message timestamp + + /** + * the C.M.I.'s address + */ + private @Nullable InetAddress cmiAddress; + + /** + * the CoE CAN-Node we representing + */ + private int node; + + public TACmiHandler(final Thing thing) { + super(thing); + } + + @Override + public void initialize() { + updateStatus(ThingStatus.UNKNOWN); + + scheduler.execute(this::initializeDetached); + } + + private void initializeDetached() { + final TACmiConfiguration config = getConfigAs(TACmiConfiguration.class); + + if (config.host == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No host configured!"); + return; + } + try { + cmiAddress = InetAddress.getByName(config.host); + } catch (final UnknownHostException e1) { + // message logged by framework via updateStatus + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Failed to get IP of CMI for '" + config.host + "'"); + return; + } + + this.node = config.node; + + // initialize lookup maps... + this.channelConfigByUID.clear(); + this.podDatas.clear(); + for (final Channel chann : getThing().getChannels()) { + final ChannelTypeUID ct = chann.getChannelTypeUID(); + final boolean analog = CHANNEL_TYPE_COE_ANALOG_IN_UID.equals(ct) + || CHANNEL_TYPE_COE_ANALOG_OUT_UID.equals(ct); + final boolean outgoing = CHANNEL_TYPE_COE_ANALOG_OUT_UID.equals(ct) + || CHANNEL_TYPE_COE_DIGITAL_OUT_UID.equals(ct); + // for the analog out channel we have the measurement type. for the input + // channel we take it from the C.M.I. + final Class ccClass = CHANNEL_TYPE_COE_ANALOG_OUT_UID.equals(ct) + ? TACmiChannelConfigurationAnalog.class + : TACmiChannelConfigurationDigital.class; + final TACmiChannelConfiguration channelConfig = chann.getConfiguration().as(ccClass); + this.channelConfigByUID.put(chann.getUID(), channelConfig); + final MessageType messageType = analog ? MessageType.ANALOG : MessageType.DIGITAL; + final byte podId = this.getPodId(messageType, channelConfig.output); + final PodIdentifier pi = new PodIdentifier(messageType, podId, outgoing); + // initialize podData + PodData pd = this.getPodData(pi); + if (outgoing) { + int outputIdx = getOutputIndex(channelConfig.output, analog); + PodDataOutgoing podDataOutgoing = (PodDataOutgoing) pd; + // we have to track value state for all outgoing channels to ensure we have valid values for all + // channels in use before we send a message to the C.M.I. otherwise it could trigger some strange things + // on TA side... + boolean set = false; + if (analog) { + TACmiChannelConfigurationAnalog ca = (TACmiChannelConfigurationAnalog) channelConfig; + Double initialValue = ca.initialValue; + if (initialValue != null) { + final TACmiMeasureType measureType = TACmiMeasureType.values()[ca.type]; + final double val = initialValue.doubleValue() * measureType.getOffset(); + @Nullable + Message message = pd.message; + if (message != null) { + // shouldn't happen, just in case... + message.setValue(outputIdx, (short) val, measureType.ordinal()); + set = true; + } + } + } else { + // digital... + TACmiChannelConfigurationDigital ca = (TACmiChannelConfigurationDigital) channelConfig; + Boolean initialValue = ca.initialValue; + if (initialValue != null) { + @Nullable + DigitalMessage message = (DigitalMessage) pd.message; + if (message != null) { + // shouldn't happen, just in case... + message.setPortState(outputIdx, initialValue); + set = true; + } + } + } + podDataOutgoing.channeUIDs[outputIdx] = chann.getUID(); + podDataOutgoing.initialized[outputIdx] = set; + } + } + + final Bridge br = getBridge(); + final TACmiCoEBridgeHandler bridge = br == null ? null : (TACmiCoEBridgeHandler) br.getHandler(); + if (bridge == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "No Bridge configured!"); + return; + } + bridge.registerCMI(this); + this.bridge = bridge; + + // we set it to UNKNOWN. Will be set to ONLIN~E as soon as we start receiving + // data or to OFFLINE when no data is received within 900 seconds. + updateStatus(ThingStatus.UNKNOWN); + } + + private PodData getPodData(final PodIdentifier pi) { + PodData pd = this.podDatas.get(pi); + if (pd == null) { + if (pi.outgoing) { + pd = new PodDataOutgoing(pi, (byte) this.node); + } else { + pd = new PodData(pi, (byte) this.node); + } + this.podDatas.put(pi, pd); + } + return pd; + } + + private byte getPodId(final MessageType messageType, final int output) { + assert output >= 1 && output <= 32; // range 1-32 + // pod ID's: 0 & 9 for digital states, 1-8 for analog values + boolean analog = messageType == MessageType.ANALOG; + int outputIdx = getOutputIndex(output, analog); + if (messageType == MessageType.ANALOG) { + return (byte) (outputIdx + 1); + } + return (byte) (outputIdx == 0 ? 0 : 9); + } + + /** + * calculates output index position within the POD. + * TA output index starts with 1, our arrays starts at 0. We also have to keep the pod size in mind... + * + * @param output + * @param analog + * @return + */ + private int getOutputIndex(int output, boolean analog) { + int outputIdx = output - 1; + if (analog) { + outputIdx %= 4; + } else { + outputIdx %= 16; + } + return outputIdx; + } + + @Override + public void handleCommand(final ChannelUID channelUID, final Command command) { + final TACmiChannelConfiguration channelConfig = this.channelConfigByUID.get(channelUID); + if (channelConfig == null) { + logger.debug("Recived unhandled command '{}' for unknown Channel {} ", command, channelUID); + return; + } + final Channel channel = thing.getChannel(channelUID); + if (channel == null) { + return; + } + + if (command instanceof RefreshType) { + // we try to find the last known state from cache and return it. + MessageType mt; + if ((TACmiBindingConstants.CHANNEL_TYPE_COE_DIGITAL_IN_UID.equals(channel.getChannelTypeUID()))) { + mt = MessageType.DIGITAL; + } else if ((TACmiBindingConstants.CHANNEL_TYPE_COE_ANALOG_IN_UID.equals(channel.getChannelTypeUID()))) { + mt = MessageType.ANALOG; + } else { + logger.debug("Recived unhandled command '{}' on unknown Channel type {} ", command, channelUID); + return; + } + final byte podId = getPodId(mt, channelConfig.output); + PodData pd = getPodData(new PodIdentifier(mt, podId, true)); + @Nullable + Message message = pd.message; + if (message == null) { + // no data received yet from the C.M.I. and persistence might be disabled.. + return; + } + if (mt == MessageType.ANALOG) { + final AnalogValue value = ((AnalogMessage) message).getAnalogValue(channelConfig.output); + updateState(channel.getUID(), new DecimalType(value.value)); + } else { + final boolean state = ((DigitalMessage) message).getPortState(channelConfig.output); + updateState(channel.getUID(), state ? OnOffType.ON : OnOffType.OFF); + } + return; + } + boolean analog; + MessageType mt; + if ((TACmiBindingConstants.CHANNEL_TYPE_COE_DIGITAL_OUT_UID.equals(channel.getChannelTypeUID()))) { + mt = MessageType.DIGITAL; + analog = false; + } else if ((TACmiBindingConstants.CHANNEL_TYPE_COE_ANALOG_OUT_UID.equals(channel.getChannelTypeUID()))) { + mt = MessageType.ANALOG; + analog = true; + } else { + logger.debug("Recived unhandled command '{}' on Channel {} ", command, channelUID); + return; + } + + final byte podId = getPodId(mt, channelConfig.output); + PodDataOutgoing podDataOutgoing = (PodDataOutgoing) getPodData(new PodIdentifier(mt, podId, true)); + @Nullable + Message message = podDataOutgoing.message; + if (message == null) { + logger.error("Internal error - BUG - no outgoing message for command '{}' on Channel {} ", command, + channelUID); + return; + } + int outputIdx = getOutputIndex(channelConfig.output, analog); + boolean modified; + if (analog) { + final TACmiMeasureType measureType = TACmiMeasureType + .values()[((TACmiChannelConfigurationAnalog) channelConfig).type]; + final DecimalType dt = (DecimalType) command; + final double val = dt.doubleValue() * measureType.getOffset(); + modified = message.setValue(outputIdx, (short) val, measureType.ordinal()); + } else { + final boolean state = OnOffType.ON.equals(command) ? true : false; + modified = ((DigitalMessage) message).setPortState(outputIdx, state); + } + podDataOutgoing.initialized[outputIdx] = true; + if (modified) { + try { + @Nullable + final TACmiCoEBridgeHandler br = this.bridge; + @Nullable + final InetAddress cmia = this.cmiAddress; + if (br != null && cmia != null && podDataOutgoing.isAllValuesInitialized()) { + br.sendData(message.getRaw(), cmia); + podDataOutgoing.lastSent = System.currentTimeMillis(); + } + // we also update the local state after we successfully sent out the command + // there is no feedback from the C.M.I. so we only could assume the message has been received when we + // were able to send it... + updateState(channel.getUID(), (State) command); + } catch (final IOException e) { + logger.warn("Error sending message: {}: {}", e.getClass().getName(), e.getMessage()); + } + } + } + + @Override + public void dispose() { + final TACmiCoEBridgeHandler br = this.bridge; + if (br != null) { + br.unregisterCMI(this); + } + super.dispose(); + } + + public boolean isFor(final InetAddress remoteAddress, final int node) { + @Nullable + final InetAddress cmia = this.cmiAddress; + if (cmia == null) { + return false; + } + return this.node == node && cmia.equals(remoteAddress); + } + + public void handleCoE(final Message message) { + final ChannelTypeUID channelType = message.getType() == MessageType.DIGITAL + ? TACmiBindingConstants.CHANNEL_TYPE_COE_DIGITAL_IN_UID + : TACmiBindingConstants.CHANNEL_TYPE_COE_ANALOG_IN_UID; + if (getThing().getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + this.lastMessageRecvTS = System.currentTimeMillis(); + for (final Channel channel : thing.getChannels()) { + if (!(channelType.equals(channel.getChannelTypeUID()))) { + continue; + } + final int output = ((Number) channel.getConfiguration().get(TACmiBindingConstants.CHANNEL_CONFIG_OUTPUT)) + .intValue(); + if (!message.hasPortnumber(output)) { + continue; + } + + if (message.getType() == MessageType.ANALOG) { + final AnalogValue value = ((AnalogMessage) message).getAnalogValue(output); + updateState(channel.getUID(), new DecimalType(value.value)); + } else { + final boolean state = ((DigitalMessage) message).getPortState(output); + updateState(channel.getUID(), state ? OnOffType.ON : OnOffType.OFF); + } + } + } + + public void checkForTimeout() { + final long refTs = System.currentTimeMillis(); + if (refTs - this.lastMessageRecvTS > 900000 && getThing().getStatus() != ThingStatus.OFFLINE) { + // no data received for 900 seconds - set thing status to offline.. + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "No update from C.M.I. for 15 min"); + } + for (final PodData pd : this.podDatas.values()) { + if (pd == null || !(pd instanceof PodDataOutgoing)) { + continue; + } + PodDataOutgoing podDataOutgoing = (PodDataOutgoing) pd; + @Nullable + Message message = pd.message; + if (message != null && refTs - podDataOutgoing.lastSent > 300000) { + // re-send every 300 seconds... + @Nullable + final InetAddress cmia = this.cmiAddress; + if (podDataOutgoing.isAllValuesInitialized()) { + try { + @Nullable + final TACmiCoEBridgeHandler br = this.bridge; + if (br != null && cmia != null) { + br.sendData(message.getRaw(), cmia); + podDataOutgoing.lastSent = System.currentTimeMillis(); + } + } catch (final IOException e) { + logger.warn("Error sending message to C.M.I.: {}: {}", e.getClass().getName(), e.getMessage()); + } + } else { + // pod is not entirely initialized - log warn for user but also set lastSent to prevent flooding of + // logs... + if (cmia != null) { + logger.warn("Sending data to {} {}.{} is blocked as we don't have valid values for channels {}", + cmia.getHostAddress(), this.node, podDataOutgoing.podId, + podDataOutgoing.getUninitializedChannelNames()); + } + podDataOutgoing.lastSent = System.currentTimeMillis(); + } + } + } + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/AnalogMessage.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/AnalogMessage.java new file mode 100644 index 0000000000000..68d2654a36d60 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/AnalogMessage.java @@ -0,0 +1,116 @@ +/** + * 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.tacmi.internal.message; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Format of analog messages is as follows: + * 1 2 3 4 5 6 7 8 9 10 11 12 13 14 + * 0 1 2 3 4 5 6 7 8 9 10 11 12 13 + * canNode 1|2|3|4 1.lower 1.upper 2.lower 2.upper 3.lower 3.upper 4.lower 4.upper 1.type 2.type 3.type 4.type + * + * possible values for type according to the documentation are 1 to 21. + * + * The documentation says for the types: + * + * 1: Degree Celsius + * 2: Watts per square meter + * 3: liters per hour + * 4: seconds + * 5: minutes + * 6: liters per pulse + * 7: Kelvin + * 8: Percent + * 9: Kilowatt + * 10: Megawatthours + * 11: Kilowatthours + * 12: Volt + * 13: Milliampere + * 14: hours + * 15: days + * 16: pulses + * 17: Kiloohm + * 18: Kilometers per hour + * 19: Hertz + * 20: liters per minute + * 21: bar + * + * However, reality shows that the documentation is partly not accurate. An UVR1611 device uses: + * + * 1: Degree Celsius + * 4: Seconds + * 10: Kilowatt + * 11: Megawatthours + * 12: Kilowatthours + * + * so we don't rely on the documentation. + * + * This class can be used to decode the analog values received in a message and + * also to create a new AnalogMessage used to send analog values to an analog + * CAN Input port. Creation of new message is not implemented so far. + * + * @author Timo Wendt - Initial contribution + * @author Wolfgang Klimt - improvements + * @author Christian Niessner - Ported to OpenHAB2 + */ +@NonNullByDefault +public final class AnalogMessage extends Message { + + /** + * Used to parse the data received from the CMI. + * + * @param raw + */ + public AnalogMessage(byte[] raw) { + super(raw); + } + + /** + * Create a new message to be sent to the CMI. It is only supported to use + * the first port for each podNumber. + */ + public AnalogMessage(byte canNode, byte podNumber) { + super(canNode, podNumber); + } + + /** + * Get the value for the specified port number. + * + * @param portNumber + * @return + */ + public AnalogValue getAnalogValue(int portNumber) { + // Get the internal index for portNumber within the message + int idx = (portNumber - 1) % 4; + AnalogValue value = new AnalogValue(this.getValue(idx), getMeasureType(idx)); + return value; + } + + /** + * Check if message contains a value for the specified port number. It + * doesn't matter though if the port has a value of 0. + * + * @param portNumber + * @return + */ + @Override + public boolean hasPortnumber(int portNumber) { + return (portNumber - 1) / 4 == podNumber - 1; + } + + @Override + public MessageType getType() { + return MessageType.ANALOG; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/AnalogValue.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/AnalogValue.java new file mode 100644 index 0000000000000..c40ec4bc06cd0 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/AnalogValue.java @@ -0,0 +1,37 @@ +/** + * 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.tacmi.internal.message; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.tacmi.internal.TACmiMeasureType; + +/** + * This class handles analog values as used in the analog message. + * + * @author Timo Wendt - Initial contribution + * @author Wolfgang Klimt + * @author Christian Niessner - Ported to OpenHAB2 + */ +@NonNullByDefault +public final class AnalogValue { + public double value; + public TACmiMeasureType measureType; + + /** + * Create new AnalogValue with specified value and type + */ + public AnalogValue(int rawValue, int type) { + measureType = TACmiMeasureType.fromInt(type); + value = ((double) rawValue) / measureType.getOffset(); + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/DigitalMessage.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/DigitalMessage.java new file mode 100644 index 0000000000000..d7d5d5dbaa535 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/DigitalMessage.java @@ -0,0 +1,104 @@ +/** + * 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.tacmi.internal.message; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * This class can be used to decode the digital values received in a messag and + * also to create a new DigitalMessage used to send ON/OFF to an digital CAN + * Input port + * + * @author Timo Wendt - Initial contribution + * @author Christian Niessner - Ported to OpenHAB2 + */ +@NonNullByDefault +public final class DigitalMessage extends Message { + + public DigitalMessage(byte[] raw) { + super(raw); + } + + /** + * Create a new message to be sent to the CMI. It is only supported to use the + * first port for each CAN node. This is due to the fact that all digital port + * for the specific CAN node are send within a single message. + */ + public DigitalMessage(byte canNode, byte podNr) { + super(canNode, podNr); + } + + /** + * Get the state of the specified port number. + * + * @param portNumber + * @return + */ + public boolean getPortState(int portNumber) { + return getBit(getValue(0), (portNumber - 1) % 16); + } + + /** + * Set the state of the specified port number. + * + * @param portNumber + * @param value + * @return + */ + public boolean setPortState(int portNumber, boolean value) { + short val = getValue(0); + int bit = (1 << portNumber); + if (value) { + val |= bit; + } else { + val &= ~bit; + } + return setValue(0, val, 0); + } + + /** + * Read the specified bit from the short value holding the states of all 16 + * ports. + * + * @param portBits + * @param portBit + * @return + */ + private boolean getBit(int portBits, int portBit) { + int result = (portBits >> portBit) & 0x1; + return result == 1 ? true : false; + } + + /** + * Check if message contains a value for the specified port number. portNumber + * Digital messages are in POD 0 for 1-16 and POD 9 for 17-32 + * + * @param portNumber - the portNumber in Range 1-32 + * @return + */ + @Override + public boolean hasPortnumber(int portNumber) { + if (podNumber == 0 && portNumber <= 16) { + return true; + } + if (podNumber == 9 && portNumber >= 17) { + return true; + } + return false; + } + + @Override + public MessageType getType() { + return MessageType.DIGITAL; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/Message.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/Message.java new file mode 100644 index 0000000000000..b88f61684e67f --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/Message.java @@ -0,0 +1,157 @@ +/** + * 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.tacmi.internal.message; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base message class handling generic functions. + * + * @author Timo Wendt - Initial contribution + * @author Christian Niessner - Ported to OpenHAB2 + */ +@NonNullByDefault +public abstract class Message { + + protected final Logger logger = LoggerFactory.getLogger(Message.class); + + /** + * ByteBuffer that stores the content of the message. + */ + private ByteBuffer buffer; + + /** + * CAN Node number used in the message + */ + public byte canNode; + + /** + * POD number used in the message + */ + public byte podNumber; + + /** + * Initialize from the bytes of a received message + * + * @param raw + */ + public Message(byte[] raw) { + this.buffer = ByteBuffer.wrap(raw); + this.buffer.order(ByteOrder.LITTLE_ENDIAN); + this.canNode = buffer.get(0); + this.podNumber = buffer.get(1); + } + + /** + * Used to create a new message with the specified CAN node and POD number + * + * @param canNode + * @param podNumber + */ + public Message(int canNode, int podNumber) { + this.buffer = ByteBuffer.allocate(14); + this.buffer.order(ByteOrder.LITTLE_ENDIAN); + setCanNode(canNode); + setPodNumber(podNumber); + } + + public abstract MessageType getType(); + + public abstract boolean hasPortnumber(int portNumber); + + /** + * Get the byte array. This can be sent to the CMI. + * + * @return raw + */ + public byte[] getRaw() { + return buffer.array(); + } + + /** + * Set the CAN node number for this message + * + * @param canNode + */ + public void setCanNode(int canNode) { + buffer.put(0, (byte) (canNode & 0xff)); + } + + /** + * Set the POD number for this message + * + * @param podNumber + */ + public void setPodNumber(int podNumber) { + buffer.put(1, (byte) (podNumber & 0xf)); + } + + /** + * Set the value at th specified index within the message and the defined + * measure type. The measure type is only used in analog messages. Digital + * messages always use 0 for the measure types. + * + * @param idx + * @param value + * @param measureType + * @return true when value was modified + */ + public boolean setValue(int idx, short value, int measureType) { + boolean modified = false; + int idxValue = idx * 2 + 2; + if (buffer.getShort(idxValue) != value) { + buffer.putShort(idxValue, value); + modified = true; + } + byte mtv = (byte) (measureType & 0xf); + if (buffer.get(idx + 10) != mtv) { + buffer.put(idx + 10, mtv); + modified = true; + } + return modified; + } + + /** + * Get the value at the specified index within the message. The value will + * be converted from thr signed short to an unsigned int. + * + * @param idx + * @return + */ + public short getValue(int idx) { + return (buffer.getShort(idx * 2 + 2)); + } + + /** + * Get the measure type for the specified index within the message. + * + * @param idx + * @return + */ + public int getMeasureType(int idx) { + return (buffer.get(idx + 10)) & 0xffff; + } + + @Override + public String toString() { + return ("CAN: " + this.canNode + " POD: " + this.podNumber + " Value1: " + getValue(0) + " Value2: " + + getValue(1) + " Value3: " + getValue(2) + " Value4: " + getValue(3) + " MeasureType1 " + + getMeasureType(0) + " MeasureType2 " + getMeasureType(1) + " MeasureType3 " + getMeasureType(2) + + " MeasureType4 " + getMeasureType(3)); + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/MessageType.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/MessageType.java new file mode 100644 index 0000000000000..7e708462ebe1e --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/message/MessageType.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.tacmi.internal.message; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * This enumeration represents the different message types provided by the C.M.I COE protocol. + * + * @author Timo Wendt - Initial contribution + * @author Christian Niessner - Ported to OpenHAB2 + */ +@NonNullByDefault +public enum MessageType { + ANALOG, + DIGITAL +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ApiPageEntry.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ApiPageEntry.java new file mode 100644 index 0000000000000..ba4c2a6270993 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ApiPageEntry.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.tacmi.internal.schema; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.smarthome.core.thing.Channel; +import org.eclipse.smarthome.core.types.State; + +/** + * The {@link ApiPageEntry} class contains mapping information for an entry of + * the API page. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class ApiPageEntry { + + static enum Type { + READ_ONLY_SWITCH(true), + READ_ONLY_NUMERIC(true), + NUMERIC_FORM(false), + SWITCH_BUTTON(false), + SWITCH_FORM(false), + READ_ONLY_STATE(true), + STATE_FORM(false); + + public final boolean readOnly; + + private Type(boolean readOnly) { + this.readOnly = readOnly; + } + } + + /** + * type of this entry + */ + public final Type type; + + /** + * The channel for this entry + */ + public final Channel channel; + + /** + * internal address for this channel + */ + public final @Nullable String address; + + /** + * data for handle 'changerx2' form fields + */ + public final @Nullable ChangerX2Entry changerX2Entry; + + /** + * The last known state for this item... + */ + private State lastState; + + protected ApiPageEntry(final Type type, final Channel channel, @Nullable final String address, + @Nullable ChangerX2Entry changerX2Entry, State lastState) { + this.type = type; + this.channel = channel; + this.address = address; + this.changerX2Entry = changerX2Entry; + this.lastState = lastState; + } + + public void setLastState(State lastState) { + this.lastState = lastState; + } + + public State getLastState() { + return lastState; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ApiPageParser.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ApiPageParser.java new file mode 100644 index 0000000000000..c6d9a7a151218 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ApiPageParser.java @@ -0,0 +1,494 @@ +/** + * 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.tacmi.internal.schema; + +import java.math.BigDecimal; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.attoparser.ParseException; +import org.attoparser.simple.AbstractSimpleMarkupHandler; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.smarthome.core.library.types.DecimalType; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.library.types.QuantityType; +import org.eclipse.smarthome.core.library.types.StringType; +import org.eclipse.smarthome.core.library.unit.SIUnits; +import org.eclipse.smarthome.core.library.unit.SmartHomeUnits; +import org.eclipse.smarthome.core.thing.Channel; +import org.eclipse.smarthome.core.thing.ChannelUID; +import org.eclipse.smarthome.core.thing.binding.builder.ChannelBuilder; +import org.eclipse.smarthome.core.thing.type.ChannelType; +import org.eclipse.smarthome.core.thing.type.ChannelTypeBuilder; +import org.eclipse.smarthome.core.thing.type.ChannelTypeUID; +import org.eclipse.smarthome.core.types.State; +import org.eclipse.smarthome.core.types.StateDescriptionFragmentBuilder; +import org.eclipse.smarthome.core.types.StateOption; +import org.openhab.binding.tacmi.internal.TACmiBindingConstants; +import org.openhab.binding.tacmi.internal.TACmiChannelTypeProvider; +import org.openhab.binding.tacmi.internal.schema.ApiPageEntry.Type; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ApiPageParser} class parses the 'API' schema page from the CMI and + * maps it to our channels + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class ApiPageParser extends AbstractSimpleMarkupHandler { + + private final Logger logger = LoggerFactory.getLogger(ApiPageParser.class); + + static enum ParserState { + INIT, + DATA_ENTRY + } + + static enum FieldType { + UNKNOWN, + READ_ONLY, + FORM_VALUE, + BUTTON, + IGNORE + } + + static enum ButtonValue { + UNKNOWN, + ON, + OFF + } + + private ParserState parserState = ParserState.INIT; + private TACmiSchemaHandler taCmiSchemaHandler; + private TACmiChannelTypeProvider channelTypeProvider; + private boolean configChanged = false; + private FieldType fieldType = FieldType.UNKNOWN; + private @Nullable String id; + private @Nullable String address; + private @Nullable StringBuilder value; + private ButtonValue buttonValue = ButtonValue.UNKNOWN; + private Map entries; + private Set seenNames = new HashSet<>(); + private List channels = new ArrayList<>(); + + public ApiPageParser(TACmiSchemaHandler taCmiSchemaHandler, Map entries, + TACmiChannelTypeProvider channelTypeProvider) { + super(); + this.taCmiSchemaHandler = taCmiSchemaHandler; + this.entries = entries; + this.channelTypeProvider = channelTypeProvider; + } + + @Override + public void handleDocumentStart(final long startTimeNanos, final int line, final int col) throws ParseException { + this.parserState = ParserState.INIT; + this.seenNames.clear(); + this.channels.clear(); + } + + @Override + public void handleDocumentEnd(final long endTimeNanos, final long totalTimeNanos, final int line, final int col) + throws ParseException { + if (this.parserState != ParserState.INIT) { + logger.debug("Parserstate == Init expected, but is {}", this.parserState); + } + } + + @Override + @NonNullByDefault({}) + public void handleStandaloneElement(final @Nullable String elementName, + final @Nullable Map attributes, final boolean minimized, final int line, final int col) + throws ParseException { + + logger.debug("Unexpected StandaloneElement in {}:{}: {} [{}]", line, col, elementName, attributes); + } + + @Override + @NonNullByDefault({}) + public void handleOpenElement(final @Nullable String elementName, final @Nullable Map attributes, + final int line, final int col) throws ParseException { + + if (this.parserState == ParserState.INIT && "div".equals(elementName)) { + this.parserState = ParserState.DATA_ENTRY; + String classFlags; + if (attributes == null) { + classFlags = null; + this.id = null; + this.address = null; + } else { + this.id = attributes.get("id"); + this.address = attributes.get("adresse"); + classFlags = attributes.get("class"); + } + this.fieldType = FieldType.READ_ONLY; + this.value = new StringBuilder(); + this.buttonValue = ButtonValue.UNKNOWN; + if (classFlags != null && StringUtil.isNotBlank(classFlags)) { + String[] classFlagList = classFlags.split("[ \n\r]"); + for (String classFlag : classFlagList) { + if ("changex2".equals(classFlag)) { + this.fieldType = FieldType.FORM_VALUE; + } else if ("buttonx2".equals(classFlag) || "taster".equals(classFlag)) { + this.fieldType = FieldType.BUTTON; + } else if ("visible0".equals(classFlag)) { + this.buttonValue = ButtonValue.OFF; + } else if ("visible1".equals(classFlag)) { + this.buttonValue = ButtonValue.ON; + } else if ("durchsichtig".equals(classFlag)) { // link + this.fieldType = FieldType.IGNORE; + } else if ("bord".equals(classFlag)) { // special button style - not of our interest... + } else { + logger.debug("Unhanndled class in {}:{}:{}: '{}' ", id, line, col, classFlag); + } + } + } + } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON + && "span".equals(elementName)) { + // ignored... + } else { + logger.debug("Unexpected OpenElement in {}:{}: {} [{}]", line, col, elementName, attributes); + } + } + + @Override + public void handleCloseElement(final @Nullable String elementName, final int line, final int col) + throws ParseException { + if (this.parserState == ParserState.DATA_ENTRY && "div".equals(elementName)) { + this.parserState = ParserState.INIT; + StringBuilder sb = this.value; + this.value = null; + if (sb != null) { + while (sb.length() > 0 && sb.charAt(0) == ' ') { + sb = sb.delete(0, 0); + } + if (this.fieldType == FieldType.READ_ONLY || this.fieldType == FieldType.FORM_VALUE) { + int lids = sb.lastIndexOf(":"); + int fsp = sb.indexOf(" "); + if (fsp < 0 || lids < 0 || fsp > lids) { + logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, + sb); + } else { + String shortName = sb.substring(0, fsp).trim(); + String description = sb.substring(fsp + 1, lids).trim(); + String value = sb.substring(lids + 1).trim(); + getApiPageEntry(id, line, col, shortName, description, value); + } + } else if (this.fieldType == FieldType.BUTTON) { + String sbt = sb.toString().trim().replaceAll("[\r\n ]+", " "); + int fsp = sbt.indexOf(" "); + + if (fsp < 0) { + logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, + sbt); + } else { + String shortName = sbt.substring(0, fsp).trim(); + String description = sbt.substring(fsp + 1).trim(); + getApiPageEntry(id, line, col, shortName, description, this.buttonValue); + } + } else if (this.fieldType == FieldType.IGNORE) { + // ignore + } else { + logger.debug("Unhandled setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, sb); + } + } + } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON + && "span".equals(elementName)) { + // ignored... + } else { + logger.debug("Unexpected CloseElement in {}:{}: {}", line, col, elementName); + } + } + + @Override + public void handleAutoCloseElement(final @Nullable String elementName, final int line, final int col) + throws ParseException { + logger.debug("Unexpected AutoCloseElement in {}:{}: {}", line, col, elementName); + } + + @Override + public void handleUnmatchedCloseElement(final @Nullable String elementName, final int line, final int col) + throws ParseException { + logger.debug("Unexpected UnmatchedCloseElement in {}:{}: {}", line, col, elementName); + } + + @Override + public void handleDocType(final @Nullable String elementName, final @Nullable String publicId, + final @Nullable String systemId, final @Nullable String internalSubset, final int line, final int col) + throws ParseException { + logger.debug("Unexpected DocType in {}:{}: {}/{}/{}/{}", line, col, elementName, publicId, systemId, + internalSubset); + } + + @Override + public void handleComment(final char @Nullable [] buffer, final int offset, final int len, final int line, + final int col) throws ParseException { + logger.debug("Unexpected comment in {}:{}: {}", line, col, new String(buffer, offset, len)); + } + + @Override + public void handleCDATASection(final char @Nullable [] buffer, final int offset, final int len, final int line, + final int col) throws ParseException { + logger.debug("Unexpected CDATA in {}:{}: {}", line, col, new String(buffer, offset, len)); + } + + @Override + public void handleText(final char @Nullable [] buffer, final int offset, final int len, final int line, + final int col) throws ParseException { + + if (buffer == null) { + return; + } + + if (this.parserState == ParserState.DATA_ENTRY) { + // we append it to our current value + StringBuilder sb = this.value; + if (sb != null) { + sb.append(buffer, offset, len); + } + } else if (this.parserState == ParserState.INIT && ((len == 1 && buffer[offset] == '\n') + || (len == 2 && buffer[offset] == '\r' && buffer[offset + 1] == '\n'))) { + // single newline - ignore/drop it... + } else { + String msg = new String(buffer, offset, len).replace("\n", "\\n").replace("\r", "\\r"); + logger.debug("Unexpected Text {}:{}: ParserState: {} ({}) `{}`", line, col, parserState, len, msg); + } + } + + @Override + public void handleXmlDeclaration(final @Nullable String version, final @Nullable String encoding, + final @Nullable String standalone, final int line, final int col) throws ParseException { + logger.debug("Unexpected XML Declaration {}:{}: {} {} {}", line, col, version, encoding, standalone); + } + + @Override + public void handleProcessingInstruction(final @Nullable String target, final @Nullable String content, + final int line, final int col) throws ParseException { + logger.debug("Unexpected ProcessingInstruction {}:{}: {} {}", line, col, target, content); + } + + private void getApiPageEntry(@Nullable String id2, int line, int col, String shortName, String description, + Object value) { + if (logger.isDebugEnabled()) { + logger.debug("Found parameter {}:{}:{} [{}] : {} \"{}\" = {}", id, line, col, this.fieldType, shortName, + description, value); + } + if (!this.seenNames.add(shortName)) { + logger.warn("Found duplicate parameter '{}' in {}:{}:{} [{}] : {} \"{}\" = {}", shortName, id, line, col, + this.fieldType, shortName, description, value); + return; + } + + if (value instanceof String && ((String) value).contains("can_busy")) { + return; // special state to indicate value currently cannot be retrieved.. + } + ApiPageEntry.Type type; + State state; + String channelType; + ChannelTypeUID ctuid; + switch (this.fieldType) { + case BUTTON: + type = Type.SWITCH_BUTTON; + state = this.buttonValue == ButtonValue.ON ? OnOffType.ON : OnOffType.OFF; + ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID; + channelType = "Switch"; + break; + case READ_ONLY: + case FORM_VALUE: + String vs = (String) value; + boolean isOn = "ON".equals(vs) || "EIN".equals(vs); // C.M.I. mixes up languages... + if (isOn || "OFF".equals(vs) || "AUS".equals(vs)) { + channelType = "Switch"; + state = isOn ? OnOffType.ON : OnOffType.OFF; + if (this.fieldType == FieldType.READ_ONLY || this.address == null) { + ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RO_UID; + type = Type.READ_ONLY_SWITCH; + } else { + ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID; + type = Type.SWITCH_FORM; + } + } else { + try { + // check if we have a numeric value (either with or without unit) + String[] valParts = vs.split(" "); + // It seems for some wired cases the C.M.I. uses different decimal separators for + // different device types. It seems all 'new' X2-Devices use a dot as separator, + // for the older pre-X2 devices (i.e. the UVR 1611) we get a comma. So we + // we replace all ',' with '.' to check if it's a valid number... + String val = valParts[0].replace(',', '.'); + BigDecimal bd = new BigDecimal(val); + if (valParts.length == 2) { + if ("°C".equals(valParts[1])) { + channelType = "Number:Temperature"; + state = new QuantityType<>(bd, SIUnits.CELSIUS); + } else if ("%".equals(valParts[1])) { + channelType = "Number:Percent"; + state = new QuantityType<>(bd, SmartHomeUnits.PERCENT); + } else if ("Imp".equals(valParts[1])) { + // impulses - no idea how to map this to something useful here? + channelType = "Number"; + state = new DecimalType(bd); + } else if ("V".equals(valParts[1])) { + channelType = "Number:Voltage"; + state = new QuantityType<>(bd, SmartHomeUnits.VOLT); + } else if ("A".equals(valParts[1])) { + channelType = "Number:Current"; + state = new QuantityType<>(bd, SmartHomeUnits.AMPERE); + } else if ("Hz".equals(valParts[1])) { + channelType = "Number:Frequency"; + state = new QuantityType<>(bd, SmartHomeUnits.HERTZ); + } else if ("kW".equals(valParts[1])) { + channelType = "Number:Power"; + bd = bd.multiply(new BigDecimal(1000)); + state = new QuantityType<>(bd, SmartHomeUnits.WATT); + } else if ("kWh".equals(valParts[1])) { + channelType = "Number:Power"; + bd = bd.multiply(new BigDecimal(1000)); + state = new QuantityType<>(bd, SmartHomeUnits.KILOWATT_HOUR); + } else if ("l/h".equals(valParts[1])) { + channelType = "Number:Volume"; + bd = bd.divide(new BigDecimal(60)); + state = new QuantityType<>(bd, SmartHomeUnits.LITRE_PER_MINUTE); + } else { + channelType = "Number"; + state = new DecimalType(bd); + logger.debug("Unhandled UoM for channel {} of type {} for '{}': {}", shortName, + channelType, description, valParts[1]); + } + } else { + channelType = "Number"; + state = new DecimalType(bd); + } + if (this.fieldType == FieldType.READ_ONLY || this.address == null) { + ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_NUMERIC_RO_UID; + type = Type.READ_ONLY_NUMERIC; + } else { + ctuid = null; + type = Type.NUMERIC_FORM; + } + } catch (NumberFormatException nfe) { + // not a number... + channelType = "String"; + if (this.fieldType == FieldType.READ_ONLY || this.address == null) { + ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_STATE_RO_UID; + type = Type.READ_ONLY_STATE; + } else { + ctuid = null; + type = Type.STATE_FORM; + } + state = new StringType(vs); + } + } + break; + case UNKNOWN: + case IGNORE: + return; + default: + // should't happen but we have to add default for the compiler... + return; + } + ApiPageEntry e = this.entries.get(shortName); + if (e == null || e.type != type || !channelType.equals(e.channel.getAcceptedItemType())) { + @Nullable + Channel channel = this.taCmiSchemaHandler.getThing().getChannel(shortName); + @Nullable + ChangerX2Entry cx2e = null; + if (this.fieldType == FieldType.FORM_VALUE) { + try { + URI uri = this.taCmiSchemaHandler.buildUri("INCLUDE/changerx2.cgi?sadrx2=" + address); + final ChangerX2Parser pp = this.taCmiSchemaHandler.parsePage(uri, new ChangerX2Parser(shortName)); + cx2e = pp.getParsedEntry(); + } catch (final ParseException | RuntimeException ex) { + logger.warn("Error parsing API Scheme: {} ", ex.getMessage(), ex); + } catch (final TimeoutException | InterruptedException | ExecutionException ex) { + logger.warn("Error loading API Scheme: {} ", ex.getMessage()); + } + } + if (channel == null) { + logger.debug("Creating / updating channel {} of type {} for '{}'", shortName, channelType, description); + this.configChanged = true; + ChannelUID channelUID = new ChannelUID(this.taCmiSchemaHandler.getThing().getUID(), shortName); + ChannelBuilder channelBuilder = ChannelBuilder.create(channelUID, channelType); + channelBuilder.withLabel(description); + if (ctuid != null) { + channelBuilder.withType(ctuid); + } else if (cx2e != null) { + StateDescriptionFragmentBuilder sdb = StateDescriptionFragmentBuilder.create() + .withReadOnly(type.readOnly); + String itemType; + switch (cx2e.optionType) { + case NUMBER: + itemType = "Number"; + String min = cx2e.options.get(ChangerX2Entry.NUMBER_MIN); + if (min != null && !min.trim().isEmpty()) { + sdb.withMinimum(new BigDecimal(min)); + } + String max = cx2e.options.get(ChangerX2Entry.NUMBER_MAX); + if (max != null && !max.trim().isEmpty()) { + sdb.withMaximum(new BigDecimal(max)); + } + String step = cx2e.options.get(ChangerX2Entry.NUMBER_STEP); + if (step != null && !step.trim().isEmpty()) { + sdb.withStep(new BigDecimal(step)); + } + break; + case SELECT: + itemType = "String"; + for (Entry entry : cx2e.options.entrySet()) { + String val = entry.getValue(); + if (val != null) { + sdb.withOption(new StateOption(val, entry.getKey())); + } + } + break; + default: + throw new IllegalStateException(); + } + ChannelType ct = ChannelTypeBuilder + .state(new ChannelTypeUID(TACmiBindingConstants.BINDING_ID, shortName), shortName, itemType) + .withDescription("Auto-created for " + shortName) + .withStateDescription(sdb.build().toStateDescription()).build(); + channelTypeProvider.addChannelType(ct); + channelBuilder.withType(ct.getUID()); + } else { + logger.warn("Error configurating channel for {}: channeltype cannot be determined!", shortName); + } + channel = channelBuilder.build(); // add configuration property... + } + this.configChanged = true; + e = new ApiPageEntry(type, channel, address, cx2e, state); + this.entries.put(shortName, e); + } + this.channels.add(e.channel); + e.setLastState(state); + this.taCmiSchemaHandler.updateState(e.channel.getUID(), state); + } + + protected boolean isConfigChanged() { + return this.configChanged; + } + + protected List getChannels() { + return channels; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ChangerX2Entry.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ChangerX2Entry.java new file mode 100644 index 0000000000000..216d86d51e63e --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ChangerX2Entry.java @@ -0,0 +1,71 @@ +/** + * 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.tacmi.internal.schema; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link ChangerX2Entry} class contains mapping information for a changerX2 entry of + * the API page element + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class ChangerX2Entry { + + public static final String NUMBER_MIN = "min"; + public static final String NUMBER_MAX = "max"; + public static final String NUMBER_STEP = "step"; + + static enum OptionType { + NUMBER, + SELECT, + } + + /** + * field name of the address + */ + public final String addressFieldName; + + /** + * The address these options are for + */ + public final String address; + + /** + * option type + */ + public final OptionType optionType; + + /** + * field name of the option value + */ + public final String optionFieldName; + + /** + * the valid options + */ + public final Map options; + + public ChangerX2Entry(String addressFieldName, String address, String optionFieldName, OptionType optionType, + Map options) { + this.addressFieldName = addressFieldName; + this.address = address; + this.optionFieldName = optionFieldName; + this.optionType = optionType; + this.options = options; + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ChangerX2Parser.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ChangerX2Parser.java new file mode 100644 index 0000000000000..3270c1e5d22fb --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/ChangerX2Parser.java @@ -0,0 +1,250 @@ +/** + * 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.tacmi.internal.schema; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.attoparser.ParseException; +import org.attoparser.simple.AbstractSimpleMarkupHandler; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.tacmi.internal.schema.ChangerX2Entry.OptionType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ApiPageParser} class parses the 'changerx2' page from the CMI and + * maps it to the results + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class ChangerX2Parser extends AbstractSimpleMarkupHandler { + + private final Logger logger = LoggerFactory.getLogger(ChangerX2Parser.class); + + static enum ParserState { + INIT, + INPUT, + INPUT_DATA, + SELECT, + SELECT_OPTION, + UNKNOWN + } + + private final String channelName; + private @Nullable String curOptionId; + private ParserState parserState = ParserState.INIT; + private @Nullable String address; + private @Nullable String addressFieldName; + private @Nullable String optionFieldName; + private @Nullable OptionType optionType; + private @Nullable StringBuilder curOptionValue; + private Map options; + + public ChangerX2Parser(String channelName) { + super(); + this.options = new LinkedHashMap<>(); + this.channelName = channelName; + } + + @Override + public void handleDocumentStart(final long startTimeNanos, final int line, final int col) throws ParseException { + this.parserState = ParserState.INIT; + this.options.clear(); + } + + @Override + public void handleDocumentEnd(final long endTimeNanos, final long totalTimeNanos, final int line, final int col) + throws ParseException { + if (this.parserState != ParserState.INIT) { + logger.debug("Parserstate == Init expected, but is {}", this.parserState); + } + } + + @Override + @NonNullByDefault({}) + public void handleStandaloneElement(final String elementName, final Map attributes, + final boolean minimized, final int line, final int col) throws ParseException { + + logger.debug("Error parsing options for {}: Unexpected StandaloneElement in {}{}: {} [{}]", channelName, line, + col, elementName, attributes); + } + + @Override + @NonNullByDefault({}) + public void handleOpenElement(final String elementName, final Map attributes, final int line, + final int col) throws ParseException { + + String id = attributes == null ? null : attributes.get("id"); + + if (this.parserState == ParserState.INIT && "input".equals(elementName) && "changeadr".equals(id)) { + this.parserState = ParserState.INPUT; + if (attributes == null) { + this.address = null; + this.addressFieldName = null; + } else { + this.addressFieldName = attributes.get("name"); + this.address = attributes.get("value"); + } + } else if ((this.parserState == ParserState.INIT || this.parserState == ParserState.INPUT) + && "select".equals(elementName)) { + this.parserState = ParserState.SELECT; + this.optionFieldName = attributes == null ? null : attributes.get("name"); + } else if ((this.parserState == ParserState.INIT || this.parserState == ParserState.INPUT) + && "br".equals(elementName)) { + // ignored + } else if ((this.parserState == ParserState.INIT || this.parserState == ParserState.INPUT) + && "input".equals(elementName) && "changeto".equals(id)) { + this.parserState = ParserState.INPUT_DATA; + if (attributes != null) { + this.optionFieldName = attributes.get("name"); + String type = attributes.get("type"); + if ("number".equals(type)) { + this.optionType = OptionType.NUMBER; + // we transfer the limits from the input elemnt... + this.options.put(ChangerX2Entry.NUMBER_MIN, attributes.get(ChangerX2Entry.NUMBER_MIN)); + this.options.put(ChangerX2Entry.NUMBER_MAX, attributes.get(ChangerX2Entry.NUMBER_MAX)); + this.options.put(ChangerX2Entry.NUMBER_STEP, attributes.get(ChangerX2Entry.NUMBER_STEP)); + } else { + logger.warn("Error parsing options for {}: Unhandled input field in {}:{}: {}", channelName, line, + col, attributes); + } + } + } else if (this.parserState == ParserState.SELECT && "option".equals(elementName)) { + this.parserState = ParserState.SELECT_OPTION; + this.optionType = OptionType.SELECT; + this.curOptionValue = new StringBuilder(); + this.curOptionId = attributes == null ? null : attributes.get("value"); + } else { + logger.debug("Error parsing options for {}: Unexpected OpenElement in {}:{}: {} [{}]", channelName, line, + col, elementName, attributes); + } + } + + @Override + public void handleCloseElement(final @Nullable String elementName, final int line, final int col) + throws ParseException { + if (this.parserState == ParserState.INPUT && "input".equals(elementName)) { + this.parserState = ParserState.INIT; + } else if (this.parserState == ParserState.SELECT && "select".equals(elementName)) { + this.parserState = ParserState.INIT; + } else if (this.parserState == ParserState.SELECT_OPTION && "option".equals(elementName)) { + this.parserState = ParserState.SELECT; + StringBuilder sb = this.curOptionValue; + String value = sb != null && sb.length() > 0 ? sb.toString().trim() : null; + this.curOptionValue = null; + String id = this.curOptionId; + this.curOptionId = null; + if (value != null) { + if (id == null || id.trim().isEmpty()) { + logger.debug("Error parsing options for {}: Got option with empty 'value' in {}:{}: [{}]", + channelName, line, col, value); + return; + } + // we use the value as key and the id as value, as we have to map from the value to the id... + @Nullable + String prev = this.options.put(value, id); + if (prev != null && !prev.equals(value)) { + logger.debug("Error parsing options for {}: Got duplicate options in {}:{} for {}: {} and {}", + channelName, line, col, value, prev, id); + } + } + } else { + logger.debug("Error parsing options for {}: Unexpected CloseElement in {}:{}: {}", channelName, line, col, + elementName); + } + } + + @Override + public void handleAutoCloseElement(final @Nullable String elementName, final int line, final int col) + throws ParseException { + logger.debug("Unexpected AutoCloseElement in {}:{}: {}", line, col, elementName); + } + + @Override + public void handleUnmatchedCloseElement(final @Nullable String elementName, final int line, final int col) + throws ParseException { + logger.debug("Unexpected UnmatchedCloseElement in {}:{}: {}", line, col, elementName); + } + + @Override + public void handleDocType(final @Nullable String elementName, final @Nullable String publicId, + final @Nullable String systemId, final @Nullable String internalSubset, final int line, final int col) + throws ParseException { + logger.debug("Unexpected DocType in {}:{}: {}/{}/{}/{}", line, col, elementName, publicId, systemId, + internalSubset); + } + + @Override + public void handleComment(final char @Nullable [] buffer, final int offset, final int len, final int line, + final int col) throws ParseException { + logger.debug("Unexpected comment in {}:{}: {}", line, col, new String(buffer, offset, len)); + } + + @Override + public void handleCDATASection(final char @Nullable [] buffer, final int offset, final int len, final int line, + final int col) throws ParseException { + logger.debug("Unexpected CDATA in {}:{}: {}", line, col, new String(buffer, offset, len)); + } + + @Override + public void handleText(final char @Nullable [] buffer, final int offset, final int len, final int line, + final int col) throws ParseException { + + if (buffer == null) { + return; + } + + if (this.parserState == ParserState.SELECT_OPTION) { + // logger.debug("Text {}:{}: {}", line, col, new String(buffer, offset, len)); + StringBuilder sb = this.curOptionValue; + if (sb != null) { + sb.append(buffer, offset, len); + } + } else if (this.parserState == ParserState.INIT && len == 1 && buffer[offset] == '\n') { + // single newline - ignore/drop it... + } else if (this.parserState == ParserState.INPUT) { + // this is a label next to the value input field - we currently have no use for it so + // it's dropped... + } else { + logger.debug("Error parsing options for {}: Unexpected Text {}:{}: (ctx: {} len: {}) '{}' ", + this.channelName, line, col, this.parserState, len, new String(buffer, offset, len)); + } + } + + @Override + public void handleXmlDeclaration(final @Nullable String version, final @Nullable String encoding, + final @Nullable String standalone, final int line, final int col) throws ParseException { + logger.debug("Unexpected XML Declaration {}:{}: {} {} {}", line, col, version, encoding, standalone); + } + + @Override + public void handleProcessingInstruction(final @Nullable String target, final @Nullable String content, + final int line, final int col) throws ParseException { + logger.debug("Unexpected ProcessingInstruction {}:{}: {} {}", line, col, target, content); + } + + @Nullable + protected ChangerX2Entry getParsedEntry() { + String addressFieldName = this.addressFieldName; + String address = this.address; + String optionFieldName = this.optionFieldName; + OptionType optionType = this.optionType; + if (address == null || addressFieldName == null || optionType == null || optionFieldName == null) { + return null; + } + return new ChangerX2Entry(addressFieldName, address, optionFieldName, optionType, this.options); + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/TACmiSchemaConfiguration.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/TACmiSchemaConfiguration.java new file mode 100644 index 0000000000000..081eb8d39145a --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/TACmiSchemaConfiguration.java @@ -0,0 +1,49 @@ +/** + * 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.tacmi.internal.schema; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link TACmiSchemaConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class TACmiSchemaConfiguration { + + /** + * host address of the C.M.I. + */ + public String host = ""; + + /** + * Username + */ + public String username = ""; + + /** + * Password + */ + public String password = ""; + + /** + * ID of API schema page + */ + public int schemaId; + + /** + * API page poll intervall + */ + public int pollInterval; +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/TACmiSchemaHandler.java b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/TACmiSchemaHandler.java new file mode 100644 index 0000000000000..2288a3b1addfa --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/java/org/openhab/binding/tacmi/internal/schema/TACmiSchemaHandler.java @@ -0,0 +1,292 @@ +/** + * 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.tacmi.internal.schema; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.attoparser.ParseException; +import org.attoparser.config.ParseConfiguration; +import org.attoparser.config.ParseConfiguration.ElementBalancing; +import org.attoparser.config.ParseConfiguration.UniqueRootElementPresence; +import org.attoparser.simple.AbstractSimpleMarkupHandler; +import org.attoparser.simple.ISimpleMarkupParser; +import org.attoparser.simple.SimpleMarkupParser; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.util.B64Code; +import org.eclipse.smarthome.core.library.types.OnOffType; +import org.eclipse.smarthome.core.library.types.StringType; +import org.eclipse.smarthome.core.thing.Channel; +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.builder.ThingBuilder; +import org.eclipse.smarthome.core.types.Command; +import org.eclipse.smarthome.core.types.RefreshType; +import org.eclipse.smarthome.core.types.State; +import org.openhab.binding.tacmi.internal.TACmiChannelTypeProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link TACmiSchemaHandler} is responsible for handling commands, which are sent + * to one of the channels. + * + * @author Christian Niessner - Initial contribution + */ +@NonNullByDefault +public class TACmiSchemaHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(TACmiSchemaHandler.class); + + private final HttpClient httpClient; + private final TACmiChannelTypeProvider channelTypeProvider; + private final Map entries = new HashMap<>(); + private boolean online; + private @Nullable String serverBase; + private @Nullable URI schemaApiPage; + private @Nullable String authHeader; + private @Nullable ScheduledFuture scheduledFuture; + private final ParseConfiguration noRestrictions; + + public TACmiSchemaHandler(final Thing thing, final HttpClient httpClient, + final TACmiChannelTypeProvider channelTypeProvider) { + super(thing); + this.httpClient = httpClient; + this.channelTypeProvider = channelTypeProvider; + + // the default configuration for the parser + this.noRestrictions = ParseConfiguration.xmlConfiguration(); + this.noRestrictions.setElementBalancing(ElementBalancing.NO_BALANCING); + this.noRestrictions.setNoUnmatchedCloseElementsRequired(false); + this.noRestrictions.setUniqueAttributesInElementRequired(false); + this.noRestrictions.setXmlWellFormedAttributeValuesRequired(false); + this.noRestrictions.setUniqueRootElementPresence(UniqueRootElementPresence.NOT_VALIDATED); + this.noRestrictions.getPrologParseConfiguration().setValidateProlog(false); + } + + @Override + public void initialize() { + final TACmiSchemaConfiguration config = getConfigAs(TACmiSchemaConfiguration.class); + + if (config.host.trim().isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No host configured!"); + return; + } + if (config.username.trim().isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No username configured!"); + return; + } + if (config.password.trim().isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No password configured!"); + return; + } + this.online = false; + updateStatus(ThingStatus.UNKNOWN); + + this.authHeader = "Basic " + + B64Code.encode(config.username + ":" + config.password, StandardCharsets.ISO_8859_1); + + final String serverBase = "http://" + config.host + "/"; + this.serverBase = serverBase; + this.schemaApiPage = buildUri("schematic_files/" + config.schemaId + ".cgi"); + + refreshData(); + if (config.pollInterval <= 0) { + config.pollInterval = 10; + } + // we want to trigger the initial refresh 'at once' + this.scheduledFuture = scheduler.scheduleWithFixedDelay(this::refreshData, 0, config.pollInterval, + TimeUnit.SECONDS); + } + + protected URI buildUri(String path) { + return URI.create(serverBase + path); + } + + private Request prepareRequest(final URI uri) { + final Request req = httpClient.newRequest(uri).method(HttpMethod.GET).timeout(10000, TimeUnit.MILLISECONDS); + req.header(HttpHeader.ACCEPT_LANGUAGE, "en"); // we want the on/off states in english + final String ah = this.authHeader; + if (ah != null) { + req.header(HttpHeader.AUTHORIZATION, ah); + } + return req; + } + + protected PP parsePage(URI uri, PP pp) + throws ParseException, InterruptedException, TimeoutException, ExecutionException { + final ContentResponse response = prepareRequest(uri).send(); + + String responseString = null; + String encoding = response.getEncoding(); + if (encoding == null || encoding.trim().isEmpty()) { + // the C.M.I. dosn't sometime return a valid encoding - but it defaults to UTF-8 instead of ISO... + responseString = new String(response.getContent(), StandardCharsets.UTF_8); + } else { + responseString = response.getContentAsString(); + } + + if (logger.isDebugEnabled()) { + logger.debug("Response body was: {} ", responseString); + } + + final ISimpleMarkupParser parser = new SimpleMarkupParser(this.noRestrictions); + parser.parse(responseString, pp); + return pp; + } + + private void refreshData() { + URI schemaApiPage = this.schemaApiPage; + if (schemaApiPage == null) { + return; + } + try { + final ApiPageParser pp = parsePage(schemaApiPage, + new ApiPageParser(this, entries, this.channelTypeProvider)); + + if (pp.isConfigChanged()) { + // we have to update our channels... + final List channels = pp.getChannels(); + final ThingBuilder thingBuilder = editThing(); + thingBuilder.withChannels(channels); + updateThing(thingBuilder.build()); + } + if (!this.online) { + updateStatus(ThingStatus.ONLINE); + this.online = true; + } + } catch (final InterruptedException e) { + // binding shutdown is in progress + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE); + this.online = false; + } catch (final ParseException | RuntimeException e) { + logger.debug("Error parsing API Scheme: {} ", e.getMessage(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "Error: " + e.getMessage()); + this.online = false; + } catch (final TimeoutException | ExecutionException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error: " + e.getMessage()); + this.online = false; + } + } + + @Override + public void handleCommand(final ChannelUID channelUID, final Command command) { + final ApiPageEntry e = this.entries.get(channelUID.getId()); + if (command instanceof RefreshType) { + if (e == null) { + // This might be a race condition between the 'initial' poll / fetch not finished yet or the channel + // might have been deleted in between. When the initial poll is still in progress, it will send an + // update for the channel as soon as we have the data. If the channel got deleted, there is nothing we + // can do. + return; + } + // we have our ApiPageEntry which also holds our last known state - just update it. + updateState(channelUID, e.getLastState()); + return; + } + if (e == null) { + logger.debug("Got command for unknown channel {}: {}", channelUID, command); + return; + } + final Request reqUpdate; + switch (e.type) { + case SWITCH_BUTTON: + reqUpdate = prepareRequest(buildUri("INCLUDE/change.cgi?changeadrx2=" + e.address + "&changetox2=" + + (command == OnOffType.ON ? "1" : "0"))); + reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required... + break; + case SWITCH_FORM: + ChangerX2Entry cx2e = e.changerX2Entry; + if (cx2e != null) { + reqUpdate = prepareRequest(buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2e.address + + "&changetox2=" + (command == OnOffType.ON ? "1" : "0"))); + reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required... + } else { + logger.debug("Got command for uninitalized channel {}: {}", channelUID, command); + return; + } + break; + case STATE_FORM: + ChangerX2Entry cx2sf = e.changerX2Entry; + if (cx2sf != null) { + String val = cx2sf.options.get(((StringType) command).toFullString()); + if (val != null) { + reqUpdate = prepareRequest( + buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2sf.address + "&changetox2=" + val)); + reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required... + } else { + logger.warn("Got unknown form command {} for channel {}; Valid commands are: {}", command, + channelUID, cx2sf.options.keySet()); + return; + } + } else { + logger.debug("Got command for uninitalized channel {}: {}", channelUID, command); + return; + } + break; + case READ_ONLY_NUMERIC: + case READ_ONLY_STATE: + case READ_ONLY_SWITCH: + logger.debug("Got command for ReadOnly channel {}: {}", channelUID, command); + return; + default: + logger.debug("Got command for unhandled type {} channel {}: {}", e.type, channelUID, command); + return; + } + try { + ContentResponse res = reqUpdate.send(); + if (res.getStatus() == 200) { + // update ok, we update the state + e.setLastState((State) command); + updateState(channelUID, (State) command); + } else { + logger.warn("Error sending update for {} = {}: {} {}", channelUID, command, res.getStatus(), + res.getReason()); + } + } catch (InterruptedException | TimeoutException | ExecutionException ex) { + logger.warn("Error sending update for {} = {}: {}", channelUID, command, ex.getMessage()); + } + } + + // make it accessible for ApiPageParser + @Override + protected void updateState(final ChannelUID channelUID, final State state) { + super.updateState(channelUID, state); + } + + @Override + public void dispose() { + final ScheduledFuture scheduledFuture = this.scheduledFuture; + if (scheduledFuture != null) { + scheduledFuture.cancel(true); + this.scheduledFuture = null; + } + super.dispose(); + } +} diff --git a/bundles/org.openhab.binding.tacmi/src/main/resources/ESH-INF/binding/binding.xml b/bundles/org.openhab.binding.tacmi/src/main/resources/ESH-INF/binding/binding.xml new file mode 100644 index 0000000000000..ec0ee0d1029ea --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/resources/ESH-INF/binding/binding.xml @@ -0,0 +1,10 @@ + + + + TA C.M.I. Binding + This is the binding for TA C.M.I. + Timo Wendt, Wolfgang Klimt, Christian Niessner + + diff --git a/bundles/org.openhab.binding.tacmi/src/main/resources/ESH-INF/thing/bridge.xml b/bundles/org.openhab.binding.tacmi/src/main/resources/ESH-INF/thing/bridge.xml new file mode 100644 index 0000000000000..7cbed97cc1e2e --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/resources/ESH-INF/thing/bridge.xml @@ -0,0 +1,11 @@ + + + + + + This bridge opens the CoE-UDP Port 5441 on OpenHAB for communication with "Technische Alternative C.M.I." + + diff --git a/bundles/org.openhab.binding.tacmi/src/main/resources/ESH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.tacmi/src/main/resources/ESH-INF/thing/thing-types.xml new file mode 100644 index 0000000000000..32ac895102982 --- /dev/null +++ b/bundles/org.openhab.binding.tacmi/src/main/resources/ESH-INF/thing/thing-types.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + CoE Communication to the "Technische Alternative C.M.I. Control and Monitoring Interface" + + + + + Host name of IP address of the CMI + network-address + + + + The CoE / CAN Node number openHAB should represent + + + + + + + + Switch + + A digital channel sent from C.M.I. to openHAB + + + + + C.M.I. Network Output + + + + + Switch + + A digital channel sent from OpenHAB to C.M.I. + + + + Network Output + + + + Initial value to set after startup (optional, defaults to uninitialized) + + + + + + Number + + A Analog Channel received from the C.M.I. + + + + + C.M.I. Network Output + + + + + Number + + A Analog Channel sent to the C.M.I. + + + + Network Output + + + + Measurement type for this channel + + + + + + + + + + + + + + + + + + + + + + + + + + + + Initial value to set after startup (optional, defaults to uninitialized) + + + + + + + Communication to a special "API" schema page on a "Technische Alternative C.M.I. Control and Monitoring + Interface" + + + + + Host name or IP address of the C.M.I. + network-address + + + + Username for authentication on the C.M.I. + + + + Password for authentication on the C.M.I. + password + + + + ID of the schema API page + + + + Poll interval (in seconds) how often to poll the API Page + 10 + true + + + + + + + Switch + + An On/Off state read from C.M.I. + + + + Switch + + A modifiable On/Off state read from C.M.I. + + + Number + + A numeric value read from C.M.I. + + + + String + + A state value read from C.M.I. + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index ec5165810e42a..a1e2f3bde37cd 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -254,6 +254,7 @@ org.openhab.binding.squeezebox org.openhab.binding.synopanalyzer org.openhab.binding.systeminfo + org.openhab.binding.tacmi org.openhab.binding.tado org.openhab.binding.tankerkoenig org.openhab.binding.telegram