diff --git a/CODEOWNERS b/CODEOWNERS
index 0a4f724322f04..19805de51bed5 100755
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -163,7 +163,7 @@
/bundles/org.openhab.binding.icalendar/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.icloud/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.ihc/ @paulianttila
-/bundles/org.openhab.binding.insteon/ @openhab/add-ons-maintainers
+/bundles/org.openhab.binding.insteon/ @jsetton
/bundles/org.openhab.binding.intesis/ @hmerk
/bundles/org.openhab.binding.iotawatt/ @PRosenb
/bundles/org.openhab.binding.ipcamera/ @Skinah
diff --git a/bundles/org.openhab.binding.insteon/README.md b/bundles/org.openhab.binding.insteon/README.md
index b2dc77e338aa3..5219a99fcfde6 100644
--- a/bundles/org.openhab.binding.insteon/README.md
+++ b/bundles/org.openhab.binding.insteon/README.md
@@ -1,266 +1,550 @@
# Insteon Binding
-Insteon is a home area networking technology developed primarily for connecting light switches and loads.
-Insteon devices send messages either via the power line, or by means of radio frequency (RF) waves, or both (dual-band.
-A considerable number of Insteon compatible devices such as switchable relays, thermostats, sensors etc are available.
+Insteon is a proprietary home automation system that enables light switches, lights, thermostats, leak sensors, remote controls, motion sensors, and other electrically powered devices to interoperate through power lines, radio frequency (RF) communications, or both (dual-band)
More about Insteon can be found on [Wikipedia](https://en.wikipedia.org/wiki/Insteon).
-This binding provides access to the Insteon network by means of either an Insteon PowerLinc Modem (PLM), a legacy Insteon Hub 2242-222 or the current 2245-222 Insteon Hub.
-The modem can be connected to the openHAB server either via a serial port (Model 2413S) or a USB port (Model 2413U.
+It provides access to the Insteon network by means of either an Insteon PowerLinc Modem (PLM), the legacy 2242-222 Insteon Hub or the current 2245-222 Insteon Hub 2.
+The modem can be connected to the openHAB server either via a serial port (Model 2413S) or a USB port (Model 2413U).
The Insteon PowerLinc Controller (Model 2414U) is not supported since it is a PLC not a PLM.
-The modem can also be connected via TCP (such as ser2net.
+The modem can also be connected via TCP (such as ser2net).
The binding translates openHAB commands into Insteon messages and sends them on the Insteon network.
-Relevant messages from the Insteon network (like notifications about switches being toggled) are picked up by the modem and converted to openHAB status updates by the binding.
+Relevant messages from the Insteon network (like notifications about switches being toggled) are picked up by the modem and converted to openHAB state updates by the binding.
The binding also supports sending and receiving of legacy X10 messages.
-The binding does not support linking new devices on the fly, i.e. all devices must be linked with the modem _before_ starting the Insteon binding.
+The openHAB binding supports configuring most of the device local settings, linking a device to the modem, managing link database records and scenes along with monitoring inbound/outbound messages.
+Other tools can be used to managed Insteon devices, such as the [Insteon Terminal](https://github.com/pfrommerd/insteon-terminal) open source project, or the [HouseLinc](https://www.insteon.com/houselinc) software from Insteon can also be used for configuration, but it wipes the modem link database clean on its initial use, requiring to re-link the modem to all devices.
-The openHAB binding supports minimal configuration of devices, currently only monitoring and sending messages.
-For all other configuration and set up of devices, link the devices manually via the set buttons, or use the free [Insteon Terminal](https://github.com/pfrommerd/insteon-terminal) software.
-The free HouseLinc software from Insteon can also be used for configuration, but it wipes the modem link database clean on its initial use, requiring to re-link the modem to all devices.
+At startup, the binding will download the modem database along with each configured device all-link database if not previously downloaded and currently awake.
+Therefore, the initialization on the first start may take some additional time to complete depending on the number of devices configured.
+The modem and device link databases are only downloaded once unless the binding receives an indication that a database was updated or marked to be refreshed via the [openHAB console](#console-commands).
+
+The binding has been rewritten in openHAB 4.3 to simplify the user experience by retrieving all the configuration directly from the device when possible, and improving the way the Insteon things are configured in MainUI.
+If switching from a previous release, you will need to reconfigure your Insteon environment with the new bridges, things and channels to take advantage of these enhancements.
+However, the new version is fully backward compatible by supporting the legacy things.
+On the first start, existing `device` things connected to a `network` bridge will be migrated to the `legacy-device` thing type while still keeping the same ids to prevent any breakage.
+It is important to note that once the migration has occurred, downgrading to an older version will not be possible.
## Supported Things
-| Thing | Type | Description |
-|----------|--------|------------------------------|
-| network | Bridge | An insteon PLM or hub that is used to communicate with the Insteon devices |
-|device| Thing | Insteon devices such as dimmers, keypads, sensors, etc. |
+| Thing | Type | Description |
+|-------|------|-------------|
+| hub1 | Bridge | An Insteon Hub Legacy that communicates with Insteon devices. |
+| hub2 | Bridge | An Insteon Hub 2 that communicates with Insteon devices. |
+| plm | Bridge | An Insteon PLM that communicates with Insteon devices. |
+| device | Thing | An Insteon device such as a switch, dimmer, keypad, sensor, etc. |
+| scene | Thing | An Insteon scene that controls multiple devices simultaneously. |
+| x10 | Thing | An X10 device such as a switch, dimmer or sensor. |
+
+### Legacy Things
+
+| Thing | Type | Description |
+|-------|------|-------------|
+| network | Bridge | An Insteon PLM or hub that is used to communicate with the Insteon devices. |
+| legacy_device | Thing | An Insteon or X10 device such as switches, dimmers, keypads, sensors, etc. |
## Discovery
-The network bridge is not automatically discovered, you will have to manually add the it yourself.
-Upon proper configuration of the network bridge, the network device database will be downloaded.
-Any Insteon device that exists in the database and is not currently configured is added to the inbox.
-The naming convention is **Insteon Device AABBCC**, where AA, BB and CC are from the Insteon device address.
+An Insteon bridge is not automatically discovered and will have to be manually added.
+Once configured, depending on the bridge discovery parameters, any Insteon devices or scenes that exists in the modem database and is not currently configured will be automatically be added to the inbox.
+For the legacy bridge configuration, only missing device are discovered.
+The naming convention for devices is **_Vendor_ _Model_ _Description_** if its product data is retrievable, otherwise **Insteon Device AA.BB.CC**, where `AA.BB.CC` is the Insteon device address.
+For scenes, it is **Insteon Scene 42**, where `42` is the scene group number.
+The device auto-discovery is enabled by default while disabled for scenes.
X10 devices are not auto discovered.
## Thing Configuration
-### Network Configuration
+### Insteon Hub Configuration
+
+The Insteon Hub Legacy is configured with the following parameters:
+
+| Parameter | Default | Required | Description |
+|-----------|:-------:|:--------:|-------------|
+| hostname | | Yes | Network address of the hub. |
+| port | 9761 | No | Network port of the hub. |
+| devicePollIntervalInSeconds | 300 | No | Device poll interval in seconds. The hub will be overloaded if interval is too short, leading to sluggish or no response when trying to send messages to devices. The default poll interval of 300 seconds has been tested and found to be a good compromise in a configuration of about 110 switches/dimmers. |
+| deviceDiscoveryEnabled | true | No | Discover Insteon devices found in the hub database but not configured. |
+| sceneDiscoveryEnabled | false | No | Discover Insteon scenes found in the hub database but not configured. |
+| deviceSyncEnabled | false | No | Synchronize related devices based on their all-link database. |
+
+### Insteon Hub 2 Configuration
+
+The Insteon Hub 2 is configured with the following parameters:
+
+| Parameter | Default | Required | Description |
+|-----------|:-------:|:--------:|-------------|
+| hostname | | Yes | Network address of the hub. |
+| port | 25105 | No | Network port of the hub. |
+| username | | Yes | Username to access the hub. |
+| password | | Yes | Password to access the hub. |
+| hubPollIntervalInMilliseconds | 1000 | No | Hub poll interval in milliseconds. |
+| devicePollIntervalInSeconds | 300 | No | Device poll interval in seconds. The hub will be overloaded if interval is too short, leading to sluggish or no response when trying to send messages to devices. The default poll interval of 300 seconds has been tested and found to be a good compromise in a configuration of about 110 switches/dimmers. |
+| deviceDiscoveryEnabled | true | No | Discover Insteon devices found in the hub database but not configured. |
+| sceneDiscoveryEnabled | false | No | Discover Insteon scenes found in the hub database but not configured. |
+| deviceSyncEnabled | false | No | Synchronize related devices based on their all-link database. |
+
+### Insteon PLM Configuration
+
+The Insteon PLM is configured with the following parameters:
+
+| Parameter | Default | Required | Description |
+|-----------|:-------:|:--------:|-------------|
+| serialPort | | Yes | Serial port connected to the modem. Example: `/dev/ttyS0` or `COM1` |
+| baudRate | 19200 | No | Serial port baud rate connected to the modem. |
+| devicePollIntervalInSeconds | 300 | No | Device poll interval in seconds. The modem will be overloaded if interval is too short, leading to sluggish or no response when trying to send messages to devices. The default poll interval of 300 seconds has been tested and found to be a good compromise in a configuration of about 110 switches/dimmers. |
+| deviceDiscoveryEnabled | true | No | Discover Insteon devices found in the modem database but not configured. |
+| sceneDiscoveryEnabled | false | No | Discover Insteon scenes found in the modem database but not configured. |
+| deviceSyncEnabled | false | No | Synchronize related devices based on their all-link database. |
+
+### Insteon Device Configuration
+
+The Insteon device is configured with the following parameter:
+
+| Parameter | Required | Description |
+|-----------|:--------:|-------------|
+| address | Yes | Insteon address of the device. It can be found on the device. Example: `12.34.56`. |
+
+The device type is automatically determined by the binding using the device product data.
+For a [battery powered device](#battery-powered-devices) that was never configured previously, it may take until the next time that device sends a broadcast message to be modeled properly.
+To speed up the process for this case, it is recommended to force the device to become awake after the associated bridge is online.
+Likewise, for a device that wasn't accessible during the binding initialization phase, press on its SET button once powered on to notify the binding that it is available.
+
+### Insteon Scene Configuration
+
+The Insteon scene is configured with the following parameter:
+
+| Parameter | Required | Description |
+|-----------|:--------:|-------------|
+| group | Yes | Insteon scene group number between 2 and 254. It can be found in the scene detailed information in the Insteon mobile app. |
+
+### X10 Device Configuration
+
+The X10 device is configured with the following parameters:
+
+| Parameter | Required | Description |
+|-----------|:--------:|-------------|
+| houseCode | Yes | X10 house code of the device. Example: `A`|
+| unitCode | Yes | X10 unit code of the device. Example: `1` |
+| deviceType | Yes | X10 device type |
+
+
+
+ Supported X10 device types
+
+ | Device Type | Description |
+ |-------------|-------------|
+ | X10_Switch | X10 Switch |
+ | X10_Dimmer | X10 Dimmer |
+ | X10_Sensor | X10 Sensor |
+
+
+### Legacy Network Configuration
The Insteon PLM or hub is configured with the following parameters:
| Parameter | Default | Required | Description |
-|----------|---------:|--------:|-------------|
-| port | | Yes | **Examples:**
- PLM on Linux: `/dev/ttyS0` or `/dev/ttyUSB0`
- Smartenit ZBPLM on Linux: `/dev/ttyUSB0,baudRate=115200`
- PLM on Windows: `COM1`
- Current hub (2245-222) at 192.168.1.100 on port 25105, with a poll interval of 1000 ms (1 second): `/hub2/my_user_name:my_password@192.168.1.100:25105,poll_time=1000`
- Legacy hub (2242-222) at 192.168.1.100 on port 9761:`/hub/192.168.1.100:9761`
- Networked PLM using ser2net at 192.168.1.100 on port 9761:`/tcp/192.168.1.100:9761` |
-| devicePollIntervalSeconds | 300 | No | Poll interval of devices in seconds. Poll too often and you will overload the insteon network, leading to sluggish or no response when trying to send messages to devices. The default poll interval of 300 seconds has been tested and found to be a good compromise in a configuration of about 110 switches/dimmers. |
-| additionalDevices | | No | File with additional device types. The syntax of the file is identical to the `device_types.xml` file in the source tree. Please remember to post successfully added device types to the openhab group so the developers can include them into the `device_types.xml` file! |
-| additionalFeatures | | No | File with additional feature templates, like in the `device_features.xml` file in the source tree. |
+|-----------|:-------:|:--------:|-------------|
+| port | | Yes | **Examples:**
- PLM on Linux: `/dev/ttyS0` or `/dev/ttyUSB0`
- Smartenit ZBPLM on Linux: `/dev/ttyUSB0,baudRate=115200`
- PLM on Windows: `COM1`
- Current hub (2245-222) at 192.168.1.100 on port 25105, with a poll interval of 1000 ms (1 second): `/hub2/my_user_name:my_password@192.168.1.100:25105,poll_time=1000`
- Legacy hub (2242-222) at 192.168.1.100 on port 9761:`/hub/192.168.1.100:9761`
- Networked PLM using ser2net at 192.168.1.100 on port 9761:`/tcp/192.168.1.100:9761` |
+| devicePollIntervalSeconds | 300 | No | Poll interval of devices in seconds. Poll too often and you will overload the insteon network, leading to sluggish or no response when trying to send messages to devices. The default poll interval of 300 seconds has been tested and found to be a good compromise in a configuration of about 110 switches/dimmers. |
+| additionalDevices | | No | File with additional device types. The syntax of the file is identical to the `device_types.xml` file in the source tree. Please remember to post successfully added device types to the openhab group so the developers can include them into the `device_types.xml` file! |
+| additionalFeatures | | No | File with additional feature templates, like in the `device_features.xml` file in the source tree. |
>NOTE: For users upgrading from InsteonPLM, The parameter port_1 is now port.
-### Device Configuration
-
-The Insteon device is configured with the following required parameters:
-
-| Parameter | Description |
-|----------|-------------|
-|address|Insteon or X10 address of the device. Insteon device addresses are in the format 'xx.xx.xx', and can be found on the device. X10 device address are in the format 'x.y' and are typically configured on the device.|
-|productKey|Insteon binding product key that is used to identy the device. Every Insteon device type is uniquely identified by its Insteon product key, typically a six digit hex number. For some of the older device types (in particular the SwitchLinc switches and dimmers), Insteon does not give a product key, so an arbitrary fake one of the format Fxx.xx.xx (or Xxx.xx.xx for X10 devices) is assigned by the binding.|
-|deviceConfig|Optional JSON object with device specific configuration. The JSON object will contain one or more key/value pairs. The key is a parameter for the device and the type of the value will vary.|
-
-The following is a list of the product keys and associated devices.
-These have been tested and should work out of the box:
-
-| Model | Description | Product Key | tested by |
-|-------|-------------|-------------|-----------|
-| 2477D | SwitchLinc Dimmer | F00.00.01 | Bernd Pfrommer |
-| 2477S | SwitchLinc Switch | F00.00.02 | Bernd Pfrommer |
-| 2845-222 | Hidden Door Sensor | F00.00.03 | Josenivaldo Benito |
-| 2876S | ICON Switch | F00.00.04 | Patrick Giasson |
-| 2456D3 | LampLinc V2 | F00.00.05 | Patrick Giasson |
-| 2442-222 | Micro Dimmer | F00.00.06 | Josenivaldo Benito |
-| 2453-222 | DIN Rail On/Off | F00.00.07 | Josenivaldo Benito |
-| 2452-222 | DIN Rail Dimmer | F00.00.08 | Josenivaldo Benito |
-| 2458-A1 | MorningLinc RF Lock Controller | F00.00.09 | cdeadlock |
-| 2852-222 | Leak Sensor | F00.00.0A | Kirk McCann |
-| 2672-422 | LED Dimmer | F00.00.0B | ??? |
-| 2476D | SwitchLinc Dimmer | F00.00.0C | LiberatorUSA |
-| 2634-222 | On/Off Dual-Band Outdoor Module | F00.00.0D | LiberatorUSA |
-| 2342-2 | Mini Remote | F00.00.10 | Bernd Pfrommer |
-| 2663-222 | On/Off Outlet | 0x000039 | SwissKid |
-| 2466D | ToggleLinc Dimmer | F00.00.11 | Rob Nielsen |
-| 2466S | ToggleLinc Switch | F00.00.12 | Rob Nielsen |
-| 2672-222 | LED Bulb | F00.00.13 | Rob Nielsen |
-| 2487S | KeypadLinc On/Off 6-Button | F00.00.14 | Bernd Pfrommer |
-| 2334-232 | KeypadLink Dimmer 6-Button | F00.00.15 | Rob Nielsen |
-| 2334-232 | KeypadLink Dimmer 8-Button | F00.00.16 | Rob Nielsen |
-| 2423A1 | iMeter Solo Power Meter | F00.00.17 | Rob Nielsen |
-| 2423A1 | Thermostat 2441TH | F00.00.18 | Daniel Campbell, Bernd Pfrommer |
-| 2457D2 | LampLinc Dimmer | F00.00.19 | Jonathan Huizingh |
-| 2475SDB | In-LineLinc Relay | F00.00.1A | Jim Howard |
-| 2635-222 | On/Off Module | F00.00.1B | Jonathan Huizingh |
-| 2475F | FanLinc Module | F00.00.1C | Brian Tillman |
-| 2456S3 | ApplianceLinc | F00.00.1D | ??? |
-| 2674-222 | LED Bulb (recessed) | F00.00.1E | Steve Bate |
-| 2477SA1 | 220V 30-amp Load Controller N/O | F00.00.1F | Shawn R. |
-| 2342-222 | Mini Remote (8 Button) | F00.00.20 | Bernd Pfrommer |
-| 2441V | Insteon Thermostat Adaptor for Venstar | F00.00.21 | Bernd Pfrommer |
-| 2982-222 | Insteon Smoke Bridge | F00.00.22 | Bernd Pfrommer |
-| 2487S | KeypadLinc On/Off 8-Button | F00.00.23 | Tom Weichmann |
-| 2450 | IO Link | 0x00001A | Bernd Pfrommer |
-| 2486D | KeypadLinc Dimmer | 0x000037 | Patrick Giasson, Joe Barnum |
-| 2484DWH8 | KeypadLinc Countdown Timer | 0x000041 | Rob Nielsen |
-| Various | PLM or hub | 0x000045 | Bernd Pfrommer |
-| 2843-222 | Wireless Open/Close Sensor | 0x000049 | Josenivaldo Benito |
-| 2842-222 | Motion Sensor | 0x00004A | Bernd Pfrommer |
-| 2844-222 | Motion Sensor II | F00.00.24 | Rob Nielsen |
-| 2486DWH8 | KeypadLinc Dimmer | 0x000051 | Chris Graham |
-| 2472D | OutletLincDimmer | 0x000068 | Chris Graham |
-| X10 switch | generic X10 switch | X00.00.01 | Bernd Pfrommer |
-| X10 dimmer | generic X10 dimmer | X00.00.02 | Bernd Pfrommer |
-| X10 motion | generic X10 motion sensor | X00.00.03 | Bernd Pfrommer |
+### Legacy Device Configuration
+
+| Parameter | Required | Description |
+|-----------|:--------:|-------------|
+| address | Yes | Insteon or X10 address of the device. Insteon device addresses are in the format 'xx.xx.xx', and can be found on the device. X10 device address are in the format 'x.y' and are typically configured on the device. |
+| productKey | Yes | Insteon binding product key that is used to identy the device. Every Insteon device type is uniquely identified by its Insteon product key, typically a six digit hex number. For some of the older device types (in particular the SwitchLinc switches and dimmers), Insteon does not give a product key, so an arbitrary fake one of the format Fxx.xx.xx (or Xxx.xx.xx for X10 devices) is assigned by the binding. |
+| deviceConfig | No | Optional JSON object with device specific configuration. The JSON object will contain one or more key/value pairs. The key is a parameter for the device and the type of the value will vary. |
+
+
+ Supported product keys
+
+ | Model | Description | Product Key |
+ |-------|-------------|-------------|
+ | 2477D | SwitchLinc Dimmer | F00.00.01 |
+ | 2477S | SwitchLinc Switch | F00.00.02 |
+ | 2845-222 | Hidden Door Sensor | F00.00.03 |
+ | 2876S | ICON Switch | F00.00.04 |
+ | 2456D3 | LampLinc V2 | F00.00.05 |
+ | 2442-222 | Micro Dimmer | F00.00.06 |
+ | 2453-222 | DIN Rail On/Off | F00.00.07 |
+ | 2452-222 | DIN Rail Dimmer | F00.00.08 |
+ | 2458-A1 | MorningLinc RF Lock Controller | F00.00.09 |
+ | 2852-222 | Leak Sensor | F00.00.0A |
+ | 2672-422 | LED Dimmer | F00.00.0B |
+ | 2476D | SwitchLinc Dimmer | F00.00.0C |
+ | 2634-222 | On/Off Dual-Band Outdoor Module | F00.00.0D |
+ | 2342-2 | Mini Remote | F00.00.10 |
+ | 2663-222 | On/Off Outlet | 0x000039 |
+ | 2466D | ToggleLinc Dimmer | F00.00.11 |
+ | 2466S | ToggleLinc Switch | F00.00.12 |
+ | 2672-222 | LED Bulb | F00.00.13 |
+ | 2487S | KeypadLinc On/Off 6-Button | F00.00.14 |
+ | 2334-232 | KeypadLink Dimmer 6-Button | F00.00.15 |
+ | 2334-232 | KeypadLink Dimmer 8-Button | F00.00.16 |
+ | 2423A1 | iMeter Solo Power Meter | F00.00.17 |
+ | 2423A1 | Thermostat 2441TH | F00.00.18 |
+ | 2457D2 | LampLinc Dimmer | F00.00.19 |
+ | 2475SDB | In-LineLinc Relay | F00.00.1A |
+ | 2635-222 | On/Off Module | F00.00.1B |
+ | 2475F | FanLinc Module | F00.00.1C |
+ | 2456S3 | ApplianceLinc | F00.00.1D |
+ | 2674-222 | LED Bulb (recessed) | F00.00.1E |
+ | 2477SA1 | 220V 30-amp Load Controller N/O | F00.00.1F |
+ | 2342-222 | Mini Remote (8 Button) | F00.00.20 |
+ | 2441V | Insteon Thermostat Adaptor for Venstar | F00.00.21 |
+ | 2982-222 | Insteon Smoke Bridge | F00.00.22 |
+ | 2487S | KeypadLinc On/Off 8-Button | F00.00.23 |
+ | 2450 | IO Link | 0x00001A |
+ | 2486D | KeypadLinc Dimmer | 0x000037 |
+ | 2484DWH8 | KeypadLinc Countdown Timer | 0x000041 |
+ | Various | PLM or Hub | 0x000045 |
+ | 2843-222 | Wireless Open/Close Sensor | 0x000049 |
+ | 2842-222 | Motion Sensor | 0x00004A |
+ | 2844-222 | Motion Sensor II | F00.00.24 |
+ | 2486DWH8 | KeypadLinc Dimmer | 0x000051 |
+ | 2472D | OutletLincDimmer | 0x000068 |
+ | X10 switch | generic X10 switch | X00.00.01 |
+ | X10 dimmer | generic X10 dimmer | X00.00.02 |
+ | X10 motion | generic X10 motion sensor | X00.00.03 |
+
## Channels
Below is the list of possible channels for the Insteon devices.
-In order to determine which channels a device supports, you can look at the device in the UI, or with the command `display_devices` in the console.
-
-| channel | type | description |
-|----------|--------|------------------------------|
-| acDelay | Number | AC Delay |
-| backlightDuration | Number | Back Light Duration |
-| batteryLevel | Number | Battery Level |
-| batteryPercent | Number:Dimensionless | Battery Percent |
-| batteryWatermarkLevel | Number | Battery Watermark Level |
-| beep | Switch | Beep |
-| bottomOutlet | Switch | Bottom Outlet |
-| buttonA | Switch | Button A |
-| buttonB | Switch | Button B |
-| buttonC | Switch | Button C |
-| buttonD | Switch | Button D |
-| buttonE | Switch | Button E |
-| buttonF | Switch | Button F |
-| buttonG | Switch | Button G |
-| buttonH | Switch | Button H |
-| broadcastOnOff | Switch | Broadcast On/Off |
-| contact | Contact | Contact |
-| coolSetPoint | Number | Cool Set Point |
-| dimmer | Dimmer | Dimmer |
-| fan | Number | Fan |
-| fanMode | Number | Fan Mode |
-| fastOnOff | Switch | Fast On/Off |
-| fastOnOffButtonA | Switch | Fast On/Off Button A |
-| fastOnOffButtonB | Switch | Fast On/Off Button B |
-| fastOnOffButtonC | Switch | Fast On/Off Button C |
-| fastOnOffButtonD | Switch | Fast On/Off Button D |
-| heatSetPoint | Number | Heat Set Point |
-| humidity | Number | Humidity |
-| humidityHigh | Number | Humidity High |
-| humidityLow | Number | Humidity Low |
-| isCooling | Number | Is Cooling |
-| isHeating | Number | Is Heating |
-| keypadButtonA | Switch | Keypad Button A |
-| keypadButtonB | Switch | Keypad Button B |
-| keypadButtonC | Switch | Keypad Button C |
-| keypadButtonD | Switch | Keypad Button D |
-| keypadButtonE | Switch | Keypad Button E |
-| keypadButtonF | Switch | Keypad Button F |
-| keypadButtonG | Switch | Keypad Button G |
-| keypadButtonH | Switch | Keypad Button H |
-| kWh | Number:Energy | Kilowatt Hour |
-| lastHeardFrom | DateTime | Last Heard From |
-| ledBrightness | Number | LED brightness |
-| ledOnOff | Switch | LED On/Off |
-| lightDimmer | Dimmer | light Dimmer |
-| lightLevel | Number | Light Level |
-| lightLevelAboveThreshold | Contact | Light Level Above/Below Threshold |
-| loadDimmer | Dimmer | Load Dimmer |
-| loadSwitch | Switch | Load Switch |
-| loadSwitchFastOnOff | Switch | Load Switch Fast On/Off |
-| loadSwitchManualChange | Number | Load Switch Manual Change |
-| lowBattery | Contact | Low Battery |
-| manualChange | Number | Manual Change |
-| manualChangeButtonA | Number | Manual Change Button A |
-| manualChangeButtonB | Number | Manual Change Button B |
-| manualChangeButtonC | Number | Manual Change Button C |
-| manualChangeButtonD | Number | Manual Change Button D |
-| notification | Number | Notification |
-| onLevel | Number | On Level |
-| rampDimmer | Dimmer | Ramp Dimmer |
-| rampRate | Number | Ramp Rate |
-| reset | Switch | Reset |
-| stage1Duration | Number | Stage 1 Duration |
-| switch | Switch | Switch |
-| systemMode | Number | System Mode |
-| tamperSwitch | Contact | Tamper Switch |
-| temperature | Number:Temperature | Temperature |
-| temperatureLevel | Number | Temperature Level |
-| topOutlet | Switch | Top Outlet |
-| update | Switch | Update |
-| watts | Number:Power | Watts |
+In order to determine which channels a device supports, check the device in the UI, or use the `insteon device listAll` console command.
+
+### State Channels
+
+| Channel | Type | Access Mode | Description |
+|---------|------|-------------|-------------|
+| 3WayMode | Switch | R/W | 3-Way Toggle Mode |
+| acDelay | Number:Time | R/W | AC Delay |
+| alarmDelay | Switch | R/W | Alarm Delay |
+| alarmDuration | Number:Time | R/W | Alarm Duration |
+| alarmType | String | R/W | Alarm Type |
+| armed | Switch | R/W | Armed State |
+| backlightDuration | Number:Time | R/W | Back Light Duration |
+| batteryLevel | Number:Dimensionless | R | Battery Level |
+| batteryPowered | Switch | R | Battery Powered State |
+| beep | Switch | W | Beep |
+| buttonA | Switch | R/W | Button A |
+| buttonB | Switch | R/W | Button B |
+| buttonC | Switch | R/W | Button C |
+| buttonD | Switch | R/W | Button D |
+| buttonE | Switch | R/W | Button E |
+| buttonF | Switch | R/W | Button F |
+| buttonG | Switch | R/W | Button G |
+| buttonH | Switch | R/W | Button H |
+| buttonBeep | Switch | R/W | Beep on Button Press |
+| buttonConfig | String | R/W | Button Config |
+| buttonLock | Switch | R/W | Button Lock |
+| carbonMonoxideAlarm | Switch | R | Carbon Monoxide Alarm |
+| contact | Contact | R | Contact State |
+| coolSetPoint | Number:Temperature | R/W | Cool Set Point |
+| daylight | Contact | R | Daylight State |
+| dimmer | Dimmer | R/W | Dimmer |
+| energyOffset | Number:Temperature | R/W | Energy Set Point Offset |
+| energySaving | Switch | R | Energy Saving |
+| energyUsage | Number:Energy | R | Energy Usage in Kilowatt Hour |
+| error | Switch | R | Error |
+| fanMode | String | R/W | Fan Mode |
+| fanSpeed | String | R/W | Fan Speed |
+| fanState | Switch | R | Fan State |
+| heartbeatInterval | Number:Time | R/W | Heartbeat Interval |
+| heartbeatOnOff | Switch | R/W | Heartbeat On/Off |
+| heatSetPoint | Number:Temperature | R/W | Heat Set Point |
+| humidity | Number:Dimensionless | R | Current Humidity |
+| humidityControl | String | R | Humidity Control State |
+| humidityHigh | Number:Dimensionless | R/W | Humidity High |
+| humidityLow | Number:Dimensionless | R/W | Humidity Low |
+| lastHeardFrom | DateTime | R | Last Heard From |
+| leak | Switch | R | Leak Detected |
+| ledBrightness | Dimmer | R/W | LED Brightness |
+| ledOnOff | Switch | R/W | LED On/Off |
+| ledTraffic | Switch | R/W | LED Blink on Traffic |
+| lightLevel | Number:Dimensionless | R | Light Level |
+| load | Switch | R | Load State |
+| loadSense | Switch | R/W | Load Sense |
+| loadSenseBottom | Switch | R/W | Load Sense Bottom |
+| loadSenseTop | Switch | R/W | Load Sense Top |
+| lock | Switch | R/W | Lock |
+| lowBattery | Switch | R | Low Battery Alert |
+| momentaryDuration | Number:Time | R/W | Momentary Duration |
+| monitorMode | Switch | R/W | Monitor Mode |
+| motion | Switch | R | Motion Detected |
+| onLevel | Dimmer | R/W | On Level |
+| operationMode | String | R/W | Switch Operation Mode |
+| outletBottom | Switch | R/W | Outlet Bottom |
+| outletTop | Switch | R/W | Outlet Top |
+| powerUsage | Number:Power | R | Power Usage in Watts |
+| program1 | Player | R/W | Program 1 |
+| program2 | Player | R/W | Program 2 |
+| program3 | Player | R/W | Program 3 |
+| program4 | Player | R/W | Program 4 |
+| programLock | Switch | R/W | Local Programming Lock |
+| pump | Switch | R/W | Pump |
+| rampRate | Number:Time | R/W | Ramp Rate |
+| relayMode | String | R/W | Output Relay Mode |
+| relaySensorFollow | Switch | R/W | Output Relay Follows Input Sensor |
+| reset | Switch | W | Reset |
+| resumeDim | Switch | R/W | Resume Dim |
+| reverseDirection | Switch | R/W | Reverse Direction |
+| rollershutter | Rollershutter | R/W | Rollershutter |
+| sceneOnOff | Switch | R/W | Scene On/Off |
+| sceneFastOnOff | Switch | W | Scene Fast On/Off |
+| sceneManualChange | Rollershutter | W | Scene Manual Change |
+| siren | Switch | R/W | Siren |
+| smokeAlarm | Switch | R | Smoke Alarm |
+| stage1Duration | Number:Time | R/W | Stage 1 Duration |
+| stayAwake | Switch | R/W | Stay Awake for Extended Time |
+| switch | Switch | R/W | Switch |
+| syncTime | Switch | W | Sync Time |
+| systemMode | String | R/W | System Mode |
+| systemState | String | R | System State |
+| tamperSwitch | Contact | R | Tamper Switch |
+| temperature | Number:Temperature | R | Current Temperature |
+| temperatureFormat | String | R/W | Temperature Format |
+| testAlarm | Switch | R | Test Alarm |
+| timeFormat | String | R/W | Time Format |
+| toggleModeButtonA | String | R/W | Toggle Mode Button A |
+| toggleModeButtonB | String | R/W | Toggle Mode Button B |
+| toggleModeButtonC | String | R/W | Toggle Mode Button C |
+| toggleModeButtonD | String | R/W | Toggle Mode Button D |
+| toggleModeButtonE | String | R/W | Toggle Mode Button E |
+| toggleModeButtonF | String | R/W | Toggle Mode Button F |
+| toggleModeButtonG | String | R/W | Toggle Mode Button G |
+| toggleModeButtonH | String | R/W | Toggle Mode Button H |
+| valve1 | Switch | R/W | Valve 1 |
+| valve2 | Switch | R/W | Valve 2 |
+| valve3 | Switch | R/W | Valve 3 |
+| valve4 | Switch | R/W | Valve 4 |
+| valve5 | Switch | R/W | Valve 5 |
+| valve6 | Switch | R/W | Valve 6 |
+| valve7 | Switch | R/W | Valve 7 |
+| valve8 | Switch | R/W | Valve 8 |
+
+### Trigger Channels
+
+| Channel | Description |
+|---------|-------------|
+| eventButton | Event Button |
+| eventButtonA | Event Button A |
+| eventButtonB | Event Button B |
+| eventButtonC | Event Button C |
+| eventButtonD | Event Button D |
+| eventButtonE | Event Button E |
+| eventButtonF | Event Button F |
+| eventButtonG | Event Button G |
+| eventButtonH | Event Button H |
+| eventButtonMain | Event Button Main |
+| eventButtonBottom | Event Button Bottom |
+| eventButtonTop | Event Button Top |
+| imEventButton | Event Button |
+
+The supported triggered events for Insteon Device things:
+
+| Event | Description |
+|-------|-------------|
+| `PRESSED_ON` | Button Pressed On (Regular On) |
+| `PRESSED_OFF` | Button Pressed Off (Regular Off) |
+| `DOUBLE_PRESSED_ON` | Button Double Pressed On (Fast On) |
+| `DOUBLE_PRESSED_OFF` | Button Double Pressed Off (Fast Off) |
+| `HELD_UP` | Button Held Up (Manual Change Up) |
+| `HELD_DOWN` | Button Held Down (Manual Change Down) |
+| `RELEASED` | Button Released (Manual Change Stop) |
+
+And for Insteon Hub and PLM things:
+
+| Event | Description |
+|-------|-------------|
+| `PRESSED` | Button Pressed |
+| `HELD` | Button Held |
+| `RELEASED` | Button Released |
+
+
+### Legacy Channels
+
+
+
+ | channel | type | description |
+ |---------|------|-------------|
+ | acDelay | Number | AC Delay |
+ | backlightDuration | Number | Back Light Duration |
+ | batteryLevel | Number | Battery Level |
+ | batteryPercent | Number:Dimensionless | Battery Percent |
+ | batteryWatermarkLevel | Number | Battery Watermark Level |
+ | beep | Switch | Beep |
+ | bottomOutlet | Switch | Bottom Outlet |
+ | buttonA | Switch | Button A |
+ | buttonB | Switch | Button B |
+ | buttonC | Switch | Button C |
+ | buttonD | Switch | Button D |
+ | buttonE | Switch | Button E |
+ | buttonF | Switch | Button F |
+ | buttonG | Switch | Button G |
+ | buttonH | Switch | Button H |
+ | broadcastOnOff | Switch | Broadcast On/Off |
+ | contact | Contact | Contact |
+ | coolSetPoint | Number | Cool Set Point |
+ | dimmer | Dimmer | Dimmer |
+ | fan | Number | Fan |
+ | fanMode | Number | Fan Mode |
+ | fastOnOff | Switch | Fast On/Off |
+ | fastOnOffButtonA | Switch | Fast On/Off Button A |
+ | fastOnOffButtonB | Switch | Fast On/Off Button B |
+ | fastOnOffButtonC | Switch | Fast On/Off Button C |
+ | fastOnOffButtonD | Switch | Fast On/Off Button D |
+ | heatSetPoint | Number | Heat Set Point |
+ | humidity | Number | Humidity |
+ | humidityHigh | Number | Humidity High |
+ | humidityLow | Number | Humidity Low |
+ | isCooling | Number | Is Cooling |
+ | isHeating | Number | Is Heating |
+ | keypadButtonA | Switch | Keypad Button A |
+ | keypadButtonB | Switch | Keypad Button B |
+ | keypadButtonC | Switch | Keypad Button C |
+ | keypadButtonD | Switch | Keypad Button D |
+ | keypadButtonE | Switch | Keypad Button E |
+ | keypadButtonF | Switch | Keypad Button F |
+ | keypadButtonG | Switch | Keypad Button G |
+ | keypadButtonH | Switch | Keypad Button H |
+ | kWh | Number:Energy | Kilowatt Hour |
+ | lastHeardFrom | DateTime | Last Heard From |
+ | ledBrightness | Number | LED brightness |
+ | ledOnOff | Switch | LED On/Off |
+ | lightDimmer | Dimmer | light Dimmer |
+ | lightLevel | Number | Light Level |
+ | lightLevelAboveThreshold | Contact | Light Level Above/Below Threshold |
+ | loadDimmer | Dimmer | Load Dimmer |
+ | loadSwitch | Switch | Load Switch |
+ | loadSwitchFastOnOff | Switch | Load Switch Fast On/Off |
+ | loadSwitchManualChange | Number | Load Switch Manual Change |
+ | lowBattery | Contact | Low Battery |
+ | manualChange | Number | Manual Change |
+ | manualChangeButtonA | Number | Manual Change Button A |
+ | manualChangeButtonB | Number | Manual Change Button B |
+ | manualChangeButtonC | Number | Manual Change Button C |
+ | manualChangeButtonD | Number | Manual Change Button D |
+ | notification | Number | Notification |
+ | onLevel | Number | On Level |
+ | rampDimmer | Dimmer | Ramp Dimmer |
+ | rampRate | Number | Ramp Rate |
+ | reset | Switch | Reset |
+ | stage1Duration | Number | Stage 1 Duration |
+ | switch | Switch | Switch |
+ | systemMode | Number | System Mode |
+ | tamperSwitch | Contact | Tamper Switch |
+ | temperature | Number:Temperature | Temperature |
+ | temperatureLevel | Number | Temperature Level |
+ | topOutlet | Switch | Top Outlet |
+ | update | Switch | Update |
+ | watts | Number:Power | Watts |
+
+
## Full Example
-Sample things file:
+### Things
```java
-Bridge insteon:network:home [port="/dev/ttyUSB0"] {
- Thing device 22F8A8 [address="22.F8.A8", productKey="F00.00.15"] {
- Channels:
- Type keypadButtonA : keypadButtonA [ group=3 ]
- Type keypadButtonB : keypadButtonB [ group=4 ]
- Type keypadButtonC : keypadButtonC [ group=5 ]
- Type keypadButtonD : keypadButtonD [ group=6 ]
- }
- Thing device 238D93 [address="23.8D.93", productKey="F00.00.12"]
- Thing device 238F55 [address="23.8F.55", productKey="F00.00.11"] {
- Channels:
- Type dimmer : dimmer [related="23.B0.D9+23.8F.C9"]
- }
- Thing device 238FC9 [address="23.8F.C9", productKey="F00.00.11"] {
- Channels:
- Type dimmer : dimmer [related="23.8F.55+23.B0.D9"]
- }
- Thing device 23B0D9 [address="23.B0.D9", productKey="F00.00.11"] {
- Channels:
- Type dimmer : dimmer [related="23.8F.55+23.8F.C9"]
- }
- Thing device 243141 [address="24.31.41", productKey="F00.00.11"] {
- Channels:
- Type dimmer : dimmer [dimmermax=60]
- }
+Bridge insteon:plm:home [serialPort="/dev/ttyUSB0"] {
+ Thing device 22F8A8 [address="22.F8.A8"]
+ Thing device 238D93 [address="23.8D.93"]
+ Thing device 238F55 [address="23.8F.55"]
+ Thing device 238FC9 [address="23.8F.C9"]
+ Thing device 23B0D9 [address="23.B0.D9"]
+ Thing scene scene42 [group=42]
+ Thing x10 A2 [houseCode="A", unitCode=2, deviceType="X10_Switch"]
}
```
-Sample items file:
+
+ Legacy
+
+ ```java
+ Bridge insteon:network:home [port="/dev/ttyUSB0"] {
+ Thing device 22F8A8 [address="22.F8.A8", productKey="F00.00.15"] {
+ Channels:
+ Type keypadButtonA : keypadButtonA [ group=3 ]
+ Type keypadButtonB : keypadButtonB [ group=4 ]
+ Type keypadButtonC : keypadButtonC [ group=5 ]
+ Type keypadButtonD : keypadButtonD [ group=6 ]
+ }
+ Thing device 238D93 [address="23.8D.93", productKey="F00.00.12"]
+ Thing device 238F55 [address="23.8F.55", productKey="F00.00.11"] {
+ Channels:
+ Type dimmer : dimmer [related="23.B0.D9+23.8F.C9"]
+ }
+ Thing device 238FC9 [address="23.8F.C9", productKey="F00.00.11"] {
+ Channels:
+ Type dimmer : dimmer [related="23.8F.55+23.B0.D9"]
+ }
+ Thing device 23B0D9 [address="23.B0.D9", productKey="F00.00.11"] {
+ Channels:
+ Type dimmer : dimmer [related="23.8F.55+23.8F.C9"]
+ }
+ Thing device 243141 [address="24.31.41", productKey="F00.00.11"] {
+ Channels:
+ Type dimmer : dimmer [dimmermax=60]
+ }
+ }
+ ```
+
+
+
+### Items
```java
Switch switch1 { channel="insteon:device:home:243141:switch" }
Dimmer dimmer1 { channel="insteon:device:home:238F55:dimmer" }
Dimmer dimmer2 { channel="insteon:device:home:23B0D9:dimmer" }
Dimmer dimmer3 { channel="insteon:device:home:238FC9:dimmer" }
-Dimmer keypad { channel="insteon:device:home:22F8A8:loadDimmer" }
-Switch keypadA { channel="insteon:device:home:22F8A8:keypadButtonA" }
-Switch keypadB { channel="insteon:device:home:22F8A8:keypadButtonB" }
-Switch keypadC { channel="insteon:device:home:22F8A8:keypadButtonC" }
-Switch keypadD { channel="insteon:device:home:22F8A8:keypadButtonD" }
-Dimmer dimmer { channel="insteon:device:home:238D93:dimmer" }
+Dimmer keypad { channel="insteon:device:home:22F8A8:dimmer" }
+Switch keypadA { channel="insteon:device:home:22F8A8:buttonA" }
+Switch keypadB { channel="insteon:device:home:22F8A8:buttonB" }
+Switch keypadC { channel="insteon:device:home:22F8A8:buttonC" }
+Switch keypadD { channel="insteon:device:home:22F8A8:buttonD" }
+Switch scene42 { channel="insteon:scene:home:scene42:sceneOnOff" }
+Switch switch2 { channel="insteon:x10:home:A2:switch" }
```
## Console Commands
-The binding provides commands you can use to help with troubleshooting.
-Enter `openhab:insteon` or `insteon` in the console and you will get a list of available commands.
-The `openhab:` prefix is optional:
+The binding provides commands to help with configuring and troubleshooting.
+Most commands support auto-completion during input based on the existing configuration.
+If a legacy network bridge is active, the console will revert to legacy commands.
+Enter `openhab:insteon` or `insteon` in the console to get a list of available commands.
```shell
-openhab> openhab:insteon
-Usage: openhab:insteon display_devices - display devices that are online, along with available channels
-Usage: openhab:insteon display_channels - display channels that are linked, along with configuration information
-Usage: openhab:insteon display_local_database - display Insteon PLM or hub database details
-Usage: openhab:insteon display_monitored - display monitored device(s)
-Usage: openhab:insteon start_monitoring all|address - start displaying messages received from device(s)
-Usage: openhab:insteon stop_monitoring all|address - stop displaying messages received from device(s)
-Usage: openhab:insteon send_standard_message address flags cmd1 cmd2 - send standard message to a device
-Usage: openhab:insteon send_extended_message address flags cmd1 cmd2 [up to 13 bytes] - send extended message to a device
-Usage: openhab:insteon send_extended_message_2 address flags cmd1 cmd2 [up to 12 bytes] - send extended message with a two byte crc to a device
+openhab> insteon
+Usage: openhab:insteon modem - Insteon modem commands
+Usage: openhab:insteon device - Insteon/X10 device commands
+Usage: openhab:insteon scene - Insteon scene commands
+Usage: openhab:insteon channel - Insteon channel commands
+Usage: openhab:insteon debug - Insteon debug commands
```
-Here is an example of command: `insteon display_local_database`.
+
+ Legacy
+
+ ```shell
+ openhab> insteon
+ Usage: openhab:insteon display_devices - display devices that are online, along with available channels
+ Usage: openhab:insteon display_channels - display channels that are linked, along with configuration information
+ Usage: openhab:insteon display_local_database - display Insteon PLM or hub database details
+ Usage: openhab:insteon display_monitored - display monitored device(s)
+ Usage: openhab:insteon start_monitoring all|address - start displaying messages received from device(s)
+ Usage: openhab:insteon stop_monitoring all|address - stop displaying messages received from device(s)
+ Usage: openhab:insteon send_standard_message address flags cmd1 cmd2 - send standard message to a device
+ Usage: openhab:insteon send_extended_message address flags cmd1 cmd2 [up to 13 bytes] - send extended message to a device
+ Usage: openhab:insteon send_extended_message_2 address flags cmd1 cmd2 [up to 12 bytes] - send extended message with a two byte crc to a device
+ ```
-The send message commands do not display any results.
-If you want to see the response from the device, you will need to monitor the device.
+
## Insteon Groups and Scenes
@@ -268,47 +552,52 @@ How do Insteon devices tell other devices on the network that their state has ch
All devices (called _responders_) that are configured to listen to this message will then go into a pre-defined state.
For instance when light switch A is switched to "ON", it will send out a message to group #1, and all responders will react to it, e.g they may go into the "ON" position as well.
Since more than one device can participate, the sending out of the broadcast message and the subsequent state change of the responders is referred to as "triggering a scene".
-At the device and PLM level, the concept of a "scene" does not exist, so you will find it notably absent in the binding code and this document.
-A scene is strictly a higher level concept, introduced to shield the user from the details of how the communication is implemented.
Many Insteon devices send out messages on different group numbers, depending on what happens to them.
A leak sensor may send out a message on group #1 when dry, and on group #2 when wet.
The default group used for e.g. linking two light switches is usually group #1.
+The binding can now automatically determines the broadcast groups between the modem and linked devices, based on their all-link databases.
+
+By default, the binding only sends direct messages to the intended device to update its state, leaving the state of the related devices unchanged.
+Whenever the bridge parameter `deviceSyncEnabled` is set to `true`, broadcast messages for supported Insteon commands (e.g. on/off, bright/dim, manual change) are sent to all responders of a given group, updating all related devices in one request.
+If no broadcast group is determined or for Insteon commands that don't support broadcasting (e.g. percent), direct messages are sent to each related device instead, to adjust their level based on their all-link database.
+
## Insteon Binding Process
Before Insteon devices communicate with one another, they must be linked.
-During the linking process, one of the devices will be the "Controller", the other the "Responder" (see e.g. the [SwitchLinc Instructions](https://www.insteon.com/pdf/2477S.pdf)).
+During the linking process, one of the devices will be the "Controller", the other the "Responder".
The responder listens to messages from the controller, and reacts to them.
-Note that except for the case of a motion detector (which is just a controller to the modem), the modem controls the device (e.g. send on/off messages to it), and the device controls the modem (so the modem learns about the switch being toggled.
+Note that except for the case of a motion detector (which is just a controller to the modem), the modem controls the device (e.g. send on/off messages to it), and the device controls the modem, so it learns about the switch being toggled.
For this reason, most devices and in particular switches/dimmers should be linked twice, with one taking the role of controller during the first linking, and the other acting as controller during the second linking process.
To do so, first press and hold the "Set" button on the modem until the light starts blinking.
-Then press and hold the "Set" button on the remote device,
-e.g. the light switch, until it double beeps (the light on the modem should go off as well.
+Then press and hold the "Set" button on the remote device, e.g. the light switch, until it double beeps (the light on the modem should go off as well).
Now do exactly the reverse: press and hold the "Set" button on the remote device until its light starts blinking, then press and hold the "Set" button on the modem until it double beeps, and the light of the remote device (switch) goes off.
-For some of the more sophisticated devices the complete linking process can no longer be done with the set buttons, but requires software like [Insteon Terminal](https://github.com/pfrommerd/insteon-terminal).
+Alternatively, the binding can link a device to the modem programmatically using the `insteon modem addDevice` console command.
+Based on the initial set button pressed event received, the device will be linked one or both ways.
+Once the newly linked device is added as a thing, additional links for more complex devices can be added using the `insteon device addMissingLinks` console command.
-## Insteon Features
+## Insteon Devices
-Since Insteon devices can have multiple features (for instance a switchable relay and a contact sensor) under a single Insteon address, an openHAB item is not bound to a device, but to a given feature of a device.
+Since Insteon devices can have multiple features (for instance a switchable relay and a contact sensor) under a single Insteon device, an openHAB item is not bound to a device, but to a given feature of a device.
For example, the following lines would create two Number items referring to the same thermostat device, but to different features of it:
```java
-Number thermostatCoolPoint "cool point [%.1f °F]" { channel="insteon:device:home:32F422:coolSetPoint" }
-Number thermostatHeatPoint "heat point [%.1f °F]" { channel="insteon:device:home:32F422:heatSetPoint" }
+Number:Temperature thermostatCoolPoint "cool point [%.1f °F]" { channel="insteon:device:home:32F422:coolSetPoint" }
+Number:Temperature thermostatHeatPoint "heat point [%.1f °F]" { channel="insteon:device:home:32F422:heatSetPoint" }
```
-### Simple Light Switches
+### Switches
The following example shows how to configure a simple light switch (2477S) in the .items file:
```java
-Switch officeLight "office light" { channel="insteon:device:home:AABBCC:switch" }
+Switch officeLight "office light" { channel="insteon:device:home:AABBCC:switch" }
```
-### Simple Dimmers
+### Dimmers
Here is how to configure a simple dimmer (2477D) in the .items file:
@@ -316,46 +605,218 @@ Here is how to configure a simple dimmer (2477D) in the .items file:
Dimmer kitchenChandelier "kitchen chandelier" { channel="insteon:device:home:AABBCC:dimmer" }
```
-Dimmers can be configured with a maximum level when turning a device on or setting a percentage level.
-If a maximum level is configured, openHAB will never set the level of the dimmer above the level specified.
-The parameter dimmermax must be defined for the channel.
-The below example sets a maximum level of 70% for dim 1 and 60% for dim 2:
+For `ON` command requests, the binding uses the device on level and ramp rate local settings to set the dimmer level, the same way it would be set when physically pressing on the dimmer.
+These settings can be controlled using the `onLevel` and `rampRate` channels.
+
+Alternatively, these settings can be overridden using the `onLevel` and `rampRate` channel parameters.
+Doing so will result in different type of commands being triggered as opposed to having separate channels previously such as `fastOnOff`, `manualChange` and `rampDimmer` handling it.
+
+When the `rampRate` parameter is configured, the binding will send a ramp rate command (previously generated by the `rampDimmer` channel) to the relevant device to set the level at the defined ramp rate.
+When this parameter is set to instant (0.1 sec), on/off commands will trigger what used to be handled by the `fastOnOff` channel.
+And percent commands will trigger what is defined in the Insteon protocol as instant change requests.
-#### Things
+As far as the previously known `manualChange` channel, it has been rolled into the `rollershutter` channel for [window covering](#window-coverings) using `UP`, `DOWN` and `STOP` commands.
+For the `dimmer` channel, the `INCREASE` and `DECREASE` commands can be used instead.
+
+Ultimately, the `dimmer` channel parameters can be used to create custom channels via a thing file that can work as an alternative to having to configure an Insteon scene for a single device.
```java
-Bridge insteon:network:home [port="/dev/ttyUSB0"] {
- Thing device AABBCC [address="AA.BB.CC", productKey="F00.00.11"] {
- Channels:
- Type dimmer : dimmer [dimmermax=70]
- }
- Thing device AABBCD [address="AA.BB.CD", productKey="F00.00.15"] {
- Channels:
- Type loadDimmer : loadDimmer [dimmermax=60]
+Thing device 23B0D9 [address="23.B0.D9"] {
+ Channels:
+ // 50% on level at 2.5 minutes ramp rate
+ Type dimmer : custom1 [onLevel=50, rampRate=150]
+ // 80% on level at device configured ramp rate
+ Type dimmer : custom2 [onLevel=80]
+ // device configured on level at 8 minutes ramp rate
+ Type dimmer : custom3 [rampRate=480]
+}
+```
+
+
+ Legacy
+
+ ```java
+ Bridge insteon:network:home [port="/dev/ttyUSB0"] {
+ Thing device AABBCC [address="AA.BB.CC", productKey="F00.00.11"] {
+ Channels:
+ Type dimmer : dimmer [dimmermax=70]
+ }
+ Thing device AABBCD [address="AA.BB.CD", productKey="F00.00.15"] {
+ Channels:
+ Type loadDimmer : loadDimmer [dimmermax=60]
+ }
}
+ ```
+
+
+
+### Keypads
+
+The Insteon keypad devices typically control one main load and have a number of buttons that will send out group broadcast messages to trigger a scene.
+To use the main load switch within openHAB, link the modem and device with the set buttons as usual.
+For the scene buttons, each one will send out a message for a different, predefined group.
+The button numbering used internally by the device must be mapped to whatever labels are printed on the physical buttons of the device.
+Here is an example correspondence table:
+
+| Group | Button Number | 2487S Label |
+|-------|---------------|-------------|
+| 0x01 | 1 | (Load) |
+| 0x03 | 3 | A |
+| 0x04 | 4 | B |
+| 0x05 | 5 | C |
+| 0x06 | 6 | D |
+
+When e.g. the "A" button is pressed (that's button #3 internally) a broadcast message will be sent out to all responders configured to listen to Insteon group #3.
+In this case, the modem must be configured as a responder to group #3 (and #4, #5, #6) messages coming from the keypad.
+These groups can be linked programmatically using the `insteon device addMissingLinks` console command, or via the device set buttons (see the keypad instructions).
+
+While previously, keypad buttons required a broadcast group to be configured, the binding now automatically determines that setting, based on the device link databases, deprecating the `group` channel parameter.
+By default, the binding will only change the button led state when receiving on/off commands, depending on the keypad local radio group settings.
+For button broadcast group support, set the bridge parameter `deviceSyncEnabled` to `true`.
+Additionally, for button toggle mode set to always on or off, only `ON` or `OFF` commands will be processed, in line with the physical interaction.
+
+#### Keypad Switches
+
+##### Items
+
+The following items will expose a keypad switch and its associated buttons:
+
+```java
+Switch keypadSwitch "main switch" { channel="insteon:device:home:AABBCC:switch" }
+Switch keypadSwitchA "button A" { channel="insteon:device:home:AABBCC:buttonA"}
+Switch keypadSwitchB "button B" { channel="insteon:device:home:AABBCC:buttonB"}
+Switch keypadSwitchC "button C" { channel="insteon:device:home:AABBCC:buttonC"}
+Switch keypadSwitchD "button D" { channel="insteon:device:home:AABBCC:buttonD"}
+```
+
+##### Sitemap
+
+The following sitemap will bring the items to life in the GUI:
+
+```perl
+Frame label="Keypad" {
+ Switch item=keypadSwitch label="main"
+ Switch item=keypadSwitchA label="button A"
+ Switch item=keypadSwitchB label="button B"
+ Switch item=keypadSwitchC label="button C"
+ Switch item=keypadSwitchD label="button D"
}
```
-#### Items
+##### Rules
+
+The following rules will monitor regular on/off, fast on/off and manual change button events:
+
+```php
+rule "Main Button Off Event"
+when
+ Channel 'insteon:device:home:AABBCC:eventButtonMain' triggered PRESSED_OFF
+then
+ // do something
+end
+
+rule "Main Button Fast On/Off Events"
+when
+ Channel 'insteon:device:home:AABBCC:eventButtonMain' triggered DOUBLE_PRESSED_ON or
+ Channel 'insteon:device:home:AABBCC:eventButtonMain' triggered DOUBLE_PRESSED_OFF
+then
+ // do something
+end
+
+rule "Main Button Manual Change Stop Event"
+when
+ Channel 'insteon:device:home:AABBCC:eventButtonMain' triggered RELEASED
+then
+ // do something
+end
+
+rule "Keypad Button A On Event"
+when
+ Channel 'insteon:device:home:AABBCC:eventButtonA' triggered PRESSED_ON
+then
+ // do something
+end
+```
+
+
+ Legacy
+
+ ##### Items
+
+ Here is a simple example, just using the load (main) switch:
+
+ ```java
+ Switch keypadSwitch "main load" { channel="insteon:device:home:AABBCC:loadSwitch" }
+ Number keypadSwitchManualChange "main manual change" { channel="insteon:device:home:AABBCC:loadSwitchManualChange" }
+ Switch keypadSwitchFastOnOff "main fast on/off" { channel="insteon:device:home:AABBCC:loadSwitchFastOnOff" }
+ Switch keypadSwitchA "keypad button A" { channel="insteon:device:home:AABBCC:keypadButtonA"}
+ Switch keypadSwitchB "keypad button B" { channel="insteon:device:home:AABBCC:keypadButtonB"}
+ Switch keypadSwitchC "keypad button C" { channel="insteon:device:home:AABBCC:keypadButtonC"}
+ Switch keypadSwitchD "keypad button D" { channel="insteon:device:home:AABBCC:keypadButtonD"}
+ ```
+
+ ##### Things
+
+ The value after group must either be a number or string.
+ The hexadecimal value 0xf3 can either converted to a numeric value 243 or the string value "0xf3".
+
+ ```java
+ Bridge insteon:network:home [port="/dev/ttyUSB0"] {
+ Thing device AABBCC [address="AA.BB.CC", productKey="F00.00.15"] {
+ Channels:
+ Type keypadButtonA : keypadButtonA [ group="0xf3" ]
+ Type keypadButtonB : keypadButtonB [ group="0xf4" ]
+ Type keypadButtonC : keypadButtonC [ group="0xf5" ]
+ Type keypadButtonD : keypadButtonD [ group="0xf6" ]
+ }
+ }
+ ```
+
+ ##### Sitemap
+
+ The following sitemap will bring the items to life in the GUI:
+
+ ```perl
+ Frame label="Keypad" {
+ Switch item=keypadSwitch label="main"
+ Switch item=keypadSwitchFastOnOff label="fast on/off"
+ Switch item=keypadSwitchManualChange label="manual change" mappings=[ 0="DOWN", 1="STOP", 2="UP"]
+ Switch item=keypadSwitchA label="button A"
+ Switch item=keypadSwitchB label="button B"
+ Switch item=keypadSwitchC label="button C"
+ Switch item=keypadSwitchD label="button D"
+ }
+ ```
+
+
+
+#### Keypad Dimmers
+
+The keypad dimmers are like keypad switches, except that the main load is dimmable.
+
+##### Items
```java
-Dimmer d1 "dimmer 1" { channel="insteon:device:home:AABBCC:dimmer"}
-Dimmer d2 "dimmer 2" { channel="insteon:device:home:AABBCD:loadDimmer"}
+Dimmer keypadDimmer "main dimmer" { channel="insteon:device:home:AABBCC:dimmer" }
+Switch keypadDimmerButtonA "button A" { channel="insteon:device:home:AABBCC:buttonA" }
```
-Setting a maximum level does not affect manual turning on or dimming a switch.
+##### Sitemap
+
+```perl
+Slider item=keypadDimmer label="main" switchSupport
+Switch item=keypadDimmerButtonA label="button A"
+```
-### On/Off Outlets
+### Outlets
Here's how to configure the top and bottom outlet of the in-wall 2 outlet controller:
```java
-Switch fOutTop "Front Outlet Top" { channel="insteon:device:home:AABBCC:topOutlet" }
-Switch fOutBot "Front Outlet Bottom" { channel="insteon:device:home:AABBCC:bottomOutlet" }
+Switch outletTop "Outlet Top" { channel="insteon:device:home:AABBCC:topOutlet" }
+Switch outletBottom "Outlet Bottom" { channel="insteon:device:home:AABBCC:bottomOutlet" }
```
-This will give you individual control of each outlet.
-
### Mini Remotes
Link the mini remote to be a controller of the modem by using the set button.
@@ -370,82 +831,97 @@ The modem's link database (see [Insteon Terminal](https://github.com/pfrommerd/i
0000 xx.xx.xx xx.xx.xx RESP 10100010 group: 04 data: 02 2c 41
```
-**Items**
-This goes into the items file:
+The mini remote buttons cannot be modeled as items since they don't have a state or can receive commands. However, button triggered events can be monitored through rules that can set off subsequent actions:
-```java
-Switch miniRemoteButtonA "mini remote button a" { channel="insteon:device:home:AABBCC:buttonA", autoupdate="false" }
-Switch miniRemoteButtonB "mini remote button b" { channel="insteon:device:home:AABBCC:buttonB", autoupdate="false" }
-Switch miniRemoteButtonC "mini remote button c" { channel="insteon:device:home:AABBCC:buttonC", autoupdate="false" }
-Switch miniRemoteButtonD "mini remote button d" { channel="insteon:device:home:AABBCC:buttonD", autoupdate="false" }
-```
-
-**Sitemap**
-This goes into the sitemap file:
+##### Rules
-```perl
-Switch item=miniRemoteButtonA label="mini remote button a" mappings=[ OFF="Off", ON="On"]
-Switch item=miniRemoteButtonB label="mini remote button b" mappings=[ OFF="Off", ON="On"]
-Switch item=miniRemoteButtonC label="mini remote button c" mappings=[ OFF="Off", ON="On"]
-Switch item=miniRemoteButtonD label="mini remote button d" mappings=[ OFF="Off", ON="On"]
+```php
+rule "Mini Remote Button A Pressed On"
+when
+ Channel 'insteon:device:home:miniRemote:eventButtonA' triggered PRESSED_ON
+then
+ // do something
+end
```
-The switches in the GUI just display the mini remote's most recent button presses.
-They are not operable because the PLM cannot trigger the mini remotes scenes.
-
### Motion Sensors
Link such that the modem is a responder to the motion sensor.
-Create a contact.map file in the transforms directory as described elsewhere in this document.
-Then create entries in the .items file like this:
-#### Items
+##### Items
```java
-Contact motionSensor "motion sensor [MAP(contact.map):%s]" { channel="insteon:device:home:AABBCC:contact"}
-Number motionSensorBatteryLevel "motion sensor battery level" { channel="insteon:device:home:AABBCC:batteryLevel" }
-Number motionSensorLightLevel "motion sensor light level" { channel="insteon:device:home:AABBCC:lightLevel" }
+Switch motionSensor "motion sensor [MAP(motion.map):%s]" { channel="insteon:device:home:AABBCC:motion"}
+Number:Dimensionless motionSensorBatteryLevel "battery level [%.1f %%]" { channel="insteon:device:home:AABBCC:batteryLevel" }
+Number:Dimensionless motionSensorLightLevel "light level [%.1f %%]" { channel="insteon:device:home:AABBCC:lightLevel" }
+```
+
+ ```java
+ Contact motionSensor "motion sensor [MAP(motion.map):%s]" { channel="insteon:device:home:AABBCC:contact"}
+ Number motionSensorBatteryLevel "motion sensor battery level" { channel="insteon:device:home:AABBCC:batteryLevel" }
+ Number motionSensorLightLevel "motion sensor light level" { channel="insteon:device:home:AABBCC:lightLevel" }
+ ```
+
+
+
+and create a file "motion.map" in the transforms directory with these entries:
+
+```text
+ON=detected
+OFF=cleared
+-=unknown
```
-This will give you a contact, the battery level, and the light level.
-The motion sensor II includes three additional channels:
+The motion sensor II includes additional channels:
```java
-Number motionSensorBatteryPercent "motion sensor battery percent" { channel="insteon:device:home:AABBCC:batteryPercent" }
-Contact motionSensorTamperSwitch "motion sensor tamper switch [MAP(contact.map):%s]" { channel="insteon:device:home:AABBCC:tamperSwitch"}
-Number motionSensorTemperatureLevel "motion sensor temperature level" { channel="insteon:device:home:AABBCC:temperatureLevel" }
+Contact motionSensorTamperSwitch "tamper switch [MAP(contact.map):%s]" { channel="insteon:device:home:AABBCC:tamperSwitch" }
+Number:Temperature motionSensorTemperature "temperature [%.1f °F]" { channel="insteon:device:home:AABBCC:temperature" }
```
-The battery, light level and temperature level are updated when either there is motion, light level above/below threshold, tamper switch activated, or the sensor battery runs low.
-This is accomplished by querying the device for the data.
-The motion sensor II will also periodically send data if the alternate heartbeat is enabled on the device.
+The temperature is automatically calculated in Fahrenheit based on the motion sensor II powered source.
+Since that sensor might not be calibrated correctly, the output temperature may need to be offset on the openHAB side.
-If the alternate heartbeat is enabled, the device can be configured to not query the device and rely on the data from the alternate heartbeat.
-Disabling the querying of the device should provide more accurate battery data since it appears to fluctuate with queries of the device.
-This can be configured with the device configuration parameter of the device.
-The key in the JSON object is `heartbeatOnly` and the value is a boolean:
+Note that battery and light level are only updated when either there is motion, light level above/below threshold, tamper switch activated, or the sensor battery runs low.
-#### Things
+
+ Legacy
-```java
-Bridge insteon:network:home [port="/dev/ttyUSB0"] {
- Thing device AABBCC [address="AA.BB.CC", productKey="F00.00.24", deviceConfig="{'heartbeatOnly': true}"]
-}
+ If the alternate heartbeat is enabled, the device can be configured to not query the device and rely on the data from the alternate heartbeat.
+ Disabling the querying of the device should provide more accurate battery data since it appears to fluctuate with queries of the device.
+ This can be configured with the device configuration parameter of the device.
+ The key in the JSON object is `heartbeatOnly` and the value is a boolean:
-```
+ #### Things
+
+ ```java
+ Bridge insteon:network:home [port="/dev/ttyUSB0"] {
+ Thing device AABBCC [address="AA.BB.CC", productKey="F00.00.24", deviceConfig="{'heartbeatOnly': true}"]
+ }
+ ```
+
+ The temperature can be calculated in Fahrenheit using the following formulas:
-The temperature can be calculated in Fahrenheit using the following formulas:
+ - If the device is battery powered: `temperature = 0.73 * motionSensorTemperatureLevel - 20.53`
+ - If the device is USB powered: `temperature = 0.72 * motionSensorTemperatureLevel - 24.61`
-- If the device is battery powered: `temperature = 0.73 * motionSensorTemperatureLevel - 20.53`
-- If the device is USB powered: `temperature = 0.72 * motionSensorTemperatureLevel - 24.61`
+ Since the motion sensor II might not be calibrated correctly, the values `20.53` and `24.61` can be adjusted as necessary to produce the correct temperature.
-Since the motion sensor II might not be calibrated correctly, the values `20.53` and `24.61` can be adjusted as necessary to produce the correct temperature.
+
### Hidden Door Sensors
Similar in operation to the motion sensor above.
Link such that the modem is a responder to the motion sensor.
-Create a contact.map file in the transforms directory like the following:
+
+##### Items
+
+```java
+Contact doorSensor "door sensor [MAP(contact.map):%s]" { channel="insteon:device:home:AABBCC:contact" }
+Number:Dimensionless doorSensorBatteryLevel "battery level [%.1f %%]" { channel="insteon:device:home:AABBCC:batteryLevel" }
+```
+
+and create a file "contact.map" in the transforms directory with these entries:
```text
OPEN=open
@@ -453,33 +929,32 @@ CLOSED=closed
-=unknown
```
-**Items**
-Then create entries in the .items file like this:
-
-```java
-Contact doorSensor "Door sensor [MAP(contact.map):%s]" { channel="insteon:device:home:AABBCC:contact" }
-Number doorSensorBatteryLevel "Door sensor battery level [%.1f]" { channel="insteon:device:home:AABBCC:batteryLevel" }
-```
-
-This will give you a contact and the battery level.
-Note that battery level is only updated when either there is motion, or the sensor battery runs low.
+Note that battery level is only updated when the sensor is triggered or through its daily heartbeat.
### Locks
-Read the instructions very carefully: sync with lock within 5 feet to avoid bad connection, link twice for both ON and OFF functionality.
+It is important to sync with the lock contorller within 5 feet to avoid bad connection and link twice for both ON and OFF functionality.
-**Items**
-Put something like this into your .items file:
+##### Items
```java
-Switch doorLock "Front Door [MAP(lock.map):%s]" { channel="insteon:device:home:AABBCC:switch" }
+Switch doorLock "Front Door [MAP(lock.map):%s]" { channel="insteon:device:home:AABBCC:lock" }
```
+
+ Legacy
+
+ ```java
+ Switch doorLock "Front Door [MAP(lock.map):%s]" { channel="insteon:device:home:AABBCC:switch" }
+ ```
+
+
+
and create a file "lock.map" in the transforms directory with these entries:
```text
-ON=Lock
-OFF=Unlock
+ON=locked
+OFF=unlocked
-=unknown
```
@@ -493,7 +968,14 @@ This is based on the status of the contact when it is linked, and was intended f
The binding expects the contact to be inverted to work properly.
Ensure the contact is OFF (status LED is dark/garage door open) when linking the modem as a responder to the I/O Linc in order for it to function properly.
-Add this map into your transforms directory as "contact.map":
+##### Items
+
+```java
+Switch garageDoorOpener "door opener" { channel="insteon:device:home:AABBCC:switch" }
+Contact garageDoorContact "door contact [MAP(contact.map):%s]" { channel="insteon:device:home:AABBCC:contact" }
+```
+
+and create a file "contact.map" in the transforms directory with these entries:
```text
OPEN=open
@@ -501,320 +983,434 @@ CLOSED=closed
-=unknown
```
-**Items**
-Along with this into your .items file:
-
-```java
-Switch garageDoorOpener "garage door opener" { channel="insteon:device:home:AABBCC:switch", autoupdate="false" }
-Contact garageDoorContact "garage door contact [MAP(contact.map):%s]" { channel="insteon:device:home:AABBCC:contact" }
-```
+> NOTE: If the I/O Linc contact status appears delayed, or returns the wrong value when the sensor changes states, the contact was likely ON (status LED lit) when the modem was linked as a responder.
+Examples of this behavior would include: The status remaining CLOSED for up to 3 minutes after the door is opened, or the status remains OPEN for up to three minutes after the garage is opened and immediately closed again.
+To resolve this behavior the I/O Linc will need to be unlinked and then re-linked to the modem with the contact OFF (stats LED off).
+That would be with the door open when using the Insteon garage kit.
-**Sitemap**
-To make it visible in the GUI, put this into your sitemap file:
+### Fan Controllers
-```perl
-Switch item=garageDoorOpener label="garage door opener" mappings=[ ON="OPEN/CLOSE"]
-Text item=garageDoorContact
-```
+Here is an example configuration for a FanLinc module, which has a dimmable light and a variable speed fan:
-For safety reasons, only close the garage door if you have visual contact to make sure there is no obstruction! The use of automated rules for closing garage doors is dangerous.
+##### Items
-> NOTE: If the I/O Linc contact status appears delayed, or returns the wrong value when the sensor changes states, the contact was likely ON (status LED lit) when the modem was linked as a responder.
-Examples of this behavior would include: The status remaining CLOSED for up to 3 minutes after the door is opened, or the status remains OPEN for up to three minutes after the garage is opened and immediately closed again.
-To resolve this behavior the I/O Linc will need to be unlinked and then re-linked to the modem with the contact OFF (stats LED off).
-That would be with the door open when using the Insteon garage kit.
+```java
+Dimmer fanLincDimmer "dimmer [%d %%]" { channel="insteon:device:home:AABBCC:dimmer" }
+String fanLincFan "fan speed" { channel="insteon:device:home:AABBCC:fanSpeed" }
+```
-### Keypads
+
+ Legacy
-Before you attempt to configure the keypads, please familiarize yourself with the concept of an Insteon group.
+ ```java
+ Dimmer fanLincDimmer "dimmer [%d %%]" { channel="insteon:device:home:AABBCC:lightDimmer" }
+ Number fanLincFan "fan" { channel="insteon:device:home:AABBCC:fan"}
+ ```
-The Insteon keypad devices typically control one main load and have a number of buttons that will send out group broadcast messages to trigger a scene.
-If you just want to use the main load switch within openHAB just link modem and device with the set buttons as usual, no complicated linking is necessary.
-But if you want to get the buttons to work, read on.
+
-Each button will send out a message for a different, predefined group.
-Complicating matters further, the button numbering used internally by the device must be mapped to whatever labels are printed on the physical buttons of the device.
-Here is an example correspondence table:
+##### Sitemap
-| Group | Button Number | 2487S Label |
-|-------|---------------|-------------|
-| 0x01 | 1 | (Load) |
-| 0x03 | 3 | A |
-| 0x04 | 4 | B |
-| 0x05 | 5 | C |
-| 0x06 | 6 | D |
+```perl
+Slider item=fanLincDimmer switchSupport
+Switch item=fanLincFan mappings=[ OFF="OFF", LOW="LOW", MEDIUM="MEDIUM", HIGH="HIGH" ]
+```
-When e.g. the "A" button is pressed (that's button #3 internally) a broadcast message will be sent out to all responders configured to listen to Insteon group #3.
-This means you must configure the modem as a responder to group #3 (and #4, #5, #6) messages coming from your keypad.
-For instructions how to do this, check out the [Insteon Terminal](https://github.com/pfrommerd/insteon-terminal).
-You can even do that with the set buttons (see instructions that come with the keypad).
+### Power Meters
-While capturing the messages that the buttons emit is pretty straight forward, controlling the buttons is another matter.
-They cannot be simply toggled with a direct command to the device, but instead a broadcast message must be sent on a group number that the button has been programmed to listen to.
-This means you need to pick a set of unused groups that is globally unique (if you have multiple keypads, each one of them has to use different groups), one group for each button.
-The example configuration below uses groups 0xf3, 0xf4, 0xf5, and 0xf6.
-Then link the buttons such that they respond to those groups, and link the modem as a controller for them (see [Insteon Terminal](https://github.com/pfrommerd/insteon-terminal) documentation.
-In your items file you specify these groups with the "group=" parameters such that the binding knows what group number to put on the outgoing message.
+The iMeter Solo reports both wattage and kilowatt hours, and is updated during the normal polling process of the devices.
+Send a `REFRESH` command to force update the current values for the device.
+Additionally, the device can be reset.
-#### Keypad Switches
+See the example below:
##### Items
-Here is a simple example, just using the load (main) switch:
-
```java
-Switch keypadSwitch "main load" { channel="insteon:device:home:AABBCC:loadSwitch" }
-Number keypadSwitchManualChange "main manual change" { channel="insteon:device:home:AABBCC:loadSwitchManualChange" }
-Switch keypadSwitchFastOnOff "main fast on/off" { channel="insteon:device:home:AABBCC:loadSwitchFastOnOff" }
+Number:Power iMeterPower "power [%d W]" { channel="insteon:device:home:AABBCC:powerUsage" }
+Number:Energy iMeterEnergy "energy [%.04f kWh]" { channel="insteon:device:home:AABBCC:energyUsage" }
+Switch iMeterReset "reset" { channel="insteon:device:home:AABBCC:reset" }
```
-Most people will not use the fast on/off features or the manual change feature, so you really only need the first line.
-To make the buttons available, add the following:
+
+ Legacy
-###### Things
+ ```java
+ Number:Power iMeterWatts "iMeter [%d watts]" { channel="insteon:device:home:AABBCC:watts" }
+ Number:Energy iMeterKwh "iMeter [%.04f kWh]" { channel="insteon:device:home:AABBCC:kWh" }
+ Switch iMeterUpdate "iMeter Update" { channel="insteon:device:home:AABBCC:update" }
+ Switch iMeterReset "iMeter Reset" { channel="insteon:device:home:AABBCC:reset" }
+ ```
-```java
-Bridge insteon:network:home [port="/dev/ttyUSB0"] {
- Thing device AABBCC [address="AA.BB.CC", productKey="F00.00.15"] {
- Channels:
- Type keypadButtonA : keypadButtonA [ group="0xf3" ]
- Type keypadButtonB : keypadButtonB [ group="0xf4" ]
- Type keypadButtonC : keypadButtonC [ group="0xf5" ]
- Type keypadButtonD : keypadButtonD [ group="0xf6" ]
- }
-}
-```
+
-The value after group must either be a number or string.
-The hexadecimal value 0xf3 can either converted to a numeric value 243 or the string value "0xf3".
+### Sirens
-###### Items
+When turning on the siren directly, the binding will trigger the siren with no delay and up to the maximum duration (~2 minutes).
+The channels to change the alarm delay and duration are only for the siren arming behavior.
+
+Here is an example configuration for a siren module:
+
+##### Items
```java
-Switch keypadSwitchA "keypad button A" { channel="insteon:device:home:AABBCC:keypadButtonA"}
-Switch keypadSwitchB "keypad button B" { channel="insteon:device:home:AABBCC:keypadButtonB"}
-Switch keypadSwitchC "keypad button C" { channel="insteon:device:home:AABBCC:keypadButtonC"}
-Switch keypadSwitchD "keypad button D" { channel="insteon:device:home:AABBCC:keypadButtonD"}
+Switch siren "siren" { channel="insteon:device:home:AABBCC:siren" }
+Switch sirenArmed "armed" { channel="insteon:device:home:AABBCC:armed" }
+Switch sirenAlarmDelay "alarm delay" { channel="insteon:device:home:AABBCC:alarmDelay" }
+Number:Time sirenAlarmDuration "alarm duration [%d s]" { channel="insteon:device:home:AABBCC:alarmDuration" }
+String sirenAlarmType "alarm type [%s]" { channel="insteon:device:home:AABBCC:alarmType" }
```
##### Sitemap
-The following sitemap will bring the items to life in the GUI:
-
```perl
-Frame label="Keypad" {
- Switch item=keypadSwitch label="main"
- Switch item=keypadSwitchFastOnOff label="fast on/off"
- Switch item=keypadSwitchManualChange label="manual change" mappings=[ 0="DOWN", 1="STOP", 2="UP"]
- Switch item=keypadSwitchA label="button A"
- Switch item=keypadSwitchB label="button B"
- Switch item=keypadSwitchC label="button C"
- Switch item=keypadSwitchD label="button D"
-}
+Switch item=siren
+Text item=sirenArmed
+Switch item=sirenAlarmDelay
+Setpoint item=sirenAlarmDuration minValue=0 maxValue=127 step=1
+Switch item=sirenAlarmType mappings=[ CHIME="CHIME", LOUD_SIREN="LOUD SIREN" ]
```
-#### Keypad Dimmers
+### Sprinklers
-The keypad dimmers are like keypad switches, except that the main load is dimmable.
+The EZRain device controls up to 8 sprinkler valves and 4 programs.
+It can also enable pump control on the 8th valve.
+Only one sprinkler valve can be on at the time.
+When pump control is enabled, the 8th valve will remain on and cannot be controlled at the valve level.
+Each sprinkler program can be turned on/off by using `PLAY` and `PAUSE` commands.
+To skip forward or back to the next or previous valve in the program, use `NEXT` and `PREVIOUS` commands.
##### Items
```java
-Dimmer keypadDimmer "dimmer" { channel="insteon:device:home:AABBCC:loadDimmer" }
-Switch keypadDimmerButtonA "keypad dimmer button A [%d %%]" { channel="insteon:device:home:AABBCC:keypadButtonA" }
-```
-
-##### Sitemap
-
-```perl
-Slider item=keypadDimmer switchSupport
-Switch item=keypadDimmerButtonA label="buttonA"
+Switch valve1 "valve 1" { channel="insteon:device:home:AABBCC:valve1" }
+Switch valve2 "valve 2" { channel="insteon:device:home:AABBCC:valve2" }
+Switch valve3 "valve 3" { channel="insteon:device:home:AABBCC:valve3" }
+Switch valve4 "valve 4" { channel="insteon:device:home:AABBCC:valve4" }
+Switch valve5 "valve 5" { channel="insteon:device:home:AABBCC:valve5" }
+Switch valve6 "valve 6" { channel="insteon:device:home:AABBCC:valve6" }
+Switch valve7 "valve 7" { channel="insteon:device:home:AABBCC:valve7" }
+Switch valve8 "valve 8" { channel="insteon:device:home:AABBCC:valve8" }
+Switch pump "pump" { channel="insteon:device:home:AABBCC:pump" }
+Player program1 "program 1" { channel="insteon:device:home:AABBCC:program1" }
+Player program2 "program 2" { channel="insteon:device:home:AABBCC:program2" }
+Player program3 "program 3" { channel="insteon:device:home:AABBCC:program3" }
+Player program4 "program 4" { channel="insteon:device:home:AABBCC:program4" }
```
### Thermostats
The thermostat (2441TH) is one of the most complex Insteon devices available.
-It must first be properly linked to the modem using configuration software like [Insteon Terminal](.
-The Insteon Terminal wiki describes in detail how to link the thermostat, and how to make it publish status update reports.
-
-When all is set and done the modem must be configured as a controller to group 0 (not sure why), and a responder to groups 1-5 such that it picks up when the thermostat switches on/off heating and cooling etc, and it must be a responder to special group 0xEF to get status update reports when measured values (temperature) change.
-Symmetrically, the thermostat must be a responder to group 0, and a controller for groups 1-5 and 0xEF.
-The linking process is not difficult but needs some persistence.
-Again, refer to the [Insteon Terminal](https://github.com/pfrommerd/insteon-terminal) documentation.
-
-#### Items
+To ensure all links are configured between the modem and device, and the status reporting is enabled, use the `insteon device addMissingLinks` console command.
-This is an example of what to put into your .items file:
+##### Items
```java
-Number thermostatCoolPoint "cool point [%.1f °F]" { channel="insteon:device:home:AABBCC:coolSetPoint" }
-Number thermostatHeatPoint "heat point [%.1f °F]" { channel="insteon:device:home:AABBCC:heatSetPoint" }
-Number thermostatSystemMode "system mode [%d]" { channel="insteon:device:home:AABBCC:systemMode" }
-Number thermostatFanMode "fan mode [%d]" { channel="insteon:device:home:AABBCC:fanMode" }
-Number thermostatIsHeating "is heating [%d]" { channel="insteon:device:home:AABBCC:isHeating"}
-Number thermostatIsCooling "is cooling [%d]" { channel="insteon:device:home:AABBCC:isCooling" }
-Number:Temperature thermostatTemperature "temperature [%.1f %unit%]" { channel="insteon:device:home:AABBCC:temperature" }
-Number thermostatHumidity "humidity [%.0f %%]" { channel="insteon:device:home:AABBCC:humidity" }
+Number:Temperature thermostatCoolPoint "cool point [%.1f °F]" { channel="insteon:device:home:AABBCC:coolSetPoint" }
+Number:Temperature thermostatHeatPoint "heat point [%.1f °F]" { channel="insteon:device:home:AABBCC:heatSetPoint" }
+String thermostatSystemMode "system mode [%s]" { channel="insteon:device:home:AABBCC:systemMode" }
+String thermostatSystemState "system state [%s]" { channel="insteon:device:home:AABBCC:systemState" }
+String thermostatFanMode "fan mode [%s]" { channel="insteon:device:home:AABBCC:fanMode" }
+Number:Temperature thermostatTemperature "temperature [%.1f °F]" { channel="insteon:device:home:AABBCC:temperature" }
+Number:Dimensionless thermostatHumidity "humidity [%.0f %%]" { channel="insteon:device:home:AABBCC:humidity" }
```
Add this as well for some more exotic features:
```java
-Number thermostatACDelay "A/C delay [%d min]" { channel="insteon:device:home:AABBCC:acDelay" }
-Number thermostatBacklight "backlight [%d sec]" { channel="insteon:device:home:AABBCC:backlightDuration" }
-Number thermostatStage1 "A/C stage 1 time [%d min]" { channel="insteon:device:home:AABBCC:stage1Duration" }
-Number thermostatHumidityHigh "humidity high [%d %%]" { channel="insteon:device:home:AABBCC:humidityHigh" }
-Number thermostatHumidityLow "humidity low [%d %%]" { channel="insteon:device:home:AABBCC:humidityLow" }
+Number:Time thermostatACDelay "A/C delay [%d min]" { channel="insteon:device:home:AABBCC:acDelay" }
+Number:Time thermostatBacklight "backlight [%d sec]" { channel="insteon:device:home:AABBCC:backlightDuration" }
+Number:Time thermostatStage1 "A/C stage 1 time [%d min]" { channel="insteon:device:home:AABBCC:stage1Duration" }
+Number:Dimensionless thermostatHumidityHigh "humidity high [%d %%]" { channel="insteon:device:home:AABBCC:humidityHigh" }
+Number:Dimensionless thermostatHumidityLow "humidity low [%d %%]" { channel="insteon:device:home:AABBCC:humidityLow" }
+String thermostatTempFormat "temperature format [%s]" { channel="insteon:device:home:AABBCC:temperatureFormat" }
+String thermostatTimeFormat "time format [%s]" { channel="insteon:device:home:AABBCC:timeFormat" }
```
-#### Sitemap
+
+ Legacy
+
+ ```java
+ Number thermostatCoolPoint "cool point [%.1f °F]" { channel="insteon:device:home:AABBCC:coolSetPoint" }
+ Number thermostatHeatPoint "heat point [%.1f °F]" { channel="insteon:device:home:AABBCC:heatSetPoint" }
+ Number thermostatSystemMode "system mode [%d]" { channel="insteon:device:home:AABBCC:systemMode" }
+ Number thermostatFanMode "fan mode [%d]" { channel="insteon:device:home:AABBCC:fanMode" }
+ Number thermostatIsHeating "is heating [%d]" { channel="insteon:device:home:AABBCC:isHeating"}
+ Number thermostatIsCooling "is cooling [%d]" { channel="insteon:device:home:AABBCC:isCooling" }
+ Number:Temperature thermostatTemperature "temperature [%.1f %unit%]" { channel="insteon:device:home:AABBCC:temperature" }
+ Number thermostatHumidity "humidity [%.0f %%]" { channel="insteon:device:home:AABBCC:humidity" }
+ ```
+
+ Add this as well for some more exotic features:
+
+ ```java
+ Number thermostatACDelay "A/C delay [%d min]" { channel="insteon:device:home:AABBCC:acDelay" }
+ Number thermostatBacklight "backlight [%d sec]" { channel="insteon:device:home:AABBCC:backlightDuration" }
+ Number thermostatStage1 "A/C stage 1 time [%d min]" { channel="insteon:device:home:AABBCC:stage1Duration" }
+ Number thermostatHumidityHigh "humidity high [%d %%]" { channel="insteon:device:home:AABBCC:humidityHigh" }
+ Number thermostatHumidityLow "humidity low [%d %%]" { channel="insteon:device:home:AABBCC:humidityLow" }
+ ```
+
+
+
+##### Sitemap
For the thermostat to display in the GUI, add this to the sitemap file:
```perl
-Text item=thermostatTemperature icon="temperature"
-Text item=thermostatHumidity
+Text item=thermostatTemperature icon="temperature"
+Text item=thermostatHumidity
Setpoint item=thermostatCoolPoint icon="temperature" minValue=63 maxValue=90 step=1
Setpoint item=thermostatHeatPoint icon="temperature" minValue=50 maxValue=80 step=1
-Switch item=thermostatSystemMode label="system mode" mappings=[ 0="OFF", 1="HEAT", 2="COOL", 3="AUTO", 4="PROGRAM"]
-Switch item=thermostatFanMode label="fan mode" mappings=[ 0="AUTO", 1="ALWAYS ON"]
-Switch item=thermostatIsHeating label="is heating" mappings=[ 0="OFF", 1="HEATING"]
-Switch item=thermostatIsCooling label="is cooling" mappings=[ 0="OFF", 1="COOLING"]
-Setpoint item=thermostatACDelay minValue=2 maxValue=20 step=1
-Setpoint item=thermostatBacklight minValue=0 maxValue=100 step=1
-Setpoint item=thermostatHumidityHigh minValue=0 maxValue=100 step=1
-Setpoint item=thermostatHumidityLow minValue=0 maxValue=100 step=1
-Setpoint item=thermostatStage1 minValue=1 maxValue=60 step=1
+Switch item=thermostatSystemMode mappings=[ OFF="OFF", HEAT="HEAT", COOL="COOL", AUTO="AUTO", PROGRAM="PROGRAM" ]
+Text item=thermostatSystemState
+Switch item=thermostatFanMode mappings=[ AUTO="AUTO", ON="ALWAYS ON" ]
+Setpoint item=thermostatACDelay minValue=2 maxValue=20 step=1
+Setpoint item=thermostatBacklight minValue=0 maxValue=100 step=1
+Setpoint item=thermostatHumidityHigh minValue=0 maxValue=100 step=1
+Setpoint item=thermostatHumidityLow minValue=0 maxValue=100 step=1
+Setpoint item=thermostatStage1 minValue=1 maxValue=60 step=1
+Switch item=thermostatTempFormat mappings=[ CELSIUS="CELSIUS", FAHRENHEIT="FAHRENHEIT" ]
```
-### Power Meters
+
+ Legacy
-The iMeter Solo reports both wattage and kilowatt hours, and is updated during the normal polling process of the devices.
-You can also manually update the current values from the device and reset the device.
-See the example below:
+ ```perl
+ Text item=thermostatTemperature icon="temperature"
+ Text item=thermostatHumidity
+ Setpoint item=thermostatCoolPoint icon="temperature" minValue=63 maxValue=90 step=1
+ Setpoint item=thermostatHeatPoint icon="temperature" minValue=50 maxValue=80 step=1
+ Switch item=thermostatSystemMode label="system mode" mappings=[ 0="OFF", 1="HEAT", 2="COOL", 3="AUTO", 4="PROGRAM"]
+ Switch item=thermostatFanMode label="fan mode" mappings=[ 0="AUTO", 1="ALWAYS ON"]
+ Switch item=thermostatIsHeating label="is heating" mappings=[ 0="OFF", 1="HEATING"]
+ Switch item=thermostatIsCooling label="is cooling" mappings=[ 0="OFF", 1="COOLING"]
+ Setpoint item=thermostatACDelay minValue=2 maxValue=20 step=1
+ Setpoint item=thermostatBacklight minValue=0 maxValue=100 step=1
+ Setpoint item=thermostatHumidityHigh minValue=0 maxValue=100 step=1
+ Setpoint item=thermostatHumidityLow minValue=0 maxValue=100 step=1
+ Setpoint item=thermostatStage1 minValue=1 maxValue=60 step=1
+ ```
-#### Items
+
+
+### Window Coverings
+
+Here is an example configuration for a micro open/close module (2444-222) in the .items file:
```java
-Number:Power iMeterWatts "iMeter [%d watts]" { channel="insteon:device:home:AABBCC:watts" }
-Number:Energy iMeterKwh "iMeter [%.04f kWh]" { channel="insteon:device:home:AABBCC:kWh" }
-Switch iMeterUpdate "iMeter Update" { channel="insteon:device:home:AABBCC:update" }
-Switch iMeterReset "iMeter Reset" { channel="insteon:device:home:AABBCC:reset" }
+Rollershutter windowShade "window shade" { channel="insteon:device:home:AABBCC:rollershutter" }
```
-### Fan Controllers
+Similar to [dimmers](#dimmers), the binding uses the device on level and ramp rate local settings to set the rollershutter level, the same way it would be set when physically interacting with the controller, and can be overridden using the `onLevel` and `rampRate`channel parameters.
-Here is an example configuration for a FanLinc module, which has a dimmable light and a variable speed fan:
+## Insteon Scenes
-#### Items
+The binding can trigger scenes by commanding the modem to send broadcasts to a given Insteon group.
+
+### Things
+
+```java
+Bridge insteon:plm:home [serialPort="/dev/ttyUSB0"] {
+ Thing scene scene42 [group=42]
+}
+```
+
+### Items
```java
-Dimmer fanLincDimmer "fanlinc dimmer [%d %%]" { channel="insteon:device:home:AABBCC:lightDimmer" }
-Number fanLincFan "fanlinc fan" { channel="insteon:device:home:AABBCC:fan"}
+Switch sceneOnOff "scene on/off" { channel="insteon:scene:home:scene42:sceneOnOff" }
+Switch sceneFastOnOff "scene fast on/off" { channel="insteon:scene:home:scene42:sceneFastOnOff" }
+Rollershutter sceneManualChange "scene manual change" { channel="insteon:scene:home:scene42:sceneManualChange" }
```
-#### Sitemap
+### Sitemap
```perl
-Slider item=fanLincDimmer switchSupport
-Switch item=fanLincFan label="fan speed" mappings=[ 0="OFF", 1="LOW", 2="MEDIUM", 3="HIGH"]
+Switch item=sceneOnOff
+Switch item=sceneFastOnOff mappings=[ ON="ON", OFF="OFF" ]
+Switch item=sceneManualChange mappings=[ UP="UP", DOWN="DOWN", STOP="STOP" ]
```
-### X10 Devices
+Sending `ON` command to `sceneOnOff` will cause the modem to send a broadcast message with group=42, and all devices that are configured to respond to it should react.
+The current state of a scene is published on the `sceneOnOff` channel.
+An `ON` state indicates that all the device states associated to a scene are matching their configured link on level.
-It is worth noting that both the Inseon PLM and the 2014 Hub can both command X10 devices over the powerline, and also set switch stats based on X10 signals received over the powerline.
-This allows openHAB not only control X10 devices without the need for other hardwaare, but it can also have rules that react to incoming X10 powerline commands.
-While you cannot bind the the X10 devices to the Insteon PLM/HUB, here are some examples for configuring X10 devices.
-Be aware that most X10 switches/dimmers send no status updates, i.e. openHAB will not learn about switches that are toggled manually.
-Further note that X10 devices are addressed with `houseCode.unitCode`, e.g. `A.2`.
+
+ Legacy
-#### Items
+ The binding can command the modem to send broadcasts to a given Insteon group.
+ Since it is a broadcast message, the corresponding item does _not_ take the address of any device, but of the modem itself.
+ The format is `broadcastOnOff#X` where X is the group that you want to be able to broadcast messages to:
-```java
-Switch x10Switch "X10 switch" { channel="insteon:device:home:AABB:switch" }
-Dimmer x10Dimmer "X10 dimmer" { channel="insteon:device:home:AABB:dimmer" }
-Contact x10Motion "X10 motion" { channel="insteon:device:home:AABB:contact" }
-```
+ ### Things
+
+ ```java
+ Bridge insteon:network:home [port="/dev/ttyUSB0"] {
+ Thing device AABBCC [address="AA.BB.CC", productKey="0x000045"] {
+ Channels:
+ Type broadcastOnOff : broadcastOnOff#2
+ }
+ }
+ ```
+
+ Or setting the device configuration parameter with a JSON object with `broadcastGroups` key and the broadcast group array value:
+
+ ```java
+ Bridge insteon:network:home [port="/dev/ttyUSB0"] {
+ Thing device AABBCC [address="AA.BB.CC", productKey="0x000045", deviceConfig="{'broadcastGroups': [2]}"]
+ }
+ ```
-## Direct Sending of Group Broadcasts (Triggering Scenes)
+ ### Items
-The binding can command the modem to send broadcasts to a given Insteon group.
-Since it is a broadcast message, the corresponding item does _not_ take the address of any device, but of the modem itself.
-The format is `broadcastOnOff#X` where X is the group that you want to be able to broadcast messages to:
+ ```java
+ Switch broadcastOnOff "group on/off" { channel="insteon:device:home:AABBCC:broadcastOnOff#2" }
+ ```
+
+ Flipping this switch to "ON" will cause the modem to send a broadcast message with group=2, and all devices that are configured to respond to it should react.
+
+
+
+## X10 Devices
+
+It is worth noting that both the Insteon PLM and the 2014 Hub can both command X10 devices over the powerline, and also set switch stats based on X10 signals received over the powerline.
+This allows openHAB not only control X10 devices without the need for other hardware, but it can also have rules that react to incoming X10 powerline commands.
+
+Note that X10 switches/dimmers send no status updates when toggled manually.
### Things
```java
-Bridge insteon:network:home [port="/dev/ttyUSB0"] {
- Thing device AABBCC [address="AA.BB.CC", productKey="0x000045"] {
- Channels:
- Type broadcastOnOff : broadcastOnOff#2
- }
+Bridge insteon:plm:home [serialPort="/dev/ttyUSB0"] {
+ Thing x10 A2 [houseCode="A", unitCode=2, deviceType="X10_Switch"]
+ Thing x10 B4 [houseCode="B", unitCode=4, deviceType="X10_Dimmer"]
+ Thing x10 C6 [houseCode="C", unitCode=6, deviceType="X10_Sensor"]
}
-
```
+
+ Legacy
+
+ ```java
+ Bridge insteon:network:home [port="/dev/ttyUSB0"] {
+ Thing device A2 [address="A.2", productKey="X00.00.01"]
+ Thing device B4 [address="B.4", productKey="X00.00.02"]
+ Thing device C6 [address="C.6", productKey="X00.00.03"]
+ }
+ ```
+
+
+
### Items
```java
-Switch broadcastOnOff "group on/off" { channel="insteon:device:home:AABBCC:broadcastOnOff#2" }
+Switch x10Switch "X10 switch" { channel="insteon:x10:home:A2:switch" }
+Dimmer x10Dimmer "X10 dimmer" { channel="insteon:x10:home:B4:dimmer" }
+Contact x10Contact "X10 contact" { channel="insteon:x10:home:C6:contact" }
```
-Flipping this switch to "ON" will cause the modem to send a broadcast message with group=2, and all devices that are configured to respond to it should react.
+## Battery Powered Devices
-Channels can also be configured using the device configuration parameter of the device.
-The key in the JSON object is `broadcastGroups` and the value is an array of integers:
+Battery powered devices (mostly sensors) work differently than standard wired one.
+To conserve battery, these devices are only pollable when there are awake.
+Typically they send a heartbeat every 24 hours.
+When the binding receives a message from one of these devices, it polls additional information needed during the awake period (about 4 seconds).
+Some wireless devices have a `stayAwake` channel that can extend the period up to 4 minutes but at the cost of using more battery.
+It shouldn't be used in most cases except during initial device configuration.
+Same goes with commands, the binding will queue up commands requested on these devices and send them during the awake time window.
+Only one command per channel is queued, this mean that subsequent requests will overwrite previous ones.
-### Things (device Config)
-
-```java
-Bridge insteon:network:home [port="/dev/ttyUSB0"] {
- Thing device AABBCC [address="AA.BB.CC", productKey="0x000045", deviceConfig="{'broadcastGroups': [2]}"]
-}
+### Heartbeat Timeout Monitor
-```
+Sensor devices that supports heartbeat have a timeout monitor.
+If no broadcast message is received within a specific interval, the associated thing status will go offline until the binding receives a broadcast message from that device.
+The heartbeat interval on most sensor devices is hard coded as 24 hours but some have the ability to change that interval through the `heartbeatInterval` channel.
+It is enabled by default on devices that supports that feature and will be disabled on devices that have the ability to turn off their heartbeat through the `heartbeatOnOff` channel.
+It is important that the heartbeat group (typically 4) is linked properly to the modem by using the `insteon device addMissingLinks` console command.
+Otherwise, if the link is missing, the timeout monitor will be disabled.
+If necessary, the heartbeat timeout monitor can be manually reset by disabling and re-enabling the associated device thing.
-## Channel "related" Property
+## Related Devices
When an Insteon device changes its state because it is directly operated (for example by flipping a switch manually), it sends out a broadcast message to announce the state change, and the binding (if the PLM modem is properly linked as a responder) should update the corresponding openHAB items.
Other linked devices however may also change their state in response, but those devices will _not_ send out a broadcast message, and so openHAB will not learn about their state change until the next poll.
One common scenario is e.g. a switch in a 3-way configuration, with one switch controlling the load, and the other switch being linked as a controller.
-In this scenario, the "related" keyword can be used to have the binding poll a related device whenever a state change occurs for another device.
-A typical example would be two dimmers (A and B) in a 3-way configuration:
-
-```java
-Bridge insteon:network:home [port="/dev/ttyUSB0"] {
- Thing device AABBCC [address="AA.BB.CC", productKey="F00.00.11"] {
- Channels:
- Type dimmer : dimmer [related="AA.BB.DD"]
+In this scenario, when the binding receives a broadcast message from one of these devices indicating a state change, it will poll the other related devices shortly after, instead of waiting until the next scheduled device poll which can take minutes.
+It is important to note, that the binding will now automatically determine related devices, based on device link databases, deprecating the `related` channel parameter.
+Likewise, the related devices from triggered button events will be polled as well.
+For scenes, these will be polled based on the modem database, after sending a group broadcast message.
+
+
+ Legacy
+
+ The `related` channel parameter can be used to have the binding poll a related device whenever a state change occurs for another device.
+ A typical example would be two dimmers (A and B) in a 3-way configuration:
+
+ ```java
+ Bridge insteon:network:home [port="/dev/ttyUSB0"] {
+ Thing device AABBCC [address="AA.BB.CC", productKey="F00.00.11"] {
+ Channels:
+ Type dimmer : dimmer [related="AA.BB.DD"]
+ }
+ Thing device AABBDD [address="AA.BB.DD", productKey="F00.00.11"] {
+ Channels:
+ Type dimmer : dimmer [related="AA.BB.CC"]
+ }
}
- Thing device AABBDD [address="AA.BB.DD", productKey="F00.00.11"] {
- Channels:
- Type dimmer : dimmer [related="AA.BB.CC"]
+ ```
+
+ The binding doesn't know which devices have responded to the message since its a broadcast message.
+ The `related` channel parameter can be used to have the binding poll one or more related device when group message are sent.
+ More than one device can be polled by separating them with `+` sign.
+ A typical example would be a switch configured to broadcast to a group, and one or more devices configured to respond to the message:
+
+ ```java
+ Bridge insteon:network:home [port="/dev/ttyUSB0"] {
+ Thing device AABBCC [address="AA.BB.CC", productKey="0x000045"] {
+ Channels:
+ Type broadcastOnOff : broadcastOnOff#3 [related="AA.BB.DD+AA.BB.EE"]
+ }
+ Thing device AABBDD [address="AA.BB.DD", productKey="F00.00.11"]
+ Thing device AABBEE [address="AA.BB.EE", productKey="F00.00.11"]
}
-}
-```
-
-Another scenario is a group broadcast message, the binding doesn't know which devices have responded to the message since its a broadcast message.
-In this scenario, the "related" keyword can be used to have the binding poll one or more related device when group message are sent.
-A typical example would be a switch configured to broadcast to a group, and one or more devices configured to respond to the message:
-
-```java
-Bridge insteon:network:home [port="/dev/ttyUSB0"] {
- Thing device AABBCC [address="AA.BB.CC", productKey="0x000045"] {
- Channels:
- Type broadcastOnOff : broadcastOnOff#3 [related="AA.BB.DD"]
+ ```
+
+
+
+## Triggered Events
+
+In order to monitor if an Insteon device button was directly operated and the type of interaction, triggered event channels can be used.
+These channels have the sole purpose to be used in rules in order to set off subsequent actions based on these events.
+Below are examples, including all available events, of a dimmer button and a keypad button:
+
+```php
+rule "Dimmer Paddle Events"
+when
+ Channel 'insteon:device:home:dimmer:eventButton' triggered
+then
+ switch receivedEvent {
+ case PRESSED_ON: // do something (regular on)
+ case PRESSED_OFF: // do something (regular off)
+ case DOUBLE_PRESSED_ON: // do something (fast on)
+ case DOUBLE_PRESSED_OFF: // do something (fast off)
+ case HELD_UP: // do something (manual change up)
+ case HELD_DOWN: // do something (manual change down)
+ case RELEASED: // do something (manual change stop)
}
- Thing device AABBDD [address="AA.BB.DD", productKey="F00.00.11"]
-}
+end
+
+rule "Keypad Button A Pressed Off"
+when
+ Channel 'insteon:device:home:keypad:eventButtonA' triggered PRESSED_OFF
+then
+ // do something
+end
```
-More than one device can be polled by separating them with "+" sign, e.g. "related=aa.bb.cc+xx.yy.zz" would poll both of these devices.
-The implemenation of the _related_ keyword is simple: if you add it to a channel, and that channel changes its state, then the _related_ device will be polled to see if its state has updated.
-
## Troubleshooting
-Turn on DEBUG or TRACE logging for `org.openhab.binding.insteon.
+Turn on DEBUG or TRACE logging for `org.openhab.binding.insteon`.
See [logging in openHAB](https://www.openhab.org/docs/administration/logging.html) for more info.
### Device Permissions / Linux Device Locks
@@ -822,7 +1418,7 @@ See [logging in openHAB](https://www.openhab.org/docs/administration/logging.htm
When openHAB is running as a non-root user (Linux/OSX) it is important to ensure it has write access not just to the PLM device, but to the os lock directory.
Under openSUSE this is `/run/lock` and is managed by the **lock** group.
-Example commands to grant openHAB access (adjust for your distribution):
+Example commands to grant openHAB access, depending on Linux distribution:
```shell
usermod -a -G dialout openhab
@@ -831,66 +1427,64 @@ usermod -a -G lock openhab
Insufficient access to the lock directory will result in openHAB failing to access the device, even if the device itself is writable.
-### Adding New Device Types (Using Existing Device Features)
+## Legacy Device Customization
-Device types are defined in the file `device_types.xml`, which is inside the Insteon bundle and thus not visible to the user.
-You can however load your own device_types.xml by referencing it in the network config parameters:
+
-```text
-additionalDevices="/usr/local/openhab/rt/my_own_devices.xml"
-```
+ ### Adding New Legacy Device Types (Using Existing Device Features)
-Where the `my_own_devices.xml` file defines a new device like this:
+ Device types are defined in the file `legacy_device_types.xml`, which is inside the Insteon bundle and thus not visible to the user.
+ You can however load your own device_types.xml by referencing it in the network config parameters:
-```xml
-
-
- 2456-D3
- LampLinc V2
- GenericDimmer
- GenericLastTime
-
-
-```
+ ```text
+ additionalDevices="/usr/local/openhab/rt/my_own_devices.xml"
+ ```
-Finding the Insteon product key can be tricky since Insteon has not updated the product key table () since 2008.
-If a web search does not turn up the product key, make one up, starting with "F", like: F00.00.99.
-Avoid duplicate keys by finding the highest fake product key in the `device_types.xml` file, and incrementing by one.
+ Where the `my_own_devices.xml` file defines a new device like this:
-### Adding New Device Features
+ ```xml
+
+
+ 2456-D3
+ LampLinc V2
+ GenericDimmer
+ GenericLastTime
+
+
+ ```
-If you can't build a new device out of the existing device features (for a complete list see `device_features.xml`) you can add new features by specifying a file (let's call it `my_own_features.xml`) with the "additionalDevices" option in the network config parameters:
+ Finding the Insteon product key can be tricky since Insteon has not updated the product key table () since 2008.
+ If a web search does not turn up the product key, make one up, starting with "F", like: F00.00.99.
+ Avoid duplicate keys by finding the highest fake product key in the `legacy_device_types.xml` file, and incrementing by one.
-```text
-additionalFeatures="/usr/local/openhab/rt/my_own_features.xml"
-```
+ ### Adding New Legacy Device Features
-In this file you can define your own features (or even overwrite an existing feature.
-In the example below a new feature "MyFeature" is defined, which can then be referenced from the `device_types.xml` file (or from `my_own_devices.xml`):
+ If you can't build a new device out of the existing device features (for a complete list see `legacy_device_features.xml`) you can add new features by specifying a file (let's call it `my_own_features.xml`) with the "additionalDevices" option in the network config parameters:
-```xml
-
-
- DefaultDispatcher
- NoOpMsgHandler
- NoOpMsgHandler
- NoOpMsgHandler
- NoOpMsgHandler
- LightStateSwitchHandler
- IOLincOnOffCommandHandler
- DefaultPollHandler
-
-
-```
+ ```text
+ additionalFeatures="/usr/local/openhab/rt/my_own_features.xml"
+ ```
+
+ In this file you can define your own features (or even overwrite an existing feature.
+ In the example below a new feature "MyFeature" is defined, which can then be referenced from the `legacy_device_types.xml` file (or from `my_own_devices.xml`):
+
+ ```xml
+
+
+ DefaultDispatcher
+ NoOpMsgHandler
+ NoOpMsgHandler
+ NoOpMsgHandler
+ NoOpMsgHandler
+ LightStateSwitchHandler
+ IOLincOnOffCommandHandler
+ DefaultPollHandler
+
+
+ ```
+
+
## Known Limitations and Issues
-- Devices cannot be linked to the modem while the binding is running.
-If new devices are linked, the binding must be restarted.
-- Setting up Insteon groups and linking devices cannot be done from within openHAB.
-Use the [Insteon Terminal](https://github.com/pfrommerd/insteon-terminal) for that.
-If using Insteon Terminal (especially as root), ensure any stale lock files (For example, /var/lock/LCK..ttyUSB0) are removed before starting openHAB runtime.
-Failure to do so may result in "found no ports".
-- The Insteon PLM or hub is know to break in about 2-3 years due to poorly sized capacitors.
-You can repair it yourself using basic soldering skills, search for "Insteon PLM repair" or "Insteon hub repair".
-- Using the Insteon Hub 2014 in conjunction with other applications (such as the InsteonApp) is not supported. Concretely, openHAB will not learn when a switch is flipped via the Insteon App until the next poll, which could take minutes.
+- Using the Insteon binding in conjunction with other applications (such as the [Insteon Terminal](https://github.com/pfrommerd/insteon-terminal) or the Insteon App) can result in some unexpected behavior.
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonBindingConstants.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonBindingConstants.java
new file mode 100644
index 0000000000000..7b3c21f6e2ca5
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonBindingConstants.java
@@ -0,0 +1,110 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal;
+
+import java.io.File;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.VenstarSystemMode;
+import org.openhab.core.OpenHAB;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link InsteonBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class InsteonBindingConstants {
+ public static final String BINDING_ID = "insteon";
+ public static final String BINDING_DATA_DIR = OpenHAB.getUserDataFolder() + File.separator + BINDING_ID;
+
+ // List of all thing type uids
+ public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device");
+ public static final ThingTypeUID THING_TYPE_HUB1 = new ThingTypeUID(BINDING_ID, "hub1");
+ public static final ThingTypeUID THING_TYPE_HUB2 = new ThingTypeUID(BINDING_ID, "hub2");
+ public static final ThingTypeUID THING_TYPE_PLM = new ThingTypeUID(BINDING_ID, "plm");
+ public static final ThingTypeUID THING_TYPE_SCENE = new ThingTypeUID(BINDING_ID, "scene");
+ public static final ThingTypeUID THING_TYPE_X10 = new ThingTypeUID(BINDING_ID, "x10");
+ public static final ThingTypeUID THING_TYPE_LEGACY_DEVICE = new ThingTypeUID(BINDING_ID, "legacy-device");
+ public static final ThingTypeUID THING_TYPE_LEGACY_NETWORK = new ThingTypeUID(BINDING_ID, "network");
+
+ public static final Set DISCOVERABLE_THING_TYPES_UIDS = Set.of(THING_TYPE_DEVICE, THING_TYPE_SCENE);
+ public static final Set DISCOVERABLE_LEGACY_THING_TYPES_UIDS = Set.of(THING_TYPE_LEGACY_DEVICE);
+
+ public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_DEVICE, THING_TYPE_HUB1,
+ THING_TYPE_HUB2, THING_TYPE_PLM, THING_TYPE_SCENE, THING_TYPE_X10, THING_TYPE_LEGACY_DEVICE,
+ THING_TYPE_LEGACY_NETWORK);
+
+ // List of all thing properties
+ public static final String PROPERTY_DEVICE_ADDRESS = "address";
+ public static final String PROPERTY_DEVICE_TYPE = "deviceType";
+ public static final String PROPERTY_ENGINE_VERSION = "engineVersion";
+ public static final String PROPERTY_PRODUCT_ID = "productId";
+ public static final String PROPERTY_SCENE_GROUP = "group";
+
+ // List of all channel parameters
+ public static final String PARAMETER_GROUP = "group";
+ public static final String PARAMETER_ON_LEVEL = "onLevel";
+ public static final String PARAMETER_RAMP_RATE = "rampRate";
+
+ // List of specific device feature names
+ public static final String FEATURE_DATABASE_DELTA = "databaseDelta";
+ public static final String FEATURE_HEARTBEAT = "heartbeat";
+ public static final String FEATURE_HEARTBEAT_INTERVAL = "heartbeatInterval";
+ public static final String FEATURE_HEARTBEAT_ON_OFF = "heartbeatOnOff";
+ public static final String FEATURE_INSTEON_ENGINE = "insteonEngine";
+ public static final String FEATURE_LED_CONTROL = "ledControl";
+ public static final String FEATURE_LED_ON_OFF = "ledOnOff";
+ public static final String FEATURE_LINK_FF_GROUP = "linkFFGroup";
+ public static final String FEATURE_LOW_BATTERY_THRESHOLD = "lowBatteryThreshold";
+ public static final String FEATURE_ON_LEVEL = "onLevel";
+ public static final String FEATURE_PING = "ping";
+ public static final String FEATURE_RAMP_RATE = "rampRate";
+ public static final String FEATURE_SCENE_ON_OFF = "sceneOnOff";
+ public static final String FEATURE_STAY_AWAKE = "stayAwake";
+ public static final String FEATURE_SYSTEM_MODE = "systemMode";
+ public static final String FEATURE_TEMPERATURE_FORMAT = "temperatureFormat";
+ public static final String FEATURE_TWO_GROUPS = "2Groups";
+
+ // List of specific device feature types
+ public static final String FEATURE_TYPE_FANLINC_FAN = "FanLincFan";
+ public static final String FEATURE_TYPE_GENERIC_DIMMER = "GenericDimmer";
+ public static final String FEATURE_TYPE_GENERIC_SWITCH = "GenericSwitch";
+ public static final String FEATURE_TYPE_KEYPAD_BUTTON = "KeypadButton";
+ public static final String FEATURE_TYPE_KEYPAD_BUTTON_OFF_MASK = "KeypadButtonOffMask";
+ public static final String FEATURE_TYPE_KEYPAD_BUTTON_ON_MASK = "KeypadButtonOnMask";
+ public static final String FEATURE_TYPE_KEYPAD_BUTTON_TOGGLE_MODE = "KeypadButtonToggleMode";
+ public static final String FEATURE_TYPE_OUTLET_SWITCH = "OutletSwitch";
+ public static final String FEATURE_TYPE_THERMOSTAT_FAN_MODE = "ThermostatFanMode";
+ public static final String FEATURE_TYPE_THERMOSTAT_SYSTEM_MODE = "ThermostatSystemMode";
+ public static final String FEATURE_TYPE_THERMOSTAT_COOL_SETPOINT = "ThermostatCoolSetPoint";
+ public static final String FEATURE_TYPE_THERMOSTAT_HEAT_SETPOINT = "ThermostatHeatSetPoint";
+ public static final String FEATURE_TYPE_VENSTAR_FAN_MODE = "VenstarFanMode";
+ public static final String FEATURE_TYPE_VENSTAR_SYSTEM_MODE = "VenstarSystemMode";
+ public static final String FEATURE_TYPE_VENSTAR_COOL_SETPOINT = "VenstarCoolSetPoint";
+ public static final String FEATURE_TYPE_VENSTAR_HEAT_SETPOINT = "VenstarHeatSetPoint";
+
+ // List of specific device types
+ public static final String DEVICE_TYPE_CLIMATE_CONTROL_VENSTAR_THERMOSTAT = "ClimateControl_VenstarThermostat";
+
+ // Map of custom state description options
+ public static final Map CUSTOM_STATE_DESCRIPTION_OPTIONS = Map.ofEntries(
+ // Venstar Thermostat System Mode
+ Map.entry(DEVICE_TYPE_CLIMATE_CONTROL_VENSTAR_THERMOSTAT + ":" + FEATURE_SYSTEM_MODE,
+ VenstarSystemMode.names().toArray(String[]::new)));
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonHandlerFactory.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonHandlerFactory.java
index 8a10d4e7be692..d130910550893 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonHandlerFactory.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonHandlerFactory.java
@@ -12,31 +12,36 @@
*/
package org.openhab.binding.insteon.internal;
-import static org.openhab.binding.insteon.internal.InsteonLegacyBindingConstants.*;
+import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
-import java.util.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
-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.openhab.binding.insteon.internal.discovery.InsteonDiscoveryService;
import org.openhab.binding.insteon.internal.discovery.InsteonLegacyDiscoveryService;
+import org.openhab.binding.insteon.internal.handler.InsteonBridgeHandler;
+import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler;
import org.openhab.binding.insteon.internal.handler.InsteonLegacyDeviceHandler;
import org.openhab.binding.insteon.internal.handler.InsteonLegacyNetworkHandler;
+import org.openhab.binding.insteon.internal.handler.InsteonSceneHandler;
+import org.openhab.binding.insteon.internal.handler.X10DeviceHandler;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.io.transport.serial.SerialPortManager;
+import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingManager;
+import org.openhab.core.thing.ThingRegistry;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
@@ -45,26 +50,29 @@
* handlers.
*
* @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
*/
@NonNullByDefault
@Component(configurationPid = "binding.insteon", service = ThingHandlerFactory.class)
public class InsteonHandlerFactory extends BaseThingHandlerFactory {
- private static final Set SUPPORTED_THING_TYPES_UIDS = Collections
- .unmodifiableSet(Stream.of(DEVICE_THING_TYPE, NETWORK_THING_TYPE).collect(Collectors.toSet()));
-
+ private final SerialPortManager serialPortManager;
+ private final InsteonStateDescriptionProvider stateDescriptionProvider;
+ private final StorageService storageService;
+ private final ThingManager thingManager;
+ private final ThingRegistry thingRegistry;
private final Map> discoveryServiceRegs = new HashMap<>();
- private final Map> serviceRegs = new HashMap<>();
-
- private @Nullable SerialPortManager serialPortManager;
- @Reference
- protected void setSerialPortManager(final SerialPortManager serialPortManager) {
+ @Activate
+ public InsteonHandlerFactory(final @Reference SerialPortManager serialPortManager,
+ final @Reference InsteonStateDescriptionProvider stateDescriptionProvider,
+ final @Reference StorageService storageService, final @Reference ThingManager thingManager,
+ final @Reference ThingRegistry thingRegistry) {
this.serialPortManager = serialPortManager;
- }
-
- protected void unsetSerialPortManager(final SerialPortManager serialPortManager) {
- this.serialPortManager = null;
+ this.stateDescriptionProvider = stateDescriptionProvider;
+ this.storageService = storageService;
+ this.thingManager = thingManager;
+ this.thingRegistry = thingRegistry;
}
@Override
@@ -76,41 +84,42 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
- if (NETWORK_THING_TYPE.equals(thingTypeUID)) {
- InsteonLegacyNetworkHandler insteonNetworkHandler = new InsteonLegacyNetworkHandler((Bridge) thing,
- serialPortManager);
- registerServices(insteonNetworkHandler);
-
- return insteonNetworkHandler;
- } else if (DEVICE_THING_TYPE.equals(thingTypeUID)) {
+ if (THING_TYPE_HUB1.equals(thingTypeUID) || THING_TYPE_HUB2.equals(thingTypeUID)
+ || THING_TYPE_PLM.equals(thingTypeUID)) {
+ InsteonBridgeHandler handler = new InsteonBridgeHandler((Bridge) thing, serialPortManager, storageService,
+ thingRegistry);
+ InsteonDiscoveryService service = new InsteonDiscoveryService(handler);
+ registerDiscoveryService(handler, service);
+ return handler;
+ } else if (THING_TYPE_LEGACY_NETWORK.equals(thingTypeUID)) {
+ InsteonLegacyNetworkHandler handler = new InsteonLegacyNetworkHandler((Bridge) thing, serialPortManager,
+ thingManager, thingRegistry);
+ InsteonLegacyDiscoveryService service = new InsteonLegacyDiscoveryService(handler);
+ registerDiscoveryService(handler, service);
+ return handler;
+ } else if (THING_TYPE_DEVICE.equals(thingTypeUID)) {
+ return new InsteonDeviceHandler(thing, stateDescriptionProvider);
+ } else if (THING_TYPE_LEGACY_DEVICE.equals(thingTypeUID)) {
return new InsteonLegacyDeviceHandler(thing);
+ } else if (THING_TYPE_SCENE.equals(thingTypeUID)) {
+ return new InsteonSceneHandler(thing);
+ } else if (THING_TYPE_X10.equals(thingTypeUID)) {
+ return new X10DeviceHandler(thing);
}
return null;
}
@Override
- protected synchronized void removeHandler(ThingHandler thingHandler) {
- if (thingHandler instanceof InsteonLegacyNetworkHandler) {
- ThingUID uid = thingHandler.getThing().getUID();
- ServiceRegistration> serviceRegs = this.serviceRegs.remove(uid);
- if (serviceRegs != null) {
- serviceRegs.unregister();
- }
-
- ServiceRegistration> discoveryServiceRegs = this.discoveryServiceRegs.remove(uid);
- if (discoveryServiceRegs != null) {
- discoveryServiceRegs.unregister();
- }
+ protected synchronized void removeHandler(ThingHandler handler) {
+ ServiceRegistration> serviceReg = discoveryServiceRegs.remove(handler.getThing().getUID());
+ if (serviceReg != null) {
+ serviceReg.unregister();
}
}
- private synchronized void registerServices(InsteonLegacyNetworkHandler handler) {
- this.serviceRegs.put(handler.getThing().getUID(),
- bundleContext.registerService(InsteonLegacyNetworkHandler.class.getName(), handler, new Hashtable<>()));
-
- InsteonLegacyDiscoveryService discoveryService = new InsteonLegacyDiscoveryService(handler);
- this.discoveryServiceRegs.put(handler.getThing().getUID(),
- bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>()));
+ private synchronized void registerDiscoveryService(ThingHandler handler, DiscoveryService service) {
+ discoveryServiceRegs.put(handler.getThing().getUID(),
+ bundleContext.registerService(DiscoveryService.class.getName(), service, new Hashtable<>()));
}
}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBinding.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBinding.java
index c1292e4939f62..44208c06a0801 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBinding.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBinding.java
@@ -33,13 +33,14 @@
import org.openhab.binding.insteon.internal.config.InsteonLegacyChannelConfiguration;
import org.openhab.binding.insteon.internal.config.InsteonLegacyNetworkConfiguration;
import org.openhab.binding.insteon.internal.database.LegacyModemDBEntry;
+import org.openhab.binding.insteon.internal.device.DeviceAddress;
import org.openhab.binding.insteon.internal.device.InsteonAddress;
import org.openhab.binding.insteon.internal.device.LegacyDevice;
import org.openhab.binding.insteon.internal.device.LegacyDevice.DeviceStatus;
import org.openhab.binding.insteon.internal.device.LegacyDeviceFeature;
import org.openhab.binding.insteon.internal.device.LegacyDeviceType;
import org.openhab.binding.insteon.internal.device.LegacyDeviceTypeLoader;
-import org.openhab.binding.insteon.internal.handler.InsteonLegacyDeviceHandler;
+import org.openhab.binding.insteon.internal.device.X10Address;
import org.openhab.binding.insteon.internal.handler.InsteonLegacyNetworkHandler;
import org.openhab.binding.insteon.internal.listener.LegacyDriverListener;
import org.openhab.binding.insteon.internal.listener.LegacyFeatureListener;
@@ -50,7 +51,6 @@
import org.openhab.binding.insteon.internal.transport.LegacyPort;
import org.openhab.binding.insteon.internal.transport.message.FieldException;
import org.openhab.binding.insteon.internal.transport.message.Msg;
-import org.openhab.binding.insteon.internal.utils.Utils;
import org.openhab.core.io.transport.serial.SerialPortManager;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.types.Command;
@@ -104,6 +104,7 @@
* @author Bernd Pfrommer - Initial contribution
* @author Daniel Pfrommer - openHAB 1 insteonplm binding
* @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
*/
@NonNullByDefault
public class InsteonLegacyBinding {
@@ -112,7 +113,7 @@ public class InsteonLegacyBinding {
private final Logger logger = LoggerFactory.getLogger(InsteonLegacyBinding.class);
private LegacyDriver driver;
- private Map devices = new ConcurrentHashMap<>();
+ private Map devices = new ConcurrentHashMap<>();
private Map bindingConfigs = new ConcurrentHashMap<>();
private PortListener portListener = new PortListener();
private int devicePollIntervalMilliseconds = 300000;
@@ -127,10 +128,10 @@ public InsteonLegacyBinding(InsteonLegacyNetworkHandler handler, InsteonLegacyNe
SerialPortManager serialPortManager, ScheduledExecutorService scheduler) {
this.handler = handler;
- String port = config.getPort();
- logger.debug("port = '{}'", Utils.redactPassword(port));
+ String port = config.getRedactedPort();
+ logger.debug("port = '{}'", port);
- driver = new LegacyDriver(port, portListener, serialPortManager, scheduler);
+ driver = new LegacyDriver(config, portListener, serialPortManager, scheduler);
driver.addMsgListener(portListener);
Integer devicePollIntervalSeconds = config.getDevicePollIntervalSeconds();
@@ -208,7 +209,7 @@ public void sendCommand(String channelName, Command command) {
public void addFeatureListener(InsteonLegacyChannelConfiguration bindingConfig) {
logger.debug("adding listener for channel {}", bindingConfig.getChannelName());
- InsteonAddress address = bindingConfig.getAddress();
+ DeviceAddress address = bindingConfig.getAddress();
LegacyDevice dev = getDevice(address);
if (dev == null) {
logger.warn("device for address {} is null", address);
@@ -249,7 +250,7 @@ public void removeFeatureListener(ChannelUID channelUID) {
logger.debug("removing listener for channel {}", channelName);
- for (Iterator> it = devices.entrySet().iterator(); it.hasNext();) {
+ for (Iterator> it = devices.entrySet().iterator(); it.hasNext();) {
LegacyDevice dev = it.next().getValue();
boolean removedListener = dev.removeFeatureListener(channelName);
if (removedListener) {
@@ -262,7 +263,7 @@ public void updateFeatureState(ChannelUID channelUID, State state) {
handler.updateState(channelUID, state);
}
- public @Nullable LegacyDevice makeNewDevice(InsteonAddress addr, String productKey,
+ public @Nullable LegacyDevice makeNewDevice(DeviceAddress addr, String productKey,
Map deviceConfigMap) {
LegacyDeviceTypeLoader instance = LegacyDeviceTypeLoader.instance();
if (instance == null) {
@@ -276,7 +277,7 @@ public void updateFeatureState(ChannelUID channelUID, State state) {
dev.setAddress(addr);
dev.setProductKey(productKey);
dev.setDriver(driver);
- dev.setIsModem(productKey.equals(InsteonLegacyDeviceHandler.PLM_PRODUCT_KEY));
+ dev.setIsModem(productKey.equals(InsteonLegacyBindingConstants.PLM_PRODUCT_KEY));
dev.setDeviceConfigMap(deviceConfigMap);
if (!dev.hasValidPollingInterval()) {
dev.setPollInterval(devicePollIntervalMilliseconds);
@@ -295,7 +296,7 @@ public void updateFeatureState(ChannelUID channelUID, State state) {
return (dev);
}
- public void removeDevice(InsteonAddress addr) {
+ public void removeDevice(DeviceAddress addr) {
LegacyDevice dev = devices.remove(addr);
if (dev == null) {
return;
@@ -315,7 +316,7 @@ public void removeDevice(InsteonAddress addr) {
*/
private int checkIfInModemDatabase(LegacyDevice dev) {
try {
- InsteonAddress addr = dev.getAddress();
+ InsteonAddress addr = (InsteonAddress) dev.getAddress();
Map dbes = driver.lockModemDBEntries();
if (dbes.containsKey(addr)) {
if (!dev.hasModemDBEntry()) {
@@ -323,7 +324,7 @@ private int checkIfInModemDatabase(LegacyDevice dev) {
dev.setHasModemDBEntry(true);
}
} else {
- if (driver.isModemDBComplete() && !addr.isX10()) {
+ if (driver.isModemDBComplete() && addr instanceof InsteonAddress) {
logger.warn("device {} not found in the modem database. Did you forget to link?", addr);
handler.deviceNotLinked(addr);
}
@@ -376,7 +377,7 @@ public void shutdown() {
* @param aAddr the insteon address to search for
* @return reference to the device, or null if not found
*/
- public @Nullable LegacyDevice getDevice(@Nullable InsteonAddress aAddr) {
+ public @Nullable LegacyDevice getDevice(@Nullable DeviceAddress aAddr) {
LegacyDevice dev = (aAddr == null) ? null : devices.get(aAddr);
return (dev);
}
@@ -393,8 +394,8 @@ private String getLinkInfo(Map dbes, Insteon
if (port == null) {
return "";
}
- String deviceName = port.getDeviceName();
- String s = deviceName.startsWith("/hub") ? "hub" : "plm";
+ String portName = port.getName();
+ String s = portName.startsWith("/hub") ? "hub" : "plm";
StringBuilder buf = new StringBuilder();
if (port.isModem(a)) {
if (prefix) {
@@ -402,7 +403,7 @@ private String getLinkInfo(Map dbes, Insteon
}
buf.append(s);
buf.append(" (");
- buf.append(Utils.redactPassword(deviceName));
+ buf.append(portName);
buf.append(")");
} else {
if (prefix) {
@@ -469,10 +470,14 @@ public void msg(Msg msg) {
}
messagesReceived++;
logger.debug("got msg: {}", msg);
- if (msg.isX10()) {
- handleX10Message(msg);
- } else {
- handleInsteonMessage(msg);
+ try {
+ if (msg.isX10()) {
+ handleX10Message(msg);
+ } else if (msg.isInsteon()) {
+ handleInsteonMessage(msg);
+ }
+ } catch (FieldException e) {
+ logger.warn("got bad message: {}", msg, e);
}
}
@@ -490,21 +495,20 @@ public void driverCompletelyInitialized() {
}
Set addrs = new HashSet<>();
for (LegacyDevice dev : devices.values()) {
- InsteonAddress a = dev.getAddress();
- if (!dbes.containsKey(a)) {
- if (!a.isX10()) {
+ if (dev.getAddress() instanceof InsteonAddress a) {
+ if (!dbes.containsKey(a)) {
logger.warn("device {} not found in the modem database. Did you forget to link?", a);
handler.deviceNotLinked(a);
- }
- } else {
- if (!dev.hasModemDBEntry()) {
- addrs.add(a);
- logger.debug("device {} found in the modem database and {}.", a,
- getLinkInfo(dbes, a, true));
- dev.setHasModemDBEntry(true);
- }
- if (dev.getStatus() != DeviceStatus.POLLING) {
- LegacyPollManager.instance().startPolling(dev, dbes.size());
+ } else {
+ if (!dev.hasModemDBEntry()) {
+ addrs.add(a);
+ logger.debug("device {} found in the modem database and {}.", a,
+ getLinkInfo(dbes, a, true));
+ dev.setHasModemDBEntry(true);
+ }
+ if (dev.getStatus() != DeviceStatus.POLLING) {
+ LegacyPollManager.instance().startPolling(dev, dbes.size());
+ }
}
}
}
@@ -533,40 +537,31 @@ public void disconnected() {
handler.bindingDisconnected();
}
- private void handleInsteonMessage(Msg msg) {
- InsteonAddress toAddr = msg.getAddr("toAddress");
+ private void handleInsteonMessage(Msg msg) throws FieldException {
+ InsteonAddress toAddr = msg.getInsteonAddress("toAddress");
if (!msg.isBroadcast() && !driver.isMsgForUs(toAddr)) {
// not for one of our modems, do not process
return;
}
- InsteonAddress fromAddr = msg.getAddr("fromAddress");
- if (fromAddr == null) {
- logger.debug("invalid fromAddress, ignoring msg {}", msg);
- return;
- }
+ InsteonAddress fromAddr = msg.getInsteonAddress("fromAddress");
handleMessage(fromAddr, msg);
}
- private void handleX10Message(Msg msg) {
- try {
- int x10Flag = msg.getByte("X10Flag") & 0xff;
- int rawX10 = msg.getByte("rawX10") & 0xff;
- if (x10Flag == 0x80) { // actual command
- if (x10HouseUnit != -1) {
- InsteonAddress fromAddr = new InsteonAddress((byte) x10HouseUnit);
- handleMessage(fromAddr, msg);
- }
- } else if (x10Flag == 0) {
- // what unit the next cmd will apply to
- x10HouseUnit = rawX10 & 0xFF;
+ private void handleX10Message(Msg msg) throws FieldException {
+ int x10Flag = msg.getByte("X10Flag") & 0xff;
+ int rawX10 = msg.getByte("rawX10") & 0xff;
+ if (x10Flag == 0x80) { // actual command
+ if (x10HouseUnit != -1) {
+ X10Address fromAddr = new X10Address((byte) x10HouseUnit);
+ handleMessage(fromAddr, msg);
}
- } catch (FieldException e) {
- logger.warn("got bad X10 message: {}", msg, e);
- return;
+ } else if (x10Flag == 0) {
+ // what unit the next cmd will apply to
+ x10HouseUnit = rawX10 & 0xFF;
}
}
- private void handleMessage(InsteonAddress fromAddr, Msg msg) {
+ private void handleMessage(DeviceAddress fromAddr, Msg msg) {
LegacyDevice dev = getDevice(fromAddr);
if (dev == null) {
logger.debug("dropping message from unknown device with address {}", fromAddr);
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBindingConstants.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBindingConstants.java
index 415878ed000c2..fc21dc1b0ec55 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBindingConstants.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBindingConstants.java
@@ -12,23 +12,19 @@
*/
package org.openhab.binding.insteon.internal;
+import java.util.Set;
+
import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link InsteonLegacyBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
*/
@NonNullByDefault
public class InsteonLegacyBindingConstants {
- public static final String BINDING_ID = "insteon";
-
- // List of all Thing Type UIDs
- public static final ThingTypeUID DEVICE_THING_TYPE = new ThingTypeUID(BINDING_ID, "device");
- public static final ThingTypeUID NETWORK_THING_TYPE = new ThingTypeUID(BINDING_ID, "network");
-
// List of all Channel ids
public static final String AC_DELAY = "acDelay";
public static final String BACKLIGHT_DURATION = "backlightDuration";
@@ -109,4 +105,40 @@ public class InsteonLegacyBindingConstants {
public static final String TOP_OUTLET = "topOutlet";
public static final String UPDATE = "update";
public static final String WATTS = "watts";
+
+ public static final Set ALL_CHANNEL_IDS = Set.of(AC_DELAY, BACKLIGHT_DURATION, BATTERY_LEVEL,
+ BATTERY_PERCENT, BATTERY_WATERMARK_LEVEL, BEEP, BOTTOM_OUTLET, BUTTON_A, BUTTON_B, BUTTON_C, BUTTON_D,
+ BUTTON_E, BUTTON_F, BUTTON_G, BUTTON_H, BROADCAST_ON_OFF, CONTACT, COOL_SET_POINT, DIMMER, FAN, FAN_MODE,
+ FAST_ON_OFF, FAST_ON_OFF_BUTTON_A, FAST_ON_OFF_BUTTON_B, FAST_ON_OFF_BUTTON_C, FAST_ON_OFF_BUTTON_D,
+ FAST_ON_OFF_BUTTON_E, FAST_ON_OFF_BUTTON_F, FAST_ON_OFF_BUTTON_G, FAST_ON_OFF_BUTTON_H, HEAT_SET_POINT,
+ HUMIDITY, HUMIDITY_HIGH, HUMIDITY_LOW, IS_COOLING, IS_HEATING, KEYPAD_BUTTON_A, KEYPAD_BUTTON_B,
+ KEYPAD_BUTTON_C, KEYPAD_BUTTON_D, KEYPAD_BUTTON_E, KEYPAD_BUTTON_F, KEYPAD_BUTTON_G, KEYPAD_BUTTON_H, KWH,
+ LAST_HEARD_FROM, LED_BRIGHTNESS, LED_ONOFF, LIGHT_DIMMER, LIGHT_LEVEL, LIGHT_LEVEL_ABOVE_THRESHOLD,
+ LOAD_DIMMER, LOAD_SWITCH, LOAD_SWITCH_FAST_ON_OFF, LOAD_SWITCH_MANUAL_CHANGE, LOWBATTERY, MANUAL_CHANGE,
+ MANUAL_CHANGE_BUTTON_A, MANUAL_CHANGE_BUTTON_B, MANUAL_CHANGE_BUTTON_C, MANUAL_CHANGE_BUTTON_D,
+ MANUAL_CHANGE_BUTTON_E, MANUAL_CHANGE_BUTTON_F, MANUAL_CHANGE_BUTTON_G, MANUAL_CHANGE_BUTTON_H,
+ NOTIFICATION, ON_LEVEL, RAMP_DIMMER, RAMP_RATE, RESET, STAGE1_DURATION, SWITCH, SYSTEM_MODE, TAMPER_SWITCH,
+ TEMPERATURE, TEMPERATURE_LEVEL, TOP_OUTLET, UPDATE, WATTS);
+
+ public static final String BROADCAST_GROUPS = "broadcastGroups";
+ public static final String CMD = "cmd";
+ public static final String CMD_RESET = "reset";
+ public static final String CMD_UPDATE = "update";
+ public static final String DATA = "data";
+ public static final String FIELD = "field";
+ public static final String FIELD_BATTERY_LEVEL = "battery_level";
+ public static final String FIELD_BATTERY_PERCENTAGE = "battery_percentage";
+ public static final String FIELD_BATTERY_WATERMARK_LEVEL = "battery_watermark_level";
+ public static final String FIELD_KWH = "kwh";
+ public static final String FIELD_LIGHT_LEVEL = "light_level";
+ public static final String FIELD_TEMPERATURE_LEVEL = "temperature_level";
+ public static final String FIELD_WATTS = "watts";
+ public static final String GROUP = "group";
+ public static final String METER = "meter";
+
+ public static final String HIDDEN_DOOR_SENSOR_PRODUCT_KEY = "F00.00.03";
+ public static final String MOTION_SENSOR_II_PRODUCT_KEY = "F00.00.24";
+ public static final String MOTION_SENSOR_PRODUCT_KEY = "0x00004A";
+ public static final String PLM_PRODUCT_KEY = "0x000045";
+ public static final String POWER_METER_PRODUCT_KEY = "F00.00.17";
}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonStateDescriptionProvider.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonStateDescriptionProvider.java
new file mode 100644
index 0000000000000..86110bf38c4c2
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonStateDescriptionProvider.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider;
+import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService;
+import org.openhab.core.thing.link.ItemChannelLinkRegistry;
+import org.openhab.core.thing.type.DynamicStateDescriptionProvider;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link InsteonStateDescriptionProvider} is a dynamic provider of state options for Insteon channels
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@Component(service = { DynamicStateDescriptionProvider.class, InsteonStateDescriptionProvider.class })
+@NonNullByDefault
+public class InsteonStateDescriptionProvider extends BaseDynamicStateDescriptionProvider {
+
+ @Activate
+ public InsteonStateDescriptionProvider(final @Reference EventPublisher eventPublisher,
+ final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry,
+ final @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
+ this.eventPublisher = eventPublisher;
+ this.itemChannelLinkRegistry = itemChannelLinkRegistry;
+ this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/cache/DatabaseCache.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/cache/DatabaseCache.java
new file mode 100644
index 0000000000000..e544cf8701c8e
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/cache/DatabaseCache.java
@@ -0,0 +1,147 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.cache;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.database.DatabaseRecord;
+import org.openhab.binding.insteon.internal.database.LinkDB;
+import org.openhab.binding.insteon.internal.database.LinkDBRecord;
+import org.openhab.binding.insteon.internal.database.ModemDB;
+import org.openhab.binding.insteon.internal.database.ModemDBRecord;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.ProductData;
+
+/**
+ * The {@link DatabaseCache} represents a database cache
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class DatabaseCache {
+ private @Nullable Integer delta;
+ private @Nullable Boolean reload;
+ private @Nullable List records;
+ private @Nullable Map products;
+
+ public int getDelta() {
+ return Objects.requireNonNullElse(delta, -1);
+ }
+
+ public boolean getReload() {
+ return Objects.requireNonNullElse(reload, false);
+ }
+
+ public List getRecords() {
+ return Objects.requireNonNullElse(records, Collections.emptyList());
+ }
+
+ public Map getProducts() {
+ return Objects.requireNonNullElse(products, Collections.emptyMap());
+ }
+
+ /**
+ * Loads this database cache into a link database
+ *
+ * @param linkDB the link database to use
+ */
+ public void load(LinkDB linkDB) {
+ // set link db delta if defined
+ int delta = getDelta();
+ if (delta != -1) {
+ linkDB.setDatabaseDelta(delta);
+ }
+
+ // set link db reload if true
+ boolean reload = getReload();
+ if (reload) {
+ linkDB.setReload(reload);
+ }
+
+ // load link db records if not empty
+ List records = getRecords().stream().map(LinkDBRecord::new).toList();
+ if (!records.isEmpty()) {
+ linkDB.loadRecords(records);
+ }
+ }
+
+ /**
+ * Loads this database cache into a modem database
+ *
+ * @param modemDB the modem database to use
+ */
+ public void load(ModemDB modemDB) {
+ // load modem db products if not empty
+ Map products = getProducts().entrySet().stream()
+ .collect(Collectors.toMap(entry -> new InsteonAddress(entry.getKey()), Map.Entry::getValue));
+ if (!products.isEmpty()) {
+ modemDB.loadProducts(products);
+ }
+
+ // load modem db records if not empty
+ List records = getRecords().stream().map(ModemDBRecord::new).toList();
+ if (!records.isEmpty()) {
+ modemDB.loadRecords(records);
+ }
+ }
+
+ /**
+ * Class that represents a database cache builder
+ */
+ public static class Builder {
+ private final DatabaseCache cache = new DatabaseCache();
+
+ private Builder() {
+ }
+
+ public Builder withDatabaseDelta(int delta) {
+ cache.delta = delta;
+ return this;
+ }
+
+ public Builder withReload(boolean reload) {
+ cache.reload = reload;
+ return this;
+ }
+
+ public Builder withRecords(List extends DatabaseRecord> records) {
+ cache.records = records.stream().map(DatabaseRecord.class::cast).toList();
+ return this;
+ }
+
+ public Builder withProducts(Map products) {
+ cache.products = products.entrySet().stream()
+ .collect(Collectors.toMap(entry -> entry.getKey().toString(), Map.Entry::getValue));
+ return this;
+ }
+
+ public DatabaseCache build() {
+ return cache;
+ }
+ }
+
+ /**
+ * Factory method for creating a database cache builder
+ *
+ * @return the newly created database cache builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/cache/DeviceCache.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/cache/DeviceCache.java
new file mode 100644
index 0000000000000..868819f51d05d
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/cache/DeviceCache.java
@@ -0,0 +1,146 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.cache;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.database.LinkDB;
+import org.openhab.binding.insteon.internal.database.ModemDB;
+import org.openhab.binding.insteon.internal.device.Device;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+import org.openhab.binding.insteon.internal.device.InsteonDevice;
+import org.openhab.binding.insteon.internal.device.InsteonEngine;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.device.ProductData;
+
+/**
+ * The {@link DeviceCache} represents a device cache
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceCache {
+ private @Nullable ProductData productData;
+ private @Nullable InsteonEngine engine;
+ private @Nullable DatabaseCache database;
+ private @Nullable Map features;
+
+ public @Nullable ProductData getProductData() {
+ return productData;
+ }
+
+ public InsteonEngine getInsteonEngine() {
+ return Objects.requireNonNullElse(engine, InsteonEngine.UNKNOWN);
+ }
+
+ public @Nullable DatabaseCache getDatabaseCache() {
+ return database;
+ }
+
+ public Map getFeatureCaches() {
+ return Objects.requireNonNullElse(features, Collections.emptyMap());
+ }
+
+ /**
+ * Loads this device cache into a device
+ *
+ * @param device the device to use
+ */
+ public void load(Device device) {
+ // load device feature caches
+ getFeatureCaches().forEach((name, cache) -> {
+ DeviceFeature feature = device.getFeature(name);
+ if (feature != null) {
+ cache.load(feature);
+ }
+ });
+
+ if (device instanceof InsteonDevice insteonDevice) {
+ // set device insteon engine if known
+ InsteonEngine engine = getInsteonEngine();
+ if (engine != InsteonEngine.UNKNOWN) {
+ insteonDevice.setInsteonEngine(engine);
+ }
+
+ // load device database cache if defined
+ DatabaseCache database = getDatabaseCache();
+ if (database != null) {
+ database.load(insteonDevice.getLinkDB());
+ }
+ } else if (device instanceof InsteonModem insteonModem) {
+ // load modem database cache if defined
+ DatabaseCache database = getDatabaseCache();
+ if (database != null) {
+ database.load(insteonModem.getDB());
+ }
+ }
+ }
+
+ /**
+ * Class that represents a device cache builder
+ */
+ public static class Builder {
+ private final DeviceCache cache = new DeviceCache();
+
+ private Builder() {
+ }
+
+ public Builder withProductData(@Nullable ProductData productData) {
+ cache.productData = productData;
+ return this;
+ }
+
+ public Builder withInsteonEngine(InsteonEngine engine) {
+ cache.engine = engine;
+ return this;
+ }
+
+ public Builder withDatabase(LinkDB linkDB) {
+ cache.database = DatabaseCache.builder().withDatabaseDelta(linkDB.getDatabaseDelta())
+ .withReload(linkDB.shouldReload()).withRecords(linkDB.getRecords()).build();
+ return this;
+ }
+
+ public Builder withDatabase(ModemDB modemDB) {
+ cache.database = DatabaseCache.builder().withProducts(modemDB.getProducts())
+ .withRecords(modemDB.getRecords()).build();
+ return this;
+ }
+
+ public Builder withFeatures(List features) {
+ cache.features = features.stream().filter(feature -> !feature.isEventFeature() && !feature.isGroupFeature())
+ .collect(Collectors.toMap(DeviceFeature::getName, feature -> FeatureCache.builder()
+ .withState(feature.getState()).withLastMsgValue(feature.getLastMsgValue()).build()));
+ return this;
+ }
+
+ public DeviceCache build() {
+ return cache;
+ }
+ }
+
+ /**
+ * Factory method for creating a device cache builder
+ *
+ * @return the newly created device cache builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/cache/FeatureCache.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/cache/FeatureCache.java
new file mode 100644
index 0000000000000..71c2119fc628d
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/cache/FeatureCache.java
@@ -0,0 +1,110 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.cache;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+import org.openhab.core.types.State;
+import org.openhab.core.types.TypeParser;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * The {@link FeatureCache} represents a device feature cache
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class FeatureCache {
+ private static final String TYPE_SEPARATOR = "@@@";
+
+ private @Nullable String state;
+ private @Nullable Double lastMsgValue;
+
+ public @Nullable State getState() {
+ String state = this.state;
+ if (state == null) {
+ return null;
+ }
+ String[] parts = state.split(TYPE_SEPARATOR, 2);
+ if (parts.length != 2) {
+ return null;
+ }
+ try {
+ @SuppressWarnings("unchecked")
+ Class extends State> type = (Class extends State>) Class.forName(parts[0]);
+ return TypeParser.parseState(List.of(type), parts[1]);
+ } catch (ClassNotFoundException e) {
+ return null;
+ }
+ }
+
+ public @Nullable Double getLastMsgValue() {
+ return lastMsgValue;
+ }
+
+ /**
+ * Loads this feature cache into a device feature
+ *
+ * @param feature the device feature to use
+ */
+ public void load(DeviceFeature feature) {
+ // set feature state if defined
+ State state = getState();
+ if (state != null) {
+ feature.setState(state);
+ }
+
+ // set feature last message value if defined
+ Double lastMsgValue = getLastMsgValue();
+ if (lastMsgValue != null) {
+ feature.setLastMsgValue(lastMsgValue.doubleValue());
+ }
+ }
+
+ /**
+ * Class that represents a feature cache builder
+ */
+ public static class Builder {
+ private final FeatureCache cache = new FeatureCache();
+
+ private Builder() {
+ }
+
+ public Builder withState(State state) {
+ cache.state = state instanceof UnDefType ? null
+ : state.getClass().getName() + TYPE_SEPARATOR + state.toFullString();
+ return this;
+ }
+
+ public Builder withLastMsgValue(@Nullable Double lastMsgValue) {
+ cache.lastMsgValue = lastMsgValue;
+ return this;
+ }
+
+ public FeatureCache build() {
+ return cache;
+ }
+ }
+
+ /**
+ * Factory method for creating a feature cache builder
+ *
+ * @return the newly created feature cache builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/ChannelCommand.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/ChannelCommand.java
new file mode 100644
index 0000000000000..cba2e89147e8b
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/ChannelCommand.java
@@ -0,0 +1,93 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.command;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.console.StringsCompleter;
+
+/**
+ *
+ * The {@link ChannelCommand} represents an Insteon console channel command
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class ChannelCommand extends InsteonCommand {
+ private static final String NAME = "channel";
+ private static final String DESCRIPTION = "Insteon channel commands";
+
+ private static final String LIST_ALL = "listAll";
+
+ private static final List SUBCMDS = List.of(LIST_ALL);
+
+ public ChannelCommand(InsteonCommandExtension commandExtension) {
+ super(NAME, DESCRIPTION, commandExtension);
+ }
+
+ @Override
+ public List getUsages() {
+ return List.of(buildCommandUsage(LIST_ALL, "list available channel ids with configuration and link state"));
+ }
+
+ @Override
+ public void execute(String[] args, Console console) {
+ if (args.length == 0) {
+ printUsage(console);
+ return;
+ }
+
+ switch (args[0]) {
+ case LIST_ALL:
+ if (args.length == 1) {
+ listAll(console);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ default:
+ console.println("Unknown command '" + args[0] + "'");
+ printUsage(console);
+ break;
+ }
+ }
+
+ @Override
+ public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List candidates) {
+ List strings = List.of();
+ if (cursorArgumentIndex == 0) {
+ strings = SUBCMDS;
+ }
+
+ return new StringsCompleter(strings, false).complete(args, cursorArgumentIndex, cursorPosition, candidates);
+ }
+
+ private void listAll(Console console) {
+ Map channels = Stream
+ .concat(Stream.of(getBridgeHandler()), getBridgeHandler().getChildHandlers())
+ .flatMap(handler -> handler.getChannelsInfo().entrySet().stream())
+ .collect(Collectors.toMap(Entry::getKey, Entry::getValue));
+ if (channels.isEmpty()) {
+ console.println("No channel available!");
+ } else {
+ console.println("There are " + channels.size() + " channels available:");
+ print(console, channels);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/DebugCommand.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/DebugCommand.java
new file mode 100644
index 0000000000000..428807ca9c600
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/DebugCommand.java
@@ -0,0 +1,478 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.command;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.InsteonBindingConstants;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.InsteonScene;
+import org.openhab.binding.insteon.internal.device.X10Address;
+import org.openhab.binding.insteon.internal.device.X10Device;
+import org.openhab.binding.insteon.internal.listener.PortListener;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.openhab.binding.insteon.internal.transport.message.Msg.Direction;
+import org.openhab.binding.insteon.internal.transport.message.MsgDefinitionRegistry;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.console.StringsCompleter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ *
+ * The {@link DebugCommand} represents an Insteon console debug command
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class DebugCommand extends InsteonCommand implements PortListener {
+ private static final String NAME = "debug";
+ private static final String DESCRIPTION = "Insteon debug commands";
+
+ private static final String LIST_MONITORED = "listMonitored";
+ private static final String START_MONITORING = "startMonitoring";
+ private static final String STOP_MONITORING = "stopMonitoring";
+ private static final String SEND_BROADCAST_MESSAGE = "sendBroadcastMessage";
+ private static final String SEND_STANDARD_MESSAGE = "sendStandardMessage";
+ private static final String SEND_EXTENDED_MESSAGE = "sendExtendedMessage";
+ private static final String SEND_EXTENDED_2_MESSAGE = "sendExtended2Message";
+ private static final String SEND_X10_MESSAGE = "sendX10Message";
+ private static final String SEND_IM_MESSAGE = "sendIMMessage";
+
+ private static final List SUBCMDS = List.of(LIST_MONITORED, START_MONITORING, STOP_MONITORING,
+ SEND_BROADCAST_MESSAGE, SEND_STANDARD_MESSAGE, SEND_EXTENDED_MESSAGE, SEND_EXTENDED_2_MESSAGE,
+ SEND_X10_MESSAGE, SEND_IM_MESSAGE);
+
+ private static final String ALL_OPTION = "--all";
+
+ private static final String MSG_EVENTS_FILE_PREFIX = "messageEvents";
+
+ private static enum MessageType {
+ STANDARD,
+ EXTENDED,
+ EXTENDED_2
+ }
+
+ private final Logger logger = LoggerFactory.getLogger(DebugCommand.class);
+
+ private boolean monitoring = false;
+ private boolean monitorAllDevices = false;
+ private Set monitoredAddresses = new HashSet<>();
+
+ public DebugCommand(InsteonCommandExtension commandExtension) {
+ super(NAME, DESCRIPTION, commandExtension);
+ }
+
+ @Override
+ public List getUsages() {
+ return List.of(buildCommandUsage(LIST_MONITORED, "list monitored device(s)"),
+ buildCommandUsage(START_MONITORING + " " + ALL_OPTION + "|",
+ "start logging message events for device(s) in separate file(s)"),
+ buildCommandUsage(STOP_MONITORING + " " + ALL_OPTION + "|",
+ "stop logging message events for device(s) in separate file(s)"),
+ buildCommandUsage(SEND_BROADCAST_MESSAGE + " ",
+ "send an Insteon broadcast message to a group"),
+ buildCommandUsage(SEND_STANDARD_MESSAGE + " ",
+ "send an Insteon standard message to a device"),
+ buildCommandUsage(SEND_EXTENDED_MESSAGE + " [ ... ]",
+ "send an Insteon extended message with standard crc to a device"),
+ buildCommandUsage(SEND_EXTENDED_2_MESSAGE + " [ ... ]",
+ "send an Insteon extended message with a two-byte crc to a device"),
+ buildCommandUsage(SEND_X10_MESSAGE + " ", "send an X10 message to a device"),
+ buildCommandUsage(SEND_IM_MESSAGE + " [ ...]",
+ "send an IM message to the modem"));
+ }
+
+ @Override
+ public void execute(String[] args, Console console) {
+ if (args.length == 0) {
+ printUsage(console);
+ return;
+ }
+
+ switch (args[0]) {
+ case LIST_MONITORED:
+ if (args.length == 1) {
+ listMonitoredDevices(console);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case START_MONITORING:
+ if (args.length == 2) {
+ startMonitoring(console, args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case STOP_MONITORING:
+ if (args.length == 2) {
+ stopMonitoring(console, args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case SEND_BROADCAST_MESSAGE:
+ if (args.length == 4) {
+ sendBroadcastMessage(console, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case SEND_STANDARD_MESSAGE:
+ if (args.length == 4) {
+ sendDirectMessage(console, MessageType.STANDARD, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case SEND_EXTENDED_MESSAGE:
+ if (args.length >= 4 && args.length <= 17) {
+ sendDirectMessage(console, MessageType.EXTENDED, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case SEND_EXTENDED_2_MESSAGE:
+ if (args.length >= 4 && args.length <= 16) {
+ sendDirectMessage(console, MessageType.EXTENDED_2, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case SEND_X10_MESSAGE:
+ if (args.length == 3) {
+ sendX10Message(console, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case SEND_IM_MESSAGE:
+ if (args.length >= 2) {
+ sendIMMessage(console, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ default:
+ console.println("Unknown command '" + args[0] + "'");
+ printUsage(console);
+ break;
+ }
+ }
+
+ @Override
+ public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List candidates) {
+ List strings = List.of();
+ if (cursorArgumentIndex == 0) {
+ strings = SUBCMDS;
+ } else if (cursorArgumentIndex == 1) {
+ switch (args[0]) {
+ case START_MONITORING:
+ case STOP_MONITORING:
+ strings = Stream.concat(Stream.of(ALL_OPTION),
+ getModem().getDB().getDevices().stream().map(InsteonAddress::toString)).toList();
+ break;
+ case SEND_BROADCAST_MESSAGE:
+ strings = getModem().getDB().getBroadcastGroups().stream().map(String::valueOf).toList();
+ break;
+ case SEND_STANDARD_MESSAGE:
+ case SEND_EXTENDED_MESSAGE:
+ case SEND_EXTENDED_2_MESSAGE:
+ strings = getModem().getDB().getDevices().stream().map(InsteonAddress::toString).toList();
+ break;
+ case SEND_X10_MESSAGE:
+ strings = getModem().getX10Devices().stream().map(X10Device::getAddress).map(X10Address::toString)
+ .toList();
+ break;
+ case SEND_IM_MESSAGE:
+ strings = MsgDefinitionRegistry.getInstance().getDefinitions().entrySet().stream()
+ .filter(entry -> entry.getValue().getDirection() == Direction.TO_MODEM).map(Entry::getKey)
+ .toList();
+ break;
+ }
+ }
+
+ return new StringsCompleter(strings, false).complete(args, cursorArgumentIndex, cursorPosition, candidates);
+ }
+
+ @Override
+ public void disconnected() {
+ // do nothing
+ }
+
+ @Override
+ public void messageReceived(Msg msg) {
+ try {
+ InsteonAddress address = msg.getInsteonAddress(msg.isReply() ? "toAddress" : "fromAddress");
+ if (monitorAllDevices || monitoredAddresses.contains(address)) {
+ logMessageEvent(address, msg);
+ }
+ } catch (FieldException ignored) {
+ // ignore message with no address field
+ }
+ }
+
+ @Override
+ public void messageSent(Msg msg) {
+ try {
+ InsteonAddress address = msg.getInsteonAddress("toAddress");
+ if (monitorAllDevices || monitoredAddresses.contains(address)) {
+ logMessageEvent(address, msg);
+ }
+ } catch (FieldException ignored) {
+ // ignore message with no address field
+ }
+ }
+
+ private String getMsgEventsFileName(String address) {
+ return MSG_EVENTS_FILE_PREFIX + "-" + address.replace(".", "") + ".log";
+ }
+
+ private String getMsgEventsFilePath(String address) {
+ return InsteonBindingConstants.BINDING_DATA_DIR + File.separator + getMsgEventsFileName(address);
+ }
+
+ private void clearMonitorFiles(String address) {
+ File folder = new File(InsteonBindingConstants.BINDING_DATA_DIR);
+ String prefix = ALL_OPTION.equals(address) ? MSG_EVENTS_FILE_PREFIX : getMsgEventsFileName(address);
+
+ if (folder.isDirectory()) {
+ Arrays.asList(folder.listFiles()).stream().filter(file -> file.getName().startsWith(prefix))
+ .forEach(File::delete);
+ }
+ }
+
+ private void logMessageEvent(InsteonAddress address, Msg msg) {
+ String timestamp = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(new Date());
+ String pathname = getMsgEventsFilePath(address.toString());
+
+ try {
+ File file = new File(pathname);
+ File parent = file.getParentFile();
+ if (parent == null) {
+ throw new IOException(pathname + " does not name a parent directory");
+ }
+ parent.mkdirs();
+ file.createNewFile();
+
+ PrintStream ps = new PrintStream(new FileOutputStream(file, true));
+ ps.println(timestamp + " " + msg.toString());
+ ps.close();
+ } catch (IOException e) {
+ logger.warn("failed to write to message event file", e);
+ }
+ }
+
+ private void listMonitoredDevices(Console console) {
+ String addresses = monitoredAddresses.stream().map(InsteonAddress::toString).collect(Collectors.joining(", "));
+ if (!addresses.isEmpty()) {
+ console.println("The monitored device(s) are: " + addresses);
+ } else if (monitorAllDevices) {
+ console.println("All devices are monitored.");
+ } else {
+ console.println("Not monitoring any devices.");
+ }
+ }
+
+ private void startMonitoring(Console console, String address) {
+ if (ALL_OPTION.equals(address)) {
+ if (!monitorAllDevices) {
+ monitorAllDevices = true;
+ monitoredAddresses.clear();
+ console.println("Started monitoring all devices.");
+ console.println("Message events logged in " + InsteonBindingConstants.BINDING_DATA_DIR);
+ clearMonitorFiles(address);
+ } else {
+ console.println("Already monitoring all devices.");
+ }
+ } else if (InsteonAddress.isValid(address)) {
+ if (monitorAllDevices) {
+ console.println("Already monitoring all devices.");
+ } else if (monitoredAddresses.add(new InsteonAddress(address))) {
+ console.println("Started monitoring the device " + address + ".");
+ console.println("Message events logged in " + getMsgEventsFilePath(address));
+ clearMonitorFiles(address);
+ } else {
+ console.println("Already monitoring the device " + address + ".");
+ }
+ } else {
+ console.println("Invalid device address" + address + ".");
+ return;
+ }
+
+ if (!monitoring) {
+ getModem().getPort().registerListener(this);
+ monitoring = true;
+ }
+ }
+
+ private void stopMonitoring(Console console, String address) {
+ if (!monitoring) {
+ console.println("Not monitoring any devices.");
+ return;
+ }
+
+ if (ALL_OPTION.equals(address)) {
+ if (monitorAllDevices) {
+ monitorAllDevices = false;
+ console.println("Stopped monitoring all devices.");
+ } else {
+ console.println("Not monitoring all devices.");
+ }
+ } else if (InsteonAddress.isValid(address)) {
+ if (monitorAllDevices) {
+ console.println("Not monitoring individual devices.");
+ } else if (monitoredAddresses.remove(new InsteonAddress(address))) {
+ console.println("Stopped monitoring the device " + address + ".");
+ } else {
+ console.println("Not monitoring the device " + address + ".");
+ return;
+ }
+ } else {
+ console.println("Invalid address device address " + address + ".");
+ return;
+ }
+
+ if (!monitorAllDevices && monitoredAddresses.isEmpty()) {
+ getModem().getPort().unregisterListener(this);
+ monitoring = false;
+ }
+ }
+
+ private void sendBroadcastMessage(Console console, String[] args) {
+ if (!InsteonScene.isValidGroup(args[1])) {
+ console.println("Invalid group argument: " + args[1]);
+ } else if (!HexUtils.isValidHexStringArray(args, 2, args.length)) {
+ console.println("Invalid hex argument(s).");
+ } else if (!getModem().getDB().isComplete()) {
+ console.println("Not ready to send messages yet.");
+ } else {
+ try {
+ int group = Integer.parseInt(args[1]);
+ byte cmd1 = (byte) HexUtils.toInteger(args[2]);
+ byte cmd2 = (byte) HexUtils.toInteger(args[3]);
+ Msg msg = Msg.makeBroadcastMessage(group, cmd1, cmd2);
+ getModem().writeMessage(msg);
+ console.println("Broadcast message sent to group " + group + ".");
+ console.println(msg.toString());
+ } catch (FieldException | InvalidMessageTypeException | NumberFormatException e) {
+ console.println("Error while trying to create message.");
+ } catch (IOException e) {
+ console.println("Failed to send message.");
+ }
+ }
+ }
+
+ private void sendDirectMessage(Console console, MessageType messageType, String[] args) {
+ if (!InsteonAddress.isValid(args[1])) {
+ console.println("Invalid device address argument: " + args[1]);
+ } else if (!HexUtils.isValidHexStringArray(args, 2, args.length)) {
+ console.println("Invalid hex argument(s).");
+ } else if (!getModem().getDB().isComplete()) {
+ console.println("Not ready to send messages yet.");
+ } else {
+ try {
+ InsteonAddress address = new InsteonAddress(args[1]);
+ byte cmd1 = (byte) HexUtils.toInteger(args[2]);
+ byte cmd2 = (byte) HexUtils.toInteger(args[3]);
+ Msg msg;
+ if (messageType == MessageType.STANDARD) {
+ msg = Msg.makeStandardMessage(address, cmd1, cmd2);
+ } else {
+ byte[] data = HexUtils.toByteArray(args, 4, args.length);
+ if (messageType == MessageType.EXTENDED) {
+ msg = Msg.makeExtendedMessage(address, cmd1, cmd2, data, true);
+ } else {
+ msg = Msg.makeExtendedMessageCRC2(address, cmd1, cmd2, data);
+ }
+ }
+ getModem().writeMessage(msg);
+ console.println("Direct message sent to device " + address + ".");
+ console.println(msg.toString());
+ } catch (FieldException | InvalidMessageTypeException | NumberFormatException e) {
+ console.println("Error while trying to create message.");
+ } catch (IOException e) {
+ console.println("Failed to send message.");
+ }
+ }
+ }
+
+ private void sendX10Message(Console console, String[] args) {
+ if (!X10Address.isValid(args[1])) {
+ console.println("Invalid x10 address argument: " + args[1]);
+ } else if (!HexUtils.isValidHexStringArray(args, 2, args.length)) {
+ console.println("Invalid hex argument(s).");
+ } else if (!getModem().getDB().isComplete()) {
+ console.println("Not ready to send messages yet.");
+ } else {
+ try {
+ X10Address address = new X10Address(args[1]);
+ byte cmd = (byte) HexUtils.toInteger(args[2]);
+ Msg maddr = Msg.makeX10AddressMessage(address);
+ getModem().writeMessage(maddr);
+ Msg mcmd = Msg.makeX10CommandMessage(cmd);
+ getModem().writeMessage(mcmd);
+ console.println("X10 message sent to device " + address + ".");
+ console.println(maddr.toString());
+ console.println(mcmd.toString());
+ } catch (FieldException | InvalidMessageTypeException | NumberFormatException e) {
+ console.println("Error while trying to create message.");
+ } catch (IOException e) {
+ console.println("Failed to send message.");
+ }
+ }
+ }
+
+ private void sendIMMessage(Console console, String[] args) {
+ if (!HexUtils.isValidHexStringArray(args, 2, args.length)) {
+ console.println("Invalid hex argument(s).");
+ } else if (!getModem().getDB().isComplete()) {
+ console.println("Not ready to send messages yet.");
+ } else {
+ try {
+ Msg msg = Msg.makeMessage(args[1]);
+ byte[] data = msg.getData();
+ int headerLength = msg.getHeaderLength();
+ for (int i = 0; i + 2 < args.length; i++) {
+ data[i + headerLength] = (byte) HexUtils.toInteger(args[i + 2]);
+ }
+ getModem().writeMessage(msg);
+ console.println("IM message sent to the modem.");
+ console.println(msg.toString());
+ } catch (ArrayIndexOutOfBoundsException e) {
+ console.println("Too many data bytes provided.");
+ } catch (InvalidMessageTypeException e) {
+ console.println("Error while trying to create message.");
+ } catch (IOException e) {
+ console.println("Failed to send message.");
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/DeviceCommand.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/DeviceCommand.java
new file mode 100644
index 0000000000000..8ebf41539d938
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/DeviceCommand.java
@@ -0,0 +1,719 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.command;
+
+import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.database.LinkDBRecord;
+import org.openhab.binding.insteon.internal.device.Device;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.InsteonDevice;
+import org.openhab.binding.insteon.internal.device.ProductData;
+import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.KeypadButtonToggleMode;
+import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler;
+import org.openhab.binding.insteon.internal.handler.InsteonThingHandler;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.console.StringsCompleter;
+
+/**
+ *
+ * The {@link DeviceCommand} represents an Insteon console device command
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceCommand extends InsteonCommand {
+ private static final String NAME = "device";
+ private static final String DESCRIPTION = "Insteon/X10 device commands";
+
+ private static final String LIST_ALL = "listAll";
+ private static final String LIST_DATABASE = "listDatabase";
+ private static final String LIST_FEATURES = "listFeatures";
+ private static final String LIST_PRODUCT_DATA = "listProductData";
+ private static final String LIST_MISSING_LINKS = "listMissingLinks";
+ private static final String ADD_MISSING_LINKS = "addMissingLinks";
+ private static final String ADD_DATABASE_CONTROLLER = "addDatabaseController";
+ private static final String ADD_DATABASE_RESPONDER = "addDatabaseResponder";
+ private static final String DELETE_DATABASE_CONTROLLER = "deleteDatabaseController";
+ private static final String DELETE_DATABASE_RESPONDER = "deleteDatabaseResponder";
+ private static final String APPLY_DATABASE_CHANGES = "applyDatabaseChanges";
+ private static final String CLEAR_DATABASE_CHANGES = "clearDatabaseChanges";
+ private static final String SET_BUTTON_RADIO_GROUP = "setButtonRadioGroup";
+ private static final String CLEAR_BUTTON_RADIO_GROUP = "clearButtonRadioGroup";
+ private static final String REFRESH = "refresh";
+
+ private static final List SUBCMDS = List.of(LIST_ALL, LIST_DATABASE, LIST_FEATURES, LIST_PRODUCT_DATA,
+ LIST_MISSING_LINKS, ADD_MISSING_LINKS, ADD_DATABASE_CONTROLLER, ADD_DATABASE_RESPONDER,
+ DELETE_DATABASE_CONTROLLER, DELETE_DATABASE_RESPONDER, APPLY_DATABASE_CHANGES, CLEAR_DATABASE_CHANGES,
+ SET_BUTTON_RADIO_GROUP, CLEAR_BUTTON_RADIO_GROUP, REFRESH);
+
+ private static final String ALL_OPTION = "--all";
+ private static final String CONFIRM_OPTION = "--confirm";
+
+ public DeviceCommand(InsteonCommandExtension commandExtension) {
+ super(NAME, DESCRIPTION, commandExtension);
+ }
+
+ @Override
+ public List getUsages() {
+ return List.of(
+ buildCommandUsage(LIST_ALL, "list configured Insteon/X10 devices with related channels and status"),
+ buildCommandUsage(LIST_DATABASE + " ",
+ "list all-link database records and pending changes for a configured Insteon device"),
+ buildCommandUsage(LIST_FEATURES + " ", "list features for a configured Insteon/X10 device"),
+ buildCommandUsage(LIST_PRODUCT_DATA + " ",
+ "list product data for a configured Insteon/X10 device"),
+ buildCommandUsage(LIST_MISSING_LINKS + " " + ALL_OPTION + "|",
+ "list missing links for a specific or all configured Insteon devices"),
+ buildCommandUsage(ADD_MISSING_LINKS + " " + ALL_OPTION + "|",
+ "add missing links for a specific or all configured Insteon devices"),
+ buildCommandUsage(ADD_DATABASE_CONTROLLER + " ",
+ "add a controller record to all-link database for a configured Insteon device"),
+ buildCommandUsage(ADD_DATABASE_RESPONDER + " ",
+ "add a responder record to all-link database for a configured Insteon device"),
+ buildCommandUsage(DELETE_DATABASE_CONTROLLER + " ",
+ "delete a controller record from all-link database for a configured Insteon device"),
+ buildCommandUsage(DELETE_DATABASE_RESPONDER + " ",
+ "delete a responder record from all-link database for a configured Insteon device"),
+ buildCommandUsage(APPLY_DATABASE_CHANGES + " " + CONFIRM_OPTION,
+ "apply all-link database pending changes for a configured Insteon device"),
+ buildCommandUsage(CLEAR_DATABASE_CHANGES + " ",
+ "clear all-link database pending changes for a configured Insteon device"),
+ buildCommandUsage(SET_BUTTON_RADIO_GROUP + " [ ... ]",
+ "set a button radio group for a configured Insteon KeypadLinc device"),
+ buildCommandUsage(CLEAR_BUTTON_RADIO_GROUP + " [ ... ]",
+ "clear a button radio group for a configured Insteon KeypadLinc device"),
+ buildCommandUsage(REFRESH + " ", "refresh data for a configured Insteon device"));
+ }
+
+ @Override
+ public void execute(String[] args, Console console) {
+ if (args.length == 0) {
+ printUsage(console);
+ return;
+ }
+
+ switch (args[0]) {
+ case LIST_ALL:
+ if (args.length == 1) {
+ listAll(console);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case LIST_DATABASE:
+ if (args.length == 2) {
+ listDatabaseRecords(console, args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case LIST_FEATURES:
+ if (args.length == 2) {
+ listFeatures(console, args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case LIST_PRODUCT_DATA:
+ if (args.length == 2) {
+ listProductData(console, args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case LIST_MISSING_LINKS:
+ if (args.length == 2) {
+ if (ALL_OPTION.equals(args[1])) {
+ listMissingLinks(console);
+ } else {
+ listMissingLinks(console, args[1]);
+ }
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case ADD_MISSING_LINKS:
+ if (args.length == 2) {
+ if (ALL_OPTION.equals(args[1])) {
+ addMissingLinks(console);
+ } else {
+ addMissingLinks(console, args[1]);
+ }
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case ADD_DATABASE_CONTROLLER:
+ if (args.length == 7) {
+ addDatabaseRecord(console, args, true);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case ADD_DATABASE_RESPONDER:
+ if (args.length == 7) {
+ addDatabaseRecord(console, args, false);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case DELETE_DATABASE_CONTROLLER:
+ if (args.length == 5) {
+ deleteDatabaseRecord(console, args, true);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case DELETE_DATABASE_RESPONDER:
+ if (args.length == 5) {
+ deleteDatabaseRecord(console, args, false);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case APPLY_DATABASE_CHANGES:
+ if (args.length == 2 || args.length == 3 && CONFIRM_OPTION.equals(args[2])) {
+ applyDatabaseChanges(console, args[1], args.length == 3);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case CLEAR_DATABASE_CHANGES:
+ if (args.length == 2) {
+ clearDatabaseChanges(console, args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case SET_BUTTON_RADIO_GROUP:
+ if (args.length >= 4 && args.length <= 9) {
+ setButtonRadioGroup(console, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case CLEAR_BUTTON_RADIO_GROUP:
+ if (args.length >= 4 && args.length <= 9) {
+ clearButtonRadioGroup(console, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case REFRESH:
+ if (args.length == 2) {
+ refreshDevice(console, args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ default:
+ console.println("Unknown command '" + args[0] + "'");
+ printUsage(console);
+ break;
+ }
+ }
+
+ @Override
+ public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List candidates) {
+ List strings = List.of();
+ if (cursorArgumentIndex == 0) {
+ strings = SUBCMDS;
+ } else if (cursorArgumentIndex == 1) {
+ switch (args[0]) {
+ case LIST_FEATURES:
+ case LIST_PRODUCT_DATA:
+ strings = getAllDeviceHandlers().map(InsteonThingHandler::getThingId).toList();
+ break;
+ case LIST_DATABASE:
+ case REFRESH:
+ strings = getInsteonDeviceHandlers().map(InsteonDeviceHandler::getThingId).toList();
+ break;
+ case ADD_DATABASE_CONTROLLER:
+ case DELETE_DATABASE_CONTROLLER:
+ strings = getInsteonDeviceHandlers().filter(handler -> {
+ InsteonDevice device = handler.getDevice();
+ return device != null && !device.getControllerFeatures().isEmpty();
+ }).map(InsteonDeviceHandler::getThingId).toList();
+ break;
+ case ADD_DATABASE_RESPONDER:
+ case DELETE_DATABASE_RESPONDER:
+ strings = getInsteonDeviceHandlers().filter(handler -> {
+ InsteonDevice device = handler.getDevice();
+ return device != null && !device.getResponderFeatures().isEmpty();
+ }).map(InsteonDeviceHandler::getThingId).toList();
+ break;
+ case APPLY_DATABASE_CHANGES:
+ case CLEAR_DATABASE_CHANGES:
+ strings = getInsteonDeviceHandlers().filter(handler -> {
+ InsteonDevice device = handler.getDevice();
+ return device != null && !device.getLinkDB().getChanges().isEmpty();
+ }).map(InsteonDeviceHandler::getThingId).toList();
+ break;
+ case LIST_MISSING_LINKS:
+ case ADD_MISSING_LINKS:
+ strings = Stream.concat(Stream.of(ALL_OPTION),
+ getInsteonDeviceHandlers().map(InsteonDeviceHandler::getThingId)).toList();
+ break;
+ case SET_BUTTON_RADIO_GROUP:
+ case CLEAR_BUTTON_RADIO_GROUP:
+ strings = getInsteonDeviceHandlers().filter(handler -> {
+ InsteonDevice device = handler.getDevice();
+ return device != null && !device.getFeatures(FEATURE_TYPE_KEYPAD_BUTTON).isEmpty();
+ }).map(InsteonDeviceHandler::getThingId).toList();
+ break;
+ }
+ } else if (cursorArgumentIndex == 2) {
+ InsteonDevice device = getInsteonDevice(args[1]);
+ switch (args[0]) {
+ case ADD_DATABASE_CONTROLLER:
+ case ADD_DATABASE_RESPONDER:
+ if (device != null) {
+ strings = Stream
+ .concat(Stream.of(getModem().getAddress()),
+ getModem().getDB().getDevices().stream()
+ .filter(address -> !device.getAddress().equals(address)))
+ .map(InsteonAddress::toString).toList();
+ }
+ break;
+ case DELETE_DATABASE_CONTROLLER:
+ if (device != null) {
+ strings = device.getLinkDB().getControllerRecords().stream()
+ .map(record -> record.getAddress().toString()).distinct().toList();
+ }
+ break;
+ case DELETE_DATABASE_RESPONDER:
+ if (device != null) {
+ strings = device.getLinkDB().getResponderRecords().stream()
+ .map(record -> record.getAddress().toString()).distinct().toList();
+ }
+ break;
+ case APPLY_DATABASE_CHANGES:
+ strings = List.of(CONFIRM_OPTION);
+ break;
+ }
+ } else if (cursorArgumentIndex == 3) {
+ InsteonDevice device = getInsteonDevice(args[1]);
+ InsteonAddress address = InsteonAddress.isValid(args[2]) ? new InsteonAddress(args[2]) : null;
+ switch (args[0]) {
+ case DELETE_DATABASE_CONTROLLER:
+ if (device != null && address != null) {
+ strings = device.getLinkDB().getControllerRecords(address).stream()
+ .map(record -> HexUtils.getHexString(record.getGroup())).distinct().toList();
+ }
+ break;
+ case DELETE_DATABASE_RESPONDER:
+ if (device != null && address != null) {
+ strings = device.getLinkDB().getResponderRecords(address).stream()
+ .map(record -> HexUtils.getHexString(record.getGroup())).distinct().toList();
+ }
+ break;
+ }
+ } else if (cursorArgumentIndex == 4) {
+ InsteonDevice device = getInsteonDevice(args[1]);
+ InsteonAddress address = InsteonAddress.isValid(args[2]) ? new InsteonAddress(args[2]) : null;
+ int group = HexUtils.isValidHexString(args[3]) ? HexUtils.toInteger(args[3]) : -1;
+ switch (args[0]) {
+ case DELETE_DATABASE_CONTROLLER:
+ if (device != null && address != null && group != -1) {
+ strings = device.getLinkDB().getControllerRecords(address, group).stream()
+ .map(record -> HexUtils.getHexString(record.getComponentId())).distinct().toList();
+ }
+ break;
+ case DELETE_DATABASE_RESPONDER:
+ if (device != null && address != null && group != -1) {
+ strings = device.getLinkDB().getResponderRecords(address, group).stream()
+ .map(record -> HexUtils.getHexString(record.getComponentId())).distinct().toList();
+ }
+ break;
+ }
+ }
+
+ if (cursorArgumentIndex >= 2) {
+ InsteonDevice device = getInsteonDevice(args[1]);
+ switch (args[0]) {
+ case SET_BUTTON_RADIO_GROUP:
+ case CLEAR_BUTTON_RADIO_GROUP:
+ if (device != null) {
+ strings = device.getFeatures(FEATURE_TYPE_KEYPAD_BUTTON).stream().map(DeviceFeature::getName)
+ .filter(name -> !Arrays.asList(args).subList(2, cursorArgumentIndex).contains(name))
+ .toList();
+ }
+ break;
+ }
+ }
+
+ return new StringsCompleter(strings, false).complete(args, cursorArgumentIndex, cursorPosition, candidates);
+ }
+
+ private void listAll(Console console) {
+ Map devices = getAllDeviceHandlers()
+ .collect(Collectors.toMap(InsteonThingHandler::getThingId, InsteonThingHandler::getThingInfo));
+ if (devices.isEmpty()) {
+ console.println("No device configured or enabled!");
+ } else {
+ console.println("There are " + devices.size() + " devices configured:");
+ print(console, devices);
+ }
+ }
+
+ private void listDatabaseRecords(Console console, String thingId) {
+ InsteonDevice device = getInsteonDevice(thingId);
+ if (device == null) {
+ console.println("The device " + thingId + " is not configured or enabled!");
+ return;
+ }
+ List records = device.getLinkDB().getRecords().stream().map(String::valueOf).toList();
+ if (records.isEmpty()) {
+ console.println("The all-link database for device " + device.getAddress() + " is empty");
+ } else {
+ console.println("The all-link database for device " + device.getAddress() + " contains " + records.size()
+ + " records:" + (!device.getLinkDB().isComplete() ? " (Partial)" : ""));
+ print(console, records);
+ listDatabaseChanges(console, thingId);
+ }
+ }
+
+ private void listDatabaseChanges(Console console, String thingId) {
+ InsteonDevice device = getInsteonDevice(thingId);
+ if (device == null) {
+ console.println("The device " + thingId + " is not configured or enabled!");
+ return;
+ }
+ List changes = device.getLinkDB().getChanges().stream().map(String::valueOf).toList();
+ if (!changes.isEmpty()) {
+ console.println("The all-link database for device " + device.getAddress() + " has " + changes.size()
+ + " pending changes:");
+ print(console, changes);
+ }
+ }
+
+ private void listFeatures(Console console, String thingId) {
+ Device device = getDevice(thingId);
+ if (device == null) {
+ console.println("The device " + thingId + " is not configured or enabled!");
+ return;
+ }
+ List features = device.getFeatures().stream()
+ .filter(feature -> !feature.isEventFeature() && !feature.isGroupFeature())
+ .map(feature -> String.format("%s: type=%s state=%s isHidden=%s", feature.getName(), feature.getType(),
+ feature.getState().toFullString(), feature.isHiddenFeature()))
+ .sorted().toList();
+ if (features.isEmpty()) {
+ console.println("The features for device " + device.getAddress() + " are not defined");
+ } else {
+ console.println("The features for device " + device.getAddress() + " are:");
+ print(console, features);
+ }
+ }
+
+ private void listProductData(Console console, String thingId) {
+ Device device = getDevice(thingId);
+ if (device == null) {
+ console.println("The device " + thingId + " is not configured or enabled!");
+ return;
+ }
+ ProductData productData = device.getProductData();
+ if (productData == null) {
+ console.println("The product data for device " + device.getAddress() + " is not defined");
+ } else {
+ console.println("The product data for device " + device.getAddress() + " is:");
+ console.println(productData.toString().replace("|", "\n"));
+ }
+ }
+
+ private void listMissingLinks(Console console) {
+ if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ } else {
+ getInsteonDeviceHandlers().forEach(handler -> listMissingLinks(console, handler.getThingId()));
+ }
+ }
+
+ private void listMissingLinks(Console console, String thingId) {
+ InsteonDevice device = getInsteonDevice(thingId);
+ if (device == null) {
+ console.println("The device " + thingId + " is not configured or enabled!");
+ } else if (!device.getLinkDB().isComplete()) {
+ console.println("The link database for device " + thingId + " is not loaded yet.");
+ } else if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ } else {
+ List deviceLinks = device.getMissingDeviceLinks().entrySet().stream()
+ .map(entry -> String.format("%s: %s", entry.getKey(), entry.getValue().getRecord())).toList();
+ List modemLinks = device.getMissingModemLinks().entrySet().stream()
+ .map(entry -> String.format("%s: %s", entry.getKey(), entry.getValue().getRecord())).toList();
+ if (deviceLinks.isEmpty() && modemLinks.isEmpty()) {
+ console.println("There are no missing links for device " + device.getAddress() + ".");
+ } else {
+ if (!deviceLinks.isEmpty()) {
+ console.println("There are " + deviceLinks.size()
+ + " missing links from the link database for device " + device.getAddress() + ":");
+ print(console, deviceLinks);
+ }
+ if (!modemLinks.isEmpty()) {
+ console.println("There are " + modemLinks.size()
+ + " missing links from the modem database for device " + device.getAddress() + ":");
+ print(console, modemLinks);
+ }
+ }
+ }
+ }
+
+ private void addMissingLinks(Console console) {
+ if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ } else {
+ getInsteonDeviceHandlers().forEach(handler -> addMissingLinks(console, handler.getThingId()));
+ }
+ }
+
+ private void addMissingLinks(Console console, String thingId) {
+ InsteonDevice device = getInsteonDevice(thingId);
+ if (device == null) {
+ console.println("The device " + thingId + " is not configured or enabled!");
+ } else if (!device.getLinkDB().isComplete()) {
+ console.println("The link database for device " + thingId + " is not loaded yet.");
+ } else if (!device.getLinkDB().getChanges().isEmpty()) {
+ console.println("The link database for device " + thingId + " has pending changes.");
+ } else if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ } else if (!getModem().getDB().getChanges().isEmpty()) {
+ console.println("The modem database has pending changes.");
+ } else {
+ int deviceLinkCount = device.getMissingDeviceLinks().size();
+ int modemLinkCount = device.getMissingModemLinks().size();
+ if (deviceLinkCount == 0 && modemLinkCount == 0) {
+ console.println("There are no missing links for device " + device.getAddress() + ".");
+ } else {
+ if (deviceLinkCount > 0) {
+ if (!device.isAwake() || !device.isResponding()) {
+ console.println("Scheduling " + deviceLinkCount + " missing links for device "
+ + device.getAddress() + " to be added to its link database the next time it is "
+ + (device.isBatteryPowered() ? "awake" : "responding") + ".");
+ } else {
+ console.println("Adding " + deviceLinkCount + " missing links for device " + device.getAddress()
+ + " to its link database...");
+ }
+ device.addMissingDeviceLinks();
+ }
+ if (modemLinkCount > 0) {
+ console.println("Adding " + modemLinkCount + " missing links for device " + device.getAddress()
+ + " to the modem database...");
+ device.addMissingModemLinks();
+ }
+ }
+ }
+ }
+
+ private void addDatabaseRecord(Console console, String[] args, boolean isController) {
+ InsteonDevice device = getInsteonDevice(args[1]);
+ if (device == null) {
+ console.println("The device " + args[1] + " is not configured or enabled!");
+ } else if (!device.getLinkDB().isComplete()) {
+ console.println("The link database for device " + args[1] + " is not loaded yet.");
+ } else if (!InsteonAddress.isValid(args[2])) {
+ console.println("Invalid record address argument: " + args[2]);
+ } else if (!HexUtils.isValidHexString(args[3])) {
+ console.println("Invalid record group hex argument: " + args[3]);
+ } else if (!HexUtils.isValidHexStringArray(args, 4, args.length)) {
+ console.println("Invalid record data hex argument(s).");
+ } else {
+ InsteonAddress address = new InsteonAddress(args[2]);
+ int group = HexUtils.toInteger(args[3]);
+ byte[] data = HexUtils.toByteArray(args, 4, args.length);
+
+ LinkDBRecord record = device.getLinkDB().getActiveRecord(address, group, isController, data[2]);
+ if (record == null) {
+ device.getLinkDB().markRecordForAdd(address, group, isController, data);
+ console.println("Added a pending change to add link database "
+ + (isController ? "controller" : "responder") + " record with address " + address
+ + " and group " + group + " for device " + device.getAddress() + ".");
+ } else {
+ device.getLinkDB().markRecordForModify(record, data);
+ console.println("Added a pending change to modify link database record located at "
+ + HexUtils.getHexString(record.getLocation(), 4) + " for device " + device.getAddress() + ".");
+ }
+ }
+ }
+
+ private void deleteDatabaseRecord(Console console, String[] args, boolean isController) {
+ InsteonDevice device = getInsteonDevice(args[1]);
+ if (device == null) {
+ console.println("The device " + args[1] + " is not configured or enabled!");
+ } else if (!device.getLinkDB().isComplete()) {
+ console.println("The link database for device " + args[1] + " is not loaded yet.");
+ } else if (!InsteonAddress.isValid(args[2])) {
+ console.println("Invalid record address argument: " + args[2]);
+ } else if (!HexUtils.isValidHexString(args[3])) {
+ console.println("Invalid record group hex argument: " + args[3]);
+ } else if (!HexUtils.isValidHexString(args[4])) {
+ console.println("Invalid record data3 hex argument: " + args[4]);
+ } else {
+ InsteonAddress address = new InsteonAddress(args[2]);
+ int group = HexUtils.toInteger(args[3]);
+ int componentId = HexUtils.toInteger(args[4]); // data3 as component id
+
+ LinkDBRecord record = device.getLinkDB().getActiveRecord(address, group, isController, componentId);
+ if (record == null) {
+ console.println("No link database " + (isController ? "controller" : "responder")
+ + " record with address " + address + " and group " + group + " to delete for device "
+ + device.getAddress() + ".");
+ } else {
+ device.getLinkDB().markRecordForDelete(record);
+ console.println("Added a pending change to delete link database record located at "
+ + HexUtils.getHexString(record.getLocation(), 4) + " for device " + device.getAddress() + ".");
+ }
+ }
+ }
+
+ private void applyDatabaseChanges(Console console, String thingId, boolean isConfirmed) {
+ InsteonDevice device = getInsteonDevice(thingId);
+ if (device == null) {
+ console.println("The device " + thingId + " is not configured or enabled!");
+ } else if (!device.getLinkDB().isComplete()) {
+ console.println("The link database for device " + thingId + " is not loaded yet.");
+ } else if (device.getLinkDB().getChanges().isEmpty()) {
+ console.println("The link database for device " + thingId + " has no pending changes.");
+ } else if (!isConfirmed) {
+ listDatabaseChanges(console, thingId);
+ console.println("Please run the same command with " + CONFIRM_OPTION
+ + " option to have these changes written to the link database for device " + device.getAddress()
+ + ".");
+ } else {
+ int count = device.getLinkDB().getChanges().size();
+ if (!device.isAwake() || !device.isResponding() || !getModem().getDB().isComplete()) {
+ console.println("Scheduling " + count + " pending changes for device " + device.getAddress()
+ + " to be applied to its link database the next time it is "
+ + (device.isBatteryPowered() ? "awake" : "responding") + ".");
+ } else {
+ console.println("Applying " + count + " pending changes to link database for device "
+ + device.getAddress() + "...");
+ }
+ device.getLinkDB().update();
+ }
+ }
+
+ private void clearDatabaseChanges(Console console, String thingId) {
+ InsteonDevice device = getInsteonDevice(thingId);
+ if (device == null) {
+ console.println("The device " + thingId + " is not configured or enabled!");
+ } else if (device.getLinkDB().getChanges().isEmpty()) {
+ console.println("The link database for device " + thingId + " has no pending changes.");
+ } else {
+ int count = device.getLinkDB().getChanges().size();
+ device.getLinkDB().clearChanges();
+ console.println(
+ "Cleared " + count + " pending changes from link database for device " + device.getAddress() + ".");
+ }
+ }
+
+ private void setButtonRadioGroup(Console console, String[] args) {
+ InsteonDevice device = getInsteonDevice(args[1]);
+ if (device == null) {
+ console.println("The device " + args[1] + " is not configured or enabled!");
+ } else if (device.getFeatures(FEATURE_TYPE_KEYPAD_BUTTON).isEmpty()) {
+ console.println("The device " + args[1] + " does not have keypad buttons.");
+ } else {
+ List buttons = new ArrayList<>();
+ for (int i = 2; i < args.length; i++) {
+ DeviceFeature feature = device.getFeature(args[i]);
+ if (feature == null || !feature.getType().equals(FEATURE_TYPE_KEYPAD_BUTTON)) {
+ console.println("The feature " + args[i] + " is not configured or a keypad button.");
+ return;
+ }
+ int group = feature.getGroup();
+ if (!buttons.contains(group)) {
+ buttons.add(group);
+ }
+ }
+ if (buttons.size() < 2) {
+ console.println("Requires at least two buttons to set a radio group.");
+ return;
+ }
+
+ console.println("Setting a radio group for device " + device.getAddress() + "...");
+ device.setButtonRadioGroup(buttons);
+ device.setButtonToggleMode(buttons, KeypadButtonToggleMode.ALWAYS_ON);
+ }
+ }
+
+ private void clearButtonRadioGroup(Console console, String[] args) {
+ InsteonDevice device = getInsteonDevice(args[1]);
+ if (device == null) {
+ console.println("The device " + args[1] + " is not configured or enabled!");
+ } else if (device.getFeatures(FEATURE_TYPE_KEYPAD_BUTTON).isEmpty()) {
+ console.println("The device " + args[1] + " does not have keypad buttons.");
+ } else {
+ List buttons = new ArrayList<>();
+ for (int i = 2; i < args.length; i++) {
+ DeviceFeature feature = device.getFeature(args[i]);
+ if (feature == null || !feature.getType().equals(FEATURE_TYPE_KEYPAD_BUTTON)) {
+ console.println(
+ "The device " + args[1] + " feature " + args[i] + " is not configured or a keypad button.");
+ return;
+ }
+ int group = feature.getGroup();
+ int offMask = device.getLastMsgValueAsInteger(FEATURE_TYPE_KEYPAD_BUTTON_OFF_MASK, group, 0);
+ if (offMask == 0) {
+ console.println("The keypad button " + args[i] + " is not part of a radio group.");
+ return;
+ }
+ if (!buttons.contains(group)) {
+ buttons.add(group);
+ }
+ }
+ if (buttons.size() < 2) {
+ console.println("Requires at least two buttons to clear a radio group.");
+ return;
+ }
+
+ console.println("Clearing a radio group for device " + device.getAddress() + "...");
+ device.clearButtonRadioGroup(buttons);
+ device.setButtonToggleMode(buttons, KeypadButtonToggleMode.TOGGLE);
+ }
+ }
+
+ private void refreshDevice(Console console, String thingId) {
+ InsteonDevice device = getInsteonDevice(thingId);
+ if (device == null) {
+ console.println("The device " + thingId + " is not configured or enabled!");
+ } else if (device.getProductData() == null) {
+ console.println("The device " + thingId + " is unknown.");
+ } else if (device.getType() == null) {
+ console.println("The device " + thingId + " is unsupported.");
+ } else {
+ device.getLinkDB().setReload(true);
+ device.resetFeaturesQueryStatus();
+
+ if (!device.isAwake() || !device.isResponding() || !getModem().getDB().isComplete()) {
+ console.println(
+ "The device " + device.getAddress() + " is scheduled to be refreshed the next time it is "
+ + (device.isBatteryPowered() ? "awake" : "responding") + ".");
+ } else {
+ console.println("Refreshing device " + device.getAddress() + "...");
+ device.doPoll(0L);
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonCommand.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonCommand.java
new file mode 100644
index 0000000000000..7295dc2460656
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonCommand.java
@@ -0,0 +1,168 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.command;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.Device;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.InsteonDevice;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.device.InsteonScene;
+import org.openhab.binding.insteon.internal.device.X10Address;
+import org.openhab.binding.insteon.internal.device.X10Device;
+import org.openhab.binding.insteon.internal.handler.InsteonBridgeHandler;
+import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler;
+import org.openhab.binding.insteon.internal.handler.InsteonSceneHandler;
+import org.openhab.binding.insteon.internal.handler.InsteonThingHandler;
+import org.openhab.binding.insteon.internal.handler.X10DeviceHandler;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.console.ConsoleCommandCompleter;
+
+/**
+ *
+ * The {@link InsteonCommand} represents a base Insteon console command
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public abstract class InsteonCommand implements ConsoleCommandCompleter {
+ private final String name;
+ private final String description;
+ private final InsteonCommandExtension commandExtension;
+
+ public InsteonCommand(String name, String description, InsteonCommandExtension commandExtension) {
+ this.name = name;
+ this.description = description;
+ this.commandExtension = commandExtension;
+ }
+
+ public String getCommand() {
+ return commandExtension.getCommand();
+ }
+
+ public String getSubCommand() {
+ return name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public abstract List getUsages();
+
+ public abstract void execute(String[] args, Console console);
+
+ protected String buildCommandUsage(final String syntax, final String description) {
+ return String.format("%s %s %s - %s", getCommand(), getSubCommand(), syntax, description);
+ }
+
+ protected void printUsage(Console console) {
+ getUsages().forEach(console::printUsage);
+ }
+
+ protected void printUsage(Console console, String cmd) {
+ getUsages().stream().filter(usage -> usage.split(" ")[2].equals(cmd)).findAny().ifPresent(console::printUsage);
+ }
+
+ protected void print(Console console, List list) {
+ list.forEach(console::println);
+ }
+
+ protected void print(Console console, Map map) {
+ map.entrySet().stream().sorted(Entry.comparingByKey()).map(Entry::getValue).forEach(console::println);
+ }
+
+ protected InsteonBridgeHandler getBridgeHandler() {
+ return Objects.requireNonNull(commandExtension.getBridgeHandler());
+ }
+
+ protected @Nullable InsteonBridgeHandler getBridgeHandler(String thingId) {
+ return getBridgeHandlers().filter(handler -> handler.getThingId().equals(thingId)).findFirst().orElse(null);
+ }
+
+ protected Stream getBridgeHandlers() {
+ return commandExtension.getBridgeHandlers();
+ }
+
+ protected void setBridgeHandler(InsteonBridgeHandler handler) {
+ commandExtension.setBridgeHandler(handler);
+ }
+
+ protected Stream getAllDeviceHandlers() {
+ return Stream.concat(getInsteonDeviceHandlers(), getX10DeviceHandlers());
+ }
+
+ protected Stream getInsteonDeviceHandlers() {
+ return getBridgeHandler().getChildHandlers().filter(InsteonDeviceHandler.class::isInstance)
+ .map(InsteonDeviceHandler.class::cast);
+ }
+
+ protected Stream getX10DeviceHandlers() {
+ return getBridgeHandler().getChildHandlers().filter(X10DeviceHandler.class::isInstance)
+ .map(X10DeviceHandler.class::cast);
+ }
+
+ protected Stream getInsteonSceneHandlers() {
+ return getBridgeHandler().getChildHandlers().filter(InsteonSceneHandler.class::isInstance)
+ .map(InsteonSceneHandler.class::cast);
+ }
+
+ protected InsteonModem getModem() {
+ return Objects.requireNonNull(getBridgeHandler().getModem());
+ }
+
+ protected @Nullable Device getDevice(String thingId) {
+ if (InsteonAddress.isValid(thingId)) {
+ return getModem().getDevice(new InsteonAddress(thingId));
+ } else if (X10Address.isValid(thingId)) {
+ return getModem().getDevice(new X10Address(thingId));
+ } else {
+ return getAllDeviceHandlers().filter(handler -> handler.getThingId().equals(thingId))
+ .map(InsteonThingHandler::getDevice).findFirst().orElse(null);
+ }
+ }
+
+ protected @Nullable InsteonDevice getInsteonDevice(String thingId) {
+ if (InsteonAddress.isValid(thingId)) {
+ return getModem().getInsteonDevice(new InsteonAddress(thingId));
+ } else {
+ return getInsteonDeviceHandlers().filter(handler -> handler.getThingId().equals(thingId))
+ .map(InsteonDeviceHandler::getDevice).findFirst().orElse(null);
+ }
+ }
+
+ protected @Nullable X10Device getX10Device(String thingId) {
+ if (X10Address.isValid(thingId)) {
+ return getModem().getX10Device(new X10Address(thingId));
+ } else {
+ return getX10DeviceHandlers().filter(handler -> handler.getThingId().equals(thingId))
+ .map(X10DeviceHandler::getDevice).findFirst().orElse(null);
+ }
+ }
+
+ protected @Nullable InsteonScene getInsteonScene(String thingId) {
+ if (InsteonScene.isValidGroup(thingId)) {
+ return getModem().getScene(Integer.parseInt(thingId));
+ } else {
+ return getInsteonSceneHandlers().filter(handler -> handler.getThingId().equals(thingId))
+ .map(InsteonSceneHandler::getScene).findFirst().orElse(null);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonCommandExtension.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonCommandExtension.java
new file mode 100644
index 0000000000000..a28af4f1a2fe4
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonCommandExtension.java
@@ -0,0 +1,159 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.command;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.InsteonBindingConstants;
+import org.openhab.binding.insteon.internal.handler.InsteonBridgeHandler;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.console.ConsoleCommandCompleter;
+import org.openhab.core.io.console.StringsCompleter;
+import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
+import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingRegistry;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ *
+ * The {@link InsteonCommandExtension} is responsible for handling console commands
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = ConsoleCommandExtension.class)
+public class InsteonCommandExtension extends AbstractConsoleCommandExtension implements ConsoleCommandCompleter {
+
+ private static final List> SUBCMD_CLASSES = List.of(ModemCommand.class,
+ DeviceCommand.class, SceneCommand.class, ChannelCommand.class, DebugCommand.class);
+
+ private final ThingRegistry thingRegistry;
+ private final InsteonLegacyCommandExtension legacyCommandExtension;
+ private final Map subCommands;
+
+ private @Nullable InsteonBridgeHandler handler;
+
+ @Activate
+ public InsteonCommandExtension(final @Reference ThingRegistry thingRegistry) {
+ super(InsteonBindingConstants.BINDING_ID, "Interact with the Insteon integration.");
+ this.thingRegistry = thingRegistry;
+
+ this.legacyCommandExtension = new InsteonLegacyCommandExtension(thingRegistry);
+ this.subCommands = SUBCMD_CLASSES.stream().map(this::instantiateCommand).filter(Objects::nonNull)
+ .collect(Collectors.toMap(InsteonCommand::getSubCommand, Function.identity(), (key1, key2) -> key1,
+ LinkedHashMap::new));
+ }
+
+ @Override
+ public List getUsages() {
+ if (legacyCommandExtension.isAvailable()) {
+ return legacyCommandExtension.getUsages();
+ }
+ return subCommands.values().stream().map(cmd -> buildCommandUsage(cmd.getSubCommand(), cmd.getDescription()))
+ .toList();
+ }
+
+ @Override
+ public @Nullable ConsoleCommandCompleter getCompleter() {
+ return this;
+ }
+
+ @Override
+ public void execute(String[] args, Console console) {
+ if (legacyCommandExtension.isAvailable()) {
+ legacyCommandExtension.execute(args, console);
+ return;
+ }
+
+ InsteonBridgeHandler handler = getBridgeHandler();
+ if (handler == null) {
+ console.println("No Insteon bridge configured or enabled.");
+ return;
+ }
+
+ if (handler.getModem() == null) {
+ console.println("Insteon bridge " + handler.getThing().getUID() + " not initialized yet.");
+ return;
+ }
+
+ if (args.length == 0) {
+ printUsage(console);
+ return;
+ }
+
+ InsteonCommand command = subCommands.get(args[0]);
+ if (command != null) {
+ command.execute(Arrays.copyOfRange(args, 1, args.length), console);
+ } else {
+ console.println("Unknown command '" + args[0] + "'");
+ printUsage(console);
+ }
+ }
+
+ @Override
+ public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List candidates) {
+ InsteonBridgeHandler handler = getBridgeHandler();
+ if (!legacyCommandExtension.isAvailable() && handler != null && handler.getModem() != null) {
+ if (cursorArgumentIndex == 0) {
+ return new StringsCompleter(subCommands.keySet(), false).complete(args, cursorArgumentIndex,
+ cursorPosition, candidates);
+ }
+
+ ConsoleCommandCompleter completer = subCommands.get(args[0]);
+ if (completer != null) {
+ return completer.complete(Arrays.copyOfRange(args, 1, args.length), cursorArgumentIndex - 1,
+ cursorPosition, candidates);
+ }
+ }
+ return false;
+ }
+
+ public @Nullable InsteonBridgeHandler getBridgeHandler() {
+ InsteonBridgeHandler handler = this.handler;
+ if (handler == null || !handler.getThing().isEnabled()) {
+ return getBridgeHandlers().findFirst().orElse(null);
+ }
+ return handler;
+ }
+
+ public Stream getBridgeHandlers() {
+ return thingRegistry.getAll().stream().filter(Thing::isEnabled).map(Thing::getHandler)
+ .filter(InsteonBridgeHandler.class::isInstance).map(InsteonBridgeHandler.class::cast);
+ }
+
+ public void setBridgeHandler(InsteonBridgeHandler handler) {
+ this.handler = handler;
+ }
+
+ private @Nullable InsteonCommand instantiateCommand(Class extends InsteonCommand> clazz) {
+ try {
+ return clazz.getDeclaredConstructor(InsteonCommandExtension.class).newInstance(this);
+ } catch (NoSuchMethodException | InstantiationException | IllegalAccessException
+ | InvocationTargetException e) {
+ return null;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonLegacyCommandExtension.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonLegacyCommandExtension.java
index efec941400350..ed43546d7bd56 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonLegacyCommandExtension.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonLegacyCommandExtension.java
@@ -13,7 +13,6 @@
package org.openhab.binding.insteon.internal.command;
import java.text.SimpleDateFormat;
-import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
@@ -21,6 +20,7 @@
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.InsteonBindingConstants;
import org.openhab.binding.insteon.internal.InsteonLegacyBinding;
import org.openhab.binding.insteon.internal.device.InsteonAddress;
import org.openhab.binding.insteon.internal.device.LegacyDevice;
@@ -30,25 +30,22 @@
import org.openhab.binding.insteon.internal.transport.message.FieldException;
import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
import org.openhab.binding.insteon.internal.transport.message.Msg;
-import org.openhab.binding.insteon.internal.utils.Utils;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
import org.openhab.core.io.console.Console;
import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
-import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
-import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Reference;
-import org.osgi.service.component.annotations.ReferenceCardinality;
-import org.osgi.service.component.annotations.ReferencePolicy;
-import org.osgi.service.component.annotations.ReferencePolicyOption;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingRegistry;
/**
*
- * Console commands for the Insteon binding
+ * The {@link InsteonLegacyCommandExtension} is responsible for handling legacy console commands
*
* @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
*/
@NonNullByDefault
-@Component(service = ConsoleCommandExtension.class)
public class InsteonLegacyCommandExtension extends AbstractConsoleCommandExtension implements LegacyMsgListener {
+
private static final String DISPLAY_DEVICES = "display_devices";
private static final String DISPLAY_CHANNELS = "display_channels";
private static final String DISPLAY_LOCAL_DATABASE = "display_local_database";
@@ -65,24 +62,25 @@ private enum MessageType {
EXTENDED_2
}
- @Nullable
- private InsteonLegacyNetworkHandler handler;
+ private final ThingRegistry thingRegistry;
+
@Nullable
private Console console;
private boolean monitoring = false;
private boolean monitorAllDevices = false;
private Set monitoredAddresses = new HashSet<>();
- public InsteonLegacyCommandExtension() {
- super("insteon", "Interact with the Insteon integration.");
+ public InsteonLegacyCommandExtension(final ThingRegistry thingRegistry) {
+ super(InsteonBindingConstants.BINDING_ID, "Interact with the Insteon integration.");
+ this.thingRegistry = thingRegistry;
}
@Override
public void execute(String[] args, Console console) {
if (args.length > 0) {
- InsteonLegacyNetworkHandler handler = this.handler; // fix eclipse warnings about nullable
+ InsteonLegacyNetworkHandler handler = getLegacyNetworkHandler();
if (handler == null) {
- console.println("No Insteon network bridge configured.");
+ console.println("No Insteon legacy network bridge configured.");
} else {
switch (args[0]) {
case DISPLAY_DEVICES:
@@ -161,10 +159,11 @@ public void execute(String[] args, Console console) {
@Override
public List getUsages() {
- return Arrays.asList(new String[] {
- buildCommandUsage(DISPLAY_DEVICES, "display devices that are online, along with available channels"),
+ return List.of(
+ buildCommandUsage(DISPLAY_DEVICES,
+ "display legacy devices that are online, along with available channels"),
buildCommandUsage(DISPLAY_CHANNELS,
- "display channels that are linked, along with configuration information"),
+ "display legacy channels that are linked, along with configuration information"),
buildCommandUsage(DISPLAY_LOCAL_DATABASE, "display Insteon PLM or hub database details"),
buildCommandUsage(DISPLAY_MONITORED, "display monitored device(s)"),
buildCommandUsage(START_MONITORING + " all|address",
@@ -175,27 +174,26 @@ public List getUsages() {
buildCommandUsage(SEND_EXTENDED_MESSAGE + " address flags cmd1 cmd2 [up to 13 bytes]",
"send extended message to a device"),
buildCommandUsage(SEND_EXTENDED_MESSAGE_2 + " address flags cmd1 cmd2 [up to 12 bytes]",
- "send extended message with a two byte crc to a device") });
+ "send extended message with a two byte crc to a device"));
}
@Override
public void msg(Msg msg) {
- if (monitorAllDevices || monitoredAddresses.contains(msg.getAddr("fromAddress"))) {
- String date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(new Date());
- Console console = this.console;
- if (console != null) {
- console.println(date + " " + msg.toString());
+ try {
+ if (monitorAllDevices || monitoredAddresses.contains(msg.getInsteonAddress("fromAddress"))) {
+ String date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS").format(new Date());
+ Console console = this.console;
+ if (console != null) {
+ console.println(date + " " + msg.toString());
+ }
}
+ } catch (FieldException ignored) {
+ // ignore message with no address field
}
}
- @Reference(cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC, policyOption = ReferencePolicyOption.GREEDY)
- public void setInsteonNetworkHandler(InsteonLegacyNetworkHandler handler) {
- this.handler = handler;
- }
-
- public void unsetInsteonNetworkHandler(InsteonLegacyNetworkHandler handler) {
- this.handler = null;
+ public boolean isAvailable() {
+ return getLegacyNetworkHandler() != null;
}
private void displayMonitoredDevices(Console console) {
@@ -213,7 +211,7 @@ private void displayMonitoredDevices(Console console) {
} else if (monitorAllDevices) {
console.println("All devices are monitored.");
} else {
- console.println("Not mointoring any devices.");
+ console.println("Not monitoring any devices.");
}
}
@@ -251,7 +249,7 @@ private void startMonitoring(Console console, String addr) {
private void stopMonitoring(Console console, String addr) {
if (!monitoring) {
- console.println("Not mointoring any devices.");
+ console.println("Not monitoring any devices.");
return;
}
@@ -312,22 +310,24 @@ private void sendMessage(Console console, MessageType messageType, String[] args
}
try {
- byte flags = (byte) Utils.fromHexString(args[2]);
- byte cmd1 = (byte) Utils.fromHexString(args[3]);
- byte cmd2 = (byte) Utils.fromHexString(args[4]);
+ InsteonAddress address = (InsteonAddress) device.getAddress();
+ byte flags = (byte) HexUtils.toInteger(args[2]);
+ byte cmd1 = (byte) HexUtils.toInteger(args[3]);
+ byte cmd2 = (byte) HexUtils.toInteger(args[4]);
Msg msg;
if (messageType == MessageType.STANDARD) {
- msg = device.makeStandardMessage(flags, cmd1, cmd2);
+ msg = Msg.makeStandardMessage(address, flags, cmd1, cmd2);
} else {
byte[] data = new byte[args.length - 5];
for (int i = 0; i + 5 < args.length; i++) {
- data[i] = (byte) Utils.fromHexString(args[i + 5]);
+ data[i] = (byte) HexUtils.toInteger(args[i + 5]);
}
+ msg = Msg.makeExtendedMessage(address, flags, cmd1, cmd2, data, false);
if (messageType == MessageType.EXTENDED) {
- msg = device.makeExtendedMessage(flags, cmd1, cmd2, data);
+ msg.setCRC();
} else {
- msg = device.makeExtendedMessageCRC2(flags, cmd1, cmd2, data);
+ msg.setCRC2();
}
}
device.enqueueMessage(msg, new LegacyDeviceFeature(device, "console"));
@@ -336,10 +336,16 @@ private void sendMessage(Console console, MessageType messageType, String[] args
}
}
+ private @Nullable InsteonLegacyNetworkHandler getLegacyNetworkHandler() {
+ return thingRegistry.getAll().stream().filter(Thing::isEnabled).map(Thing::getHandler)
+ .filter(InsteonLegacyNetworkHandler.class::isInstance).map(InsteonLegacyNetworkHandler.class::cast)
+ .findFirst().orElse(null);
+ }
+
private InsteonLegacyBinding getInsteonBinding() {
- InsteonLegacyNetworkHandler handler = this.handler;
+ InsteonLegacyNetworkHandler handler = getLegacyNetworkHandler();
if (handler == null) {
- throw new IllegalArgumentException("No Insteon network bridge configured.");
+ throw new IllegalArgumentException("No Insteon legacy network bridge configured.");
}
return handler.getInsteonBinding();
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/ModemCommand.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/ModemCommand.java
new file mode 100644
index 0000000000000..6a458130b55a1
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/ModemCommand.java
@@ -0,0 +1,417 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.command;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.database.ModemDBEntry;
+import org.openhab.binding.insteon.internal.database.ModemDBRecord;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.ProductData;
+import org.openhab.binding.insteon.internal.handler.InsteonBridgeHandler;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.console.StringsCompleter;
+
+/**
+ *
+ * The {@link ModemCommand} represents an Insteon console modem command
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class ModemCommand extends InsteonCommand {
+ private static final String NAME = "modem";
+ private static final String DESCRIPTION = "Insteon modem commands";
+
+ private static final String LIST_ALL = "listAll";
+ private static final String LIST_DATABASE = "listDatabase";
+ private static final String RELOAD_DATABASE = "reloadDatabase";
+ private static final String ADD_DATABASE_CONTROLLER = "addDatabaseController";
+ private static final String ADD_DATABASE_RESPONDER = "addDatabaseResponder";
+ private static final String DELETE_DATABASE_RECORD = "deleteDatabaseRecord";
+ private static final String APPLY_DATABASE_CHANGES = "applyDatabaseChanges";
+ private static final String CLEAR_DATABASE_CHANGES = "clearDatabaseChanges";
+ private static final String ADD_DEVICE = "addDevice";
+ private static final String REMOVE_DEVICE = "removeDevice";
+ private static final String SWITCH = "switch";
+
+ private static final List SUBCMDS = List.of(LIST_ALL, LIST_DATABASE, RELOAD_DATABASE,
+ ADD_DATABASE_CONTROLLER, ADD_DATABASE_RESPONDER, DELETE_DATABASE_RECORD, APPLY_DATABASE_CHANGES,
+ CLEAR_DATABASE_CHANGES, ADD_DEVICE, REMOVE_DEVICE, SWITCH);
+
+ private static final String CONFIRM_OPTION = "--confirm";
+ private static final String FORCE_OPTION = "--force";
+ private static final String RECORDS_OPTION = "--records";
+
+ public ModemCommand(InsteonCommandExtension commandExtension) {
+ super(NAME, DESCRIPTION, commandExtension);
+ }
+
+ @Override
+ public List getUsages() {
+ return List.of(
+ buildCommandUsage(LIST_ALL, "list configured Insteon modem bridges with related channels and status"),
+ buildCommandUsage(LIST_DATABASE + " [" + RECORDS_OPTION + "]",
+ "list all-link database summary or records and pending changes for the Insteon modem"),
+ buildCommandUsage(RELOAD_DATABASE, "reload all-link database from the Insteon modem"),
+ buildCommandUsage(ADD_DATABASE_CONTROLLER + " [ ]",
+ "add a controller record to all-link database for the Insteon modem"),
+ buildCommandUsage(ADD_DATABASE_RESPONDER + " ",
+ "add a responder record to all-link database for the Insteon modem"),
+ buildCommandUsage(DELETE_DATABASE_RECORD + " ",
+ "delete a controller/responder record from all-link database for the Insteon modem"),
+ buildCommandUsage(APPLY_DATABASE_CHANGES + " " + CONFIRM_OPTION,
+ "apply all-link database pending changes for the Insteon modem"),
+ buildCommandUsage(CLEAR_DATABASE_CHANGES,
+ "clear all-link database pending changes for the Insteon modem"),
+ buildCommandUsage(ADD_DEVICE + " []",
+ "add an Insteon device to the modem, optionally providing its address"),
+ buildCommandUsage(REMOVE_DEVICE + " [" + FORCE_OPTION + "]",
+ "remove an Insteon device from the modem"),
+ buildCommandUsage(SWITCH + " ",
+ "switch Insteon modem bridge to use if more than one configured and enabled"));
+ }
+
+ @Override
+ public void execute(String[] args, Console console) {
+ if (args.length == 0) {
+ printUsage(console);
+ return;
+ }
+
+ switch (args[0]) {
+ case LIST_ALL:
+ if (args.length == 1) {
+ listAll(console);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case LIST_DATABASE:
+ if (args.length == 1) {
+ listDatabaseSummary(console);
+ } else if (args.length == 2 && RECORDS_OPTION.equals(args[1])) {
+ listDatabaseRecords(console);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case RELOAD_DATABASE:
+ if (args.length == 1) {
+ reloadDatabase(console);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case ADD_DATABASE_CONTROLLER:
+ if (args.length == 3 || args.length == 6) {
+ addDatabaseRecord(console, args, true);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case ADD_DATABASE_RESPONDER:
+ if (args.length == 3) {
+ addDatabaseRecord(console, args, false);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case DELETE_DATABASE_RECORD:
+ if (args.length == 3) {
+ deleteDatabaseRecord(console, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case APPLY_DATABASE_CHANGES:
+ if (args.length == 1 || args.length == 2 && CONFIRM_OPTION.equals(args[1])) {
+ applyDatabaseChanges(console, args.length == 2);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case CLEAR_DATABASE_CHANGES:
+ if (args.length == 1) {
+ clearDatabaseChanges(console);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case ADD_DEVICE:
+ if (args.length >= 1 && args.length <= 2) {
+ addDevice(console, args.length == 1 ? null : args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case REMOVE_DEVICE:
+ if (args.length == 2 || args.length == 3 && FORCE_OPTION.equals(args[2])) {
+ removeDevice(console, args[1], args.length == 3);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case SWITCH:
+ if (args.length == 2) {
+ switchModem(console, args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ default:
+ console.println("Unknown command '" + args[0] + "'");
+ printUsage(console);
+ break;
+ }
+ }
+
+ @Override
+ public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List candidates) {
+ List strings = List.of();
+ if (cursorArgumentIndex == 0) {
+ strings = SUBCMDS;
+ } else if (cursorArgumentIndex == 1) {
+ switch (args[0]) {
+ case LIST_DATABASE:
+ strings = List.of(RECORDS_OPTION);
+ break;
+ case ADD_DATABASE_CONTROLLER:
+ case ADD_DATABASE_RESPONDER:
+ case REMOVE_DEVICE:
+ strings = getModem().getDB().getDevices().stream().map(InsteonAddress::toString).toList();
+ break;
+ case DELETE_DATABASE_RECORD:
+ strings = getModem().getDB().getRecords().stream().map(record -> record.getAddress().toString())
+ .distinct().toList();
+ break;
+ case SWITCH:
+ strings = getBridgeHandlers().map(InsteonBridgeHandler::getThingId).toList();
+ break;
+ }
+ } else if (cursorArgumentIndex == 2) {
+ InsteonAddress address = InsteonAddress.isValid(args[1]) ? new InsteonAddress(args[1]) : null;
+ switch (args[0]) {
+ case DELETE_DATABASE_RECORD:
+ if (address != null) {
+ strings = getModem().getDB().getRecords(address).stream()
+ .map(record -> HexUtils.getHexString(record.getGroup())).distinct().toList();
+ }
+ break;
+ case REMOVE_DEVICE:
+ strings = List.of(FORCE_OPTION);
+ break;
+ }
+ }
+
+ return new StringsCompleter(strings, false).complete(args, cursorArgumentIndex, cursorPosition, candidates);
+ }
+
+ private void listAll(Console console) {
+ Map bridges = getBridgeHandlers()
+ .collect(Collectors.toMap(InsteonBridgeHandler::getThingId, InsteonBridgeHandler::getThingInfo));
+ if (bridges.isEmpty()) {
+ console.println("No modem bridge configured or enabled!");
+ } else {
+ console.println("There are " + bridges.size() + " modem bridges configured:");
+ print(console, bridges);
+ }
+ }
+
+ private void listDatabaseSummary(Console console) {
+ InsteonAddress address = getModem().getAddress();
+ Map entries = getModem().getDB().getEntries().stream()
+ .collect(Collectors.toMap(ModemDBEntry::getId, ModemDBEntry::toString));
+ if (InsteonAddress.UNKNOWN.equals(address)) {
+ console.println("No modem found!");
+ } else if (entries.isEmpty()) {
+ console.println("The all-link database for modem " + address + " is empty");
+ } else {
+ console.println("The all-link database for modem " + address + " contains " + entries.size() + " devices:");
+ print(console, entries);
+ }
+ }
+
+ private void listDatabaseRecords(Console console) {
+ InsteonAddress address = getModem().getAddress();
+ List records = getModem().getDB().getRecords().stream().map(ModemDBRecord::toString).toList();
+ if (InsteonAddress.UNKNOWN.equals(address)) {
+ console.println("No modem found!");
+ } else if (records.isEmpty()) {
+ console.println("The all-link database for modem " + address + " is empty");
+ } else {
+ console.println("The all-link database for modem " + address + " contains " + records.size() + " records:");
+ print(console, records);
+ listDatabaseChanges(console);
+ }
+ }
+
+ private void listDatabaseChanges(Console console) {
+ InsteonAddress address = getModem().getAddress();
+ List changes = getModem().getDB().getChanges().stream().map(String::valueOf).toList();
+ if (InsteonAddress.UNKNOWN.equals(address)) {
+ console.println("No modem found!");
+ } else if (!changes.isEmpty()) {
+ console.println(
+ "The all-link database for modem " + address + " has " + changes.size() + " pending changes:");
+ print(console, changes);
+ }
+ }
+
+ private void reloadDatabase(Console console) {
+ InsteonAddress address = getModem().getAddress();
+ InsteonBridgeHandler handler = getBridgeHandler();
+ if (InsteonAddress.UNKNOWN.equals(address)) {
+ console.println("No modem found!");
+ } else {
+ console.println("Reloading all-link database for modem " + address + ".");
+ getModem().getDB().clear();
+ handler.reset(0);
+ }
+ }
+
+ private void addDatabaseRecord(Console console, String[] args, boolean isController) {
+ if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ } else if (!InsteonAddress.isValid(args[1])) {
+ console.println("Invalid record address argument: " + args[1]);
+ } else if (!HexUtils.isValidHexString(args[2])) {
+ console.println("Invalid record group hex argument: " + args[2]);
+ } else if (isController && args.length == 6 && !HexUtils.isValidHexStringArray(args, 3, args.length)) {
+ console.println("Invalid product data hex argument(s).");
+ } else if (isController && args.length == 3
+ && !getModem().getDB().hasProductData(new InsteonAddress(args[1]))) {
+ console.println("No product data available for " + args[1] + ".");
+ } else {
+ InsteonAddress address = new InsteonAddress(args[1]);
+ int group = HexUtils.toInteger(args[2]);
+ byte data[] = new byte[3];
+ if (isController) {
+ ProductData productData = getModem().getDB().getProductData(address);
+ if (args.length == 6) {
+ data = HexUtils.toByteArray(args, 3, args.length);
+ } else if (args.length == 3 && productData != null) {
+ data = productData.getRecordData();
+ }
+ }
+
+ ModemDBRecord record = getModem().getDB().getRecord(address, group, isController);
+ if (record == null) {
+ getModem().getDB().markRecordForAdd(address, group, isController, data);
+
+ } else {
+ getModem().getDB().markRecordForModify(record, data);
+ }
+ console.println("Added a pending change to " + (record == null ? "add" : "modify") + " modem database "
+ + (isController ? "controller" : "responder") + " record with address " + address + " and group "
+ + group + ".");
+ }
+ }
+
+ private void deleteDatabaseRecord(Console console, String[] args) {
+ if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ } else if (!InsteonAddress.isValid(args[1])) {
+ console.println("Invalid record address argument: " + args[1]);
+ } else if (!HexUtils.isValidHexString(args[2])) {
+ console.println("Invalid record group hex argument: " + args[2]);
+ } else {
+ InsteonAddress address = new InsteonAddress(args[1]);
+ int group = HexUtils.toInteger(args[2]);
+
+ ModemDBRecord record = getModem().getDB().getRecord(address, group);
+ if (record == null) {
+ console.println(
+ "No modem database record with address " + address + " and group " + group + " to delete.");
+ } else {
+ getModem().getDB().markRecordForDelete(record);
+ console.println("Added a pending change to delete modem database "
+ + (record.isController() ? "controller" : "responder") + " record with address " + address
+ + " and group " + group + ".");
+ }
+ }
+ }
+
+ private void applyDatabaseChanges(Console console, boolean isConfirmed) {
+ if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ } else if (getModem().getDB().getChanges().isEmpty()) {
+ console.println("The modem database has no pending changes.");
+ } else if (!isConfirmed) {
+ listDatabaseChanges(console);
+ console.println("Please run the same command with " + CONFIRM_OPTION
+ + " option to have these changes written to the modem database.");
+ } else {
+ int count = getModem().getDB().getChanges().size();
+ console.println("Applying " + count + " pending changes to the modem database...");
+ getModem().getDB().update();
+ }
+ }
+
+ private void clearDatabaseChanges(Console console) {
+ if (getModem().getDB().getChanges().isEmpty()) {
+ console.println("The modem database has no pending changes.");
+ } else {
+ int count = getModem().getDB().getChanges().size();
+ getModem().getDB().clearChanges();
+ console.println("Cleared " + count + " pending changes from the modem database.");
+ }
+ }
+
+ private void addDevice(Console console, @Nullable String address) {
+ if (address != null && !InsteonAddress.isValid(address)) {
+ console.println("The device address " + address + " is not valid.");
+ } else if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ } else if (getModem().getLinkManager().isRunning()) {
+ console.println("Another device is currently being added or removed.");
+ } else if (address == null) {
+ console.println("Adding device...");
+ console.println("Press the device SET button to link.");
+ getModem().getLinkManager().link(null);
+ } else {
+ console.println("Adding device " + address + "...");
+ getModem().getLinkManager().link(new InsteonAddress(address));
+ }
+ }
+
+ private void removeDevice(Console console, String address, boolean force) {
+ if (!InsteonAddress.isValid(address)) {
+ console.println("The device address " + address + " is not valid.");
+ } else if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ } else if (!getModem().getDB().hasEntry(new InsteonAddress(address))) {
+ console.println("The device " + address + " is not in modem database.");
+ } else if (getModem().getLinkManager().isRunning()) {
+ console.println("Another device is currently being added or removed.");
+ } else {
+ console.println("Removing device " + address + "...");
+ getModem().getLinkManager().unlink(new InsteonAddress(address), force);
+ }
+ }
+
+ private void switchModem(Console console, String thingId) {
+ InsteonBridgeHandler handler = getBridgeHandler(thingId);
+ if (handler == null) {
+ console.println("No Insteon bridge " + thingId + " configured or enabled.");
+ } else {
+ console.println("Using Insteon bridge " + handler.getThing().getUID());
+ setBridgeHandler(handler);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/SceneCommand.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/SceneCommand.java
new file mode 100644
index 0000000000000..c5c63934c1fb2
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/SceneCommand.java
@@ -0,0 +1,318 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.command;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.InsteonDevice;
+import org.openhab.binding.insteon.internal.device.InsteonScene;
+import org.openhab.binding.insteon.internal.device.OnLevel;
+import org.openhab.binding.insteon.internal.device.RampRate;
+import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler;
+import org.openhab.binding.insteon.internal.handler.InsteonSceneHandler;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.console.StringsCompleter;
+
+/**
+ *
+ * The {@link SceneCommand} represents an Insteon console scene command
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class SceneCommand extends InsteonCommand {
+ private static final String NAME = "scene";
+ private static final String DESCRIPTION = "Insteon scene commands";
+
+ private static final String LIST_ALL = "listAll";
+ private static final String LIST_DETAILS = "listDetails";
+ private static final String ADD_DEVICE = "addDevice";
+ private static final String REMOVE_DEVICE = "removeDevice";
+
+ private static final List SUBCMDS = List.of(LIST_ALL, LIST_DETAILS, ADD_DEVICE, REMOVE_DEVICE);
+
+ private static final String NEW_OPTION = "--new";
+
+ public SceneCommand(InsteonCommandExtension commandExtension) {
+ super(NAME, DESCRIPTION, commandExtension);
+ }
+
+ @Override
+ public List getUsages() {
+ return List.of(buildCommandUsage(LIST_ALL, "list configured Insteon scenes with related channels and status"),
+ buildCommandUsage(LIST_DETAILS + " ", "list details for a configured Insteon scene"),
+ buildCommandUsage(ADD_DEVICE + " " + NEW_OPTION + "| []",
+ "add an Insteon device feature to a new or configured Insteon scene"),
+ buildCommandUsage(REMOVE_DEVICE + " ",
+ "remove an Insteon device feature from a configured Insteon scene"));
+ }
+
+ @Override
+ public void execute(String[] args, Console console) {
+ if (args.length == 0) {
+ printUsage(console);
+ return;
+ }
+
+ switch (args[0]) {
+ case LIST_ALL:
+ if (args.length == 1) {
+ listAll(console);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case LIST_DETAILS:
+ if (args.length == 2) {
+ listDetails(console, args[1]);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case ADD_DEVICE:
+ if (args.length >= 5 && args.length <= 6) {
+ addDevice(console, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ case REMOVE_DEVICE:
+ if (args.length == 4) {
+ removeDevice(console, args);
+ } else {
+ printUsage(console, args[0]);
+ }
+ break;
+ default:
+ console.println("Unknown command '" + args[0] + "'");
+ printUsage(console);
+ break;
+ }
+ }
+
+ @Override
+ public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List candidates) {
+ List strings = List.of();
+ if (cursorArgumentIndex == 0) {
+ strings = SUBCMDS;
+ } else if (cursorArgumentIndex == 1) {
+ switch (args[0]) {
+ case LIST_DETAILS:
+ case REMOVE_DEVICE:
+ strings = getInsteonSceneHandlers().map(InsteonSceneHandler::getThingId).toList();
+ break;
+ case ADD_DEVICE:
+ strings = Stream.concat(Stream.of(NEW_OPTION),
+ getInsteonSceneHandlers().map(InsteonSceneHandler::getThingId)).toList();
+ break;
+ }
+ } else if (cursorArgumentIndex == 2) {
+ InsteonScene scene = getInsteonScene(args[1]);
+ switch (args[0]) {
+ case ADD_DEVICE:
+ strings = getInsteonDeviceHandlers().filter(handler -> {
+ InsteonDevice device = handler.getDevice();
+ return device != null && !device.getResponderFeatures().isEmpty();
+ }).map(InsteonDeviceHandler::getThingId).toList();
+ break;
+ case REMOVE_DEVICE:
+ strings = getInsteonDeviceHandlers().filter(handler -> {
+ InsteonDevice device = handler.getDevice();
+ return device != null && scene != null && scene.hasEntry(device.getAddress());
+ }).map(InsteonDeviceHandler::getThingId).toList();
+ break;
+ }
+ } else if (cursorArgumentIndex == 3) {
+ InsteonScene scene = getInsteonScene(args[1]);
+ InsteonDevice device = getInsteonDevice(args[2]);
+ switch (args[0]) {
+ case ADD_DEVICE:
+ if (device != null) {
+ strings = device.getResponderFeatures().stream().map(DeviceFeature::getName).toList();
+ }
+ break;
+ case REMOVE_DEVICE:
+ if (device != null && scene != null) {
+ strings = scene.getFeatures(device.getAddress()).stream().map(DeviceFeature::getName).toList();
+ }
+ break;
+ }
+
+ } else if (cursorArgumentIndex == 4) {
+ InsteonDevice device = getInsteonDevice(args[2]);
+ DeviceFeature feature = device != null ? device.getFeature(args[3]) : null;
+ switch (args[0]) {
+ case ADD_DEVICE:
+ if (feature != null) {
+ strings = OnLevel.getSupportedValues(feature.getType());
+ }
+ break;
+ }
+ } else if (cursorArgumentIndex == 5) {
+ InsteonDevice device = getInsteonDevice(args[2]);
+ DeviceFeature feature = device != null ? device.getFeature(args[3]) : null;
+ switch (args[0]) {
+ case ADD_DEVICE:
+ if (feature != null && RampRate.supportsFeatureType(feature.getType())) {
+ strings = Stream.of(RampRate.values()).map(String::valueOf).toList();
+ }
+ break;
+ }
+ }
+
+ return new StringsCompleter(strings, false).complete(args, cursorArgumentIndex, cursorPosition, candidates);
+ }
+
+ private void listAll(Console console) {
+ Map scenes = getInsteonSceneHandlers()
+ .collect(Collectors.toMap(InsteonSceneHandler::getThingId, InsteonSceneHandler::getThingInfo));
+ if (scenes.isEmpty()) {
+ console.println("No scene configured or enabled!");
+ } else {
+ console.println("There are " + scenes.size() + " scenes configured:");
+ print(console, scenes);
+ }
+ }
+
+ private void listDetails(Console console, String thingId) {
+ InsteonScene scene = getInsteonScene(thingId);
+ if (scene == null) {
+ console.println("The scene " + thingId + " is not configured or enabled!");
+ return;
+ }
+ List devices = scene.getDevices();
+ List entries = scene.getEntries().stream().map(String::valueOf).sorted().toList();
+ if (devices.isEmpty()) {
+ console.println("The scene " + scene.getGroup() + " has no associated device configured or enabled.");
+ } else {
+ console.println("The scene " + scene.getGroup() + " is currently " + scene.getState() + ". It controls "
+ + devices.size() + " devices:" + (scene.isComplete() ? "" : " (Partial)"));
+ print(console, entries);
+ }
+ }
+
+ private void addDevice(Console console, String[] args) {
+ InsteonScene scene;
+ if (NEW_OPTION.equals(args[1])) {
+ int group = getModem().getDB().getNextAvailableBroadcastGroup();
+ if (group != -1) {
+ scene = InsteonScene.makeScene(group, getModem());
+ } else {
+ console.println("Unable to create new scene, no broadcast group available!");
+ return;
+ }
+ } else {
+ scene = getInsteonScene(args[1]);
+ if (scene == null) {
+ console.println("The scene " + args[1] + " is not configured or enabled!");
+ return;
+ }
+ }
+ InsteonDevice device = getInsteonDevice(args[2]);
+ if (device == null) {
+ console.println("The device " + args[2] + " is not configured or enabled!");
+ return;
+ }
+ DeviceFeature feature = device.getFeature(args[3]);
+ if (feature == null) {
+ console.println("The device " + args[2] + " feature " + args[3] + " is not configured!");
+ return;
+ }
+ if (!feature.isResponderFeature()) {
+ console.println("The device " + args[2] + " feature " + args[3] + " is not a responder feature.");
+ return;
+ }
+ if (!device.getLinkDB().isComplete()) {
+ console.println("The link database for device " + args[2] + " is not loaded yet.");
+ return;
+ }
+ if (!device.getLinkDB().getChanges().isEmpty()) {
+ console.println("The link database for device " + args[2] + " has pending changes.");
+ return;
+ }
+ if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ return;
+ }
+ if (!getModem().getDB().getChanges().isEmpty()) {
+ console.println("The modem database has pending changes.");
+ return;
+ }
+ int onLevel = OnLevel.getHexValue(args[4], feature.getType());
+ if (onLevel == -1) {
+ console.println("The feature " + args[3] + " onLevel " + args[4] + " is not valid.");
+ return;
+ }
+ RampRate rampRate = null;
+ if (RampRate.supportsFeatureType(feature.getType())) {
+ rampRate = args.length == 6 ? RampRate.fromString(args[5]) : RampRate.DEFAULT;
+ if (rampRate == null) {
+ console.println("The feature " + args[3] + " rampRate " + args[5] + " is not valid.");
+ return;
+ }
+ }
+
+ console.println("Adding device " + device.getAddress() + " feature " + feature.getName() + " to scene "
+ + scene.getGroup() + ".");
+ scene.addDeviceFeature(device, onLevel, rampRate, feature.getComponentId());
+ }
+
+ private void removeDevice(Console console, String[] args) {
+ InsteonScene scene = getInsteonScene(args[1]);
+ if (scene == null) {
+ console.println("The scene " + args[1] + " is not configured or enabled!");
+ return;
+ }
+ InsteonDevice device = getInsteonDevice(args[2]);
+ if (device == null) {
+ console.println("The device " + args[2] + " is not configured or enabled!");
+ return;
+ }
+ DeviceFeature feature = device.getFeature(args[3]);
+ if (feature == null) {
+ console.println("The device " + args[2] + " feature " + args[3] + " is not configured!");
+ return;
+ }
+ if (!device.getLinkDB().isComplete()) {
+ console.println("The link database for device " + args[2] + " is not loaded yet.");
+ return;
+ }
+ if (!device.getLinkDB().getChanges().isEmpty()) {
+ console.println("The link database for device " + args[2] + " has pending changes.");
+ return;
+ }
+ if (!getModem().getDB().isComplete()) {
+ console.println("The modem database is not loaded yet.");
+ return;
+ }
+ if (!getModem().getDB().getChanges().isEmpty()) {
+ console.println("The modem database has pending changes.");
+ return;
+ }
+ if (!scene.hasEntry(device.getAddress(), feature.getName())) {
+ console.println(
+ "The device " + args[2] + " feature " + args[3] + " is not associated to scene" + args[1] + ".");
+ return;
+ }
+
+ console.println("Removing device " + device.getAddress() + " feature " + feature.getName() + " from scene "
+ + scene.getGroup() + ".");
+ scene.removeDeviceFeature(device, feature.getComponentId());
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonBridgeConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonBridgeConfiguration.java
new file mode 100644
index 0000000000000..ccbb7fc4418a2
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonBridgeConfiguration.java
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link InsteonBridgeConfiguration} is the base configuration for insteon bridges.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public abstract class InsteonBridgeConfiguration {
+
+ private int devicePollIntervalInSeconds = 300;
+ private boolean deviceDiscoveryEnabled = true;
+ private boolean sceneDiscoveryEnabled = false;
+ private boolean deviceSyncEnabled = false;
+
+ public int getDevicePollInterval() {
+ return devicePollIntervalInSeconds * 1000; // in milliseconds
+ }
+
+ public boolean isDeviceDiscoveryEnabled() {
+ return deviceDiscoveryEnabled;
+ }
+
+ public boolean isSceneDiscoveryEnabled() {
+ return sceneDiscoveryEnabled;
+ }
+
+ public boolean isDeviceSyncEnabled() {
+ return deviceSyncEnabled;
+ }
+
+ public abstract String getId();
+
+ @Override
+ public String toString() {
+ String s = "";
+ s += " devicePollIntervalInSeconds=" + devicePollIntervalInSeconds;
+ s += " deviceDiscoveryEnabled=" + deviceDiscoveryEnabled;
+ s += " sceneDiscoveryEnabled=" + sceneDiscoveryEnabled;
+ s += " deviceSyncEnabled=" + deviceSyncEnabled;
+ return s;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonChannelConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonChannelConfiguration.java
new file mode 100644
index 0000000000000..07397bc564d8e
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonChannelConfiguration.java
@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.RampRate;
+
+/**
+ *
+ * The {@link InsteonChannelConfiguration} is the configuration for an insteon channel.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class InsteonChannelConfiguration {
+
+ private int group = -1;
+ private int onLevel = -1;
+ private double rampRate = -1;
+ private boolean original = true;
+
+ public int getGroup() {
+ return group;
+ }
+
+ public int getOnLevel() {
+ return onLevel;
+ }
+
+ public @Nullable RampRate getRampRate() {
+ return rampRate != -1 ? RampRate.fromTime(rampRate) : null;
+ }
+
+ public boolean isOriginal() {
+ return original;
+ }
+
+ @Override
+ public String toString() {
+ String s = "";
+ if (group != -1) {
+ s += " group=" + group;
+ }
+ if (onLevel != -1) {
+ s += " onLevel=" + onLevel;
+ }
+ if (rampRate != -1) {
+ s += " rampRate=" + rampRate;
+ }
+ return s;
+ }
+
+ public static InsteonChannelConfiguration copyOf(InsteonChannelConfiguration original, int onLevel,
+ RampRate rampRate) {
+ InsteonChannelConfiguration config = new InsteonChannelConfiguration();
+ config.group = original.group;
+ config.onLevel = original.onLevel != -1 ? original.onLevel : onLevel;
+ config.rampRate = original.rampRate != -1 ? original.rampRate : rampRate.getTimeInSeconds();
+ config.original = false;
+ return config;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonDeviceConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonDeviceConfiguration.java
new file mode 100644
index 0000000000000..cafd9efd1d988
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonDeviceConfiguration.java
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link InsteonDeviceConfiguration} is the configuration for an insteon device thing.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class InsteonDeviceConfiguration {
+
+ private String address = "";
+
+ public String getAddress() {
+ return address;
+ }
+
+ @Override
+ public String toString() {
+ String s = "";
+ s += " address=" + address;
+ return s;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonHub1Configuration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonHub1Configuration.java
new file mode 100644
index 0000000000000..6a4a56d2fd5a9
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonHub1Configuration.java
@@ -0,0 +1,83 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link InsteonHub1Configuration} is the configuration for an insteon hub 1 bridge.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class InsteonHub1Configuration extends InsteonBridgeConfiguration {
+
+ private String hostname = "";
+ private int port = 9761;
+
+ public String getHostname() {
+ return hostname;
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ @Override
+ public String getId() {
+ return hostname + ":" + port;
+ }
+
+ @Override
+ public String toString() {
+ String s = "";
+ s += " hostname=" + hostname;
+ s += " port=" + port;
+ s += super.toString();
+ return s;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ InsteonHub1Configuration other = (InsteonHub1Configuration) obj;
+ return hostname.equals(other.hostname) && port == other.port;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + hostname.hashCode();
+ result = prime * result + port;
+ return result;
+ }
+
+ public static InsteonHub1Configuration valueOf(String hostname, @Nullable Integer port) {
+ InsteonHub1Configuration config = new InsteonHub1Configuration();
+ config.hostname = hostname;
+ if (port != null) {
+ config.port = port;
+ }
+ return config;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonHub2Configuration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonHub2Configuration.java
new file mode 100644
index 0000000000000..ceb71077ad7b6
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonHub2Configuration.java
@@ -0,0 +1,107 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link InsteonHub2Configuration} is the configuration for an insteon hub 2 bridge.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class InsteonHub2Configuration extends InsteonBridgeConfiguration {
+
+ private String hostname = "";
+ private int port = 25105;
+ private String username = "";
+ private String password = "";
+ private int hubPollIntervalInMilliseconds = 1000;
+
+ public String getHostname() {
+ return hostname;
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public int getHubPollInterval() {
+ return hubPollIntervalInMilliseconds;
+ }
+
+ @Override
+ public String getId() {
+ return hostname + ":" + port;
+ }
+
+ @Override
+ public String toString() {
+ String s = "";
+ s += " hostname=" + hostname;
+ s += " port=" + port;
+ s += " username=" + username;
+ s += " password=" + "*".repeat(password.length());
+ s += " hubPollIntervalInMilliseconds=" + hubPollIntervalInMilliseconds;
+ s += super.toString();
+ return s;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ InsteonHub2Configuration other = (InsteonHub2Configuration) obj;
+ return hostname.equals(other.hostname) && port == other.port;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + hostname.hashCode();
+ result = prime * result + port;
+ return result;
+ }
+
+ public static InsteonHub2Configuration valueOf(String hostname, @Nullable Integer port, String username,
+ String password, @Nullable Integer hubPollIntervalInMilliseconds) {
+ InsteonHub2Configuration config = new InsteonHub2Configuration();
+ config.hostname = hostname;
+ if (port != null) {
+ config.port = port;
+ }
+ config.username = username;
+ config.password = password;
+ if (hubPollIntervalInMilliseconds != null) {
+ config.hubPollIntervalInMilliseconds = hubPollIntervalInMilliseconds;
+ }
+ return config;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyChannelConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyChannelConfiguration.java
index 4f687f1eada5f..eed8f747afb59 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyChannelConfiguration.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyChannelConfiguration.java
@@ -15,7 +15,8 @@
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.DeviceAddress;
import org.openhab.core.thing.ChannelUID;
/**
@@ -23,18 +24,19 @@
* This file contains config information needed for each channel
*
* @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
*/
@NonNullByDefault
public class InsteonLegacyChannelConfiguration {
private final ChannelUID channelUID;
private final String channelName;
- private final InsteonAddress address;
+ private final DeviceAddress address;
private final String feature;
private final String productKey;
private final Map parameters;
- public InsteonLegacyChannelConfiguration(ChannelUID channelUID, String feature, InsteonAddress address,
+ public InsteonLegacyChannelConfiguration(ChannelUID channelUID, String feature, DeviceAddress address,
String productKey, Map parameters) {
this.channelUID = channelUID;
this.feature = feature;
@@ -53,7 +55,7 @@ public String getChannelName() {
return channelName;
}
- public InsteonAddress getAddress() {
+ public DeviceAddress getAddress() {
return address;
}
@@ -68,4 +70,8 @@ public String getProductKey() {
public Map getParameters() {
return parameters;
}
+
+ public @Nullable String getParameter(String key) {
+ return parameters.get(key);
+ }
}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyDeviceConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyDeviceConfiguration.java
index 4a3b45b4f501c..b8deb23b9cb59 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyDeviceConfiguration.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyDeviceConfiguration.java
@@ -23,13 +23,8 @@
@NonNullByDefault
public class InsteonLegacyDeviceConfiguration {
- // required parameter
private String address = "";
-
- // required parameter
private String productKey = "";
-
- // optional parameter
private @Nullable String deviceConfig;
public String getAddress() {
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyNetworkConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyNetworkConfiguration.java
index 90ad83e9b90ca..96f03d525bd56 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyNetworkConfiguration.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyNetworkConfiguration.java
@@ -12,6 +12,10 @@
*/
package org.openhab.binding.insteon.internal.config;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@@ -19,23 +23,30 @@
* The {@link InsteonLegacyNetworkConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
*/
@NonNullByDefault
public class InsteonLegacyNetworkConfiguration {
+ private static final Pattern HUB1_PORT_PATTERN = Pattern
+ .compile("/(?:hub|tcp)/(?[^:]+)(?::(?\\d+))?");
+ private static final Pattern HUB2_PORT_PATTERN = Pattern.compile(
+ "/hub2/(?[^:]+):(?[^@]+)@(?[^:,]+)(?::(?\\d+))?(?:,poll_time=(?\\d+))?");
+ private static final Pattern PLM_PORT_PATTERN = Pattern
+ .compile("(?[^,]+)(?:,baudRate=(?\\d+))?");
- // required parameter
private String port = "";
-
private @Nullable Integer devicePollIntervalSeconds;
-
private @Nullable String additionalDevices;
-
private @Nullable String additionalFeatures;
public String getPort() {
return port;
}
+ public String getRedactedPort() {
+ return port.startsWith("/hub2/") ? port.replaceAll(":\\w+@", ":******@") : port;
+ }
+
public @Nullable Integer getDevicePollIntervalSeconds() {
return devicePollIntervalSeconds;
}
@@ -47,4 +58,50 @@ public String getPort() {
public @Nullable String getAdditionalFeatures() {
return additionalFeatures;
}
+
+ public boolean isParsable() {
+ try {
+ parse();
+ return true;
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ public InsteonBridgeConfiguration parse() {
+ Matcher hub1PortMatcher = HUB1_PORT_PATTERN.matcher(port);
+ if (hub1PortMatcher.matches()) {
+ return getHub1Config(hub1PortMatcher);
+ }
+ Matcher hub2PortMatcher = HUB2_PORT_PATTERN.matcher(port);
+ if (hub2PortMatcher.matches()) {
+ return getHub2Config(hub2PortMatcher);
+ }
+ Matcher plmPortMatcher = PLM_PORT_PATTERN.matcher(port);
+ if (plmPortMatcher.matches()) {
+ return getPLMConfig(plmPortMatcher);
+ }
+ throw new IllegalArgumentException("unable to parse bridge port parameter");
+ }
+
+ private InsteonHub1Configuration getHub1Config(Matcher matcher) {
+ String hostname = matcher.group("hostname");
+ Integer port = Optional.ofNullable(matcher.group("port")).map(Integer::parseInt).orElse(null);
+ return InsteonHub1Configuration.valueOf(hostname, port);
+ }
+
+ private InsteonHub2Configuration getHub2Config(Matcher matcher) {
+ String hostname = matcher.group("hostname");
+ Integer port = Optional.ofNullable(matcher.group("port")).map(Integer::parseInt).orElse(null);
+ String username = matcher.group("username");
+ String password = matcher.group("password");
+ Integer pollInterval = Optional.ofNullable(matcher.group("pollInterval")).map(Integer::parseInt).orElse(null);
+ return InsteonHub2Configuration.valueOf(hostname, port, username, password, pollInterval);
+ }
+
+ private InsteonPLMConfiguration getPLMConfig(Matcher matcher) {
+ String serialPort = matcher.group("serialPort");
+ Integer baudRate = Optional.ofNullable(matcher.group("baudRate")).map(Integer::parseInt).orElse(null);
+ return InsteonPLMConfiguration.valueOf(serialPort, baudRate);
+ }
}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonPLMConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonPLMConfiguration.java
new file mode 100644
index 0000000000000..497e3db4917a0
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonPLMConfiguration.java
@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link InsteonPLMConfiguration} is the configuration for an insteon plm bridge.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class InsteonPLMConfiguration extends InsteonBridgeConfiguration {
+
+ private String serialPort = "";
+ private int baudRate = 19200;
+
+ public String getSerialPort() {
+ return serialPort;
+ }
+
+ public int getBaudRate() {
+ return baudRate;
+ }
+
+ @Override
+ public String getId() {
+ return serialPort;
+ }
+
+ @Override
+ public String toString() {
+ String s = "";
+ s += " serialPort=" + serialPort;
+ s += " baudRate=" + baudRate;
+ s += super.toString();
+ return s;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ InsteonPLMConfiguration other = (InsteonPLMConfiguration) obj;
+ return serialPort.equals(other.serialPort);
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + serialPort.hashCode();
+ return result;
+ }
+
+ public static InsteonPLMConfiguration valueOf(String serialPort, @Nullable Integer baudRate) {
+ InsteonPLMConfiguration config = new InsteonPLMConfiguration();
+ config.serialPort = serialPort;
+ if (baudRate != null) {
+ config.baudRate = baudRate;
+ }
+ return config;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonSceneConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonSceneConfiguration.java
new file mode 100644
index 0000000000000..d3e6c82ffee0f
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonSceneConfiguration.java
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link InsteonSceneConfiguration} is the configuration for an insteon scene thing.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class InsteonSceneConfiguration {
+
+ private int group = -1;
+
+ public int getGroup() {
+ return group;
+ }
+
+ @Override
+ public String toString() {
+ String s = "";
+ s += " group=" + group;
+ return s;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/X10DeviceConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/X10DeviceConfiguration.java
new file mode 100644
index 0000000000000..9211179174a16
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/X10DeviceConfiguration.java
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link X10DeviceConfiguration} is the configuration for an x10 device thing.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class X10DeviceConfiguration {
+
+ private String houseCode = "";
+ private int unitCode = 0;
+ private String deviceType = "";
+
+ public String getHouseCode() {
+ return houseCode;
+ }
+
+ public int getUnitCode() {
+ return unitCode;
+ }
+
+ public String getAddress() {
+ return houseCode + "." + unitCode;
+ }
+
+ public String getDeviceType() {
+ return deviceType;
+ }
+
+ @Override
+ public String toString() {
+ String s = "";
+ s += " houseCode=" + houseCode;
+ s += " unitCode=" + unitCode;
+ s += " deviceType=" + deviceType;
+ return s;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/DatabaseChange.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/DatabaseChange.java
new file mode 100644
index 0000000000000..6130b5398cbef
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/DatabaseChange.java
@@ -0,0 +1,77 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link DatabaseChange} holds a link database change
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public abstract class DatabaseChange<@NonNull T extends DatabaseRecord> {
+
+ protected static enum ChangeType {
+ ADD,
+ MODIFY,
+ DELETE
+ }
+
+ protected T record;
+ protected ChangeType type;
+
+ public DatabaseChange(T record, ChangeType type) {
+ this.record = record;
+ this.type = type;
+ }
+
+ public T getRecord() {
+ return record;
+ }
+
+ public boolean isDelete() {
+ return type == ChangeType.DELETE;
+ }
+
+ @Override
+ public String toString() {
+ return record + " (" + type + ")";
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ DatabaseChange> other = (DatabaseChange>) obj;
+ return record.equals(other.record) && type == other.type;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + record.hashCode();
+ result = prime * result + type.hashCode();
+ return result;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/DatabaseRecord.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/DatabaseRecord.java
new file mode 100644
index 0000000000000..e72f91d80e3bb
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/DatabaseRecord.java
@@ -0,0 +1,154 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.util.Arrays;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+
+/**
+ * The {@link DatabaseRecord} holds a link database record
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class DatabaseRecord {
+ public static final int LOCATION_ZERO = 0;
+
+ private final int location;
+ private final RecordType type;
+ private final int group;
+ private final InsteonAddress address;
+ private final byte[] data;
+
+ public DatabaseRecord(int location, RecordType type, int group, InsteonAddress address, byte[] data) {
+ this.location = location;
+ this.type = type;
+ this.group = group;
+ this.address = address;
+ this.data = data;
+ }
+
+ public DatabaseRecord(DatabaseRecord record) {
+ this.location = record.location;
+ this.type = record.type;
+ this.group = record.group;
+ this.address = record.address;
+ this.data = record.data;
+ }
+
+ public int getLocation() {
+ return location;
+ }
+
+ public RecordType getType() {
+ return type;
+ }
+
+ public int getFlags() {
+ return type.getFlags();
+ }
+
+ public int getGroup() {
+ return group;
+ }
+
+ public InsteonAddress getAddress() {
+ return address;
+ }
+
+ public byte[] getData() {
+ return data;
+ }
+
+ public int getData1() {
+ return Byte.toUnsignedInt(data[0]);
+ }
+
+ public int getData2() {
+ return Byte.toUnsignedInt(data[1]);
+ }
+
+ public int getData3() {
+ return Byte.toUnsignedInt(data[2]);
+ }
+
+ public boolean isController() {
+ return type.isController();
+ }
+
+ public boolean isResponder() {
+ return type.isResponder();
+ }
+
+ public boolean isActive() {
+ return type.isActive();
+ }
+
+ public boolean isAvailable() {
+ return !type.isActive();
+ }
+
+ public boolean isLast() {
+ return type.isHighWaterMark();
+ }
+
+ public byte[] getBytes() {
+ return new byte[] { (byte) type.getFlags(), (byte) group, address.getHighByte(), address.getMiddleByte(),
+ address.getLowByte(), data[0], data[1], data[2] };
+ }
+
+ @Override
+ public String toString() {
+ String s = "";
+ if (location != LOCATION_ZERO) {
+ s += HexUtils.getHexString(location, 4) + " ";
+ }
+ s += address + " " + type;
+ s += " group: " + HexUtils.getHexString(group);
+ s += " data1: " + HexUtils.getHexString(data[0]);
+ s += " data2: " + HexUtils.getHexString(data[1]);
+ s += " data3: " + HexUtils.getHexString(data[2]);
+ return s;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ DatabaseRecord other = (DatabaseRecord) obj;
+ return group == other.group && address.equals(other.address) && type.equals(other.type)
+ && Arrays.equals(data, other.data);
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + group;
+ result = prime * result + address.hashCode();
+ result = prime * result + type.hashCode();
+ result = prime * result + Arrays.hashCode(data);
+ return result;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LegacyModemDBBuilder.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LegacyModemDBBuilder.java
index e3011b0e2f4f5..608a92787ab39 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LegacyModemDBBuilder.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LegacyModemDBBuilder.java
@@ -28,7 +28,7 @@
import org.openhab.binding.insteon.internal.transport.message.FieldException;
import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
import org.openhab.binding.insteon.internal.transport.message.Msg;
-import org.openhab.binding.insteon.internal.utils.Utils;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -37,6 +37,7 @@
*
* @author Bernd Pfrommer - Initial contribution
* @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
*/
@NonNullByDefault
public class LegacyModemDBBuilder implements LegacyMsgListener {
@@ -63,12 +64,7 @@ public void start() {
startDownload();
job = scheduler.scheduleWithFixedDelay(() -> {
if (isComplete()) {
- logger.trace("modem db builder finished");
- ScheduledFuture> job = this.job;
- if (job != null) {
- job.cancel(false);
- }
- this.job = null;
+ stop();
} else {
if (System.currentTimeMillis() - lastMessageTimestamp > MESSAGE_TIMEOUT) {
String s = "";
@@ -93,10 +89,23 @@ private void startDownload() {
getFirstLinkRecord();
}
+ public void stop() {
+ logger.trace("modem db builder finished");
+ ScheduledFuture> job = this.job;
+ if (job != null) {
+ job.cancel(true);
+ this.job = null;
+ }
+ }
+
public boolean isComplete() {
return isComplete;
}
+ public boolean isRunning() {
+ return job != null;
+ }
+
private void getFirstLinkRecord() {
try {
port.writeMessage(Msg.makeMessage("GetFirstALLLinkRecord"));
@@ -131,7 +140,7 @@ public void msg(Msg msg) {
}
} else if (msg.getByte("Cmd") == 0x57) {
// we got the link record response
- updateModemDB(msg.getAddress("LinkAddr"), port, msg, false);
+ updateModemDB(msg.getInsteonAddress("LinkAddr"), port, msg, false);
port.writeMessage(Msg.makeMessage("GetNextALLLinkRecord"));
}
} catch (FieldException e) {
@@ -176,7 +185,7 @@ private void logModemDB() {
}
public static String toHex(byte b) {
- return Utils.getHexString(b);
+ return HexUtils.getHexString(b);
}
public void updateModemDB(InsteonAddress linkAddr, LegacyPort port, @Nullable Msg m, boolean isModem) {
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LegacyModemDBEntry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LegacyModemDBEntry.java
index b2a20130a1120..40a3ded1a8b26 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LegacyModemDBEntry.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LegacyModemDBEntry.java
@@ -20,7 +20,7 @@
import org.openhab.binding.insteon.internal.device.InsteonAddress;
import org.openhab.binding.insteon.internal.transport.LegacyPort;
import org.openhab.binding.insteon.internal.transport.message.Msg;
-import org.openhab.binding.insteon.internal.utils.Utils;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
/**
* The ModemDBEntry class holds a modem device type record
@@ -99,7 +99,7 @@ private String toGroupString(ArrayList group) {
buf.append(",");
}
buf.append("0x");
- buf.append(Utils.getHexString(b));
+ buf.append(HexUtils.getHexString(b));
}
return buf.toString();
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDB.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDB.java
new file mode 100644
index 0000000000000..3503ecbd6c1c8
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDB.java
@@ -0,0 +1,630 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.InsteonDevice;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.device.InsteonScene;
+import org.openhab.binding.insteon.internal.manager.DatabaseManager;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link LinkDB} holds all-link database records for a device
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class LinkDB {
+ public static final int RECORD_BYTE_SIZE = 8;
+
+ private static enum DatabaseStatus {
+ EMPTY,
+ COMPLETE,
+ PARTIAL,
+ LOADING
+ }
+
+ public static enum ReadWriteMode {
+ STANDARD,
+ PEEK_POKE,
+ UNKNOWN
+ }
+
+ private final Logger logger = LoggerFactory.getLogger(LinkDB.class);
+
+ private InsteonDevice device;
+ private TreeMap records = new TreeMap<>(Collections.reverseOrder());
+ private TreeMap changes = new TreeMap<>(Collections.reverseOrder());
+ private DatabaseStatus status = DatabaseStatus.EMPTY;
+ private int delta = -1;
+ private int firstLocation = 0x0FFF;
+ private boolean reload = false;
+ private boolean update = false;
+
+ public LinkDB(InsteonDevice device) {
+ this.device = device;
+ }
+
+ private @Nullable InsteonModem getModem() {
+ return device.getModem();
+ }
+
+ public @Nullable DatabaseManager getDatabaseManager() {
+ return Optional.ofNullable(getModem()).map(InsteonModem::getDBM).orElse(null);
+ }
+
+ public int getDatabaseDelta() {
+ return delta;
+ }
+
+ public int getFirstRecordLocation() {
+ return firstLocation;
+ }
+
+ public int getLastRecordLocation() {
+ synchronized (records) {
+ return records.isEmpty() ? getFirstRecordLocation() : records.lastKey();
+ }
+ }
+
+ public @Nullable LinkDBRecord getFirstRecord() {
+ synchronized (records) {
+ return records.isEmpty() ? null : records.firstEntry().getValue();
+ }
+ }
+
+ public int getFirstRecordComponentId() {
+ return Optional.ofNullable(getFirstRecord()).map(LinkDBRecord::getComponentId).orElse(0);
+ }
+
+ public @Nullable LinkDBRecord getRecord(int location) {
+ synchronized (records) {
+ return records.get(location);
+ }
+ }
+
+ public List getRecords() {
+ synchronized (records) {
+ return records.values().stream().toList();
+ }
+ }
+
+ private Stream getRecords(@Nullable InsteonAddress address, @Nullable Integer group,
+ @Nullable Boolean isController, @Nullable Boolean isActive, @Nullable Integer componentId) {
+ return getRecords().stream()
+ .filter(record -> (address == null || record.getAddress().equals(address))
+ && (group == null || record.getGroup() == group)
+ && (isController == null || record.isController() == isController)
+ && (isActive == null || record.isActive() == isActive)
+ && (componentId == null || record.getComponentId() == componentId));
+ }
+
+ public List getControllerRecords() {
+ return getRecords(null, null, true, true, null).toList();
+ }
+
+ public List getControllerRecords(InsteonAddress address) {
+ return getRecords(address, null, true, true, null).toList();
+ }
+
+ public List getControllerRecords(InsteonAddress address, int group) {
+ return getRecords(address, group, true, true, null).toList();
+ }
+
+ public List getResponderRecords() {
+ return getRecords(null, null, false, true, null).toList();
+ }
+
+ public List getResponderRecords(InsteonAddress address) {
+ return getRecords(address, null, false, true, null).toList();
+ }
+
+ public List getResponderRecords(InsteonAddress address, int group) {
+ return getRecords(address, group, false, true, null).toList();
+ }
+
+ public @Nullable LinkDBRecord getActiveRecord(InsteonAddress address, int group, boolean isController,
+ int componentId) {
+ return getRecords(address, group, isController, true, componentId).findFirst().orElse(null);
+ }
+
+ public boolean hasRecord(@Nullable InsteonAddress address, @Nullable Integer group, @Nullable Boolean isController,
+ @Nullable Boolean isActive, @Nullable Integer componentId) {
+ return getRecords(address, group, isController, isActive, componentId).findAny().isPresent();
+ }
+
+ public boolean hasComponentIdRecord(int componentId, boolean isController) {
+ return getRecords(null, null, isController, true, componentId).findAny().isPresent();
+ }
+
+ public boolean hasGroupRecord(int group, boolean isController) {
+ return getRecords(null, group, isController, true, null).findAny().isPresent();
+ }
+
+ public int size() {
+ return getRecords().size();
+ }
+
+ public int getLastChangeLocation() {
+ synchronized (changes) {
+ return changes.isEmpty() ? getFirstRecordLocation() : changes.lastKey();
+ }
+ }
+
+ public List getChanges() {
+ synchronized (changes) {
+ return changes.values().stream().toList();
+ }
+ }
+
+ private Stream getChanges(@Nullable InsteonAddress address, @Nullable Integer group,
+ @Nullable Boolean isController, @Nullable Integer componentId) {
+ return getChanges().stream()
+ .filter(changes -> (address == null || changes.getRecord().getAddress().equals(address))
+ && (group == null || changes.getRecord().getGroup() == group)
+ && (isController == null || changes.getRecord().isController() == isController)
+ && (componentId == null || changes.getRecord().getComponentId() == componentId));
+ }
+
+ public @Nullable LinkDBChange getChange(InsteonAddress address, int group, boolean isController, int componentId) {
+ return getChanges(address, group, isController, componentId).findFirst().orElse(null);
+ }
+
+ public @Nullable LinkDBChange pollNextChange() {
+ synchronized (changes) {
+ return Optional.ofNullable(changes.pollFirstEntry()).map(Entry::getValue).orElse(null);
+ }
+ }
+
+ public boolean isComplete() {
+ return status == DatabaseStatus.COMPLETE;
+ }
+
+ public boolean shouldReload() {
+ return reload;
+ }
+
+ public boolean shouldUpdate() {
+ return update;
+ }
+
+ public synchronized void setDatabaseDelta(int delta) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("setting link db delta to {} for {}", delta, device.getAddress());
+ }
+ this.delta = delta;
+ }
+
+ public synchronized void setFirstRecordLocation(int firstLocation) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("setting link db first record location to {} for {}", HexUtils.getHexString(firstLocation),
+ device.getAddress());
+ }
+ this.firstLocation = firstLocation;
+ }
+
+ public synchronized void setReload(boolean reload) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("setting link db reload to {} for {}", reload, device.getAddress());
+ }
+ this.reload = reload;
+ }
+
+ private synchronized void setUpdate(boolean update) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("setting link db update to {} for {}", update, device.getAddress());
+ }
+ this.update = update;
+ }
+
+ private synchronized void setStatus(DatabaseStatus status) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("setting link db status to {} for {}", status, device.getAddress());
+ }
+ this.status = status;
+ }
+
+ /**
+ * Returns a change location for a given address, group, controller flag and component id
+ *
+ * @param address the record address
+ * @param group the record group
+ * @param isController if is controller record
+ * @param componentId the record componentId
+ * @return change location if found, otherwise next available location
+ */
+ public int getChangeLocation(InsteonAddress address, int group, boolean isController, int componentId) {
+ LinkDBChange change = getChange(address, group, isController, componentId);
+ return change != null ? change.getLocation() : getNextAvailableLocation();
+ }
+
+ /**
+ * Returns next available record location
+ *
+ * @return first available record location if found, otherwise the next lowest record or change location
+ */
+ public int getNextAvailableLocation() {
+ return getRecords().stream().filter(LinkDBRecord::isAvailable).map(LinkDBRecord::getLocation).findFirst()
+ .orElse(Math.min(getLastRecordLocation(), getLastChangeLocation() - RECORD_BYTE_SIZE));
+ }
+
+ /**
+ * Returns database read/write mode
+ *
+ * @return read/write mode based on device insteon engine
+ */
+ public ReadWriteMode getReadWriteMode() {
+ switch (device.getInsteonEngine()) {
+ case I1:
+ case I2:
+ return ReadWriteMode.PEEK_POKE;
+ case I2CS:
+ return ReadWriteMode.STANDARD;
+ default:
+ return ReadWriteMode.UNKNOWN;
+ }
+ }
+
+ /**
+ * Clears this link db
+ */
+ public synchronized void clear() {
+ if (logger.isDebugEnabled()) {
+ logger.debug("clearing link db for {}", device.getAddress());
+ }
+ records.clear();
+ changes.clear();
+ status = DatabaseStatus.EMPTY;
+ delta = -1;
+ reload = false;
+ update = false;
+ }
+
+ /**
+ * Loads this link db
+ */
+ public void load() {
+ load(0L);
+ }
+
+ /**
+ * Loads this link db with a delay
+ *
+ * @param delay reading delay (in milliseconds)
+ */
+ public void load(long delay) {
+ DatabaseManager dbm = getDatabaseManager();
+ if (!device.isAwake() || !device.isResponding()) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("deferring load link db for {}, device is not awake or responding", device.getAddress());
+ }
+ setReload(true);
+ } else if (dbm == null) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("unable to load link db for {}, database manager not available", device.getAddress());
+ }
+ } else {
+ clear();
+ setStatus(DatabaseStatus.LOADING);
+ dbm.read(device, delay);
+ }
+ }
+
+ /**
+ * Updates this link db with changes
+ */
+ public void update() {
+ update(0L);
+ }
+
+ /**
+ * Updates this link db with changes and a delay
+ *
+ * @param delay writing delay (in milliseconds)
+ */
+ public void update(long delay) {
+ DatabaseManager dbm = getDatabaseManager();
+ if (getChanges().isEmpty()) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("no changes to update link db for {}", device.getAddress());
+ }
+ setUpdate(false);
+ } else if (!device.isAwake() || !device.isResponding()) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("deferring update link db for {}, device is not awake or responding", device.getAddress());
+ }
+ setUpdate(true);
+ } else if (dbm == null) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("unable to update link db for {}, database manager not available", device.getAddress());
+ }
+ } else {
+ dbm.write(device, delay);
+ }
+ }
+
+ /**
+ * Adds a link db record
+ *
+ * @param record the record to add
+ * @return the previous record if overwritten
+ */
+ public @Nullable LinkDBRecord addRecord(LinkDBRecord record) {
+ synchronized (records) {
+ LinkDBRecord prevRecord = records.put(record.getLocation(), record);
+ // move last record if overwritten
+ if (prevRecord != null && prevRecord.isLast()) {
+ int location = prevRecord.getLocation() - RECORD_BYTE_SIZE;
+ records.put(location, LinkDBRecord.withNewLocation(location, prevRecord));
+ if (logger.isTraceEnabled()) {
+ logger.trace("moved last record for {} to location {}", device.getAddress(),
+ HexUtils.getHexString(location));
+ }
+ }
+ return prevRecord;
+ }
+ }
+
+ /**
+ * Loads a list of link db records
+ *
+ * @param records list of records to load
+ */
+ public void loadRecords(List records) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("loading link db records for {}", device.getAddress());
+ }
+ records.forEach(this::addRecord);
+ recordsLoaded();
+ }
+
+ /**
+ * Logs the link db records
+ */
+ private void logRecords() {
+ if (logger.isDebugEnabled()) {
+ if (getRecords().isEmpty()) {
+ logger.debug("no link records found for {}", device.getAddress());
+ } else {
+ logger.debug("---------------- start of link records for {} ----------------", device.getAddress());
+ getRecords().stream().map(String::valueOf).forEach(logger::debug);
+ logger.debug("----------------- end of link records for {} -----------------", device.getAddress());
+ }
+ }
+ }
+
+ /**
+ * Notifies that the link db records have been loaded
+ */
+ public void recordsLoaded() {
+ logRecords();
+ updateStatus();
+ device.linkDBUpdated();
+ }
+
+ /**
+ * Clears the link db changes
+ */
+ public void clearChanges() {
+ if (logger.isDebugEnabled()) {
+ logger.debug("clearing link db changes for {}", device.getAddress());
+ }
+ synchronized (changes) {
+ changes.clear();
+ }
+ }
+
+ /**
+ * Adds a link db change
+ *
+ * @param change the change to add
+ */
+ public void addChange(LinkDBChange change) {
+ synchronized (changes) {
+ LinkDBChange prevChange = changes.put(change.getLocation(), change);
+ if (prevChange == null) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("added change: {}", change);
+ }
+ } else {
+ if (logger.isTraceEnabled()) {
+ logger.trace("modified change from: {} to: {}", prevChange, change);
+ }
+ }
+ }
+ }
+
+ /**
+ * Marks a link db record to be added
+ *
+ * @param address the record address to use
+ * @param group the record group to use
+ * @param isController if is controller record
+ * @param data the record data to use
+ */
+ public void markRecordForAdd(InsteonAddress address, int group, boolean isController, byte[] data) {
+ int location = getChangeLocation(address, group, isController, data[2]);
+ addChange(LinkDBChange.forAdd(location, address, group, isController, data));
+ }
+
+ /**
+ * Marks a link db record to be modified
+ *
+ * @param record the record to modify
+ * @param data the record data to use
+ */
+ public void markRecordForModify(LinkDBRecord record, byte[] data) {
+ addChange(LinkDBChange.forModify(record, data));
+ }
+
+ /**
+ * Marks a link db record to be added or modified
+ *
+ * @param address the record address to use
+ * @param group the record group to use
+ * @param isController if is controller record
+ * @param data the record data to use
+ */
+ public void markRecordForAddOrModify(InsteonAddress address, int group, boolean isController, byte[] data) {
+ LinkDBRecord record = getActiveRecord(address, group, isController, data[2]);
+ if (record == null) {
+ markRecordForAdd(address, group, isController, data);
+ } else {
+ markRecordForModify(record, data);
+ }
+ }
+
+ /**
+ * Marks a link db record to be deleted
+ *
+ * @param record the record to delete
+ */
+ public void markRecordForDelete(LinkDBRecord record) {
+ if (record.isAvailable()) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("ignoring already deleted record: {}", record);
+ }
+ return;
+ }
+ addChange(LinkDBChange.forDelete(record));
+ }
+
+ /**
+ * Marks a link db record to be deleted
+ *
+ * @param address the record address to use
+ * @param group the record group to use
+ * @param isController if is controller record
+ * @param componentId the record component id to use
+ */
+ public void markRecordForDelete(InsteonAddress address, int group, boolean isController, int componentId) {
+ LinkDBRecord record = getActiveRecord(address, group, isController, componentId);
+ if (record == null) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("no active record found for {} group:{} isController:{} componentId:{}", address, group,
+ isController, HexUtils.getHexString(componentId));
+ }
+ return;
+ }
+ markRecordForDelete(record);
+ }
+
+ /**
+ * Updates link database delta
+ *
+ * @param newDelta the database delta to update to
+ */
+ public void updateDatabaseDelta(int newDelta) {
+ int oldDelta = getDatabaseDelta();
+ // ignore delta if not defined or equal to old one
+ if (newDelta == -1 || oldDelta == newDelta) {
+ return;
+ }
+ // set database delta
+ setDatabaseDelta(newDelta);
+ // set db to reload if old delta defined and less than new one
+ if (oldDelta != -1 && oldDelta < newDelta) {
+ setReload(true);
+ }
+ }
+
+ /**
+ * Updates link database status
+ */
+ public synchronized void updateStatus() {
+ if (records.isEmpty()) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("no link db records for {}", device.getAddress());
+ }
+ setStatus(DatabaseStatus.EMPTY);
+ return;
+ }
+
+ int firstLocation = records.firstKey();
+ int lastLocation = records.lastKey();
+ int expected = (firstLocation - lastLocation) / RECORD_BYTE_SIZE + 1;
+ if (firstLocation != getFirstRecordLocation()) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("got unexpected first record location for {}", device.getAddress());
+ }
+ setStatus(DatabaseStatus.PARTIAL);
+ } else if (!records.lastEntry().getValue().isLast()) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("got unexpected last record type for {}", device.getAddress());
+ }
+ setStatus(DatabaseStatus.PARTIAL);
+ } else if (records.size() != expected) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("got {} records for {} expected {}", records.size(), device.getAddress(), expected);
+ }
+ setStatus(DatabaseStatus.PARTIAL);
+ } else {
+ if (logger.isDebugEnabled()) {
+ logger.debug("got complete link db records ({}) for {} ", records.size(), device.getAddress());
+ }
+ setStatus(DatabaseStatus.COMPLETE);
+ }
+ }
+
+ /**
+ * Returns broadcast group for a given component id
+ *
+ * @param componentId the record data3 field
+ * @return list of the broadcast groups
+ */
+ public List getBroadcastGroups(int componentId) {
+ List groups = List.of();
+ InsteonModem modem = getModem();
+ if (modem != null) {
+ // unique groups from modem responder records matching component id and on level > 0
+ groups = getRecords().stream()
+ .filter(record -> record.isActive() && record.isResponder()
+ && record.getAddress().equals(modem.getAddress()) && record.getComponentId() == componentId
+ && record.getOnLevel() > 0)
+ .map(LinkDBRecord::getGroup).filter(InsteonScene::isValidGroup).map(Integer::valueOf).distinct()
+ .toList();
+ }
+ return groups;
+ }
+
+ /**
+ * Returns a list of related devices for a given group
+ *
+ * @param group the record group
+ * @return list of related device addresses
+ */
+ public List getRelatedDevices(int group) {
+ List devices = List.of();
+ InsteonModem modem = getModem();
+ if (modem != null) {
+ // unique addresses from controller records matching group and is in modem database
+ devices = getRecords().stream()
+ .filter(record -> record.isActive() && record.isController() && record.getGroup() == group
+ && modem.getDB().hasEntry(record.getAddress()))
+ .map(LinkDBRecord::getAddress).distinct().toList();
+ }
+ return devices;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBChange.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBChange.java
new file mode 100644
index 0000000000000..0aaedaf24b90b
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBChange.java
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+
+/**
+ * The {@link DatabaseChange} holds a link database change for a device
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class LinkDBChange extends DatabaseChange {
+
+ public LinkDBChange(LinkDBRecord record, ChangeType type) {
+ super(record, type);
+ }
+
+ public int getLocation() {
+ return record.getLocation();
+ }
+
+ @Override
+ public LinkDBRecord getRecord() {
+ return type == ChangeType.DELETE ? LinkDBRecord.asInactive(record) : record;
+ }
+
+ /**
+ * Factory method for creating a new LinkDBChange for add
+ *
+ * @param location the record location to use
+ * @param address the record address to use
+ * @param group the record group to use
+ * @param isController if is controller record
+ * @param data the record data to use
+ * @return the link db change
+ */
+ public static LinkDBChange forAdd(int location, InsteonAddress address, int group, boolean isController,
+ byte[] data) {
+ return new LinkDBChange(LinkDBRecord.create(location, address, group, isController, data), ChangeType.ADD);
+ }
+
+ /**
+ * Factory method for creating a new LinkDBChange for add
+ *
+ * @param record the record to add
+ * @return the link db change
+ */
+ public static LinkDBChange forAdd(LinkDBRecord record) {
+ return new LinkDBChange(record, ChangeType.ADD);
+ }
+
+ /**
+ * Factory method for creating a new LinkDBChange for modify
+ *
+ * @param record the record to modify
+ * @param data the data record to use
+ * @return the link db change
+ */
+ public static LinkDBChange forModify(LinkDBRecord record, byte[] data) {
+ return new LinkDBChange(LinkDBRecord.withNewData(data, record), ChangeType.MODIFY);
+ }
+
+ /**
+ * Factory method for creating a new LinkDBChange for delete
+ *
+ * @param record the record to delete
+ * @return the link db change
+ */
+ public static LinkDBChange forDelete(LinkDBRecord record) {
+ return new LinkDBChange(record, ChangeType.DELETE);
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBReader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBReader.java
new file mode 100644
index 0000000000000..a2909eb9e0244
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBReader.java
@@ -0,0 +1,259 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.InsteonDevice;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.listener.PortListener;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link LinkDBReader} manages all-link database read requests
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class LinkDBReader implements PortListener {
+ private static final int MESSAGE_TIMEOUT = 4000; // in milliseconds
+
+ private final Logger logger = LoggerFactory.getLogger(LinkDBReader.class);
+
+ private InsteonDevice device = new InsteonDevice();
+ private InsteonModem modem;
+ private ScheduledExecutorService scheduler;
+ private @Nullable ScheduledFuture> job;
+ private ByteArrayOutputStream stream = new ByteArrayOutputStream();
+ private boolean done;
+ private long lastMsgReceived;
+ private int location;
+ private int lastMSB;
+
+ public LinkDBReader(InsteonModem modem, ScheduledExecutorService scheduler) {
+ this.modem = modem;
+ this.scheduler = scheduler;
+ }
+
+ public boolean isRunning() {
+ return job != null;
+ }
+
+ public void read(InsteonDevice device) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("starting link database reader for {}", device.getAddress());
+ }
+
+ this.device = device;
+
+ getAllRecords();
+
+ job = scheduler.scheduleWithFixedDelay(() -> {
+ if (System.currentTimeMillis() - lastMsgReceived > MESSAGE_TIMEOUT) {
+ logger.debug("link database reader timed out for {}, aborting", device.getAddress());
+ done();
+ }
+ }, 0, 1000, TimeUnit.MILLISECONDS);
+ }
+
+ private void getAllRecords() {
+ lastMsgReceived = System.currentTimeMillis();
+ done = false;
+
+ modem.getPort().registerListener(this);
+
+ switch (device.getLinkDB().getReadWriteMode()) {
+ case STANDARD:
+ getAllLinkRecords();
+ break;
+ case PEEK_POKE:
+ getPeekRecords();
+ break;
+ default:
+ logger.debug("unsupported database read/write mode for {}, aborting", device.getAddress());
+ done();
+ }
+ }
+
+ public void stop() {
+ if (logger.isDebugEnabled()) {
+ logger.debug("link database reader finished for {}", device.getAddress());
+ }
+
+ ScheduledFuture> job = this.job;
+ if (job != null) {
+ job.cancel(true);
+ this.job = null;
+ }
+
+ modem.getPort().unregisterListener(this);
+ modem.getDBM().operationCompleted();
+ }
+
+ private void done() {
+ device.getLinkDB().recordsLoaded();
+ done = true;
+ stop();
+ }
+
+ private void getPeekRecords() {
+ location = device.getLinkDB().getFirstRecordLocation();
+ lastMSB = -1;
+ getNextPeekRecord();
+ }
+
+ private void getNextPeekRecord() {
+ stream.reset();
+ getNextPeekByte();
+ }
+
+ private void getNextPeekByte() {
+ int address = location - stream.size();
+ int msb = address >> 8;
+ int lsb = address & 0xFF;
+
+ if (msb != lastMSB) {
+ setMSBAddress(msb);
+ lastMSB = msb;
+ } else {
+ getPeekByte(lsb);
+ }
+ }
+
+ private void setMSBAddress(int msb) {
+ try {
+ Msg msg = Msg.makeStandardMessage(device.getAddress(), (byte) 0x28, (byte) msb);
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("error sending set msb address query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ } catch (FieldException e) {
+ logger.warn("error parsing message ", e);
+ }
+ }
+
+ private void getPeekByte(int lsb) {
+ try {
+ Msg msg = Msg.makeStandardMessage(device.getAddress(), (byte) 0x2B, (byte) lsb);
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("error sending peek query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ } catch (FieldException e) {
+ logger.warn("error parsing message ", e);
+ }
+ }
+
+ private void getAllLinkRecords() {
+ try {
+ Msg msg = Msg.makeExtendedMessage(device.getAddress(), (byte) 0x2F, (byte) 0x00,
+ device.getInsteonEngine().supportsChecksum());
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("error sending get all link record query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ } catch (FieldException e) {
+ logger.warn("error parsing message ", e);
+ }
+ }
+
+ @Override
+ public void disconnected() {
+ if (!done) {
+ logger.debug("port disconnected, aborting");
+ done();
+ }
+ }
+
+ @Override
+ public void messageReceived(Msg msg) {
+ try {
+ if (!msg.isFromAddress(device.getAddress())) {
+ return;
+ }
+ lastMsgReceived = msg.getTimestamp();
+
+ if (msg.getCommand() == 0x50 && msg.getByte("command1") == 0x28) {
+ // we got a set msb address response
+ getNextPeekByte();
+ } else if (msg.getCommand() == 0x50 && msg.getByte("command1") == 0x2B) {
+ // we got a get peek byte response
+ handleRecordByte(msg.getByte("command2"));
+ } else if (msg.getCommand() == 0x51 && msg.getByte("command1") == 0x2F) {
+ // we got a get aldb record response
+ handleRecordMsg(msg);
+ }
+ } catch (FieldException e) {
+ logger.warn("error parsing link db info reply field ", e);
+ }
+ }
+
+ @Override
+ public void messageSent(Msg msg) {
+ // ignore outbound message
+ }
+
+ private void addRecord(LinkDBRecord record) {
+ if (device.getLinkDB().addRecord(record) != null) {
+ logger.trace("got duplicate link db record for {}", device.getAddress());
+ return;
+ }
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("got link db record #{} for {}", device.getLinkDB().size(), device.getAddress());
+ }
+
+ if (record.isLast()) {
+ logger.trace("got last link db record for {}", device.getAddress());
+ done();
+ }
+ }
+
+ private void handleRecordByte(byte b) {
+ // add byte to record stream
+ stream.write(b);
+ // get next peek byte if stream size below the record byte size
+ // otherwise add record and get next peek record if not done
+ if (stream.size() < LinkDB.RECORD_BYTE_SIZE) {
+ getNextPeekByte();
+ } else {
+ addRecord(LinkDBRecord.fromRecordData(stream.toByteArray(), location));
+ if (!done) {
+ location -= LinkDB.RECORD_BYTE_SIZE;
+ getNextPeekRecord();
+ }
+ }
+ }
+
+ private void handleRecordMsg(Msg msg) throws FieldException {
+ // check if message crc is valid based on device insteon engine checksum support
+ if (device.getInsteonEngine().supportsChecksum() && !msg.hasValidCRC()) {
+ logger.debug("ignoring msg with invalid crc from {}: {}", device.getAddress(), msg);
+ } else {
+ addRecord(LinkDBRecord.fromRecordMsg(msg));
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBRecord.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBRecord.java
new file mode 100644
index 0000000000000..2d68f107abc75
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBRecord.java
@@ -0,0 +1,133 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.RampRate;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+
+/**
+ * The {@link LinkDBRecord} holds a link database record for a device
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class LinkDBRecord extends DatabaseRecord {
+
+ public LinkDBRecord(int location, RecordType type, int group, InsteonAddress address, byte[] data) {
+ super(location, type, group, address, data);
+ }
+
+ public LinkDBRecord(DatabaseRecord record) {
+ super(record);
+ }
+
+ public int getOnLevel() {
+ return getData1();
+ }
+
+ public RampRate getRampRate() {
+ return RampRate.valueOf(getData2());
+ }
+
+ public int getComponentId() {
+ return getData3();
+ }
+
+ /**
+ * Factory method for creating a new LinkDBRecord from a set of parameters
+ *
+ * @param location the record location to use
+ * @param address the record address to use
+ * @param group the record group to use
+ * @param isController if is controller record
+ * @param data the record data to use
+ * @return the link db record
+ */
+ public static LinkDBRecord create(int location, InsteonAddress address, int group, boolean isController,
+ byte[] data) {
+ RecordFlags flags = isController ? RecordFlags.CONTROLLER : RecordFlags.RESPONDER;
+ RecordType type = flags.getRecordType();
+
+ return new LinkDBRecord(location, type, group, address, data);
+ }
+
+ /**
+ * Factory method for creating a new LinkDBRecord from an Insteon record data buffer
+ *
+ * @param buf the record data buffer to parse (backwards)
+ * @param location the record location to use
+ * @return the link db record
+ */
+ public static LinkDBRecord fromRecordData(byte[] buf, int location) {
+ RecordType type = new RecordType(Byte.toUnsignedInt(buf[7]));
+ int group = Byte.toUnsignedInt(buf[6]);
+ InsteonAddress address = new InsteonAddress(buf[5], buf[4], buf[3]);
+ byte[] data = { buf[2], buf[1], buf[0] };
+
+ return new LinkDBRecord(location, type, group, address, data);
+ }
+
+ /**
+ * Factory method for creating a new LinkDBRecord from an Insteon record message
+ *
+ * @param msg the record message to parse
+ * @return the link db record
+ * @throws FieldException
+ */
+ public static LinkDBRecord fromRecordMsg(Msg msg) throws FieldException {
+ int location = msg.getInt16("userData3");
+ RecordType type = new RecordType(msg.getInt("userData6"));
+ int group = msg.getInt("userData7");
+ InsteonAddress address = new InsteonAddress(msg.getBytes("userData8", 3));
+ byte[] data = msg.getBytes("userData11", 3);
+
+ return new LinkDBRecord(location, type, group, address, data);
+ }
+
+ /**
+ * Factory method for creating a new LinkDBRecord from another instance as inactive
+ *
+ * @param record the link db record to use
+ * @return the inactive link db record
+ */
+ public static LinkDBRecord asInactive(LinkDBRecord record) {
+ RecordType type = RecordType.asInactive(record.getFlags());
+
+ return new LinkDBRecord(record.getLocation(), type, record.getGroup(), record.getAddress(), record.getData());
+ }
+
+ /**
+ * Factory method for creating a new LinkDBRecord from another instance with new data
+ *
+ * @param data the new data to use
+ * @param record the link db record to use
+ * @return the link db record with new data
+ */
+ public static LinkDBRecord withNewData(byte[] data, LinkDBRecord record) {
+ return new LinkDBRecord(record.getLocation(), record.getType(), record.getGroup(), record.getAddress(), data);
+ }
+
+ /**
+ * Factory method for creating a new LinkDBRecord from another instance with new location
+ *
+ * @param location the new location to use
+ * @param record the link db record to use
+ * @return the link db record with new location
+ */
+ public static LinkDBRecord withNewLocation(int location, LinkDBRecord record) {
+ return new LinkDBRecord(location, record.getType(), record.getGroup(), record.getAddress(), record.getData());
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBWriter.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBWriter.java
new file mode 100644
index 0000000000000..99eb96c347b07
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkDBWriter.java
@@ -0,0 +1,275 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.InsteonDevice;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.listener.PortListener;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link LinkDBWriter} manages all-link database write requests
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class LinkDBWriter implements PortListener {
+ private static final int MESSAGE_TIMEOUT = 4000; // in milliseconds
+
+ private final Logger logger = LoggerFactory.getLogger(LinkDBWriter.class);
+
+ private InsteonDevice device = new InsteonDevice();
+ private InsteonModem modem;
+ private ScheduledExecutorService scheduler;
+ private @Nullable ScheduledFuture> job;
+ private ByteArrayInputStream stream = new ByteArrayInputStream(new byte[0]);
+ private boolean done;
+ private long lastMsgReceived;
+ private int location;
+ private int lastMSB;
+
+ public LinkDBWriter(InsteonModem modem, ScheduledExecutorService scheduler) {
+ this.modem = modem;
+ this.scheduler = scheduler;
+ }
+
+ public boolean isRunning() {
+ return job != null;
+ }
+
+ public void write(InsteonDevice device) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("starting link database writer for {}", device.getAddress());
+ }
+
+ this.device = device;
+
+ applyChanges();
+
+ job = scheduler.scheduleWithFixedDelay(() -> {
+ if (System.currentTimeMillis() - lastMsgReceived > MESSAGE_TIMEOUT) {
+ logger.debug("link database writer timed out for {}, aborting", device.getAddress());
+ done();
+ }
+ }, 0, 1000, TimeUnit.MILLISECONDS);
+ }
+
+ private void applyChanges() {
+ lastMsgReceived = System.currentTimeMillis();
+ done = false;
+
+ modem.getPort().registerListener(this);
+
+ switch (device.getLinkDB().getReadWriteMode()) {
+ case STANDARD:
+ setNextAllLinkRecord();
+ break;
+ case PEEK_POKE:
+ setNextPokeRecord();
+ break;
+ default:
+ logger.debug("unsupported database read/write mode for {}, aborting", device.getAddress());
+ done();
+ }
+ }
+
+ public void stop() {
+ if (logger.isDebugEnabled()) {
+ logger.debug("link database writer finished for {}", device.getAddress());
+ }
+
+ ScheduledFuture> job = this.job;
+ if (job != null) {
+ job.cancel(true);
+ this.job = null;
+ }
+
+ modem.getPort().unregisterListener(this);
+ modem.getDBM().operationCompleted();
+ }
+
+ private void done() {
+ device.getLinkDB().load();
+ done = true;
+ stop();
+ }
+
+ private void setNextAllLinkRecord() {
+ LinkDBChange change = device.getLinkDB().pollNextChange();
+ if (change == null) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("all link db changes written using standard mode for {}", device.getAddress());
+ }
+ done();
+ } else {
+ setAllLinkRecord(change.getRecord());
+ }
+ }
+
+ private void setNextPokeRecord() {
+ LinkDBChange change = device.getLinkDB().pollNextChange();
+ if (change == null) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("all link db changes written using peek/poke mode for {}", device.getAddress());
+ }
+ done();
+ } else {
+ setPokeRecord(change.getRecord());
+ }
+ }
+
+ private void setPokeRecord(LinkDBRecord record) {
+ stream = new ByteArrayInputStream(record.getBytes());
+ location = record.getLocation();
+ lastMSB = -1;
+ setNextPokeByte();
+ }
+
+ private void setNextPokeByte() {
+ int address = location - stream.available() + 1;
+ int msb = address >> 8;
+ int lsb = address & 0xFF;
+
+ if (stream.available() == 0) {
+ setNextPokeRecord();
+ } else if (msb != lastMSB) {
+ setMSBAddress(msb);
+ lastMSB = msb;
+ } else {
+ getPeekByte(lsb);
+ }
+ }
+
+ private void setMSBAddress(int msb) {
+ try {
+ Msg msg = Msg.makeStandardMessage(device.getAddress(), (byte) 0x28, (byte) msb);
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("error sending set msb address query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ } catch (FieldException e) {
+ logger.warn("error parsing message ", e);
+ }
+ }
+
+ private void setPokeByte(int value) {
+ try {
+ Msg msg = Msg.makeStandardMessage(device.getAddress(), (byte) 0x29, (byte) value);
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("error sending poke query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ } catch (FieldException e) {
+ logger.warn("error parsing message ", e);
+ }
+ }
+
+ private void getPeekByte(int lsb) {
+ try {
+ Msg msg = Msg.makeStandardMessage(device.getAddress(), (byte) 0x2B, (byte) lsb);
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("error sending peek query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ } catch (FieldException e) {
+ logger.warn("error parsing message ", e);
+ }
+ }
+
+ private void setAllLinkRecord(LinkDBRecord record) {
+ try {
+ Msg msg = Msg.makeExtendedMessage(device.getAddress(), (byte) 0x2F, (byte) 0x00, false);
+ msg.setByte("userData1", (byte) 0x00);
+ msg.setByte("userData2", (byte) 0x02);
+ msg.setByte("userData3", (byte) (record.getLocation() >> 8));
+ msg.setByte("userData4", (byte) (record.getLocation() & 0xFF));
+ msg.setByte("userData5", (byte) 0x08);
+ msg.setByte("userData6", (byte) record.getFlags());
+ msg.setByte("userData7", (byte) record.getGroup());
+ msg.setBytes("userData8", record.getAddress().getBytes());
+ msg.setBytes("userData11", record.getData());
+ if (device.getInsteonEngine().supportsChecksum()) {
+ msg.setCRC();
+ }
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("error sending set database record query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ } catch (FieldException e) {
+ logger.warn("error parsing message ", e);
+ }
+ }
+
+ @Override
+ public void disconnected() {
+ if (!done) {
+ logger.debug("port disconnected, aborting");
+ done();
+ }
+ }
+
+ @Override
+ public void messageReceived(Msg msg) {
+ try {
+ if (!msg.isFromAddress(device.getAddress())) {
+ return;
+ }
+ lastMsgReceived = msg.getTimestamp();
+
+ if (msg.getCommand() == 0x50 && (msg.getByte("command1") == 0x28 || msg.getByte("command1") == 0x29)) {
+ // we got a set msb address or poke byte response
+ setNextPokeByte();
+ } else if (msg.getCommand() == 0x50 && msg.getByte("command1") == 0x2B) {
+ // we got a get peek byte response
+ handlePeekByte(msg.getByte("command2"));
+ } else if (msg.getCommand() == 0x50 && msg.getByte("command1") == 0x2F) {
+ // we got a set aldb record response
+ setNextAllLinkRecord();
+ }
+ } catch (FieldException e) {
+ logger.warn("error parsing link db writer reply field ", e);
+ }
+ }
+
+ @Override
+ public void messageSent(Msg msg) {
+ // ignore outbound message
+ }
+
+ private void handlePeekByte(byte b) {
+ // read next record stream byte
+ int value = stream.read();
+ // set poke byte if value defined and different from existing one, otherise set next poke byte
+ if (value != -1 && value != b) {
+ setPokeByte(value);
+ } else {
+ setNextPokeByte();
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkMode.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkMode.java
new file mode 100644
index 0000000000000..7918262bd8056
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/LinkMode.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link LinkMode} represents an Insteon all-link record linking mode
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public enum LinkMode {
+ RESPONDER(0x00, RecordFlags.RESPONDER),
+ CONTROLLER(0x01, RecordFlags.CONTROLLER),
+ EITHER(0x03, RecordFlags.HIGH_WATER_MARK),
+ UNKNOWN(0xFE, RecordFlags.HIGH_WATER_MARK),
+ DELETE(0xFF, RecordFlags.INACTIVE);
+
+ private static final Map CODE_MAP = Arrays.stream(values())
+ .collect(Collectors.toUnmodifiableMap(mode -> mode.code, Function.identity()));
+
+ private final int code;
+ private final RecordFlags flags;
+
+ private LinkMode(int code, RecordFlags flags) {
+ this.code = code;
+ this.flags = flags;
+ }
+
+ public int getLinkCode() {
+ return code;
+ }
+
+ public RecordType getRecordType() {
+ return flags.getRecordType();
+ }
+
+ /**
+ * Factory method for getting a LinkMode from a link code
+ *
+ * @param code the link code
+ * @return the link mode
+ */
+ public static LinkMode valueOf(int code) {
+ return CODE_MAP.getOrDefault(code, LinkMode.UNKNOWN);
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ManageRecordAction.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ManageRecordAction.java
new file mode 100644
index 0000000000000..a346278bd03bd
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ManageRecordAction.java
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link ManageRecordAction} represents an Insteon manage all-link record action
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public enum ManageRecordAction {
+ FIND_FIRST(0x00),
+ FIND_NEXT(0x01),
+ MODIFY_OR_ADD(0x20),
+ MODIFY_CONTROLLER_OR_ADD(0x40),
+ MODIFY_RESPONDER_OR_ADD(0x41),
+ DELETE(0x80),
+ UNKNOWN(0xFF);
+
+ private static final Map CODE_MAP = Arrays.stream(values())
+ .collect(Collectors.toUnmodifiableMap(action -> action.code, Function.identity()));
+
+ private final int code;
+
+ private ManageRecordAction(int code) {
+ this.code = code;
+ }
+
+ public int getControlCode() {
+ return code;
+ }
+
+ /**
+ * Factory method for getting a ManageRecordAction from a control code
+ *
+ * @param code the control code
+ * @return the manage record action
+ */
+ public static ManageRecordAction valueOf(int code) {
+ return CODE_MAP.getOrDefault(code, ManageRecordAction.UNKNOWN);
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDB.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDB.java
new file mode 100644
index 0000000000000..4a230f4bb68ae
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDB.java
@@ -0,0 +1,637 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.device.InsteonScene;
+import org.openhab.binding.insteon.internal.device.ProductData;
+import org.openhab.binding.insteon.internal.manager.DatabaseManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ModemDB} holds all-link database entries for a modem
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class ModemDB {
+ private final Logger logger = LoggerFactory.getLogger(ModemDB.class);
+
+ private InsteonModem modem;
+ private Map dbes = new HashMap<>();
+ private List records = new ArrayList<>();
+ private List changes = new ArrayList<>();
+ private volatile boolean complete = false;
+
+ public ModemDB(InsteonModem modem) {
+ this.modem = modem;
+ }
+
+ public DatabaseManager getDatabaseManager() {
+ return modem.getDBM();
+ }
+
+ public List getDevices() {
+ synchronized (dbes) {
+ return dbes.keySet().stream().toList();
+ }
+ }
+
+ public List getEntries() {
+ synchronized (dbes) {
+ return dbes.values().stream().toList();
+ }
+ }
+
+ public @Nullable ModemDBEntry getEntry(InsteonAddress address) {
+ synchronized (dbes) {
+ return dbes.get(address);
+ }
+ }
+
+ public boolean hasEntry(InsteonAddress address) {
+ synchronized (dbes) {
+ return dbes.containsKey(address);
+ }
+ }
+
+ public List getRecords() {
+ synchronized (records) {
+ return records.stream().toList();
+ }
+ }
+
+ private Stream getRecords(@Nullable InsteonAddress address, @Nullable Integer group,
+ @Nullable Boolean isController) {
+ return getRecords().stream()
+ .filter(record -> (address == null || record.getAddress().equals(address))
+ && (group == null || record.getGroup() == group)
+ && (isController == null || record.isController() == isController));
+ }
+
+ public List getRecords(InsteonAddress address) {
+ return getRecords(address, null, null).toList();
+ }
+
+ public @Nullable ModemDBRecord getRecord(InsteonAddress address, int group, boolean isController) {
+ return getRecords(address, group, isController).findFirst().orElse(null);
+ }
+
+ public @Nullable ModemDBRecord getRecord(InsteonAddress address, int group) {
+ return getRecords(address, group, null).findFirst().orElse(null);
+ }
+
+ private int getRecordIndex(ModemDBRecord record) {
+ synchronized (records) {
+ return records.indexOf(record);
+ }
+ }
+
+ private int getRecordIndex(InsteonAddress address, int group, boolean isController) {
+ return getRecords(address, group, isController).findFirst().map(this::getRecordIndex).orElse(-1);
+ }
+
+ private int getRecordIndex(InsteonAddress address, int group) {
+ return getRecords(address, group, null).findFirst().map(this::getRecordIndex).orElse(-1);
+ }
+
+ public boolean hasRecord(@Nullable InsteonAddress address, @Nullable Integer group,
+ @Nullable Boolean isController) {
+ return getRecords(address, group, isController).findAny().isPresent();
+ }
+
+ public List getChanges() {
+ synchronized (changes) {
+ return changes.stream().toList();
+ }
+ }
+
+ private Stream getChanges(@Nullable InsteonAddress address, @Nullable Integer group,
+ @Nullable Boolean isController) {
+ return getChanges().stream()
+ .filter(change -> (address == null || change.getRecord().getAddress().equals(address))
+ && (group == null || change.getRecord().getGroup() == group)
+ && (isController == null || change.getRecord().isController() == isController));
+ }
+
+ private int getChangeIndex(ModemDBChange change) {
+ synchronized (changes) {
+ return changes.indexOf(change);
+ }
+ }
+
+ private int getChangeIndex(InsteonAddress address, int group, boolean isController) {
+ return getChanges(address, group, isController).findFirst().map(this::getChangeIndex).orElse(-1);
+ }
+
+ public @Nullable ModemDBChange pollNextChange() {
+ synchronized (changes) {
+ return changes.isEmpty() ? null : changes.remove(0);
+ }
+ }
+
+ public Map getProducts() {
+ return getEntries().stream().filter(dbe -> dbe.getProductData() != null).collect(
+ Collectors.toMap(ModemDBEntry::getAddress, dbe -> Objects.requireNonNull(dbe.getProductData())));
+ }
+
+ public @Nullable ProductData getProductData(InsteonAddress address) {
+ return getProducts().get(address);
+ }
+
+ public boolean hasProductData(InsteonAddress address) {
+ return getProducts().containsKey(address);
+ }
+
+ public boolean isComplete() {
+ return complete;
+ }
+
+ public void setIsComplete(boolean complete) {
+ this.complete = complete;
+
+ if (complete) {
+ modem.databaseCompleted();
+ }
+ }
+
+ /**
+ * Clears the modem db
+ */
+ public synchronized void clear() {
+ logger.debug("clearing modem db");
+ dbes.clear();
+ records.clear();
+ changes.clear();
+ complete = false;
+ }
+
+ /**
+ * Loads the modem db
+ */
+ public void load() {
+ clear();
+ getDatabaseManager().read(modem, 0L);
+ }
+
+ /**
+ * Updates the modem db with changes
+ */
+ public void update() {
+ if (getChanges().isEmpty()) {
+ logger.debug("no changes to update modem db");
+ } else {
+ getDatabaseManager().write(modem, 0L);
+ }
+ }
+
+ /**
+ * Adds a modem db record
+ *
+ * @param record the record to add
+ */
+ public void addRecord(ModemDBRecord record) {
+ InsteonAddress address = record.getAddress();
+ ModemDBEntry dbe = getEntry(address);
+ if (dbe == null) {
+ dbe = new ModemDBEntry(address, this);
+ dbes.put(address, dbe);
+ }
+
+ synchronized (records) {
+ records.add(record);
+ }
+
+ if (record.isController()) {
+ dbe.addControllerGroup(record.getGroup());
+ } else if (record.isResponder()) {
+ dbe.addResponderGroup(record.getGroup());
+ }
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("added record: {}", record);
+ }
+ }
+
+ /**
+ * Deletes modem db record
+ *
+ * @param record the record to delete
+ */
+ public void deleteRecord(ModemDBRecord record) {
+ InsteonAddress address = record.getAddress();
+ ModemDBEntry dbe = getEntry(address);
+ if (dbe == null) {
+ return;
+ }
+
+ synchronized (records) {
+ records.remove(record);
+ }
+
+ if (!dbe.hasRecords()) {
+ dbes.remove(address);
+ } else if (record.isController()) {
+ dbe.removeControllerGroup(record.getGroup());
+ } else if (record.isResponder()) {
+ dbe.removeResponderGroup(record.getGroup());
+ }
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("deleted record: {}", record);
+ }
+ }
+
+ /**
+ * Deletes modem db record for a given address and group
+ *
+ * @param address the record address
+ * @param group the record group to delete
+ */
+ public void deleteRecord(InsteonAddress address, int group) {
+ ModemDBRecord record = getRecord(address, group);
+ if (record == null) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("no record found to delete for {} group:{}", address, group);
+ }
+ } else {
+ deleteRecord(record);
+ }
+ }
+
+ /**
+ * Loads a list of modem db records
+ *
+ * @param records list of records to load
+ */
+ public void loadRecords(List records) {
+ logger.debug("loading modem db records");
+ records.forEach(this::addRecord);
+ recordsLoaded();
+ }
+
+ /**
+ * Modifies a modem db record
+ *
+ * @param index the record index to modify
+ * @param record the record to use
+ */
+ public void modifyRecord(int index, ModemDBRecord record) {
+ InsteonAddress address = record.getAddress();
+ ModemDBEntry dbe = getEntry(address);
+ if (dbe == null || index < 0 || index >= records.size()) {
+ return;
+ }
+
+ ModemDBRecord prevRecord;
+ synchronized (records) {
+ if (records.get(index).equals(record)) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("no change needed for record: {}", record);
+ }
+ return;
+ }
+ prevRecord = records.set(index, record);
+ }
+
+ if (prevRecord.isController()) {
+ dbe.removeControllerGroup(prevRecord.getGroup());
+ } else if (prevRecord.isResponder()) {
+ dbe.removeResponderGroup(prevRecord.getGroup());
+ }
+
+ if (record.isController()) {
+ dbe.addControllerGroup(record.getGroup());
+ } else if (record.isResponder()) {
+ dbe.addResponderGroup(record.getGroup());
+ }
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("modified record from: {} to: {}", prevRecord, record);
+ }
+ }
+
+ /**
+ * Modifies first controller or responder modem db record if found or adds it
+ *
+ * @param record the record to modify or add
+ */
+ public void modifyOrAddRecord(ModemDBRecord record) {
+ int index = getRecordIndex(record.getAddress(), record.getGroup());
+ if (index != -1) {
+ modifyRecord(index, record);
+ } else {
+ addRecord(record);
+ }
+ }
+
+ /**
+ * Modifies first controller modem db record if found or adds it
+ *
+ * @param record the record to modify or add
+ */
+ public void modifyOrAddControllerRecord(ModemDBRecord record) {
+ int index = getRecordIndex(record.getAddress(), record.getGroup(), true);
+ if (index != -1) {
+ modifyRecord(index, record);
+ } else {
+ addRecord(record);
+ }
+ }
+
+ /**
+ * Modifies first responder modem db record if found or adds it
+ *
+ * @param record the record to modify or add
+ */
+
+ public void modifyOrAddResponderRecord(ModemDBRecord record) {
+ int index = getRecordIndex(record.getAddress(), record.getGroup(), false);
+ if (index != -1) {
+ modifyRecord(index, record);
+ } else {
+ addRecord(record);
+ }
+ }
+
+ /**
+ * Clears the modem db changes
+ */
+ public void clearChanges() {
+ logger.debug("clearing modem db changes");
+
+ synchronized (changes) {
+ changes.clear();
+ }
+ }
+
+ /**
+ * Adds a modem db change
+ *
+ * @param change the change to add
+ */
+ public void addChange(ModemDBChange change) {
+ ModemDBRecord record = change.getRecord();
+ int index = getChangeIndex(record.getAddress(), record.getGroup(), record.isController());
+ if (index == -1) {
+ synchronized (changes) {
+ changes.add(change);
+ }
+ if (logger.isTraceEnabled()) {
+ logger.trace("added change: {}", change);
+ }
+ } else {
+ ModemDBChange prevChange;
+ synchronized (changes) {
+ prevChange = changes.set(index, change);
+ }
+ if (logger.isTraceEnabled()) {
+ logger.trace("modified change from: {} to: {}", prevChange, change);
+ }
+ }
+ }
+
+ /**
+ * Marks a modem db record to be added
+ *
+ * @param address the record address to use
+ * @param group the record group to use
+ * @param isController if is controller record
+ * @param data the record data to use
+ */
+ public void markRecordForAdd(InsteonAddress address, int group, boolean isController, byte[] data) {
+ addChange(ModemDBChange.forAdd(address, group, isController, data));
+ }
+
+ /**
+ * Marks a modem db record to be modified
+ *
+ * @param record the record to modify
+ * @param data the record data to use
+ */
+ public void markRecordForModify(ModemDBRecord record, byte[] data) {
+ addChange(ModemDBChange.forModify(record, data));
+ }
+
+ /**
+ * Marks a modem db record to be added or modified
+ *
+ * @param address the record address to use
+ * @param group the record group to use
+ * @param isController if is controller record
+ * @param data the record data to use
+ */
+ public void markRecordForAddOrModify(InsteonAddress address, int group, boolean isController, byte[] data) {
+ ModemDBRecord record = getRecord(address, group, isController);
+ if (record == null) {
+ markRecordForAdd(address, group, isController, data);
+ } else {
+ markRecordForModify(record, data);
+ }
+ }
+
+ /**
+ * Marks a modem db record to be added or modified
+ *
+ * @param address the record address to use
+ * @param group the record group to use
+ * @param isController if is controller record
+ */
+ public void markRecordForAddOrModify(InsteonAddress address, int group, boolean isController) {
+ ProductData productData = getProductData(address);
+ if (productData == null) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("no product data for device {}", address);
+ }
+ return;
+ }
+ byte[] data = isController ? productData.getRecordData() : new byte[3];
+ markRecordForAddOrModify(address, group, isController, data);
+ }
+
+ /**
+ * Marks a modem db record to be deleted
+ *
+ * @param record the record to delete
+ */
+ public void markRecordForDelete(ModemDBRecord record) {
+ if (record.isAvailable()) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("ignoring already deleted record: {}", record);
+ }
+ return;
+ }
+ addChange(ModemDBChange.forDelete(record));
+ }
+
+ /**
+ * Marks a modem db record to be deleted
+ *
+ * @param address the record address to use
+ * @param group the record group to use
+ */
+ public void markRecordForDelete(InsteonAddress address, int group) {
+ ModemDBRecord record = getRecord(address, group);
+ if (record == null) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("no record found to delete for {} group:{}", address, group);
+ }
+ return;
+ }
+ markRecordForDelete(record);
+ }
+
+ /**
+ * Logs all modem db entries
+ */
+ private void logEntries() {
+ if (logger.isDebugEnabled()) {
+ if (getEntries().isEmpty()) {
+ logger.debug("modem database is empty");
+ } else {
+ logger.debug("modem database has {} entries:", dbes.size());
+ getEntries().stream().map(String::valueOf).forEach(logger::debug);
+ if (logger.isTraceEnabled()) {
+ logger.trace("---------------- start of modem link records ----------------");
+ getRecords().stream().map(String::valueOf).forEach(logger::trace);
+ logger.trace("----------------- end of modem link records -----------------");
+ }
+ }
+ }
+ }
+
+ /**
+ * Logs a modem db entry for a given address
+ *
+ * @param address the address for the modem db entry to log
+ */
+ private void logEntry(InsteonAddress address) {
+ if (logger.isDebugEnabled()) {
+ ModemDBEntry dbe = getEntry(address);
+ if (dbe == null) {
+ logger.debug("no modem database entry for {}", address);
+ } else {
+ logger.debug("{}", dbe);
+ if (logger.isTraceEnabled()) {
+ logger.trace("--------- start of modem link records for {} ---------", address);
+ dbe.getRecords().stream().map(String::valueOf).forEach(logger::trace);
+ logger.trace("---------- end of modem link records for {} ----------", address);
+ }
+ }
+ }
+ }
+
+ /**
+ * Notifies that a modem db link has been updated
+ *
+ * @param address the link address
+ * @param group the link group
+ * @param is2Way if two way update
+ */
+ public void linkUpdated(InsteonAddress address, int group, boolean is2Way) {
+ logEntry(address);
+ modem.databaseLinkUpdated(address, group, is2Way);
+ }
+
+ /**
+ * Notifies that the modem db records have been loaded
+ */
+ public void recordsLoaded() {
+ logEntries();
+ setIsComplete(true);
+ }
+
+ /**
+ * Loads a map of products
+ *
+ * @param products map of products to load
+ */
+ public void loadProducts(Map products) {
+ logger.debug("loading modem db products");
+ products.forEach(this::setProductData);
+ }
+
+ /**
+ * Sets product data for a modem db entry
+ *
+ * @param address the address for the modem db entry
+ * @param productData the product data to set
+ */
+ public void setProductData(InsteonAddress address, ProductData productData) {
+ ModemDBEntry dbe = getEntry(address);
+ if (dbe == null) {
+ dbe = new ModemDBEntry(address, this);
+ dbes.put(address, dbe);
+ }
+
+ dbe.setProductData(productData);
+
+ modem.databaseProductDataUpdated(address, productData);
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("set product data for {} as {}", address, productData);
+ }
+ }
+
+ /**
+ * Returns a list of related devices for a given broadcast group
+ *
+ * @param group the broadcast group
+ * @return list of related device addresses
+ */
+ public List getRelatedDevices(int group) {
+ return getEntries().stream().filter(dbe -> dbe.getControllerGroups().contains(group))
+ .map(ModemDBEntry::getAddress).toList();
+ }
+
+ /**
+ * Returns a list of all broadcast groups
+ *
+ * @return list of all broadcast groups
+ */
+ public List getBroadcastGroups() {
+ return getEntries().stream().map(ModemDBEntry::getControllerGroups).flatMap(List::stream).distinct()
+ .filter(InsteonScene::isValidGroup).toList();
+ }
+
+ /**
+ * Returns if a broadcast group is in modem database
+ *
+ * @param group the broadcast group
+ * @return true if the broadcast group number is in modem database
+ */
+ public boolean hasBroadcastGroup(int group) {
+ return getBroadcastGroups().contains(group);
+ }
+
+ /**
+ * Returns the next available broadcast group
+ */
+ public int getNextAvailableBroadcastGroup() {
+ return IntStream.range(InsteonScene.GROUP_NEW_MIN, InsteonScene.GROUP_NEW_MAX)
+ .filter(group -> !hasBroadcastGroup(group)).min().orElse(-1);
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBChange.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBChange.java
new file mode 100644
index 0000000000000..4eba3d7297d6c
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBChange.java
@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+
+/**
+ * The {@link DatabaseChange} holds a link database change for a modem
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class ModemDBChange extends DatabaseChange {
+
+ public ModemDBChange(ModemDBRecord record, ChangeType type) {
+ super(record, type);
+ }
+
+ /**
+ * Factory method for creating a new ModemDBChange for add
+ *
+ * @param address the record address to use
+ * @param group the record group to use
+ * @param isController if is controller record
+ * @param data the record data to use
+ * @return the modem db change
+ */
+ public static ModemDBChange forAdd(InsteonAddress address, int group, boolean isController, byte[] data) {
+ return new ModemDBChange(ModemDBRecord.create(address, group, isController, data), ChangeType.ADD);
+ }
+
+ /**
+ * Factory method for creating a new ModemDBChange for add
+ *
+ * @param record the record to add
+ * @return the modem db change
+ */
+ public static ModemDBChange forAdd(ModemDBRecord record) {
+ return new ModemDBChange(record, ChangeType.ADD);
+ }
+
+ /**
+ * Factory method for creating a new ModemDBChange for modify
+ *
+ * @param record the record to modify
+ * @param data the record data to use
+ * @return the modem db change
+ */
+ public static ModemDBChange forModify(ModemDBRecord record, byte[] data) {
+ return new ModemDBChange(ModemDBRecord.withNewData(data, record), ChangeType.MODIFY);
+ }
+
+ /**
+ * Factory method for creating a new ModemDBChange for delete
+ *
+ * @param record the record to delete
+ * @return the modem db change
+ */
+ public static ModemDBChange forDelete(ModemDBRecord record) {
+ return new ModemDBChange(record, ChangeType.DELETE);
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBEntry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBEntry.java
new file mode 100644
index 0000000000000..784b7804e90c3
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBEntry.java
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.ProductData;
+
+/**
+ * The {@link ModemDBEntry} holds a modem database entry for a device
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class ModemDBEntry {
+ private InsteonAddress address;
+ private ModemDB modemDB;
+ private @Nullable ProductData productData;
+ private Set controllers = new TreeSet<>();
+ private Set responders = new TreeSet<>();
+
+ public ModemDBEntry(InsteonAddress address, ModemDB modemDB) {
+ this.address = address;
+ this.modemDB = modemDB;
+ }
+
+ public InsteonAddress getAddress() {
+ return address;
+ }
+
+ public String getId() {
+ return address.toString();
+ }
+
+ public @Nullable ProductData getProductData() {
+ return productData;
+ }
+
+ public boolean hasProductData() {
+ return productData != null;
+ }
+
+ public List getRecords() {
+ return modemDB.getRecords(address);
+ }
+
+ public boolean hasRecords() {
+ return !getRecords().isEmpty();
+ }
+
+ public synchronized List getControllerGroups() {
+ return controllers.stream().toList();
+ }
+
+ public synchronized List getResponderGroups() {
+ return responders.stream().toList();
+ }
+
+ public synchronized void addControllerGroup(int group) {
+ controllers.add(group);
+ }
+
+ public synchronized void addResponderGroup(int group) {
+ responders.add(group);
+ }
+
+ public synchronized void removeControllerGroup(int group) {
+ controllers.remove(group);
+ }
+
+ public synchronized void removeResponderGroup(int group) {
+ responders.remove(group);
+ }
+
+ public synchronized void setProductData(ProductData productData) {
+ this.productData = productData;
+ }
+
+ @Override
+ public String toString() {
+ String s = address + ":";
+ if (controllers.isEmpty()) {
+ s += " modem controls no groups";
+ } else {
+ s += " modem controls groups " + controllers;
+ }
+ if (responders.isEmpty()) {
+ s += " and responds to no groups";
+ } else {
+ s += " and responds to groups " + responders;
+ }
+ return s;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBReader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBReader.java
new file mode 100644
index 0000000000000..87a1c1391c15d
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBReader.java
@@ -0,0 +1,319 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.device.ProductData;
+import org.openhab.binding.insteon.internal.device.ProductDataRegistry;
+import org.openhab.binding.insteon.internal.listener.PortListener;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ModemDBReader} manages modem database read requests
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class ModemDBReader implements PortListener {
+ private static final int MESSAGE_TIMEOUT = 6000; // in milliseconds
+
+ private final Logger logger = LoggerFactory.getLogger(ModemDBReader.class);
+
+ private InsteonModem modem;
+ private ScheduledExecutorService scheduler;
+ private @Nullable ScheduledFuture> job;
+ private Set productQueries = new HashSet<>();
+ private boolean done;
+ private long lastMsgReceived;
+ private int messageCount;
+
+ public ModemDBReader(InsteonModem modem, ScheduledExecutorService scheduler) {
+ this.modem = modem;
+ this.scheduler = scheduler;
+
+ modem.getPort().registerListener(this);
+ }
+
+ public boolean isRunning() {
+ return job != null;
+ }
+
+ public void read() {
+ if (logger.isDebugEnabled()) {
+ logger.debug("starting modem database reader");
+ }
+
+ getAllRecords();
+
+ job = scheduler.scheduleWithFixedDelay(() -> {
+ if (System.currentTimeMillis() - lastMsgReceived > MESSAGE_TIMEOUT) {
+ String s = "";
+ if (messageCount == 0) {
+ s = """
+ No messages were received, the PLM or hub might be broken. If this continues see \
+ 'Known Limitations and Issues' in the Insteon binding documentation.\
+ """;
+ }
+ logger.warn("Failed to read modem database, restarting!{}", s);
+ restart();
+ }
+ }, 0, 1, TimeUnit.SECONDS);
+ }
+
+ public void stop() {
+ if (logger.isDebugEnabled()) {
+ logger.debug("modem database reader finished");
+ }
+
+ ScheduledFuture> job = this.job;
+ if (job != null) {
+ job.cancel(true);
+ this.job = null;
+ }
+
+ modem.getDBM().operationCompleted();
+ }
+
+ private void restart() {
+ modem.getDB().clear();
+ modem.reconnect();
+ getAllRecords();
+ }
+
+ private void getAllRecords() {
+ lastMsgReceived = System.currentTimeMillis();
+ messageCount = 0;
+ done = false;
+ getFirstLinkRecord();
+ }
+
+ private void done() {
+ modem.getDB().recordsLoaded();
+ done = true;
+ stop();
+ }
+
+ private void getFirstLinkRecord() {
+ try {
+ Msg msg = Msg.makeMessage("GetFirstALLLinkRecord");
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("error sending first link record query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ }
+ }
+
+ private void getNextLinkRecord() {
+ try {
+ Msg msg = Msg.makeMessage("GetNextALLLinkRecord");
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("error sending next link record query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ }
+ }
+
+ private void getProductId(InsteonAddress address) {
+ try {
+ Msg msg = Msg.makeStandardMessage(address, (byte) 0x10, (byte) 0x00);
+ modem.writeMessage(msg);
+ } catch (FieldException e) {
+ logger.warn("cannot access field:", e);
+ } catch (IOException e) {
+ logger.warn("error sending product id query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ }
+ }
+
+ @Override
+ public void disconnected() {
+ if (!done) {
+ logger.debug("port disconnected, restarting");
+ getAllRecords();
+ }
+ }
+
+ @Override
+ public void messageReceived(Msg msg) {
+ if (isRunning()) {
+ lastMsgReceived = msg.getTimestamp();
+ messageCount++;
+ }
+
+ try {
+ if (msg.getCommand() == 0x50 && (msg.isAllLinkCleanup() || msg.isAllLinkSuccessReport())) {
+ // we got an all link cleanup or success report message
+ handleAllLinkMessage(msg);
+ } else if (msg.getCommand() == 0x50 && msg.isBroadcast()
+ && (msg.getByte("command1") == 0x01 || msg.getByte("command1") == 0x02)) {
+ // we got a product data broadcast message
+ handleProductData(msg);
+ } else if ((msg.getCommand() == 0x50 || msg.getCommand() == 0x5C) && msg.getByte("command1") == 0x10) {
+ // we got a product data request ack
+ handleProductDataAck(msg);
+ } else if (msg.getCommand() == 0x53) {
+ // we got a linking completed message
+ handleLinkingCompleted(msg);
+ } else if (msg.getCommand() == 0x55 || msg.getCommand() == 0x67 && msg.isReplyAck()) {
+ // we got a user reset detected message or im reset reply ack
+ handleIMReset();
+ } else if (msg.getCommand() == 0x57) {
+ // we got a link record response
+ handleLinkRecord(msg);
+ } else if ((msg.getCommand() == 0x69 || msg.getCommand() == 0x6A) && msg.isReplyNack()) {
+ // we got a get link record reply nack
+ if (!done) {
+ logger.debug("got all link records");
+ done();
+ }
+ } else if (msg.getCommand() == 0x6F && msg.isReplyAck()) {
+ // we got a manage link record reply ack
+ handleLinkRecordUpdated(msg);
+ }
+ } catch (FieldException e) {
+ logger.warn("error parsing modem link record field ", e);
+ }
+ }
+
+ @Override
+ public void messageSent(Msg msg) {
+ // ignore outbound message
+ }
+
+ private void getProductData(InsteonAddress address) {
+ // skip if not in modem db or product data already known
+ if (!modem.getDB().hasEntry(address) || modem.getDB().hasProductData(address)) {
+ return;
+ }
+ // get product id if not already queried
+ synchronized (productQueries) {
+ if (productQueries.add(address)) {
+ getProductId(address);
+ }
+ }
+ }
+
+ private void handleLinkRecord(Msg msg) throws FieldException {
+ if (modem.getDB().isComplete()) {
+ logger.debug("modem database loader already completed, ignoring record");
+ return;
+ }
+ ModemDBRecord record = ModemDBRecord.fromRecordMsg(msg);
+ InsteonAddress address = msg.getInsteonAddress("LinkAddr");
+ modem.getDB().addRecord(record);
+ getProductData(address);
+ getNextLinkRecord();
+ }
+
+ private void handleLinkRecordUpdated(Msg msg) throws FieldException {
+ ModemDBRecord record = ModemDBRecord.fromRecordMsg(msg);
+ InsteonAddress address = msg.getInsteonAddress("LinkAddr");
+ int group = msg.getInt("ALLLinkGroup");
+ int code = msg.getInt("ControlCode");
+ ManageRecordAction action = ManageRecordAction.valueOf(code);
+ switch (action) {
+ case MODIFY_OR_ADD:
+ modem.getDB().modifyOrAddRecord(record);
+ break;
+ case MODIFY_CONTROLLER_OR_ADD:
+ modem.getDB().modifyOrAddControllerRecord(record);
+ break;
+ case MODIFY_RESPONDER_OR_ADD:
+ modem.getDB().modifyOrAddResponderRecord(record);
+ break;
+ case DELETE:
+ modem.getDB().deleteRecord(address, group);
+ break;
+ default:
+ logger.debug("got invalid control code: {}", HexUtils.getHexString(code));
+ return;
+ }
+ modem.getDB().linkUpdated(address, group, false);
+ getProductData(address);
+ }
+
+ private void handleLinkingCompleted(Msg msg) throws FieldException {
+ ModemDBRecord record = ModemDBRecord.fromLinkingMsg(msg);
+ InsteonAddress address = msg.getInsteonAddress("LinkAddr");
+ int group = msg.getInt("ALLLinkGroup");
+ int code = msg.getInt("LinkCode");
+ LinkMode mode = LinkMode.valueOf(code);
+ switch (mode) {
+ case CONTROLLER:
+ modem.getDB().modifyOrAddControllerRecord(record);
+ break;
+ case RESPONDER:
+ modem.getDB().modifyOrAddResponderRecord(record);
+ break;
+ case DELETE:
+ modem.getDB().deleteRecord(address, group);
+ break;
+ default:
+ logger.debug("got invalid link code: {}", HexUtils.getHexString(code));
+ return;
+ }
+ modem.getDB().linkUpdated(address, group, true);
+ getProductData(address);
+ }
+
+ private void handleAllLinkMessage(Msg msg) throws FieldException {
+ InsteonAddress address = msg.getInsteonAddress("fromAddress");
+ getProductData(address);
+ }
+
+ private void handleProductData(Msg msg) throws FieldException {
+ InsteonAddress fromAddr = msg.getInsteonAddress("fromAddress");
+ InsteonAddress toAddr = msg.getInsteonAddress("toAddress");
+ int deviceCategory = Byte.toUnsignedInt(toAddr.getHighByte());
+ int subCategory = Byte.toUnsignedInt(toAddr.getMiddleByte());
+ int firmware = Byte.toUnsignedInt(toAddr.getLowByte());
+ int hardware = msg.getInt("command2");
+ ProductData productData = ProductDataRegistry.getInstance().getProductData(deviceCategory, subCategory);
+ productData.setFirmwareVersion(firmware);
+ productData.setHardwareVersion(hardware);
+ // set product data if in modem db
+ if (modem.getDB().hasEntry(fromAddr)) {
+ modem.getDB().setProductData(fromAddr, productData);
+ }
+ }
+
+ private void handleProductDataAck(Msg msg) throws FieldException {
+ InsteonAddress address = msg.getInsteonAddress("fromAddress");
+ // remove address from product queries
+ synchronized (productQueries) {
+ productQueries.remove(address);
+ }
+ }
+
+ private void handleIMReset() {
+ modem.resetInitiated();
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBRecord.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBRecord.java
new file mode 100644
index 0000000000000..97b390d5e811f
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBRecord.java
@@ -0,0 +1,112 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+
+/**
+ * The {@link ModemDBRecord} holds a link database record for a modem
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class ModemDBRecord extends DatabaseRecord {
+
+ public ModemDBRecord(RecordType type, int group, InsteonAddress address, byte[] data) {
+ super(LOCATION_ZERO, type, group, address, data);
+ }
+
+ public ModemDBRecord(DatabaseRecord record) {
+ super(record);
+ }
+
+ public int getDeviceCategory() {
+ return getData1();
+ }
+
+ public int getSubCategory() {
+ return getData2();
+ }
+
+ public int getFirmwareVersion() {
+ return getData3();
+ }
+
+ /**
+ * Factory method for creating a new ModemDBRecord from a set of parameters
+ *
+ * @param address the record address
+ * @param group the record group
+ * @param isController if is controller record
+ * @param data the record data
+ * @return the modem db record
+ */
+ public static ModemDBRecord create(InsteonAddress address, int group, boolean isController, byte[] data) {
+ RecordFlags flags = isController ? RecordFlags.CONTROLLER : RecordFlags.RESPONDER;
+ RecordType type = flags.getRecordType();
+
+ return new ModemDBRecord(type, group, address, data);
+ }
+
+ /**
+ * Factory method for creating a new ModemDBRecord from an Insteon record message
+ *
+ * @param msg the Insteon record message to parse
+ * @return the modem db record
+ * @throws FieldException
+ */
+ public static ModemDBRecord fromRecordMsg(Msg msg) throws FieldException {
+ RecordType type = new RecordType(msg.getInt("RecordFlags"));
+ int group = msg.getInt("ALLLinkGroup");
+ InsteonAddress address = msg.getInsteonAddress("LinkAddr");
+ byte[] data = new byte[] { msg.getByte("LinkData1"), msg.getByte("LinkData2"), msg.getByte("LinkData3") };
+
+ return new ModemDBRecord(type, group, address, data);
+ }
+
+ /**
+ * Factory method for creating a new ModemDBRecord from an Insteon linking completed message
+ *
+ * @param msg the Insteon linking completed message to parse
+ * @return the modem db record
+ * @throws FieldException
+ */
+ public static ModemDBRecord fromLinkingMsg(Msg msg) throws FieldException {
+ LinkMode mode = LinkMode.valueOf(msg.getInt("LinkCode"));
+ RecordType type = mode.getRecordType();
+ int group = msg.getInt("ALLLinkGroup");
+ InsteonAddress address = msg.getInsteonAddress("LinkAddr");
+ byte[] data = new byte[3];
+
+ if (mode == LinkMode.CONTROLLER) {
+ data = new byte[] { msg.getByte("DeviceCategory"), msg.getByte("DeviceSubcategory"),
+ msg.getByte("FirmwareVersion") };
+ }
+
+ return new ModemDBRecord(type, group, address, data);
+ }
+
+ /**
+ * Factory method for creating a new ModemDBRecord from another instance with new data
+ *
+ * @param data the new record data to use
+ * @param record the modem db record to use
+ * @return the modem db record with new type
+ */
+ public static ModemDBRecord withNewData(byte[] data, ModemDBRecord record) {
+ return new ModemDBRecord(record.getType(), record.getGroup(), record.getAddress(), data);
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBWriter.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBWriter.java
new file mode 100644
index 0000000000000..3a26315218965
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/ModemDBWriter.java
@@ -0,0 +1,161 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import java.io.IOException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.listener.PortListener;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ModemDBWriter} manages modem database weite requests
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class ModemDBWriter implements PortListener {
+ private static final int MESSAGE_TIMEOUT = 4000; // in milliseconds
+
+ private final Logger logger = LoggerFactory.getLogger(ModemDBWriter.class);
+
+ private InsteonModem modem;
+ private ScheduledExecutorService scheduler;
+ private @Nullable ScheduledFuture> job;
+ private boolean done;
+ private long lastMsgReceived;
+
+ public ModemDBWriter(InsteonModem modem, ScheduledExecutorService scheduler) {
+ this.modem = modem;
+ this.scheduler = scheduler;
+ }
+
+ public boolean isRunning() {
+ return job != null;
+ }
+
+ public void write() {
+ if (logger.isDebugEnabled()) {
+ logger.debug("starting modem database writer");
+ }
+
+ applyChanges();
+
+ job = scheduler.scheduleWithFixedDelay(() -> {
+ if (System.currentTimeMillis() - lastMsgReceived > MESSAGE_TIMEOUT) {
+ logger.debug("modem database writer timed out, aborting");
+ done();
+ }
+ }, 0, 1, TimeUnit.SECONDS);
+ }
+
+ private void applyChanges() {
+ lastMsgReceived = System.currentTimeMillis();
+ done = false;
+
+ modem.getPort().registerListener(this);
+
+ manageNextModemLinkRecord();
+ }
+
+ public void stop() {
+ if (logger.isDebugEnabled()) {
+ logger.debug("modem database writer finished");
+ }
+
+ ScheduledFuture> job = this.job;
+ if (job != null) {
+ job.cancel(true);
+ this.job = null;
+ }
+
+ modem.getPort().unregisterListener(this);
+ modem.getDBM().operationCompleted();
+ }
+
+ private void done() {
+ done = true;
+ stop();
+ }
+
+ private void manageNextModemLinkRecord() {
+ ModemDBChange change = modem.getDB().pollNextChange();
+ if (change == null) {
+ logger.trace("all modem database changes written");
+ done();
+ } else {
+ ModemDBRecord record = change.getRecord();
+ ManageRecordAction action;
+ if (change.isDelete()) {
+ action = ManageRecordAction.DELETE;
+ } else if (record.isController()) {
+ action = ManageRecordAction.MODIFY_CONTROLLER_OR_ADD;
+ } else {
+ action = ManageRecordAction.MODIFY_RESPONDER_OR_ADD;
+ }
+ manageModemLinkRecord(action, record);
+ }
+ }
+
+ private void manageModemLinkRecord(ManageRecordAction action, ModemDBRecord record) {
+ try {
+ Msg msg = Msg.makeMessage("ManageALLLinkRecord");
+ msg.setByte("ControlCode", (byte) action.getControlCode());
+ msg.setByte("RecordFlags", (byte) record.getFlags());
+ msg.setByte("ALLLinkGroup", (byte) record.getGroup());
+ msg.setAddress("LinkAddr", record.getAddress());
+ msg.setByte("LinkData1", (byte) record.getData1());
+ msg.setByte("LinkData2", (byte) record.getData2());
+ msg.setByte("LinkData3", (byte) record.getData3());
+ modem.writeMessage(msg);
+ } catch (FieldException e) {
+ logger.warn("cannot access field:", e);
+ } catch (IOException e) {
+ logger.warn("error sending manage modem link record query ", e);
+ } catch (InvalidMessageTypeException e) {
+ logger.warn("invalid message ", e);
+ }
+ }
+
+ @Override
+ public void disconnected() {
+ if (!done) {
+ logger.debug("port disconnected, aborting");
+ done();
+ }
+ }
+
+ @Override
+ public void messageReceived(Msg msg) {
+ lastMsgReceived = msg.getTimestamp();
+
+ if (msg.getCommand() == 0x6F) {
+ // we got a manage link record response
+ manageNextModemLinkRecord();
+ }
+ }
+
+ @Override
+ public void messageSent(Msg msg) {
+ // ignore outbound message
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/Pair.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/RecordFlags.java
similarity index 50%
rename from bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/Pair.java
rename to bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/RecordFlags.java
index c58b015a506e7..38005c82a1aab 100644
--- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/Pair.java
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/RecordFlags.java
@@ -10,37 +10,33 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
-package org.openhab.binding.insteon.internal.utils;
+package org.openhab.binding.insteon.internal.database;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
- * Generic pair class.
+ * The {@link RecordFlags} represents Insteon all-link record flags
*
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Initial contribution
*/
@NonNullByDefault
-public class Pair {
- private K key;
- private V value;
+public enum RecordFlags {
+ CONTROLLER(0xE2),
+ RESPONDER(0xA2),
+ INACTIVE(0x22),
+ HIGH_WATER_MARK(0x00);
- /**
- * Constructs a new Pair
with a given key/value
- *
- * @param key the key
- * @param value the value
- */
- public Pair(K key, V value) {
- this.key = key;
+ private final int value;
+
+ private RecordFlags(int value) {
this.value = value;
}
- public K getKey() {
- return key;
+ public int getValue() {
+ return value;
}
- public V getValue() {
- return value;
+ public RecordType getRecordType() {
+ return new RecordType(value);
}
}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/RecordType.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/RecordType.java
new file mode 100644
index 0000000000000..16afc4f603ba4
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/database/RecordType.java
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.database;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.utils.BinaryUtils;
+
+/**
+ * The {@link RecordType} represents an Insteon all-link record type
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class RecordType {
+ private static final int BIT_ACTIVE = 7;
+ private static final int BIT_CONTROLLER = 6;
+ private static final int BIT_HIGH_WATER_MARK = 1;
+
+ private final int flags;
+
+ public RecordType(int flags) {
+ this.flags = flags;
+ }
+
+ public int getFlags() {
+ return flags;
+ }
+
+ public boolean isActive() {
+ return BinaryUtils.isBitSet(flags, BIT_ACTIVE);
+ }
+
+ public boolean isController() {
+ return BinaryUtils.isBitSet(flags, BIT_CONTROLLER);
+ }
+
+ public boolean isResponder() {
+ return !BinaryUtils.isBitSet(flags, BIT_CONTROLLER);
+ }
+
+ public boolean isHighWaterMark() {
+ return !BinaryUtils.isBitSet(flags, BIT_HIGH_WATER_MARK);
+ }
+
+ @Override
+ public String toString() {
+ String s;
+ if (isHighWaterMark()) {
+ s = "LAST";
+ } else if (!isActive()) {
+ s = "AVBL";
+ } else if (isController()) {
+ s = "CTRL";
+ } else {
+ s = "RESP";
+ }
+ return s;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ RecordType other = (RecordType) obj;
+ return flags == other.flags;
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + flags;
+ return result;
+ }
+
+ /**
+ * Factory method for creating a RecordType from record flags as inactive
+ *
+ * @param flags the record flags to use
+ * @return the inactive record type
+ */
+ public static RecordType asInactive(int flags) {
+ return new RecordType(BinaryUtils.clearBit(flags, BIT_ACTIVE));
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/BaseDevice.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/BaseDevice.java
new file mode 100644
index 0000000000000..82e64ffaa7e73
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/BaseDevice.java
@@ -0,0 +1,680 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.device;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.PriorityQueue;
+import java.util.Queue;
+import java.util.function.Predicate;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.DeviceFeature.QueryStatus;
+import org.openhab.binding.insteon.internal.device.DeviceType.FeatureEntry;
+import org.openhab.binding.insteon.internal.handler.InsteonBaseThingHandler;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link BaseDevice} represents a base device
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public abstract class BaseDevice<@NonNull T extends DeviceAddress, @NonNull S extends InsteonBaseThingHandler>
+ implements Device {
+ private static final int DIRECT_ACK_TIMEOUT = 6000; // in milliseconds
+ private static final int REQUEST_QUEUE_TIMEOUT = 30000; // in milliseconds
+
+ protected static enum DeviceStatus {
+ INITIALIZED,
+ POLLING,
+ STOPPED
+ }
+
+ protected final Logger logger = LoggerFactory.getLogger(getClass());
+
+ protected T address;
+ private @Nullable S handler;
+ private @Nullable InsteonModem modem;
+ private @Nullable ProductData productData;
+ private DeviceStatus status = DeviceStatus.INITIALIZED;
+ private Map features = new LinkedHashMap<>();
+ private Map flags = new HashMap<>();
+ private Queue requestQueue = new PriorityQueue<>();
+ private Map requestQueueHash = new HashMap<>();
+ private @Nullable DeviceFeature featureQueried;
+ private long pollInterval = -1L; // in milliseconds
+ private volatile long lastRequestQueued = 0L;
+ private volatile long lastRequestSent = 0L;
+
+ public BaseDevice(T address) {
+ this.address = address;
+ }
+
+ @Override
+ public T getAddress() {
+ return address;
+ }
+
+ public @Nullable S getHandler() {
+ return handler;
+ }
+
+ public @Nullable InsteonModem getModem() {
+ return modem;
+ }
+
+ @Override
+ public @Nullable ProductData getProductData() {
+ return productData;
+ }
+
+ @Override
+ public @Nullable DeviceType getType() {
+ return Optional.ofNullable(productData).map(ProductData::getDeviceType).orElse(null);
+ }
+
+ protected DeviceStatus getStatus() {
+ return status;
+ }
+
+ @Override
+ public List getFeatures() {
+ synchronized (features) {
+ return features.values().stream().toList();
+ }
+ }
+
+ @Override
+ public @Nullable DeviceFeature getFeature(String name) {
+ synchronized (features) {
+ return features.get(name);
+ }
+ }
+
+ public boolean hasFeatures() {
+ return !getFeatures().isEmpty();
+ }
+
+ public boolean hasFeature(String name) {
+ return getFeature(name) != null;
+ }
+
+ public double getLastMsgValueAsDouble(String name, double defaultValue) {
+ return Optional.ofNullable(getFeature(name)).map(DeviceFeature::getLastMsgValue).map(Double::doubleValue)
+ .orElse(defaultValue);
+ }
+
+ public int getLastMsgValueAsInteger(String name, int defaultValue) {
+ return Optional.ofNullable(getFeature(name)).map(DeviceFeature::getLastMsgValue).map(Double::intValue)
+ .orElse(defaultValue);
+ }
+
+ public @Nullable State getFeatureState(String name) {
+ return Optional.ofNullable(getFeature(name)).map(DeviceFeature::getState).orElse(null);
+ }
+
+ public boolean getFlag(String key, boolean def) {
+ synchronized (flags) {
+ return flags.getOrDefault(key, def);
+ }
+ }
+
+ public @Nullable DeviceFeature getFeatureQueried() {
+ synchronized (requestQueue) {
+ return featureQueried;
+ }
+ }
+
+ public void setModem(@Nullable InsteonModem modem) {
+ this.modem = modem;
+ }
+
+ public void setAddress(T address) {
+ this.address = address;
+ }
+
+ public void setHandler(S handler) {
+ this.handler = handler;
+ }
+
+ public void setProductData(ProductData productData) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("setting product data for {} to {}", address, productData);
+ }
+ this.productData = productData;
+ }
+
+ protected void setStatus(DeviceStatus status) {
+ this.status = status;
+ }
+
+ public void setFlag(String key, boolean value) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("setting {} flag for {} to {}", key, address, value);
+ }
+ synchronized (flags) {
+ flags.put(key, value);
+ }
+ }
+
+ public void setFlags(Map flags) {
+ flags.forEach(this::setFlag);
+ }
+
+ public void setFeatureQueried(@Nullable DeviceFeature featureQueried) {
+ synchronized (requestQueue) {
+ this.featureQueried = featureQueried;
+ }
+ }
+
+ public void setPollInterval(long pollInterval) {
+ if (pollInterval > 0) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("setting poll interval for {} to {}", address, pollInterval);
+ }
+ this.pollInterval = pollInterval;
+ }
+ }
+
+ @Override
+ public String toString() {
+ String s = address.toString();
+ if (productData != null) {
+ s += "|" + productData;
+ } else {
+ s += "|unknown device";
+ }
+ for (DeviceFeature feature : getFeatures()) {
+ s += "|" + feature;
+ }
+ return s;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ InsteonDevice other = (InsteonDevice) obj;
+ return address.equals(other.address);
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + address.hashCode();
+ return result;
+ }
+
+ /**
+ * Returns if device is pollable
+ *
+ * @return true if has a pollable feature
+ */
+ public boolean isPollable() {
+ return getFeatures().stream().anyMatch(DeviceFeature::isPollable);
+ }
+
+ /**
+ * Starts polling this device
+ */
+ public void startPolling() {
+ InsteonModem modem = getModem();
+ // start polling if currently disabled
+ if (modem != null && getStatus() != DeviceStatus.POLLING) {
+ getFeatures().forEach(DeviceFeature::initializeQueryStatus);
+ int ndbes = modem.getDB().getEntries().size();
+ modem.getPollManager().startPolling(this, pollInterval, ndbes);
+ setStatus(DeviceStatus.POLLING);
+ }
+ }
+
+ /**
+ * Stops polling this device
+ */
+ public void stopPolling() {
+ InsteonModem modem = getModem();
+ // stop polling if currently enabled
+ if (modem != null && getStatus() == DeviceStatus.POLLING) {
+ modem.getPollManager().stopPolling(this);
+ clearRequestQueue();
+ setStatus(DeviceStatus.STOPPED);
+ }
+ }
+
+ /**
+ * Polls this device
+ *
+ * @param delay scheduling delay (in milliseconds)
+ */
+ @Override
+ public void doPoll(long delay) {
+ schedulePoll(delay, feature -> true);
+ }
+
+ /**
+ * Polls a specific feature for this device
+ *
+ * @param name name of the feature to poll
+ * @param delay scheduling delay (in milliseconds)
+ * @return poll message
+ */
+ public @Nullable Msg pollFeature(String name, long delay) {
+ return Optional.ofNullable(getFeature(name)).map(feature -> feature.doPoll(delay)).orElse(null);
+ }
+
+ /**
+ * Schedules polling for this device
+ *
+ * @param delay scheduling delay (in milliseconds)
+ * @param featureFilter feature filter to apply
+ * @return delay spacing
+ */
+ protected long schedulePoll(long delay, Predicate featureFilter) {
+ long spacing = 0;
+ for (DeviceFeature feature : getFeatures()) {
+ // skip if is event feature or feature filter doesn't match
+ if (feature.isEventFeature() || !featureFilter.test(feature)) {
+ continue;
+ }
+ // poll feature with listeners or never queried before
+ if (feature.hasListeners() || feature.getQueryStatus() == QueryStatus.NEVER_QUERIED) {
+ Msg msg = feature.doPoll(delay + spacing);
+ if (msg != null) {
+ spacing += msg.getQuietTime();
+ }
+ }
+ }
+ return spacing;
+ }
+
+ /**
+ * Clears request queue
+ */
+ protected void clearRequestQueue() {
+ if (logger.isTraceEnabled()) {
+ logger.trace("clearing request queue for {}", address);
+ }
+ synchronized (requestQueue) {
+ requestQueue.clear();
+ requestQueueHash.clear();
+ }
+ }
+
+ /**
+ * Instantiates features for this device based on a device type
+ *
+ * @param deviceType device type to instantiate features from
+ */
+ protected void instantiateFeatures(DeviceType deviceType) {
+ for (FeatureEntry featureEntry : deviceType.getFeatures()) {
+ DeviceFeature feature = DeviceFeature.makeDeviceFeature(this, featureEntry.getName(),
+ featureEntry.getType(), featureEntry.getParameters());
+ if (feature == null) {
+ logger.warn("device type {} references unknown feature type {}", deviceType.getName(),
+ featureEntry.getType());
+ } else {
+ addFeature(feature);
+ }
+ }
+ for (FeatureEntry featureEntry : deviceType.getFeatureGroups()) {
+ DeviceFeature feature = getFeature(featureEntry.getName());
+ if (feature == null) {
+ logger.warn("device type {} references unknown feature group {}", deviceType.getName(),
+ featureEntry.getName());
+ } else {
+ connectFeatures(feature, featureEntry.getConnectedFeatures());
+ }
+ }
+ }
+
+ /**
+ * Adds feature to this device
+ *
+ * @param feature device feature to add
+ */
+ private void addFeature(DeviceFeature feature) {
+ synchronized (features) {
+ features.put(feature.getName(), feature);
+ }
+ }
+
+ /**
+ * Connects group features to its parent
+ *
+ * @param groupFeature group feature to connect to
+ * @param features connected features part of that group feature
+ */
+ private void connectFeatures(DeviceFeature groupFeature, List features) {
+ for (String name : features) {
+ DeviceFeature feature = getFeature(name);
+ if (feature == null) {
+ logger.warn("group feature {} references unknown feature {}", groupFeature.getName(), name);
+ } else {
+ if (logger.isTraceEnabled()) {
+ logger.trace("{} connected feature: {}", groupFeature.getName(), feature.getName());
+ }
+ feature.addParameters(groupFeature.getParameters());
+ feature.setGroupFeature(groupFeature);
+ feature.setPollHandler(null);
+ groupFeature.addConnectedFeature(feature);
+ }
+ }
+ }
+
+ /**
+ * Resets features query status for this device
+ */
+ public void resetFeaturesQueryStatus() {
+ if (getStatus() == DeviceStatus.POLLING) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("resetting device features query status for {}", address);
+ }
+ DeviceFeature featureQueried = getFeatureQueried();
+ getFeatures().stream().filter(feature -> !feature.equals(featureQueried))
+ .forEach(DeviceFeature::initializeQueryStatus);
+ }
+ }
+
+ /**
+ * Handles incoming message for this device by forwarding
+ * it to all features that this device supports
+ *
+ * @param msg the incoming message
+ */
+ @Override
+ public void handleMessage(Msg msg) {
+ getFeatures().stream().filter(feature -> feature.handleMessage(msg)).findFirst().ifPresent(feature -> {
+ if (logger.isTraceEnabled()) {
+ logger.trace("handled reply of direct for {}", feature.getName());
+ }
+ // mark feature queried as processed and answered
+ setFeatureQueried(null);
+ feature.setQueryMessage(null);
+ feature.setQueryStatus(QueryStatus.QUERY_ANSWERED);
+ });
+ }
+
+ /**
+ * Sends a message after a delay to this device
+ *
+ * @param msg the message to be sent
+ * @param feature device feature associated to the message
+ * @param delay time (in milliseconds) to delay before sending message
+ */
+ @Override
+ public void sendMessage(Msg msg, DeviceFeature feature, long delay) {
+ addDeviceRequest(msg, feature, delay);
+ }
+
+ /**
+ * Adds a request for this device
+ *
+ * @param msg message to be sent
+ * @param feature device feature that sent this message
+ * @param delay time (in milliseconds) to delay before sending message
+ */
+ protected void addDeviceRequest(Msg msg, DeviceFeature feature, long delay) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("enqueuing request with delay {} msec", delay);
+ }
+ synchronized (requestQueue) {
+ DeviceRequest request = new DeviceRequest(feature, msg, delay);
+ DeviceRequest prevRequest = requestQueueHash.get(msg);
+ if (prevRequest != null) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("overwriting existing request for {}: {}", feature.getName(), msg);
+ }
+ requestQueue.remove(prevRequest);
+ requestQueueHash.remove(msg);
+ }
+ requestQueue.add(request);
+ requestQueueHash.put(msg, request);
+ }
+ InsteonModem modem = getModem();
+ if (modem != null) {
+ modem.getRequestManager().addQueue(this, delay);
+ }
+ }
+
+ /**
+ * Handles next request for this device
+ *
+ * @return wait time (in milliseconds) before processing the subsequent request
+ */
+ @Override
+ public long handleNextRequest() {
+ synchronized (requestQueue) {
+ if (requestQueue.isEmpty()) {
+ return 0L;
+ }
+ long waitTime = checkFeatureQueried();
+ if (waitTime > 0) {
+ return waitTime;
+ }
+ // take the next request off the queue
+ DeviceRequest request = requestQueue.poll();
+ if (request == null) {
+ return 0L;
+ }
+ // get requested feature and message
+ DeviceFeature feature = request.getFeature();
+ Msg msg = request.getMessage();
+ // remove request from queue hash
+ requestQueueHash.remove(msg);
+ // set last request queued time
+ lastRequestQueued = System.currentTimeMillis();
+ // set feature queried for non-broadcast request message
+ if (!msg.isAllLinkBroadcast()) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("request taken off direct for {}: {}", feature.getName(), msg);
+ }
+ // mark requested feature query status as queued
+ feature.setQueryStatus(QueryStatus.QUERY_QUEUED);
+ // store requested feature query message
+ feature.setQueryMessage(msg);
+ // set feature queried
+ setFeatureQueried(feature);
+ } else {
+ if (logger.isDebugEnabled()) {
+ logger.debug("request taken off bcast for {}: {}", feature.getName(), msg);
+ }
+ }
+ // write message
+ InsteonModem modem = getModem();
+ if (modem != null) {
+ try {
+ modem.writeMessage(msg);
+ } catch (IOException e) {
+ logger.warn("message write failed for msg: {}", msg, e);
+ }
+ }
+ // determine the wait time for the next request
+ long quietTime = msg.getQuietTime();
+ long nextExpTime = Optional.ofNullable(requestQueue.peek()).map(DeviceRequest::getExpirationTime)
+ .orElse(0L);
+ long nextTime = Math.max(lastRequestQueued + quietTime, nextExpTime);
+ if (logger.isDebugEnabled()) {
+ logger.debug("next request queue processed in {} msec, quiettime {} msec", nextTime - lastRequestQueued,
+ quietTime);
+ }
+ return nextTime;
+ }
+ }
+
+ /**
+ * Checks feature queried status
+ *
+ * @return wait time if necessary otherwise 0
+ */
+ private long checkFeatureQueried() {
+ long now = System.currentTimeMillis();
+ DeviceFeature feature = getFeatureQueried();
+ if (feature != null) {
+ switch (feature.getQueryStatus()) {
+ case QUERY_QUEUED:
+ // wait for feature queried request to be sent
+ long maxQueueTime = lastRequestQueued + REQUEST_QUEUE_TIMEOUT;
+ if (maxQueueTime > now) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("still waiting for {} query to be sent to {} for another {} msec",
+ feature.getName(), address, maxQueueTime - now);
+ }
+ return now + 1000L; // retry in 1000 ms
+ }
+ if (logger.isDebugEnabled()) {
+ logger.debug("gave up waiting for {} query to be sent to {}", feature.getName(), address);
+ }
+ // reset feature queried as never queried
+ feature.setQueryMessage(null);
+ feature.setQueryStatus(QueryStatus.NEVER_QUERIED);
+ break;
+ case QUERY_SENT:
+ case QUERY_ACKED:
+ // wait for the feature queried to be answered
+ long maxAckTime = lastRequestSent + DIRECT_ACK_TIMEOUT;
+ if (maxAckTime > now) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("still waiting for {} query reply from {} for another {} msec",
+ feature.getName(), address, maxAckTime - now);
+ }
+ return now + 500L; // retry in 500 ms
+ }
+ if (logger.isDebugEnabled()) {
+ logger.debug("gave up waiting for {} query reply from {}", feature.getName(), address);
+ }
+ // reset feature queried as never queried
+ feature.setQueryMessage(null);
+ feature.setQueryStatus(QueryStatus.NEVER_QUERIED);
+ break;
+ default:
+ if (logger.isDebugEnabled()) {
+ logger.debug("unexpected feature {} query status {} for {}", feature.getName(),
+ feature.getQueryStatus(), address);
+ }
+ }
+ // reset feature queried otheriwse
+ setFeatureQueried(null);
+ }
+ return 0;
+ }
+
+ /**
+ * Notifies that a message request was replied for this device
+ *
+ * @param msg the message received
+ */
+ @Override
+ public void requestReplied(Msg msg) {
+ DeviceFeature feature = getFeatureQueried();
+ if (feature != null && feature.isMyReply(msg)) {
+ if (msg.isReplyAck()) {
+ // mark feature queried as acked
+ feature.setQueryStatus(QueryStatus.QUERY_ACKED);
+ } else {
+ if (logger.isDebugEnabled()) {
+ logger.debug("got a reply nack msg: {}", msg);
+ }
+ // mark feature queried as processed and answered
+ setFeatureQueried(null);
+ feature.setQueryMessage(null);
+ feature.setQueryStatus(QueryStatus.QUERY_ANSWERED);
+ }
+ }
+ }
+
+ /**
+ * Notifies that a message request was sent to this device
+ *
+ * @param msg the message sent
+ * @param time the time the request was sent
+ */
+ @Override
+ public void requestSent(Msg msg, long time) {
+ DeviceFeature feature = getFeatureQueried();
+ if (feature != null && msg.equals(feature.getQueryMessage())) {
+ // mark feature queried as pending
+ feature.setQueryStatus(QueryStatus.QUERY_SENT);
+ // set last request sent time
+ lastRequestSent = time;
+ }
+ }
+
+ /**
+ * Refreshes this device
+ */
+ @Override
+ public void refresh() {
+ if (logger.isTraceEnabled()) {
+ logger.trace("refreshing device {}", address);
+ }
+ @Nullable
+ S handler = getHandler();
+ if (handler != null) {
+ handler.refresh();
+ }
+ }
+
+ /**
+ * Class that represents a device request
+ */
+ protected static class DeviceRequest implements Comparable {
+ private DeviceFeature feature;
+ private Msg msg;
+ private long expirationTime;
+
+ public DeviceRequest(DeviceFeature feature, Msg msg, long delay) {
+ this.feature = feature;
+ this.msg = msg;
+ setExpirationTime(delay);
+ }
+
+ public DeviceFeature getFeature() {
+ return feature;
+ }
+
+ public Msg getMessage() {
+ return msg;
+ }
+
+ public long getExpirationTime() {
+ return expirationTime;
+ }
+
+ public void setExpirationTime(long delay) {
+ this.expirationTime = System.currentTimeMillis() + delay;
+ }
+
+ @Override
+ public int compareTo(DeviceRequest other) {
+ return (int) (expirationTime - other.expirationTime);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DefaultLink.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DefaultLink.java
new file mode 100644
index 0000000000000..6f811f8c83b9a
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DefaultLink.java
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.device;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.database.LinkDBRecord;
+import org.openhab.binding.insteon.internal.database.ModemDBRecord;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+
+/**
+ * The {@link DefaultLink} represents a device default link
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class DefaultLink {
+ private String name;
+ private LinkDBRecord linkDBRecord;
+ private ModemDBRecord modemDBRecord;
+ private List commands;
+
+ public DefaultLink(String name, LinkDBRecord linkDBRecord, ModemDBRecord modemDBRecord, List commands) {
+ this.name = name;
+ this.linkDBRecord = linkDBRecord;
+ this.modemDBRecord = modemDBRecord;
+ this.commands = commands;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public LinkDBRecord getLinkDBRecord() {
+ return linkDBRecord;
+ }
+
+ public ModemDBRecord getModemDBRecord() {
+ return modemDBRecord;
+ }
+
+ public List getCommands() {
+ return commands;
+ }
+
+ @Override
+ public String toString() {
+ String s = name + "|linkDB:" + linkDBRecord + "|modemDB:" + modemDBRecord;
+ if (!commands.isEmpty()) {
+ s += "|commands:" + commands;
+ }
+ return s;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/Device.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/Device.java
new file mode 100644
index 0000000000000..1a4be063c832c
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/Device.java
@@ -0,0 +1,113 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.device;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+
+/**
+ * Interface for classes that represent a device
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public interface Device {
+ /**
+ * Returns the address for this device
+ *
+ * @return the device address
+ */
+ public DeviceAddress getAddress();
+
+ /**
+ * Returns the product data for this device
+ *
+ * @return the device product data if defined, otherwise null
+ */
+ public @Nullable ProductData getProductData();
+
+ /**
+ * Returns the type for this device
+ *
+ * @return the device type if defined, otherwise null
+ */
+ public @Nullable DeviceType getType();
+
+ /**
+ * Returns a feature based on name for this device
+ *
+ * @param name the device feature name to match
+ * @return the device feature if found, otherwise null
+ */
+ public @Nullable DeviceFeature getFeature(String name);
+
+ /**
+ * Returns the list of features for this device
+ *
+ * @return the list of device features
+ */
+ public List getFeatures();
+
+ /**
+ * Polls this device
+ *
+ * @param delay scheduling delay (in milliseconds)
+ */
+ public void doPoll(long delay);
+
+ /**
+ * Handles an incoming message for this device
+ *
+ * @param msg the incoming message
+ */
+ public void handleMessage(Msg msg);
+
+ /**
+ * Sends a message after a delay to this device
+ *
+ * @param msg the message to be sent
+ * @param feature device feature associated to the message
+ * @param delay time (in milliseconds) to delay before sending message
+ */
+ public void sendMessage(Msg msg, DeviceFeature feature, long delay);
+
+ /**
+ * Handles next request for this device
+ *
+ * @return time (in milliseconds) before processing the subsequent request
+ */
+ public long handleNextRequest();
+
+ /**
+ * Notifies that a message request was replied for this device
+ *
+ * @param msg the message received
+ */
+ public void requestReplied(Msg msg);
+
+ /**
+ * Notifies that a message request was sent to this device
+ *
+ * @param msg the message sent
+ * @param time the time the request was sent
+ */
+ public void requestSent(Msg msg, long time);
+
+ /**
+ * Refreshes this device
+ */
+ public void refresh();
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceAddress.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceAddress.java
new file mode 100644
index 0000000000000..2294b6e7fedd5
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceAddress.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.device;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Interface for classes that represent a device address
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public interface DeviceAddress {
+ @Override
+ public String toString();
+
+ @Override
+ public boolean equals(@Nullable Object obj);
+
+ @Override
+ public int hashCode();
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceFeature.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceFeature.java
new file mode 100644
index 0000000000000..5d29c8f6220d7
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceFeature.java
@@ -0,0 +1,758 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.device;
+
+import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.config.InsteonChannelConfiguration;
+import org.openhab.binding.insteon.internal.device.feature.CommandHandler;
+import org.openhab.binding.insteon.internal.device.feature.FeatureTemplate;
+import org.openhab.binding.insteon.internal.device.feature.FeatureTemplateRegistry;
+import org.openhab.binding.insteon.internal.device.feature.MessageDispatcher;
+import org.openhab.binding.insteon.internal.device.feature.MessageHandler;
+import org.openhab.binding.insteon.internal.device.feature.PollHandler;
+import org.openhab.binding.insteon.internal.listener.FeatureListener;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.openhab.binding.insteon.internal.utils.ParameterParser;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A DeviceFeature represents a certain feature (trait) of a given Insteon device, e.g. something
+ * operating under a given InsteonAddress that can be manipulated (relay) or read (sensor).
+ *
+ * The DeviceFeature does the processing of incoming messages, and handles commands for the
+ * particular feature it represents.
+ *
+ * It uses four mechanisms for that:
+ *
+ * 1) MessageDispatcher: makes high level decisions about an incoming message and then runs the
+ * 2) MessageHandler: further processes the message, updates state etc
+ * 3) CommandHandler: translates commands from the openhab bus into an Insteon message.
+ * 4) PollHandler: creates an Insteon message to query the DeviceFeature
+ *
+ * Lastly, InsteonChannelHandler can register with the DeviceFeature to get notifications when
+ * the state of a feature is updated. In practice, a InsteonChannelHandler corresponds to an
+ * openHAB item.
+ *
+ * The character of a DeviceFeature is thus given by a set of message and command handlers.
+ * A FeatureTemplate captures exactly that: it says what set of handlers make up a DeviceFeature.
+ *
+ * DeviceFeatures are added to a new device by referencing a FeatureTemplate (defined in device_features.xml)
+ * from the Device definition file (device_types.xml).
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Bernd Pfrommer - openHAB 1 insteonplm binding
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class DeviceFeature {
+ public static enum QueryStatus {
+ NEVER_QUERIED,
+ QUERY_SCHEDULED,
+ QUERY_QUEUED,
+ QUERY_SENT,
+ QUERY_ACKED,
+ QUERY_ANSWERED,
+ NOT_POLLABLE
+ }
+
+ private final Logger logger = LoggerFactory.getLogger(DeviceFeature.class);
+
+ private String name;
+ private String type;
+ private Device device;
+ private QueryStatus queryStatus = QueryStatus.NOT_POLLABLE;
+ private State state = UnDefType.NULL;
+ private @Nullable Double lastMsgValue;
+ private @Nullable Msg queryMsg;
+
+ private MessageHandler defaultMsgHandler = MessageHandler.makeDefaultHandler(this);
+ private CommandHandler defaultCommandHandler = CommandHandler.makeDefaultHandler(this);
+ private @Nullable PollHandler pollHandler;
+ private @Nullable MessageDispatcher dispatcher;
+ private @Nullable DeviceFeature groupFeature;
+
+ private Map parameters = new HashMap<>();
+ private Map msgHandlers = new HashMap<>();
+ private Map commandHandlers = new HashMap<>();
+ private List connectedFeatures = new ArrayList<>();
+ private Set listeners = new CopyOnWriteArraySet<>();
+
+ /**
+ * Constructor
+ *
+ * @param name feature name
+ * @param type feature type
+ * @param device feature device
+ */
+ public DeviceFeature(String name, String type, Device device) {
+ this.name = name;
+ this.type = type;
+ this.device = device;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public Device getDevice() {
+ return device;
+ }
+
+ public Map getParameters() {
+ synchronized (parameters) {
+ return parameters;
+ }
+ }
+
+ public @Nullable String getParameter(String key) {
+ synchronized (parameters) {
+ return parameters.get(key);
+ }
+ }
+
+ public boolean hasParameter(String key) {
+ synchronized (parameters) {
+ return parameters.containsKey(key);
+ }
+ }
+
+ public boolean getParameterAsBoolean(String key, boolean defaultValue) {
+ return ParameterParser.getParameterAsOrDefault(getParameter(key), Boolean.class, defaultValue);
+ }
+
+ public int getParameterAsInteger(String key, int defaultValue) {
+ return ParameterParser.getParameterAsOrDefault(getParameter(key), Integer.class, defaultValue);
+ }
+
+ public synchronized @Nullable Double getLastMsgValue() {
+ return lastMsgValue;
+ }
+
+ public double getLastMsgValueAsDouble(double defaultValue) {
+ return Optional.ofNullable(getLastMsgValue()).map(Double::doubleValue).orElse(defaultValue);
+ }
+
+ public int getLastMsgValueAsInteger(int defaultValue) {
+ return Optional.ofNullable(getLastMsgValue()).map(Double::intValue).orElse(defaultValue);
+ }
+
+ public synchronized @Nullable Msg getQueryMessage() {
+ return queryMsg;
+ }
+
+ public int getQueryCommand() {
+ Msg queryMsg = getQueryMessage();
+ if (queryMsg != null) {
+ try {
+ return queryMsg.getInt("command1");
+ } catch (FieldException e) {
+ logger.warn("{}:{} error parsing msg {}", device.getAddress(), name, queryMsg, e);
+ }
+ }
+ return -1;
+ }
+
+ public synchronized QueryStatus getQueryStatus() {
+ return queryStatus;
+ }
+
+ public synchronized State getState() {
+ return state;
+ }
+
+ public boolean isGroupFeature() {
+ return !connectedFeatures.isEmpty();
+ }
+
+ public boolean isPartOfGroupFeature() {
+ return groupFeature != null;
+ }
+
+ public boolean isControllerFeature() {
+ String linkType = getParameter("link");
+ return "both".equals(linkType) || "controller".equals(linkType);
+ }
+
+ public boolean isResponderFeature() {
+ String linkType = getParameter("link");
+ return "both".equals(linkType) || "responder".equals(linkType);
+ }
+
+ public boolean isControllerOrResponderFeature() {
+ return isControllerFeature() || isResponderFeature();
+ }
+
+ public boolean isEventFeature() {
+ return getParameterAsBoolean("event", false);
+ }
+
+ public boolean isHiddenFeature() {
+ return getParameterAsBoolean("hidden", false);
+ }
+
+ public boolean isStatusFeature() {
+ return getParameterAsBoolean("status", false);
+ }
+
+ public int getGroup() {
+ return getParameterAsInteger("group", 1);
+ }
+
+ public int getComponentId() {
+ int componentId = 0;
+ if (device instanceof InsteonDevice insteonDevice) {
+ // use feature group as component id if device has more than one controller or responder feature,
+ // othewise use the component id of the link db first record
+ if (insteonDevice.getControllerOrResponderFeatures().size() > 1) {
+ componentId = getGroup();
+ } else {
+ componentId = insteonDevice.getLinkDB().getFirstRecordComponentId();
+ }
+ }
+ return componentId;
+ }
+
+ public MessageHandler getDefaultMsgHandler() {
+ return defaultMsgHandler;
+ }
+
+ public @Nullable MessageHandler getMsgHandler(int command, int group) {
+ synchronized (msgHandlers) {
+ return msgHandlers.get(MessageHandler.generateId(command, group));
+ }
+ }
+
+ public MessageHandler getOrDefaultMsgHandler(int command, int group) {
+ synchronized (msgHandlers) {
+ return msgHandlers.getOrDefault(MessageHandler.generateId(command, group), defaultMsgHandler);
+ }
+ }
+
+ public MessageHandler getOrDefaultMsgHandler(int command) {
+ return getOrDefaultMsgHandler(command, -1);
+ }
+
+ public CommandHandler getOrDefaultCommandHandler(String key) {
+ synchronized (commandHandlers) {
+ return commandHandlers.getOrDefault(key, defaultCommandHandler);
+ }
+ }
+
+ public @Nullable MessageDispatcher getMsgDispatcher() {
+ return dispatcher;
+ }
+
+ public @Nullable PollHandler getPollHandler() {
+ return pollHandler;
+ }
+
+ public boolean isPollable() {
+ PollHandler pollHandler = getPollHandler();
+ return pollHandler != null && pollHandler.makeMsg() != null;
+ }
+
+ public @Nullable DeviceFeature getGroupFeature() {
+ return groupFeature;
+ }
+
+ public List getConnectedFeatures() {
+ synchronized (connectedFeatures) {
+ return connectedFeatures;
+ }
+ }
+
+ public boolean hasControllerFeatures() {
+ return isControllerFeature() || getConnectedFeatures().stream().anyMatch(DeviceFeature::hasControllerFeatures);
+ }
+
+ public boolean hasResponderFeatures() {
+ return isResponderFeature() || getConnectedFeatures().stream().anyMatch(DeviceFeature::hasResponderFeatures);
+ }
+
+ public boolean hasListeners() {
+ return !listeners.isEmpty() || getConnectedFeatures().stream().anyMatch(DeviceFeature::hasListeners);
+ }
+
+ public void setMessageDispatcher(@Nullable MessageDispatcher dispatcher) {
+ this.dispatcher = dispatcher;
+ }
+
+ public void setPollHandler(@Nullable PollHandler pollHandler) {
+ this.pollHandler = pollHandler;
+ }
+
+ public void setDefaultCommandHandler(CommandHandler defaultCommandHandler) {
+ this.defaultCommandHandler = defaultCommandHandler;
+ }
+
+ public void setDefaultMsgHandler(MessageHandler defaultMsgHandler) {
+ this.defaultMsgHandler = defaultMsgHandler;
+ }
+
+ public void setGroupFeature(DeviceFeature groupFeature) {
+ this.groupFeature = groupFeature;
+ }
+
+ public synchronized void setLastMsgValue(double lastMsgValue) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("{}:{} setting last message value to: {}", device.getAddress(), name, lastMsgValue);
+ }
+ this.lastMsgValue = lastMsgValue;
+ }
+
+ public synchronized void setQueryMessage(@Nullable Msg queryMsg) {
+ this.queryMsg = queryMsg;
+ }
+
+ public synchronized void setQueryStatus(QueryStatus queryStatus) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("{}:{} setting query status to: {}", device.getAddress(), name, queryStatus);
+ }
+ this.queryStatus = queryStatus;
+ }
+
+ public synchronized void setState(State state) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("{}:{} setting state to: {}", device.getAddress(), name, state);
+ }
+ this.state = state;
+ }
+
+ public void initializeQueryStatus() {
+ // set query status to never queried if feature pollable,
+ // otherwise to not pollable if not already in that state
+ if (isPollable()) {
+ setQueryStatus(QueryStatus.NEVER_QUERIED);
+ } else if (queryStatus != QueryStatus.NOT_POLLABLE) {
+ setQueryStatus(QueryStatus.NOT_POLLABLE);
+ }
+ }
+
+ public void addParameters(Map params) {
+ synchronized (parameters) {
+ parameters.putAll(params);
+ }
+ // reset message handler map ids if new group parameter added
+ if (params.containsKey(PARAMETER_GROUP)) {
+ resetMessageHandlerIds();
+ }
+ }
+
+ public void addMessageHandler(String key, MessageHandler handler) {
+ synchronized (msgHandlers) {
+ if (msgHandlers.putIfAbsent(key, handler) != null) {
+ logger.warn("{}: ignoring duplicate message handler: {}->{}", type, key, handler);
+ }
+ }
+ }
+
+ public void addCommandHandler(String key, CommandHandler handler) {
+ synchronized (commandHandlers) {
+ if (commandHandlers.putIfAbsent(key, handler) != null) {
+ logger.warn("{}: ignoring duplicate command handler: {}->{}", type, key, handler);
+ }
+ }
+ }
+
+ private void resetMessageHandlerIds() {
+ synchronized (msgHandlers) {
+ if (!msgHandlers.isEmpty()) {
+ Map handlers = msgHandlers.values().stream()
+ .collect(Collectors.toMap(MessageHandler::getId, Function.identity()));
+ msgHandlers.clear();
+ msgHandlers.putAll(handlers);
+ }
+ }
+ }
+
+ public void addConnectedFeature(DeviceFeature feature) {
+ synchronized (connectedFeatures) {
+ connectedFeatures.add(feature);
+ }
+ }
+
+ public void registerListener(FeatureListener listener) {
+ listeners.add(listener);
+ }
+
+ public void unregisterListener(FeatureListener listener) {
+ listeners.remove(listener);
+ }
+
+ /**
+ * Returns if a message is a successful response queried by this feature
+ *
+ * @param msg the message to check
+ * @return true if my direct ack
+ */
+ public boolean isMyDirectAck(Msg msg) {
+ return msg.isAckOfDirect() && !msg.isReplayed() && getQueryStatus() == QueryStatus.QUERY_ACKED;
+ }
+
+ /**
+ * Returns if a message is a failed response queried by this feature
+ *
+ * @param msg the message to check
+ * @return true if my direct nack
+ */
+ public boolean isMyDirectNack(Msg msg) {
+ if (msg.isNackOfDirect() && !msg.isReplayed() && getQueryStatus() == QueryStatus.QUERY_ACKED) {
+ if (logger.isDebugEnabled()) {
+ try {
+ int cmd2 = msg.getInt("command2");
+ if (cmd2 == 0xFF) {
+ logger.debug("got a sender device id not in responder database failed command msg: {}", msg);
+ } else if (cmd2 == 0xFE) {
+ logger.debug("got a no load detected failed command msg: {}", msg);
+ } else if (cmd2 == 0xFD) {
+ logger.debug("got an incorrect checksum failed command msg: {}", msg);
+ } else if (cmd2 == 0xFC) {
+ logger.debug("got a database search timeout failed command msg: {}", msg);
+ } else if (cmd2 == 0xFB) {
+ logger.debug("got an illegal value failed command msg: {}", msg);
+ } else {
+ logger.debug("got an unknown failed command msg: {}", msg);
+ }
+ } catch (FieldException e) {
+ logger.warn("{}:{} error parsing msg {}", device.getAddress(), name, msg, e);
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns if a message is a response queried by this feature
+ *
+ * @param msg the message to check
+ * @return true if my direct ack or nack
+ */
+ public boolean isMyDirectAckOrNack(Msg msg) {
+ return isMyDirectAck(msg) || isMyDirectNack(msg);
+ }
+
+ /**
+ * Returns if a message is a reply to a query sent by this feature
+ *
+ * @param msg the message to check
+ * @return true if my reply
+ */
+ public boolean isMyReply(Msg msg) {
+ Msg queryMsg = getQueryMessage();
+ return queryMsg != null && msg.isReplyOf(queryMsg) && getQueryStatus() == QueryStatus.QUERY_SENT;
+ }
+
+ /**
+ * Handles message according to message dispatcher
+ *
+ * @param msg the message to dispatch
+ * @return true if dispatch successful
+ */
+ public boolean handleMessage(Msg msg) {
+ MessageDispatcher dispatcher = getMsgDispatcher();
+ if (dispatcher == null) {
+ logger.warn("{}:{} no dispatcher for msg {}", device.getAddress(), name, msg);
+ return false;
+ }
+ if (logger.isTraceEnabled()) {
+ logger.trace("{}:{} handling message using dispatcher {}", device.getAddress(), name,
+ dispatcher.getClass().getSimpleName());
+ }
+ return dispatcher.dispatch(msg);
+ }
+
+ /**
+ * Handles command for this device feature
+ *
+ * @param cmd the command to be executed
+ */
+ public void handleCommand(Command cmd) {
+ handleCommand(new InsteonChannelConfiguration(), cmd);
+ }
+
+ /**
+ * Handles command for this device feature
+ *
+ * @param config the channel config of the item which sends the command
+ * @param cmd the command to be executed
+ */
+ public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+ String cmdType = cmd.getClass().getSimpleName();
+ CommandHandler cmdHandler = getOrDefaultCommandHandler(cmdType);
+ if (!cmdHandler.canHandle(cmd)) {
+ if (logger.isDebugEnabled()) {
+ logger.debug("{}:{} command {}:{} cannot be handled by {}", device.getAddress(), name, cmdType, cmd,
+ cmdHandler.getClass().getSimpleName());
+ }
+ return;
+ }
+ if (logger.isTraceEnabled()) {
+ logger.trace("{}:{} handling command {}:{} using handler {}", device.getAddress(), name, cmdType, cmd,
+ cmdHandler.getClass().getSimpleName());
+ }
+ cmdHandler.handleCommand(config, cmd);
+ }
+
+ /**
+ * Makes a poll message using the configured poll message handler
+ *
+ * @return the poll message
+ */
+ public @Nullable Msg makePollMsg() {
+ PollHandler pollHandler = getPollHandler();
+ if (pollHandler == null) {
+ return null;
+ }
+ if (logger.isTraceEnabled()) {
+ logger.trace("{}:{} making poll msg using handler {}", device.getAddress(), name,
+ pollHandler.getClass().getSimpleName());
+ }
+ return pollHandler.makeMsg();
+ }
+
+ /**
+ * Sends request message to device
+ *
+ * @param msg request message to send
+ */
+ public void sendRequest(Msg msg) {
+ device.sendMessage(msg, this, 0L);
+ }
+
+ /**
+ * Updates the state for this feature
+ *
+ * @param state the state to update
+ */
+ public void updateState(State state) {
+ setState(state);
+ listeners.forEach(listener -> listener.stateUpdated(state));
+ }
+
+ /**
+ * Triggers an event this feature
+ *
+ * @param event the event name to trigger
+ */
+ public void triggerEvent(String event) {
+ if (!isEventFeature()) {
+ logger.warn("{}:{} not configured to handle triggered event", device.getAddress(), name);
+ return;
+ }
+ listeners.forEach(listener -> listener.eventTriggered(event));
+ }
+
+ /**
+ * Triggers a poll at this feature, group feature or device level,
+ * in order of precedence depending on pollability
+ *
+ * @param delay scheduling delay (in milliseconds)
+ */
+ public void triggerPoll(long delay) {
+ // determine poll delay for this feature if not provided
+ if (delay == -1) {
+ delay = getPollDelay();
+ }
+ // trigger feature poll if pollable
+ if (doPoll(delay) != null) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("{}:{} triggered poll on this feature", device.getAddress(), name);
+ }
+ return;
+ }
+ // trigger group feature poll if defined and pollable, as fallback
+ DeviceFeature groupFeature = getGroupFeature();
+ if (groupFeature != null && groupFeature.doPoll(delay) != null) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("{}:{} triggered poll on group feature {}", device.getAddress(), name,
+ groupFeature.getName());
+ }
+ return;
+ }
+ // trigger device poll limiting to responder features, otherwise
+ if (device instanceof InsteonDevice insteonDevice) {
+ insteonDevice.pollResponders(delay);
+ }
+ }
+
+ /**
+ * Returns the poll delay for this feature
+ *
+ * @return the poll delay based on device ramp rate if supported and available, otherwise 0
+ */
+ private long getPollDelay() {
+ if (RampRate.supportsFeatureType(type) && device instanceof InsteonDevice insteonDevice) {
+ State state = insteonDevice.getFeatureState(FEATURE_RAMP_RATE);
+ RampRate rampRate;
+ if (state instanceof QuantityType> rampTime) {
+ rampTime = Objects.requireNonNullElse(rampTime.toInvertibleUnit(Units.SECOND), rampTime);
+ rampRate = RampRate.fromTime(rampTime.doubleValue());
+ } else {
+ rampRate = RampRate.DEFAULT;
+ }
+ return rampRate.getTimeInMilliseconds();
+ }
+ return 0L;
+ }
+
+ /**
+ * Executes the polling of this feature
+ *
+ * @param delay scheduling delay (in milliseconds)
+ * @return poll message
+ */
+ public @Nullable Msg doPoll(long delay) {
+ Msg msg = makePollMsg();
+ if (msg != null) {
+ device.sendMessage(msg, this, delay);
+ }
+ return msg;
+ }
+
+ /**
+ * Polls related devices to this feature
+ *
+ * @param delay scheduling delay (in milliseconds)
+ */
+ public void pollRelatedDevices(long delay) {
+ if (device instanceof InsteonDevice insteonDevice) {
+ insteonDevice.pollRelatedDevices(getGroup(), delay);
+ }
+ }
+
+ /**
+ * Polls related devices to a broadcast group
+ *
+ * @param group broadcast group
+ * @param delay scheduling delay (in milliseconds)
+ */
+ public void pollRelatedDevices(int group, long delay) {
+ InsteonModem modem = device instanceof InsteonModem insteonModem ? insteonModem
+ : device instanceof InsteonDevice insteonDevice ? insteonDevice.getModem() : null;
+ if (modem != null) {
+ modem.pollRelatedDevices(group, delay);
+ }
+ }
+
+ /**
+ * Adjusts related devices to this feature
+ *
+ * @param config the channel config
+ * @param cmd the command to adjust to
+ */
+ public void adjustRelatedDevices(InsteonChannelConfiguration config, Command cmd) {
+ if (device instanceof InsteonDevice insteonDevice) {
+ insteonDevice.adjustRelatedDevices(getGroup(), config, cmd);
+ }
+ }
+
+ /**
+ * Returns broadcast group for this feature
+ *
+ * @param config the channel config
+ * @return the broadcast group if found, otherwise -1
+ */
+ public int getBroadcastGroup(InsteonChannelConfiguration config) {
+ if (device instanceof InsteonDevice insteonDevice) {
+ return insteonDevice.getBroadcastGroup(this);
+ } else if (device instanceof InsteonModem) {
+ return config.getGroup();
+ }
+ return -1;
+ }
+
+ @Override
+ public String toString() {
+ String s = name + "->" + type;
+ if (!parameters.isEmpty()) {
+ s += parameters;
+ }
+ s += "(" + commandHandlers.size() + ":" + msgHandlers.size() + ":" + listeners.size() + ")";
+ return s;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null) {
+ return false;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+ DeviceFeature other = (DeviceFeature) obj;
+ return name.equals(other.name) && type.equals(other.type) && device.equals(other.device);
+ }
+
+ @Override
+ public int hashCode() {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + name.hashCode();
+ result = prime * result + type.hashCode();
+ result = prime * result + device.hashCode();
+ return result;
+ }
+
+ /**
+ * Factory method for creating DeviceFeature
+ *
+ * @param device the feature device
+ * @param name the feature name
+ * @param type the feature type
+ * @param parameters the feature parameters
+ * @return the newly created DeviceFeature, or null if requested feature type does not exist.
+ */
+ public static @Nullable DeviceFeature makeDeviceFeature(Device device, String name, String type,
+ Map parameters) {
+ FeatureTemplate template = FeatureTemplateRegistry.getInstance().getTemplate(type);
+ if (template == null) {
+ return null;
+ }
+
+ DeviceFeature feature = template.build(name, device);
+ feature.addParameters(parameters);
+ feature.initializeQueryStatus();
+
+ return feature;
+ }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceType.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceType.java
new file mode 100644
index 0000000000000..7e08ba400284c
--- /dev/null
+++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceType.java
@@ -0,0 +1,267 @@
+/**
+ * Copyright (c) 2010-2024 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.insteon.internal.device;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+
+/**
+ * The {@link DeviceType} represents a device type
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class DeviceType {
+ private String name;
+ private Map flags = new HashMap<>();
+ private Map features = new LinkedHashMap<>();
+ private Map links = new LinkedHashMap<>();
+
+ /**
+ * Constructor
+ *
+ * @param name the name for this device type
+ * @param flags the flags for this device type
+ * @param features the features for this device type
+ * @param links the default links for this device type
+ */
+ public DeviceType(String name, Map flags, Map features,
+ Map links) {
+ this.name = name;
+ this.flags = flags;
+ this.features = features;
+ this.links = links;
+ }
+
+ /**
+ * Returns name
+ *
+ * @return the name for this device type
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Returns flags
+ *
+ * @return all flags for this device type
+ */
+ public Map getFlags() {
+ return flags;
+ }
+
+ /**
+ * Returns supported features
+ *
+ * @return all features that this device type supports
+ */
+ public List