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 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 type = (Class) 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 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 getFeatures() { + return features.values().stream().toList(); + } + + /** + * Returns supported feature groups + * + * @return all feature groups that this device type supports + */ + public List getFeatureGroups() { + return features.values().stream().filter(FeatureEntry::hasConnectedFeatures).toList(); + } + + /** + * Returns default links + * + * @return all default links for this device type + */ + public Map getDefaultLinks() { + return links; + } + + @Override + public String toString() { + String s = "name:" + name; + if (!features.isEmpty()) { + s += "|features:" + features.values().stream().map(FeatureEntry::toString).collect(Collectors.joining(",")); + } + if (!flags.isEmpty()) { + s += "|flags:" + flags.entrySet().stream().map(Entry::toString).collect(Collectors.joining(",")); + } + if (!links.isEmpty()) { + s += "|default-links:" + + links.values().stream().map(DefaultLinkEntry::toString).collect(Collectors.joining(",")); + } + return s; + } + + /** + * Class that reflects a feature entry + */ + public static class FeatureEntry { + private String name; + private String type; + private Map parameters; + private List connectedFeatures = new ArrayList<>(); + + public FeatureEntry(String name, String type, Map parameters) { + this.name = name; + this.type = type; + this.parameters = parameters; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public Map getParameters() { + return parameters; + } + + public List getConnectedFeatures() { + return connectedFeatures; + } + + public boolean hasConnectedFeatures() { + return !connectedFeatures.isEmpty(); + } + + public void addConnectedFeature(String name) { + connectedFeatures.add(name); + } + + @Override + public String toString() { + String s = name + "->" + type; + if (!connectedFeatures.isEmpty()) { + s += "|connectedFeatures:" + connectedFeatures; + } + return s; + } + } + + /** + * Class that reflects a default link entry + */ + public static class DefaultLinkEntry { + private String name; + private boolean controller; + private int group; + private byte[] data; + private List commands = new ArrayList<>(); + + public DefaultLinkEntry(String name, boolean controller, int group, byte[] data) { + this.name = name; + this.controller = controller; + this.group = group; + this.data = data; + } + + public boolean isController() { + return controller; + } + + public int getGroup() { + return group; + } + + public byte[] getData() { + return data; + } + + public List getCommands() { + return commands; + } + + public void addCommand(CommandEntry command) { + commands.add(command); + } + + @Override + public String toString() { + String s = name + "->"; + s += controller ? "CTRL" : "RESP"; + s += "|group:" + group; + s += "|data1:" + HexUtils.getHexString(data[0]); + s += "|data2:" + HexUtils.getHexString(data[1]); + s += "|data3:" + HexUtils.getHexString(data[2]); + if (!commands.isEmpty()) { + s += "|commands:" + commands; + } + return s; + } + } + + /** + * Class that reflects a command entry + */ + public static class CommandEntry { + private String name; + private int ext; + private byte cmd1; + private byte cmd2; + private byte[] data; + + public CommandEntry(String name, int ext, byte cmd1, byte cmd2, byte[] data) { + this.name = name; + this.ext = ext; + this.cmd1 = cmd1; + this.cmd2 = cmd2; + this.data = data; + } + + public @Nullable Msg getMessage(InsteonDevice device) { + try { + if (ext == 0) { + return Msg.makeStandardMessage(device.getAddress(), cmd1, cmd2); + } else if (ext == 1) { + return Msg.makeExtendedMessage(device.getAddress(), cmd1, cmd2, data, + device.getInsteonEngine().supportsChecksum()); + } else if (ext == 2) { + return Msg.makeExtendedMessageCRC2(device.getAddress(), cmd1, cmd2, data); + } + } catch (FieldException | InvalidMessageTypeException e) { + } + return null; + } + + @Override + public String toString() { + String s = name + "->"; + s += "ext:" + ext; + s += "|cmd1:" + HexUtils.getHexString(cmd1); + s += "|cmd2:" + HexUtils.getHexString(cmd2); + s += "|data1:" + HexUtils.getHexString(data[0]); + s += "|data2:" + HexUtils.getHexString(data[1]); + s += "|data3:" + HexUtils.getHexString(data[2]); + return s; + } + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceTypeRegistry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceTypeRegistry.java new file mode 100644 index 0000000000000..fb6d8e6c7772f --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceTypeRegistry.java @@ -0,0 +1,317 @@ +/** + * 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.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.device.DeviceType.CommandEntry; +import org.openhab.binding.insteon.internal.device.DeviceType.DefaultLinkEntry; +import org.openhab.binding.insteon.internal.device.DeviceType.FeatureEntry; +import org.openhab.binding.insteon.internal.utils.HexUtils; +import org.openhab.binding.insteon.internal.utils.ResourceLoader; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** + * The {@link DeviceTypeRegistry} represents the device type registry + * + * @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 DeviceTypeRegistry extends ResourceLoader { + private static final DeviceTypeRegistry DEVICE_TYPE_REGISTRY = new DeviceTypeRegistry(); + private static final String RESOURCE_NAME = "/device_types.xml"; + + private Map deviceTypes = new LinkedHashMap<>(); + private Map baseFeatures = new LinkedHashMap<>(); + + /** + * Returns the device type for a given name + * + * @param name device type name to search for + * @return the device type, or null if not found + */ + public @Nullable DeviceType getDeviceType(@Nullable String name) { + return deviceTypes.get(name); + } + + /** + * Returns known device types + * + * @return currently known device types + */ + public Map getDeviceTypes() { + return deviceTypes; + } + + /** + * Initializes device type registry + */ + @Override + protected void initialize() { + super.initialize(); + + if (logger.isDebugEnabled()) { + logger.debug("loaded {} device types", deviceTypes.size()); + if (logger.isTraceEnabled()) { + deviceTypes.values().stream().map(String::valueOf).forEach(logger::trace); + } + } + } + + /** + * Returns device type resource name + */ + @Override + protected String getResourceName() { + return RESOURCE_NAME; + } + + /** + * Parses device type document + * + * @param element element to parse + * @throws SAXException + * @throws IOException + */ + @Override + protected void parseDocument(Element element) throws SAXException, IOException { + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + String nodeName = child.getNodeName(); + if ("device-type".equals(nodeName)) { + parseDeviceType(child); + } else if ("base-features".equals(nodeName)) { + parseBaseFeatures(child); + } + } + } + } + + /** + * Parses device type node + * + * @param element element to parse + * @throws SAXException + */ + private void parseDeviceType(Element element) throws SAXException { + String name = element.getAttribute("name"); + if ("".equals(name)) { + throw new SAXException("device type in device_types file has no name!"); + } + if (deviceTypes.containsKey(name)) { + logger.warn("overwriting previous definition of device type {}", name); + deviceTypes.remove(name); + } + Map flags = getFlags(element); + Map features = new LinkedHashMap<>(); + Map links = new LinkedHashMap<>(); + + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + String nodeName = child.getNodeName(); + if ("feature".equals(nodeName)) { + parseFeature(child, features); + } else if ("feature-group".equals(nodeName)) { + parseFeatureGroup(child, features); + } else if ("default-link".equals(nodeName)) { + parseDefaultLink(child, links); + } + } + } + // add base features if device type not network brige or x10 categories + if (!name.startsWith("NetworkBridge") && !name.startsWith("X10")) { + baseFeatures.forEach(features::putIfAbsent); + } + deviceTypes.put(name, new DeviceType(name, flags, features, links)); + } + + /** + * Parses base features node + * + * @param element element to parse + * @throws SAXException + */ + private void parseBaseFeatures(Element element) throws SAXException { + if (!baseFeatures.isEmpty()) { + throw new SAXException("base features have already been loaded"); + } + + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + String nodeName = child.getNodeName(); + if ("feature".equals(nodeName)) { + parseFeature(child, baseFeatures); + } + } + } + } + + /** + * Parses feature node + * + * @param element element to parse + * @param features features map to update + * @return the parsed feature name + * @throws SAXException + */ + private String parseFeature(Element element, Map features) throws SAXException { + String name = element.getAttribute("name"); + if ("".equals(name)) { + throw new SAXException("undefined feature name"); + } + String type = element.getTextContent(); + if (type == null) { + throw new SAXException("undefined feature type"); + } + Map params = getParameters(element, List.of("name")); + FeatureEntry feature = new FeatureEntry(name, type, params); + if (features.putIfAbsent(name, feature) != null) { + throw new SAXException("duplicate feature: " + name); + } + return name; + } + + /** + * Parses feature group node + * + * @param element element to parse + * @param features features map to update + * @throws SAXException + */ + private void parseFeatureGroup(Element element, Map features) throws SAXException { + String name = element.getAttribute("name"); + if ("".equals(name)) { + throw new SAXException("undefined feature group name"); + } + String type = element.getAttribute("type"); + if ("".equals(type)) { + throw new SAXException("undefined feature group type"); + } + Map params = getParameters(element, List.of("name", "type")); + FeatureEntry feature = new FeatureEntry(name, type, params); + if (features.putIfAbsent(name, feature) != null) { + throw new SAXException("duplicate feature group: " + name); + } + + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + String nodeName = child.getNodeName(); + if ("feature".equals(nodeName)) { + feature.addConnectedFeature(parseFeature(child, features)); + } + } + } + } + + /** + * Parses default link + * + * @param element element to parse + * @param links links map to update + * @throws SAXException + */ + private void parseDefaultLink(Element element, Map links) throws SAXException { + String name = element.getAttribute("name"); + if ("".equals(name)) { + throw new SAXException("undefined default link name"); + } + boolean isController = "controller".equals(element.getAttribute("type")); + int group = getAttributeAsInteger(element, "group"); + if (group <= 0 || group >= 255) { + throw new SAXException("out of bound default link group: " + group); + } + byte[] data = { getHexAttributeAsByte(element, "data1"), getHexAttributeAsByte(element, "data2"), + getHexAttributeAsByte(element, "data3") }; + + DefaultLinkEntry link = new DefaultLinkEntry(name, isController, group, data); + + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + String nodeName = child.getNodeName(); + if ("command".equals(nodeName)) { + link.addCommand(getDefaultLinkCommand(child)); + } + } + } + + if (links.putIfAbsent(name, link) != null) { + throw new SAXException("duplicate default link: " + name); + } + } + + /** + * Returns a default link command + * + * @param element element to parse + * @return default link command + * @throws SAXException + */ + private CommandEntry getDefaultLinkCommand(Element element) throws SAXException { + String name = element.getAttribute("name"); + if ("".equals(name)) { + throw new SAXException("undefined default link command name"); + } + int ext = getAttributeAsInteger(element, "ext"); + if (ext < 0 || ext > 2) { + throw new SAXException("out of bound default link command ext argument: " + ext); + } + byte cmd1 = getHexAttributeAsByte(element, "cmd1"); + if (cmd1 == 0) { + throw new SAXException("invalid default link command cmd1 argument: " + HexUtils.getHexString(cmd1)); + } + byte cmd2 = getHexAttributeAsByte(element, "cmd2", (byte) 0x00); + byte[] data = { getHexAttributeAsByte(element, "data1", (byte) 0x00), + getHexAttributeAsByte(element, "data2", (byte) 0x00), + getHexAttributeAsByte(element, "data3", (byte) 0x00) }; + + return new CommandEntry(name, ext, cmd1, cmd2, data); + } + + /** + * Singleton instance function + * + * @return DeviceTypeRegistry singleton reference + */ + public static synchronized DeviceTypeRegistry getInstance() { + if (DEVICE_TYPE_REGISTRY.getDeviceTypes().isEmpty()) { + DEVICE_TYPE_REGISTRY.initialize(); + } + return DEVICE_TYPE_REGISTRY; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonAddress.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonAddress.java index fed14815b46e1..da26c5681adb1 100644 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonAddress.java +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonAddress.java @@ -14,88 +14,53 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.insteon.internal.utils.Utils; +import org.openhab.binding.insteon.internal.utils.HexUtils; /** - * This class wraps an Insteon Address 'xx.xx.xx' + * The {@link InsteonAddress} represents an Insteon address * * @author Daniel Pfrommer - Initial contribution * @author Rob Nielsen - Port to openHAB 2 insteon binding + * @author Jeremy Setton - Rewrite insteon binding */ @NonNullByDefault -public class InsteonAddress { - private byte highByte; - private byte middleByte; - private byte lowByte; - private boolean x10; +public class InsteonAddress implements DeviceAddress { + public static final InsteonAddress UNKNOWN = new InsteonAddress("00.00.00"); - public InsteonAddress() { - highByte = 0x00; - middleByte = 0x00; - lowByte = 0x00; - x10 = false; + private final byte highByte; + private final byte middleByte; + private final byte lowByte; + + public InsteonAddress(InsteonAddress address) { + this.highByte = address.highByte; + this.middleByte = address.middleByte; + this.lowByte = address.lowByte; } - public InsteonAddress(InsteonAddress a) { - highByte = a.highByte; - middleByte = a.middleByte; - lowByte = a.lowByte; - x10 = a.x10; + public InsteonAddress(byte highByte, byte middleByte, byte lowByte) { + this.highByte = highByte; + this.middleByte = middleByte; + this.lowByte = lowByte; } - public InsteonAddress(byte high, byte middle, byte low) { - highByte = high; - middleByte = middle; - lowByte = low; - x10 = false; + public InsteonAddress(byte[] b) throws ArrayIndexOutOfBoundsException { + this.highByte = b[0]; + this.middleByte = b[1]; + this.lowByte = b[2]; } - /** - * Constructor - * - * @param address string must have format of e.g. '2a.3c.40' or (for X10) 'H.UU' - */ public InsteonAddress(String address) throws IllegalArgumentException { - if (X10.isValidAddress(address)) { - highByte = 0; - middleByte = 0; - lowByte = X10.addressToByte(address); - x10 = true; - } else { - String[] parts = address.split("\\."); - if (parts.length != 3) { - throw new IllegalArgumentException("Address string must have 3 bytes, has: " + parts.length); - } - highByte = (byte) Utils.fromHexString(parts[0]); - middleByte = (byte) Utils.fromHexString(parts[1]); - lowByte = (byte) Utils.fromHexString(parts[2]); - x10 = false; + String[] parts = address.split("\\."); + if (parts.length != 3) { + throw new IllegalArgumentException("Address string must have 3 bytes, has: " + parts.length); + } + try { + this.highByte = (byte) HexUtils.toInteger(parts[0]); + this.middleByte = (byte) HexUtils.toInteger(parts[1]); + this.lowByte = (byte) HexUtils.toInteger(parts[2]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Address string must have hexadecimal bytes"); } - } - - /** - * Constructor for an InsteonAddress that wraps an X10 address. - * Simply stuff the X10 address into the lowest byte. - * - * @param aX10HouseUnit the house and unit number as encoded by the X10 protocol - */ - public InsteonAddress(byte aX10HouseUnit) { - highByte = 0; - middleByte = 0; - lowByte = aX10HouseUnit; - x10 = true; - } - - public void setHighByte(byte h) { - highByte = h; - } - - public void setMiddleByte(byte m) { - middleByte = m; - } - - public void setLowByte(byte l) { - lowByte = l; } public byte getHighByte() { @@ -110,45 +75,15 @@ public byte getLowByte() { return lowByte; } - public byte getX10HouseCode() { - return (byte) ((lowByte & 0xf0) >> 4); - } - - public byte getX10UnitCode() { - return (byte) ((lowByte & 0x0f)); - } - - public boolean isX10() { - return x10; - } - - public void storeBytes(byte[] bytes, int offset) { - bytes[offset] = getHighByte(); - bytes[offset + 1] = getMiddleByte(); - bytes[offset + 2] = getLowByte(); - } - - public void loadBytes(byte[] bytes, int offset) { - setHighByte(bytes[offset]); - setMiddleByte(bytes[offset + 1]); - setLowByte(bytes[offset + 2]); + public byte[] getBytes() { + return new byte[] { highByte, middleByte, lowByte }; } @Override public String toString() { - String s = null; - if (isX10()) { - byte house = (byte) (((getLowByte() & 0xf0) >> 4) & 0xff); - byte unit = (byte) ((getLowByte() & 0x0f) & 0xff); - s = X10.houseToString(house) + "." + X10.unitToInt(unit); - // s = Utils.getHexString(lowByte); - } else { - s = Utils.getHexString(highByte) + "." + Utils.getHexString(middleByte) + "." + Utils.getHexString(lowByte); - } - return s; + return String.format("%02X.%02X.%02X", highByte, middleByte, lowByte); } - @SuppressWarnings("PMD.SimplifyBooleanReturns") @Override public boolean equals(@Nullable Object obj) { if (this == obj) { @@ -161,19 +96,7 @@ public boolean equals(@Nullable Object obj) { return false; } InsteonAddress other = (InsteonAddress) obj; - if (highByte != other.highByte) { - return false; - } - if (lowByte != other.lowByte) { - return false; - } - if (middleByte != other.middleByte) { - return false; - } - if (x10 != other.x10) { - return false; - } - return true; + return highByte == other.highByte && middleByte == other.middleByte && lowByte == other.lowByte; } @Override @@ -181,46 +104,25 @@ public int hashCode() { final int prime = 31; int result = 1; result = prime * result + highByte; - result = prime * result + lowByte; result = prime * result + middleByte; - result = prime * result + (x10 ? 1231 : 1237); + result = prime * result + lowByte; return result; } /** - * Test if Insteon address is valid + * Returns if Insteon address is valid * - * @return true if address is in valid AB.CD.EF or (for X10) H.UU format + * @return true if address is valid */ - public static boolean isValid(@Nullable String addr) { - if (addr == null) { - return false; - } - if (X10.isValidAddress(addr)) { - return true; - } - String[] fields = addr.split("\\."); - if (fields.length != 3) { + public static boolean isValid(@Nullable String address) { + if (address == null) { return false; } try { - // convert the insteon xx.xx.xx address to integer to test - @SuppressWarnings("unused") - int test = Integer.parseInt(fields[2], 16) * 65536 + Integer.parseInt(fields[1], 16) * 256 - + +Integer.parseInt(fields[0], 16); - } catch (NumberFormatException e) { + new InsteonAddress(address); + return true; + } catch (IllegalArgumentException e) { return false; } - return true; - } - - /** - * Turn string into address - * - * @param val the string to convert - * @return the corresponding insteon address - */ - public static InsteonAddress parseAddress(String val) { - return new InsteonAddress(val); } } diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonDevice.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonDevice.java new file mode 100644 index 0000000000000..4d8dd8ecc92c3 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonDevice.java @@ -0,0 +1,1033 @@ +/** + * 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.io.IOException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.PriorityQueue; +import java.util.Queue; +import java.util.Set; +import java.util.function.Predicate; +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.config.InsteonChannelConfiguration; +import org.openhab.binding.insteon.internal.database.LinkDB; +import org.openhab.binding.insteon.internal.database.LinkDBChange; +import org.openhab.binding.insteon.internal.database.LinkDBRecord; +import org.openhab.binding.insteon.internal.database.ModemDB; +import org.openhab.binding.insteon.internal.database.ModemDBChange; +import org.openhab.binding.insteon.internal.database.ModemDBEntry; +import org.openhab.binding.insteon.internal.database.ModemDBRecord; +import org.openhab.binding.insteon.internal.device.DeviceFeature.QueryStatus; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.KeypadButtonToggleMode; +import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler; +import org.openhab.binding.insteon.internal.transport.message.GroupMessageStateMachine; +import org.openhab.binding.insteon.internal.transport.message.GroupMessageStateMachine.GroupMessageType; +import org.openhab.binding.insteon.internal.transport.message.Msg; +import org.openhab.binding.insteon.internal.utils.BinaryUtils; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +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; + +/** + * The {@link InsteonDevice} represents an Insteon device + * + * @author Bernd Pfrommer - Initial contribution + * @author Rob Nielsen - Port to openHAB 2 insteon binding + * @author Jeremy Setton - Rewrite insteon binding + */ +@NonNullByDefault +public class InsteonDevice extends BaseDevice { + private static final int BCAST_STATE_TIMEOUT = 2000; // in milliseconds + private static final int DEFAULT_HEARTBEAT_TIMEOUT = 1440; // in minutes + private static final int FAILED_MSG_COUNT_THRESHOLD = 5; + + private InsteonEngine engine = InsteonEngine.UNKNOWN; + private LinkDB linkDB; + private Map defaultLinks = new LinkedHashMap<>(); + private List storedMessages = new LinkedList<>(); + private Queue deferredQueue = new PriorityQueue<>(); + private Map deferredQueueHash = new HashMap<>(); + private Map lastBroadcastReceived = new HashMap<>(); + private Map groupState = new HashMap<>(); + private volatile int failedMsgCount = 0; + private volatile long lastMsgReceived = 0L; + + public InsteonDevice() { + super(InsteonAddress.UNKNOWN); + this.linkDB = new LinkDB(this); + } + + public InsteonEngine getInsteonEngine() { + return engine; + } + + public LinkDB getLinkDB() { + return linkDB; + } + + public @Nullable DefaultLink getDefaultLink(String name) { + synchronized (defaultLinks) { + return defaultLinks.get(name); + } + } + + public List getDefaultLinks() { + synchronized (defaultLinks) { + return defaultLinks.values().stream().toList(); + } + } + + public List getStoredMessages() { + synchronized (storedMessages) { + return storedMessages; + } + } + + public List getControllerFeatures() { + return getFeatures().stream().filter(DeviceFeature::isControllerFeature).toList(); + } + + public List getResponderFeatures() { + return getFeatures().stream().filter(DeviceFeature::isResponderFeature).toList(); + } + + public List getControllerOrResponderFeatures() { + return getFeatures().stream().filter(DeviceFeature::isControllerOrResponderFeature).toList(); + } + + public List getFeatures(String type) { + return getFeatures().stream().filter(feature -> feature.getType().equals(type)).toList(); + } + + public @Nullable DeviceFeature getFeature(String type, int group) { + return getFeatures().stream().filter(feature -> feature.getType().equals(type) && feature.getGroup() == group) + .findFirst().orElse(null); + } + + public double getLastMsgValueAsDouble(String type, int group, double defaultValue) { + return Optional.ofNullable(getFeature(type, group)).map(DeviceFeature::getLastMsgValue).map(Double::doubleValue) + .orElse(defaultValue); + } + + public int getLastMsgValueAsInteger(String type, int group, int defaultValue) { + return Optional.ofNullable(getFeature(type, group)).map(DeviceFeature::getLastMsgValue).map(Double::intValue) + .orElse(defaultValue); + } + + public @Nullable State getFeatureState(String type, int group) { + return Optional.ofNullable(getFeature(type, group)).map(DeviceFeature::getState).orElse(null); + } + + public boolean isResponding() { + return failedMsgCount < FAILED_MSG_COUNT_THRESHOLD; + } + + public boolean isBatteryPowered() { + return getFlag("batteryPowered", false); + } + + public boolean isDeviceSyncEnabled() { + return getFlag("deviceSyncEnabled", false); + } + + public boolean hasModemDBEntry() { + return getFlag("modemDBEntry", false); + } + + public void setInsteonEngine(InsteonEngine engine) { + if (logger.isTraceEnabled()) { + logger.trace("setting insteon engine for {} to {}", address, engine); + } + this.engine = engine; + // notify properties changed + propertiesChanged(false); + } + + public void setHasModemDBEntry(boolean value) { + setFlag("modemDBEntry", value); + // notify status changed + statusChanged(); + } + + public void setIsDeviceSyncEnabled(boolean value) { + setFlag("deviceSyncEnabled", value); + } + + /** + * Returns this device heartbeat timeout + * + * @return heartbeat timeout in minutes + */ + public int getHeartbeatTimeout() { + DeviceFeature feature = getFeature(FEATURE_HEARTBEAT_INTERVAL); + if (feature != null) { + if (feature.getState() instanceof QuantityType interval) { + return Objects.requireNonNullElse(interval.toInvertibleUnit(Units.MINUTE), interval).intValue(); + } + return 0; + } + return DEFAULT_HEARTBEAT_TIMEOUT; + } + + /** + * Returns if this device has heartbeat + * + * @return true if has heartbeat feature and heartbeat on/off feature state on when available, otherise false + */ + public boolean hasHeartbeat() { + return hasFeature(FEATURE_HEARTBEAT) && (!hasFeature(FEATURE_HEARTBEAT_ON_OFF) + || OnOffType.ON.equals(getFeatureState(FEATURE_HEARTBEAT_ON_OFF))); + } + + /** + * Returns if this device is awake + * + * @return true if device not battery powered or within awake time + */ + public boolean isAwake() { + if (isBatteryPowered()) { + // define awake time based on the stay awake feature state (ON => 4 minutes; OFF => 3 seconds) + State state = getFeatureState(FEATURE_STAY_AWAKE); + int awakeTime = OnOffType.ON.equals(state) ? 240000 : 3000; // in msec + return System.currentTimeMillis() - lastMsgReceived <= awakeTime; + } + return true; + } + + /** + * Returns if a broadcast message is duplicate + * + * @param cmd1 the cmd1 from the broadcast message received + * @param timestamp the timestamp from the broadcast message received + * @return true if the broadcast message is duplicate + */ + public boolean isDuplicateBroadcastMsg(byte cmd1, long timestamp) { + synchronized (lastBroadcastReceived) { + long timelapse = timestamp - lastBroadcastReceived.getOrDefault(cmd1, timestamp); + if (timelapse > 0 && timelapse < BCAST_STATE_TIMEOUT) { + return true; + } else { + lastBroadcastReceived.put(cmd1, timestamp); + return false; + } + } + } + + /** + * Returns if a group message is duplicate + * + * @param cmd1 cmd1 from the group message received + * @param timestamp the timestamp from the broadcast message received + * @param group the broadcast group + * @param type the group message type that was received + * @return true if the group message is duplicate + */ + public boolean isDuplicateGroupMsg(byte cmd1, long timestamp, int group, GroupMessageType type) { + synchronized (groupState) { + GroupMessageStateMachine stateMachine = groupState.get(group); + if (stateMachine == null) { + stateMachine = new GroupMessageStateMachine(); + groupState.put(group, stateMachine); + if (logger.isTraceEnabled()) { + logger.trace("{} created group {} state", address, group); + } + } + if (stateMachine.getLastCommand() == cmd1 && stateMachine.getLastTimestamp() == timestamp) { + if (logger.isTraceEnabled()) { + logger.trace("{} using previous group {} state for {}", address, group, type); + } + return stateMachine.isDuplicate(); + } else { + if (logger.isTraceEnabled()) { + logger.trace("{} updating group {} state to {}", address, group, type); + } + return stateMachine.update(address, group, cmd1, timestamp, type); + } + } + } + + /** + * Returns if device is pollable + * + * @return true if parent pollable and not battery powered + */ + @Override + public boolean isPollable() { + return super.isPollable() && !isBatteryPowered(); + } + + /** + * Polls this device + * + * @param delay scheduling delay (in milliseconds) + */ + @Override + public void doPoll(long delay) { + // process deferred queue + processDeferredQueue(delay); + // poll insteon engine if unknown or its feature never queried + DeviceFeature engineFeature = getFeature(FEATURE_INSTEON_ENGINE); + if (engineFeature != null + && (engine == InsteonEngine.UNKNOWN || engineFeature.getQueryStatus() == QueryStatus.NEVER_QUERIED)) { + engineFeature.doPoll(delay); + return; // insteon engine needs to be known before enqueueing more messages + } + // load this device link db if not complete or should be reloaded + if (!linkDB.isComplete() || linkDB.shouldReload()) { + linkDB.load(delay); + return; // link db needs to be complete before enqueueing more messages + } + // update this device link db if needed + if (linkDB.shouldUpdate()) { + linkDB.update(delay); + } + + super.doPoll(delay); + } + + /** + * Schedules polling for this device + * + * @param delay scheduling delay (in milliseconds) + * @param featureFilter feature filter to apply + * @return delay spacing + */ + @Override + protected long schedulePoll(long delay, Predicate featureFilter) { + long spacing = super.schedulePoll(delay, featureFilter); + // ping non-battery powered device if no other feature scheduled poll + if (!isBatteryPowered() && spacing == 0) { + Msg msg = pollFeature(FEATURE_PING, delay); + if (msg != null) { + spacing += msg.getQuietTime(); + } + } + return spacing; + } + + /** + * Polls all responder features for this device + * + * @param delay scheduling delay (in milliseconds) + */ + public void pollResponders(long delay) { + schedulePoll(delay, DeviceFeature::hasResponderFeatures); + } + + /** + * Polls responder features for a controller address and group + * + * @param address the controller address + * @param group the controller group + * @param delay scheduling delay (in milliseconds) + */ + public void pollResponders(InsteonAddress address, int group, long delay) { + // poll all responder features if link db not complete + if (!linkDB.isComplete()) { + getResponderFeatures().forEach(feature -> feature.triggerPoll(delay)); + return; + } + // poll responder features matching record component id (data 3) + linkDB.getResponderRecords(address, group) + .forEach(record -> getResponderFeatures().stream() + .filter(feature -> feature.getComponentId() == record.getComponentId()).findFirst() + .ifPresent(feature -> feature.triggerPoll(delay))); + } + + /** + * Polls related devices to a controller group + * + * @param group the controller group + * @param delay scheduling delay (in milliseconds) + */ + public void pollRelatedDevices(int group, long delay) { + InsteonModem modem = getModem(); + if (modem != null) { + linkDB.getRelatedDevices(group).stream().map(modem::getInsteonDevice).filter(Objects::nonNull) + .map(Objects::requireNonNull).forEach(device -> { + if (logger.isDebugEnabled()) { + logger.debug("polling related device {} to controller {} group {}", device.getAddress(), + address, group); + } + device.pollResponders(address, group, delay); + }); + } + } + + /** + * Adjusts responder features for a controller address and group + * + * @param address the controller address + * @param group the controller group + * @param onLevel the controller channel config + * @param cmd the cmd to adjust to + */ + public void adjustResponders(InsteonAddress address, int group, InsteonChannelConfiguration config, Command cmd) { + // handle command for responder feature with group matching record component id (data 3) + linkDB.getResponderRecords(address, group) + .forEach(record -> getResponderFeatures().stream() + .filter(feature -> feature.getComponentId() == record.getComponentId()).findFirst() + .ifPresent(feature -> { + InsteonChannelConfiguration adjustConfig = InsteonChannelConfiguration.copyOf(config, + record.getOnLevel(), record.getRampRate()); + feature.handleCommand(adjustConfig, cmd); + })); + } + + /** + * Adjusts related devices to a controller group + * + * @param group the controller group + * @param config the controller channel config + * @param cmd the cmd to adjust to + */ + public void adjustRelatedDevices(int group, InsteonChannelConfiguration config, Command cmd) { + InsteonModem modem = getModem(); + if (modem != null) { + linkDB.getRelatedDevices(group).stream().map(modem::getInsteonDevice).filter(Objects::nonNull) + .map(Objects::requireNonNull).forEach(device -> { + if (logger.isDebugEnabled()) { + logger.debug("adjusting related device {} to controller {} group {}", device.getAddress(), + address, group); + } + device.adjustResponders(address, group, config, cmd); + }); + } + } + + /** + * Returns broadcast group for a controller feature + * + * @param feature the device feature + * @return the brodcast group if found, otherwise -1 + */ + public int getBroadcastGroup(DeviceFeature feature) { + InsteonModem modem = getModem(); + if (modem != null) { + List relatedDevices = linkDB.getRelatedDevices(feature.getGroup()); + // return broadcast group with matching link and modem db related devices + return linkDB.getBroadcastGroups(feature.getComponentId()).stream() + .filter(group -> modem.getDB().getRelatedDevices(group).stream() + .allMatch(address -> getAddress().equals(address) || relatedDevices.contains(address))) + .findFirst().orElse(-1); + } + return -1; + } + + /** + * Replays a list of messages + */ + public void replayMessages(List messages) { + for (Msg msg : messages) { + if (logger.isTraceEnabled()) { + logger.trace("replaying msg: {}", msg); + } + msg.setIsReplayed(true); + handleMessage(msg); + } + } + + /** + * 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) { + // update last msg received if not failure report and more recent msg timestamp + if (!msg.isFailureReport() && msg.getTimestamp() > lastMsgReceived) { + lastMsgReceived = msg.getTimestamp(); + } + // store message if no feature defined + if (!hasFeatures()) { + if (logger.isDebugEnabled()) { + logger.debug("storing message for unknown device {}", address); + } + synchronized (storedMessages) { + storedMessages.add(msg); + } + return; + } + // store current responding state + boolean isPrevResponding = isResponding(); + // handle message depending if failure report or not + if (msg.isFailureReport()) { + getFeatures().stream().filter(feature -> feature.isMyDirectAckOrNack(msg)).findFirst() + .ifPresent(feature -> { + if (logger.isDebugEnabled()) { + logger.debug("got a failure report reply of direct for {}", feature.getName()); + } + // increase failed message counter + failedMsgCount++; + // mark feature queried as processed and never queried + setFeatureQueried(null); + feature.setQueryMessage(null); + feature.setQueryStatus(QueryStatus.NEVER_QUERIED); + // poll feature again if device is responding + if (isResponding()) { + feature.doPoll(0L); + } + }); + } else { + // update non-status features + getFeatures().stream().filter(feature -> !feature.isStatusFeature() && feature.handleMessage(msg)) + .findFirst().ifPresent(feature -> { + if (logger.isTraceEnabled()) { + logger.trace("handled reply of direct for {}", feature.getName()); + } + // reset failed message counter + failedMsgCount = 0; + // mark feature queried as processed and answered + setFeatureQueried(null); + feature.setQueryMessage(null); + feature.setQueryStatus(QueryStatus.QUERY_ANSWERED); + }); + // update all status features (e.g. device last update time) + getFeatures().stream().filter(DeviceFeature::isStatusFeature) + .forEach(feature -> feature.handleMessage(msg)); + } + // notify if responding state changed + if (isPrevResponding != isResponding()) { + statusChanged(); + } + } + + /** + * 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) { + if (isAwake()) { + addDeviceRequest(msg, feature, delay); + } else { + addDeferredRequest(msg, feature); + } + // mark feature query status as scheduled for non-broadcast request message + if (!msg.isAllLinkBroadcast()) { + feature.setQueryStatus(QueryStatus.QUERY_SCHEDULED); + } + } + + /** + * Processes deferred queue + * + * @param delay time (in milliseconds) to delay before sending message + */ + private void processDeferredQueue(long delay) { + synchronized (deferredQueue) { + while (!deferredQueue.isEmpty()) { + DeviceRequest request = deferredQueue.poll(); + if (request != null) { + Msg msg = request.getMessage(); + DeviceFeature feature = request.getFeature(); + deferredQueueHash.remove(msg); + request.setExpirationTime(delay); + if (logger.isTraceEnabled()) { + logger.trace("enqueuing deferred request for {}", feature.getName()); + } + addDeviceRequest(msg, feature, delay); + } + } + } + } + + /** + * Adds deferred request + * + * @param request device request to add + */ + private void addDeferredRequest(Msg msg, DeviceFeature feature) { + if (logger.isTraceEnabled()) { + logger.trace("deferring request for sleeping device {}", address); + } + synchronized (deferredQueue) { + DeviceRequest request = new DeviceRequest(feature, msg, 0L); + DeviceRequest prevRequest = deferredQueueHash.get(msg); + if (prevRequest != null) { + if (logger.isTraceEnabled()) { + logger.trace("overwriting existing deferred request for {}: {}", feature.getName(), msg); + } + deferredQueue.remove(prevRequest); + deferredQueueHash.remove(msg); + } + deferredQueue.add(request); + deferredQueueHash.put(msg, request); + } + } + + /** + * Clears request queue + */ + @Override + protected void clearRequestQueue() { + super.clearRequestQueue(); + + synchronized (deferredQueue) { + deferredQueue.clear(); + deferredQueueHash.clear(); + } + } + + /** + * Updates product data for this device + * + * @param newData the new product data to use + */ + public void updateProductData(ProductData newData) { + ProductData productData = getProductData(); + if (productData == null) { + setProductData(newData); + propertiesChanged(true); + } else { + if (logger.isTraceEnabled()) { + logger.trace("updating product data for {} to {}", address, newData); + } + if (productData.update(newData)) { + propertiesChanged(true); + } else { + propertiesChanged(false); + resetFeaturesQueryStatus(); + } + } + } + + /** + * Updates this device type + * + * @param newType the new device type to use + */ + + public void updateType(DeviceType newType) { + ProductData productData = getProductData(); + DeviceType currentType = getType(); + if (productData != null && !newType.equals(currentType)) { + if (logger.isTraceEnabled()) { + logger.trace("updating device type from {} to {} for {}", + currentType != null ? currentType.getName() : "undefined", newType.getName(), address); + } + productData.setDeviceType(newType); + propertiesChanged(true); + } + } + + /** + * Updates the default links + */ + public void updateDefaultLinks() { + InsteonModem modem = getModem(); + ProductData productData = getProductData(); + DeviceType deviceType = getType(); + State linkFFGroup = getFeatureState(FEATURE_LINK_FF_GROUP); + State twoGroups = getFeatureState(FEATURE_TWO_GROUPS); + if (modem == null || productData == null || deviceType == null || linkFFGroup == UnDefType.NULL + || twoGroups == UnDefType.NULL || InsteonAddress.UNKNOWN.equals(modem.getAddress())) { + return; + } + // clear default links + synchronized (defaultLinks) { + defaultLinks.clear(); + } + // iterate over device type default links + deviceType.getDefaultLinks().forEach((name, link) -> { + // skip default link if 2Groups feature is off and its group is 2 + if (OnOffType.OFF.equals(twoGroups) && link.getGroup() == 2) { + return; + } + // create link db record based on FFGroup feature state + LinkDBRecord linkDBRecord = LinkDBRecord.create(0, modem.getAddress(), + OnOffType.ON.equals(linkFFGroup) ? 0xFF : link.getGroup(), link.isController(), link.getData()); + // create modem db record + ModemDBRecord modemDBRecord = ModemDBRecord.create(address, link.getGroup(), !link.isController(), + !link.isController() ? productData.getRecordData() : new byte[3]); + // create default link commands + List commands = link.getCommands().stream().map(command -> command.getMessage(this)) + .filter(Objects::nonNull).map(Objects::requireNonNull).toList(); + // add default link + addDefaultLink(new DefaultLink(name, linkDBRecord, modemDBRecord, commands)); + }); + } + + /** + * Adds a default link for this device + * + * @param link the default link to add + */ + private void addDefaultLink(DefaultLink link) { + if (logger.isTraceEnabled()) { + logger.trace("adding default link {} for {}", link.getName(), address); + } + synchronized (defaultLinks) { + defaultLinks.put(link.getName(), link); + } + } + + /** + * Returns a map of missing device links for this device + * + * @return map of missing link db records based on default links + */ + public Map getMissingDeviceLinks() { + Map links = new LinkedHashMap<>(); + if (linkDB.isComplete() && hasModemDBEntry()) { + for (DefaultLink link : getDefaultLinks()) { + LinkDBRecord record = link.getLinkDBRecord(); + if ((record.getComponentId() > 0 && !linkDB.hasComponentIdRecord(record.getComponentId(), true)) + || !linkDB.hasGroupRecord(record.getGroup(), true)) { + links.put(link.getName(), LinkDBChange.forAdd(record)); + } + } + } + return links; + } + + /** + * Returns a map of missing modem links for this device + * + * @return map of missing modem db records based on default links + */ + public Map getMissingModemLinks() { + Map links = new LinkedHashMap<>(); + InsteonModem modem = getModem(); + if (modem != null && modem.getDB().isComplete() && hasModemDBEntry()) { + for (DefaultLink link : getDefaultLinks()) { + ModemDBRecord record = link.getModemDBRecord(); + if (!modem.getDB().hasRecord(record.getAddress(), record.getGroup(), record.isController())) { + links.put(link.getName(), ModemDBChange.forAdd(record)); + } + } + } + return links; + } + + /** + * Returns a set of missing links for this device + * + * @return a set of missing link names + */ + public Set getMissingLinks() { + return Stream.of(getMissingDeviceLinks().keySet(), getMissingModemLinks().keySet()).flatMap(Set::stream) + .collect(Collectors.toSet()); + } + + /** + * Logs missing links for this device + */ + public void logMissingLinks() { + Set links = getMissingLinks(); + if (!links.isEmpty()) { + logger.warn( + "device {} has missing default links {}, " + + "run 'insteon device addMissingLinks' command via openhab console to fix.", + address, links); + } + } + + /** + * Adds missing links to link db for this device + */ + public void addMissingDeviceLinks() { + if (getDefaultLinks().isEmpty()) { + return; + } + List changes = getMissingDeviceLinks().values().stream().distinct().toList(); + if (changes.isEmpty()) { + if (logger.isDebugEnabled()) { + logger.debug("no missing default links from link db to add for {}", address); + } + } else { + if (logger.isTraceEnabled()) { + logger.trace("adding missing default links to link db for {}", address); + } + linkDB.clearChanges(); + changes.forEach(linkDB::addChange); + linkDB.update(); + } + + InsteonModem modem = getModem(); + if (modem != null) { + getMissingDeviceLinks().keySet().stream().map(this::getDefaultLink).filter(Objects::nonNull) + .map(Objects::requireNonNull).flatMap(link -> link.getCommands().stream()).forEach(msg -> { + try { + modem.writeMessage(msg); + } catch (IOException e) { + logger.warn("message write failed for msg: {}", msg, e); + } + }); + } + } + + /** + * Adds missing links to modem db for this device + */ + public void addMissingModemLinks() { + InsteonModem modem = getModem(); + if (modem == null || getDefaultLinks().isEmpty()) { + return; + } + List changes = getMissingModemLinks().values().stream().distinct().toList(); + if (changes.isEmpty()) { + if (logger.isDebugEnabled()) { + logger.debug("no missing default links from modem db to add for {}", address); + } + } else { + if (logger.isTraceEnabled()) { + logger.trace("adding missing default links to modem db for {}", address); + } + ModemDB modemDB = modem.getDB(); + modemDB.clearChanges(); + changes.forEach(modemDB::addChange); + modemDB.update(); + } + } + + /** + * Sets a keypad button radio group + * + * @param buttons list of button groups to set + */ + public void setButtonRadioGroup(List buttons) { + // set each radio button to turn off each others when turned on if should set + for (int buttonGroup : buttons) { + DeviceFeature onMaskFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_ON_MASK, buttonGroup); + DeviceFeature offMaskFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_OFF_MASK, buttonGroup); + + if (onMaskFeature != null && offMaskFeature != null) { + int onMask = onMaskFeature.getLastMsgValueAsInteger(0); + int offMask = offMaskFeature.getLastMsgValueAsInteger(0); + + for (int group : buttons) { + int bit = group - 1; + onMask = BinaryUtils.clearBit(onMask, bit); + offMask = BinaryUtils.setBit(offMask, bit, buttonGroup != group); + } + onMaskFeature.handleCommand(new DecimalType(onMask)); + offMaskFeature.handleCommand(new DecimalType(offMask)); + } + } + } + + /** + * Clears a keypad button radion group + * + * @param buttons list of button groups to clear + */ + public void clearButtonRadioGroup(List buttons) { + List allButtons = getFeatures(FEATURE_TYPE_KEYPAD_BUTTON).stream().map(DeviceFeature::getGroup) + .toList(); + // clear each radio button and decouple from others + for (int buttonGroup : allButtons) { + DeviceFeature onMaskFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_ON_MASK, buttonGroup); + DeviceFeature offMaskFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_OFF_MASK, buttonGroup); + + if (onMaskFeature != null && offMaskFeature != null) { + int onMask = onMaskFeature.getLastMsgValueAsInteger(0); + int offMask = offMaskFeature.getLastMsgValueAsInteger(0); + + for (int group : buttons.contains(buttonGroup) ? allButtons : buttons) { + int bit = group - 1; + onMask = BinaryUtils.clearBit(onMask, bit); + offMask = BinaryUtils.clearBit(offMask, bit); + } + onMaskFeature.handleCommand(new DecimalType(onMask)); + offMaskFeature.handleCommand(new DecimalType(offMask)); + } + } + } + + /** + * Sets keypad button toggle mode + * + * @param buttons list of button groups to use + * @param mode toggle mode to set + */ + public void setButtonToggleMode(List buttons, KeypadButtonToggleMode mode) { + // use the first button group if available to set toggle mode + int buttonGroup = !buttons.isEmpty() ? buttons.get(0) : -1; + DeviceFeature toggleModeFeature = getFeature(FEATURE_TYPE_KEYPAD_BUTTON_TOGGLE_MODE, buttonGroup); + + if (toggleModeFeature != null) { + int nonToggleMask = toggleModeFeature.getLastMsgValueAsInteger(0) >> 8; + int alwaysOnOffMask = toggleModeFeature.getLastMsgValueAsInteger(0) & 0xFF; + + for (int group : buttons) { + int bit = group - 1; + nonToggleMask = BinaryUtils.setBit(nonToggleMask, bit, mode != KeypadButtonToggleMode.TOGGLE); + alwaysOnOffMask = BinaryUtils.setBit(alwaysOnOffMask, bit, mode == KeypadButtonToggleMode.ALWAYS_ON); + } + toggleModeFeature.handleCommand(new DecimalType(nonToggleMask << 8 | alwaysOnOffMask)); + } + } + + /** + * Initializes this device + */ + public void initialize() { + InsteonModem modem = getModem(); + if (modem == null || !modem.getDB().isComplete()) { + return; + } + + ModemDBEntry dbe = modem.getDB().getEntry(address); + if (dbe == null) { + logger.warn("device {} not found in the modem database. Did you forget to link?", address); + setHasModemDBEntry(false); + stopPolling(); + return; + } + + ProductData productData = dbe.getProductData(); + if (productData != null) { + updateProductData(productData); + } + + if (!hasModemDBEntry()) { + if (logger.isDebugEnabled()) { + logger.debug("device {} found in the modem database.", address); + } + setHasModemDBEntry(true); + } + + if (isPollable()) { + startPolling(); + } + + updateDefaultLinks(); + } + + /** + * Refreshes this device + */ + @Override + public void refresh() { + initialize(); + + super.refresh(); + } + + /** + * Resets heartbeat monitor + */ + public void resetHeartbeatMonitor() { + InsteonDeviceHandler handler = getHandler(); + if (handler != null) { + handler.resetHeartbeatMonitor(); + } + } + + /** + * Notifies that the link db has been updated for this device + */ + public void linkDBUpdated() { + if (logger.isTraceEnabled()) { + logger.trace("link db for {} has been updated", address); + } + if (linkDB.isComplete()) { + if (isBatteryPowered() && isAwake() || getStatus() == DeviceStatus.POLLING) { + // poll database delta feature + pollFeature(FEATURE_DATABASE_DELTA, 0L); + // poll remaining features for this device + doPoll(0L); + } + // log missing links + logMissingLinks(); + } + // notify device handler if defined + InsteonDeviceHandler handler = getHandler(); + if (handler != null) { + handler.deviceLinkDBUpdated(this); + } + } + + /** + * Notifies that the properties have changed for this device + * + * @param reset if the device should be reset + */ + public void propertiesChanged(boolean reset) { + if (logger.isTraceEnabled()) { + logger.trace("properties for {} has changed", address); + } + InsteonDeviceHandler handler = getHandler(); + if (handler != null) { + if (reset) { + handler.reset(this); + } else { + handler.updateProperties(this); + } + } + } + + /** + * Notifies that the status has changed for this device + */ + public void statusChanged() { + if (logger.isTraceEnabled()) { + logger.trace("status for {} has changed", address); + } + InsteonDeviceHandler handler = getHandler(); + if (handler != null) { + handler.updateStatus(); + } + } + + /** + * Factory method for creating a InsteonDevice from a device address, modem and cache + * + * @param address the device address + * @param modem the device modem + * @param productData the device product data + * @return the newly created InsteonDevice + */ + public static InsteonDevice makeDevice(InsteonAddress address, @Nullable InsteonModem modem, + @Nullable ProductData productData) { + InsteonDevice device = new InsteonDevice(); + device.setAddress(address); + device.setModem(modem); + + if (productData != null) { + DeviceType deviceType = productData.getDeviceType(); + if (deviceType != null) { + device.instantiateFeatures(deviceType); + device.setFlags(deviceType.getFlags()); + } + int location = productData.getFirstRecordLocation(); + if (location != LinkDBRecord.LOCATION_ZERO) { + device.getLinkDB().setFirstRecordLocation(location); + } + device.setProductData(productData); + } + + return device; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonEngine.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonEngine.java new file mode 100644 index 0000000000000..fb345faa186f5 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonEngine.java @@ -0,0 +1,58 @@ +/** + * 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.Arrays; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link InsteonEngine} represents an Insteon engine version + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public enum InsteonEngine { + I1(0x00, false), + I2(0x01, false), + I2CS(0x02, true), + UNKNOWN(0xFF, false); + + private static final Map VERSION_MAP = Arrays.stream(values()) + .collect(Collectors.toUnmodifiableMap(engine -> engine.version, Function.identity())); + + private final int version; + private final boolean checksum; + + private InsteonEngine(int version, boolean checksum) { + this.version = version; + this.checksum = checksum; + } + + public boolean supportsChecksum() { + return checksum; + } + + /** + * Factory method for getting a InsteonEngine from an Insteon engine version + * + * @param version the Insteon engine version + * @return the Insteon engine object + */ + public static InsteonEngine valueOf(int version) { + return VERSION_MAP.getOrDefault(version, InsteonEngine.UNKNOWN); + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonModem.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonModem.java new file mode 100644 index 0000000000000..fb3fb9df1a2ac --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonModem.java @@ -0,0 +1,531 @@ +/** + * 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.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.config.InsteonBridgeConfiguration; +import org.openhab.binding.insteon.internal.database.ModemDB; +import org.openhab.binding.insteon.internal.handler.InsteonBridgeHandler; +import org.openhab.binding.insteon.internal.listener.PortListener; +import org.openhab.binding.insteon.internal.manager.DatabaseManager; +import org.openhab.binding.insteon.internal.manager.LinkManager; +import org.openhab.binding.insteon.internal.manager.PollManager; +import org.openhab.binding.insteon.internal.manager.RequestManager; +import org.openhab.binding.insteon.internal.transport.Port; +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.core.io.transport.serial.SerialPortManager; + +/** + * The {@link InsteonModem} represents an Insteom modem + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public class InsteonModem extends BaseDevice implements PortListener { + private static final int RESET_TIME = 20; // in seconds + + private Port port; + private ModemDB modemDB; + private DatabaseManager dbm; + private LinkManager linker; + private PollManager poller; + private RequestManager requester; + private Map devices = new ConcurrentHashMap<>(); + private Map scenes = new ConcurrentHashMap<>(); + private @Nullable X10Address lastX10Address; + private boolean initialized = false; + private int msgsReceived = 0; + + public InsteonModem(InsteonBridgeConfiguration config, ScheduledExecutorService scheduler, + SerialPortManager serialPortManager) { + super(InsteonAddress.UNKNOWN); + this.port = new Port(config, scheduler, serialPortManager); + this.modemDB = new ModemDB(this); + this.dbm = new DatabaseManager(this, scheduler); + this.linker = new LinkManager(this, scheduler); + this.poller = new PollManager(config.getId()); + this.requester = new RequestManager(config.getId()); + } + + @Override + public @Nullable InsteonModem getModem() { + return this; + } + + public Port getPort() { + return port; + } + + public ModemDB getDB() { + return modemDB; + } + + public DatabaseManager getDBM() { + return dbm; + } + + public LinkManager getLinkManager() { + return linker; + } + + public PollManager getPollManager() { + return poller; + } + + public RequestManager getRequestManager() { + return requester; + } + + public @Nullable Device getDevice(DeviceAddress address) { + return devices.get(address); + } + + public boolean hasDevice(DeviceAddress address) { + return devices.containsKey(address); + } + + public List getDevices() { + return devices.values().stream().toList(); + } + + public @Nullable InsteonDevice getInsteonDevice(InsteonAddress address) { + return (InsteonDevice) getDevice(address); + } + + public List getInsteonDevices() { + return getDevices().stream().filter(InsteonDevice.class::isInstance).map(InsteonDevice.class::cast).toList(); + } + + public @Nullable X10Device getX10Device(X10Address address) { + return (X10Device) getDevice(address); + } + + public List getX10Devices() { + return getDevices().stream().filter(X10Device.class::isInstance).map(X10Device.class::cast).toList(); + } + + public @Nullable InsteonScene getScene(int group) { + return scenes.get(group); + } + + public boolean hasScene(int group) { + return scenes.containsKey(group); + } + + public List getScenes() { + return scenes.values().stream().toList(); + } + + public @Nullable ProductData getProductData(DeviceAddress address) { + ProductData productData = null; + Device device = getDevice(address); + if (device != null && device.getProductData() != null) { + productData = device.getProductData(); + } else if (address instanceof InsteonAddress insteonAddress) { + productData = modemDB.getProductData(insteonAddress); + } + return productData; + } + + public void addDevice(Device device) { + devices.put(device.getAddress(), device); + } + + public void removeDevice(Device device) { + devices.remove(device.getAddress()); + } + + public void addScene(InsteonScene scene) { + scenes.put(scene.getGroup(), scene); + } + + public void removeScene(InsteonScene scene) { + scenes.remove(scene.getGroup()); + } + + public void deleteSceneEntries(InsteonDevice device) { + getScenes().stream().filter(scene -> scene.getDevices().contains(device.getAddress())) + .forEach(scene -> scene.deleteEntries(device.getAddress())); + } + + public void updateSceneEntries(InsteonDevice device) { + getScenes().stream().filter(scene -> modemDB.getRelatedDevices(scene.getGroup()).contains(device.getAddress())) + .forEach(scene -> scene.updateEntries(device)); + } + + public boolean isInitialized() { + return initialized; + } + + public void writeMessage(Msg msg) throws IOException { + port.writeMessage(msg); + } + + public boolean connect() { + logger.debug("connecting to modem"); + if (!port.start()) { + return false; + } + + port.registerListener(this); + + poller.start(); + requester.start(); + + discover(); + + return true; + } + + public void disconnect() { + logger.debug("disconnecting from modem"); + if (linker.isRunning()) { + linker.stop(); + } + + dbm.stop(); + port.stop(); + requester.stop(); + poller.stop(); + } + + public boolean reconnect() { + logger.debug("reconnecting to modem"); + port.stop(); + return port.start(); + } + + private void discover() { + if (isInitialized()) { + logger.debug("modem {} already initialized", address); + } else { + logger.debug("discovering modem"); + getModemInfo(); + } + } + + private void getModemInfo() { + try { + Msg msg = Msg.makeMessage("GetIMInfo"); + writeMessage(msg); + } catch (IOException e) { + logger.warn("error sending modem info query ", e); + } catch (InvalidMessageTypeException e) { + logger.warn("invalid message ", e); + } + } + + private void handleModemInfo(Msg msg) throws FieldException { + InsteonAddress address = msg.getInsteonAddress("IMAddress"); + int deviceCategory = msg.getInt("DeviceCategory"); + int subCategory = msg.getInt("DeviceSubCategory"); + + ProductData productData = ProductDataRegistry.getInstance().getProductData(deviceCategory, subCategory); + productData.setFirmwareVersion(msg.getInt("FirmwareVersion")); + + DeviceType deviceType = productData.getDeviceType(); + if (deviceType == null) { + logger.warn("unsupported product data for modem {} devCat:{} subCat:{}", address, deviceCategory, + subCategory); + return; + } + setAddress(address); + setProductData(productData); + instantiateFeatures(deviceType); + setFlags(deviceType.getFlags()); + + initialized = true; + + if (logger.isDebugEnabled()) { + logger.debug("modem discovered: {}", this); + } + + InsteonBridgeHandler handler = getHandler(); + if (handler != null) { + handler.modemDiscovered(this); + } + } + + public void logDeviceStatistics() { + if (logger.isDebugEnabled()) { + logger.debug("devices: {} configured, {} polling, msgs received: {}", getDevices().size(), + getPollManager().getSizeOfQueue(), msgsReceived); + } + msgsReceived = 0; + } + + private void logDevicesAndScenes() { + if (logger.isDebugEnabled()) { + if (!getInsteonDevices().isEmpty()) { + logger.debug("configured {} insteon devices", getInsteonDevices().size()); + if (logger.isTraceEnabled()) { + getInsteonDevices().stream().map(String::valueOf).forEach(logger::trace); + } + } + if (!getX10Devices().isEmpty()) { + logger.debug("configured {} x10 devices", getX10Devices().size()); + if (logger.isTraceEnabled()) { + getX10Devices().stream().map(String::valueOf).forEach(logger::trace); + } + } + if (!getScenes().isEmpty()) { + logger.debug("configured {} insteon scenes", getScenes().size()); + if (logger.isTraceEnabled()) { + getScenes().stream().map(String::valueOf).forEach(logger::trace); + } + } + } + } + + /** + * Polls related devices to a broadcast group + * + * @param group the broadcast group + * @param delay scheduling delay (in milliseconds) + */ + public void pollRelatedDevices(int group, long delay) { + modemDB.getRelatedDevices(group).stream().map(this::getInsteonDevice).filter(Objects::nonNull) + .map(Objects::requireNonNull).forEach(device -> { + if (logger.isDebugEnabled()) { + logger.debug("polling related device {} to broadcast group {}", device.getAddress(), group); + } + device.pollResponders(address, group, delay); + }); + } + + /** + * Notifies that the database has been completed + */ + public void databaseCompleted() { + logger.debug("modem database completed"); + + getDevices().forEach(Device::refresh); + getScenes().forEach(InsteonScene::refresh); + + logDevicesAndScenes(); + + startPolling(); + refresh(); + + InsteonBridgeHandler handler = getHandler(); + if (handler != null) { + handler.modemDBCompleted(); + } + } + + /** + * Notifies that a database link has been updated + * + * @param address the link address + * @param group the link group + * @param is2Way if two way update + */ + public void databaseLinkUpdated(InsteonAddress address, int group, boolean is2Way) { + if (!modemDB.isComplete()) { + return; + } + if (logger.isDebugEnabled()) { + logger.debug("modem database link updated for device {} group {} 2way {}", address, group, is2Way); + } + InsteonDevice device = getInsteonDevice(address); + if (device != null) { + device.refresh(); + // set link db to reload on next device poll if still in modem db and is two way update + if (device.hasModemDBEntry() && is2Way) { + device.getLinkDB().setReload(true); + } + } + InsteonScene scene = getScene(group); + if (scene != null) { + scene.refresh(); + } + InsteonBridgeHandler handler = getHandler(); + if (handler != null) { + handler.modemDBLinkUpdated(address, group); + } + } + + /** + * Notifies that a database product data has been updated + * + * @param address the device address + * @param productData the updated product data + */ + public void databaseProductDataUpdated(InsteonAddress address, ProductData productData) { + if (!modemDB.isComplete()) { + return; + } + if (logger.isDebugEnabled()) { + logger.debug("product data updated for device {} {}", address, productData); + } + InsteonDevice device = getInsteonDevice(address); + if (device != null) { + device.updateProductData(productData); + } + InsteonBridgeHandler handler = getHandler(); + if (handler != null) { + handler.modemDBProductDataUpdated(address, productData); + } + } + + /** + * Notifies that the modem reset process has been initiated + */ + public void resetInitiated() { + if (logger.isDebugEnabled()) { + logger.debug("modem reset initiated"); + } + InsteonBridgeHandler handler = getHandler(); + if (handler != null) { + handler.reset(RESET_TIME); + } + } + + /** + * Notifies that the modem port has disconnected + */ + @Override + public void disconnected() { + if (logger.isDebugEnabled()) { + logger.debug("modem port disconnected"); + } + InsteonBridgeHandler handler = getHandler(); + if (handler != null) { + handler.reconnect(this); + } + } + + /** + * Notifies that the modem port has received a message + * + * @param msg the message received + */ + @Override + public void messageReceived(Msg msg) { + if (msg.isPureNack()) { + return; + } + try { + if (msg.isX10()) { + handleX10Message(msg); + } else if (msg.isInsteon()) { + handleInsteonMessage(msg); + } else { + handleIMMessage(msg); + } + } catch (FieldException e) { + logger.warn("error parsing msg: {}", msg, e); + } + } + + /** + * Notifies that the modem port has sent a message + * + * @param msg the message sent + */ + @Override + public void messageSent(Msg msg) { + if (msg.isAllLinkBroadcast()) { + return; + } + try { + DeviceAddress address = msg.isInsteon() ? msg.getInsteonAddress("toAddress") + : msg.isX10Address() ? msg.getX10Address() : msg.isX10Command() ? lastX10Address : getAddress(); + if (address == null) { + return; + } + if (msg.isX10()) { + lastX10Address = msg.isX10Address() ? (X10Address) address : null; + } + long time = System.currentTimeMillis(); + Device device = getAddress().equals(address) ? this : getDevice(address); + if (device != null) { + device.requestSent(msg, time); + } + } catch (FieldException e) { + logger.warn("error parsing msg: {}", msg, e); + } + } + + private void handleIMMessage(Msg msg) throws FieldException { + if (msg.getCommand() == 0x60) { + handleModemInfo(msg); + } else { + handleMessage(msg); + } + } + + private void handleInsteonMessage(Msg msg) throws FieldException { + if (msg.isAllLinkBroadcast() && msg.isReply()) { + return; + } + InsteonAddress toAddr = msg.getInsteonAddress("toAddress"); + if (msg.isReply()) { + handleMessage(toAddr, msg); + } else if (msg.isBroadcast() || msg.isAllLinkBroadcast() || getAddress().equals(toAddr)) { + InsteonAddress fromAddr = msg.getInsteonAddress("fromAddress"); + handleMessage(fromAddr, msg); + } + } + + private void handleX10Message(Msg msg) throws FieldException { + X10Address address = lastX10Address; + if (msg.isX10Address()) { + // store the x10 address to use with the next cmd + lastX10Address = msg.getX10Address(); + } else if (address != null) { + handleMessage(address, msg); + lastX10Address = null; + } + } + + private void handleMessage(DeviceAddress address, Msg msg) throws FieldException { + Device device = getDevice(address); + if (device == null) { + if (logger.isDebugEnabled()) { + logger.debug("unknown device with address {}, dropping message", address); + } + } else if (msg.isReply()) { + device.requestReplied(msg); + } else { + device.handleMessage(msg); + msgsReceived++; + } + } + + /** + * Factory method for creating a InsteonModem + * + * @param handler the bridge handler + * @param config the bridge config + * @param scheduler the scheduler service + * @param serialPortManager the serial port manager + * @return the newly created InsteonModem + */ + public static InsteonModem makeModem(InsteonBridgeHandler handler, InsteonBridgeConfiguration config, + ScheduledExecutorService scheduler, SerialPortManager serialPortManager) { + InsteonModem modem = new InsteonModem(config, scheduler, serialPortManager); + modem.setHandler(handler); + return modem; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonScene.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonScene.java new file mode 100644 index 0000000000000..cb853353ec984 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonScene.java @@ -0,0 +1,435 @@ +/** + * 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.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.database.LinkDBRecord; +import org.openhab.binding.insteon.internal.handler.InsteonSceneHandler; +import org.openhab.binding.insteon.internal.listener.FeatureListener; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link InsteonScene} represents an Insteon scene + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public class InsteonScene { + public static final int GROUP_MIN = 2; + public static final int GROUP_MAX = 254; + // limit new scene group minimum to 25 matching the current Insteon app behavior + public static final int GROUP_NEW_MIN = 25; + public static final int GROUP_NEW_MAX = 254; + + private final Logger logger = LoggerFactory.getLogger(InsteonScene.class); + + private int group; + private @Nullable InsteonModem modem; + private @Nullable InsteonSceneHandler handler; + private List entries = new ArrayList<>(); + private boolean modemDBEntry = false; + + public InsteonScene(int group) { + this.group = group; + } + + public int getGroup() { + return group; + } + + public @Nullable InsteonModem getModem() { + return modem; + } + + public @Nullable InsteonSceneHandler getHandler() { + return handler; + } + + public List getEntries() { + synchronized (entries) { + return entries.stream().toList(); + } + } + + public List getEntries(InsteonAddress address) { + return getEntries().stream().filter(entry -> entry.getAddress().equals(address)).toList(); + } + + public List getDevices() { + return getEntries().stream().map(SceneEntry::getAddress).distinct().toList(); + } + + public List getFeatures() { + return getEntries().stream().map(SceneEntry::getFeature).toList(); + } + + public List getFeatures(InsteonAddress address) { + return getEntries(address).stream().map(SceneEntry::getFeature).toList(); + } + + public State getState() { + return getEntries().stream().allMatch(entry -> entry.getState() == UnDefType.NULL) ? UnDefType.NULL + : OnOffType.from(getEntries().stream().filter(entry -> entry.getState() != UnDefType.NULL) + .allMatch(entry -> entry.getState().equals(entry.getOnState()))); + } + + public boolean hasEntry(InsteonAddress address) { + return getEntries().stream().anyMatch(entry -> entry.getAddress().equals(address)); + } + + public boolean hasEntry(InsteonAddress address, String featureName) { + return getEntries().stream().anyMatch( + entry -> entry.getAddress().equals(address) && entry.getFeature().getName().equals(featureName)); + } + + public boolean hasModemDBEntry() { + return modemDBEntry; + } + + public boolean isComplete() { + InsteonModem modem = getModem(); + return modem != null && modem.getDB().getRelatedDevices(group).stream().allMatch(this::hasEntry); + } + + public void setModem(@Nullable InsteonModem modem) { + this.modem = modem; + } + + public void setHandler(InsteonSceneHandler handler) { + this.handler = handler; + } + + public void setHasModemDBEntry(boolean modemDBEntry) { + this.modemDBEntry = modemDBEntry; + } + + @Override + public String toString() { + return "group:" + group + "|entries:" + entries.size(); + } + + /** + * Adds an entry to this scene + * + * @param entry the scene entry to add + */ + private void addEntry(SceneEntry entry) { + if (logger.isTraceEnabled()) { + logger.trace("adding entry to scene {}: {}", group, entry); + } + synchronized (entries) { + if (entries.add(entry)) { + entry.register(); + } + } + } + + /** + * Deletes an entry from this scene + * + * @param entry the scene entry to delete + */ + private void deleteEntry(SceneEntry entry) { + synchronized (entries) { + if (entries.remove(entry)) { + entry.unregister(); + } + } + } + + /** + * Deletes all entries from this scene + */ + public void deleteEntries() { + getEntries().forEach(this::deleteEntry); + } + + /** + * Deletes entries for a given device from this scene + * + * @param address the device address + */ + public void deleteEntries(InsteonAddress address) { + if (logger.isTraceEnabled()) { + logger.trace("removing entries from scene {} for device {}", group, address); + } + getEntries(address).forEach(this::deleteEntry); + } + + /** + * Updates all entries for this scene + */ + public void updateEntries() { + synchronized (entries) { + entries.clear(); + } + + InsteonModem modem = getModem(); + if (modem != null) { + for (InsteonAddress address : modem.getDB().getRelatedDevices(group)) { + InsteonDevice device = modem.getInsteonDevice(address); + if (device == null) { + if (logger.isDebugEnabled()) { + logger.debug("device {} part of scene {} not enabled or configured, ignoring.", address, group); + } + } else { + updateEntries(device); + } + } + } + } + + /** + * Updates entries related to a given device for this scene + * + * @param device the device + */ + public void updateEntries(InsteonDevice device) { + InsteonAddress address = device.getAddress(); + if (logger.isTraceEnabled()) { + logger.trace("updating entries for scene {} device {}", group, address); + } + + getEntries(address).forEach(this::deleteEntry); + + InsteonModem modem = getModem(); + if (modem != null) { + for (LinkDBRecord record : device.getLinkDB().getResponderRecords(modem.getAddress(), group)) { + device.getResponderFeatures().stream() + .filter(feature -> feature.getComponentId() == record.getComponentId()).findFirst() + .ifPresent(feature -> addEntry(new SceneEntry(address, feature, record.getData()))); + } + } + } + + /** + * Resets state for this scene + */ + public void resetState() { + if (logger.isTraceEnabled()) { + logger.trace("resetting state for scene {}", group); + } + getEntries().forEach(entry -> entry.setState(UnDefType.NULL)); + } + + /** + * Updates state for this scene + */ + private void updateState() { + State state = getState(); + InsteonSceneHandler handler = getHandler(); + if (handler != null && state instanceof OnOffType) { + handler.updateState(state); + } + } + + /** + * Adds a device feature to this scene + * + * @param device the device + * @param onLevel the feature on level + * @param rampRate the feature ramp rate + * @param componentId the feature component id + */ + public void addDeviceFeature(InsteonDevice device, int onLevel, @Nullable RampRate rampRate, int componentId) { + InsteonModem modem = getModem(); + if (modem == null || !modem.getDB().isComplete() || !device.getLinkDB().isComplete()) { + return; + } + + modem.getDB().clearChanges(); + modem.getDB().markRecordForAddOrModify(device.getAddress(), group, true); + modem.getDB().update(); + + device.getLinkDB().clearChanges(); + device.getLinkDB().markRecordForAddOrModify(modem.getAddress(), group, false, new byte[] { (byte) onLevel, + (byte) (rampRate != null ? rampRate.getValue() : 0x00), (byte) componentId }); + device.getLinkDB().update(); + } + + /** + * Removes a device feature from this scene + * + * @param device the device + * @param componentId the feature component id + */ + public void removeDeviceFeature(InsteonDevice device, int componentId) { + InsteonModem modem = getModem(); + if (modem == null || !modem.getDB().isComplete() || !device.getLinkDB().isComplete()) { + return; + } + + modem.getDB().clearChanges(); + modem.getDB().markRecordForDelete(device.getAddress(), group); + modem.getDB().update(); + + device.getLinkDB().clearChanges(); + device.getLinkDB().markRecordForDelete(modem.getAddress(), group, false, componentId); + device.getLinkDB().update(); + } + + /** + * Initializes this scene + */ + public void initialize() { + InsteonModem modem = getModem(); + if (modem == null || !modem.getDB().isComplete()) { + return; + } + + if (!modem.getDB().hasBroadcastGroup(group)) { + logger.warn("scene {} not found in the modem database.", group); + setHasModemDBEntry(false); + return; + } + + if (!hasModemDBEntry()) { + if (logger.isDebugEnabled()) { + logger.debug("scene {} found in the modem database.", group); + } + setHasModemDBEntry(true); + } + + updateEntries(); + } + + /** + * Refreshes this scene + */ + public void refresh() { + if (logger.isTraceEnabled()) { + logger.trace("refreshing scene {}", group); + } + initialize(); + + InsteonSceneHandler handler = getHandler(); + if (handler != null) { + handler.refresh(); + } + } + + /** + * Class that represents a scene entry + */ + public class SceneEntry implements FeatureListener { + private InsteonAddress address; + private DeviceFeature feature; + private byte[] data; + private State state = UnDefType.NULL; + + public SceneEntry(InsteonAddress address, DeviceFeature feature, byte[] data) { + this.address = address; + this.feature = feature; + this.data = data; + } + + public InsteonAddress getAddress() { + return address; + } + + public DeviceFeature getFeature() { + return feature; + } + + public State getOnState() { + return OnLevel.getState(Byte.toUnsignedInt(data[0]), feature.getType()); + } + + public RampRate getRampRate() { + return RampRate.valueOf(Byte.toUnsignedInt(data[1])); + } + + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + public void register() { + feature.registerListener(this); + + stateUpdated(feature.getState()); + } + + public void unregister() { + feature.unregisterListener(this); + } + + @Override + public String toString() { + String s = address + " " + feature.getName() + " currentState: " + state + " onState: " + getOnState(); + if (RampRate.supportsFeatureType(feature.getType())) { + s += " rampRate: " + getRampRate(); + } + return s; + } + + @Override + public void stateUpdated(State state) { + setState(state); + updateState(); + } + + @Override + public void eventTriggered(String event) { + // do nothing + } + } + + /** + * Returns if scene group is valid + * + * @param group the scene group + * @return true if group is an integer within supported range + */ + public static boolean isValidGroup(String group) { + try { + return isValidGroup(Integer.parseInt(group)); + } catch (NumberFormatException e) { + return false; + } + } + + /** + * Returns if scene group is valid + * + * @param group the scene group + * @return true if group within supported range + */ + public static boolean isValidGroup(int group) { + return group >= GROUP_MIN && group <= GROUP_MAX; + } + + /** + * Factory method for creating a InsteonScene from a scene group and modem + * + * @param group the scene group + * @param modem the scene modem + * @return the newly created InsteonScene + */ + public static InsteonScene makeScene(int group, @Nullable InsteonModem modem) { + InsteonScene scene = new InsteonScene(group); + scene.setModem(modem); + return scene; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDevice.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDevice.java index 30992d49c254d..2216ef6ad19ba 100644 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDevice.java +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDevice.java @@ -27,8 +27,6 @@ import org.openhab.binding.insteon.internal.device.LegacyDeviceType.FeatureGroup; import org.openhab.binding.insteon.internal.manager.LegacyRequestManager; import org.openhab.binding.insteon.internal.transport.LegacyDriver; -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.LegacyGroupMessageStateMachine; import org.openhab.binding.insteon.internal.transport.message.LegacyGroupMessageStateMachine.GroupMessage; import org.openhab.binding.insteon.internal.transport.message.Msg; @@ -61,7 +59,7 @@ public enum DeviceStatus { /** how far to space out poll messages */ private static final int TIME_BETWEEN_POLL_MESSAGES = 1500; - private InsteonAddress address = new InsteonAddress(); + private DeviceAddress address = InsteonAddress.UNKNOWN; private long pollInterval = -1L; // in milliseconds private @Nullable LegacyDriver driver = null; private Map features = new HashMap<>(); @@ -102,8 +100,8 @@ public DeviceStatus getStatus() { return status; } - public InsteonAddress getAddress() { - return (address); + public DeviceAddress getAddress() { + return address; } public @Nullable LegacyDriver getDriver() { @@ -127,11 +125,11 @@ public Map getFeatures() { } public byte getX10HouseCode() { - return (address.getX10HouseCode()); + return address instanceof X10Address x10Address ? x10Address.getHouseCode() : 0; } public byte getX10UnitCode() { - return (address.getX10UnitCode()); + return address instanceof X10Address x10Address ? x10Address.getUnitCode() : 0; } public boolean hasProductKey(String key) { @@ -167,7 +165,7 @@ public void setHasModemDBEntry(boolean b) { hasModemDBEntry = b; } - public void setAddress(InsteonAddress ia) { + public void setAddress(DeviceAddress ia) { address = ia; } @@ -322,120 +320,6 @@ public void handleMessage(Msg msg) { } } - /** - * Helper method to make standard message - * - * @param flags - * @param cmd1 - * @param cmd2 - * @return standard message - * @throws FieldException - * @throws InvalidMessageTypeException - */ - public Msg makeStandardMessage(byte flags, byte cmd1, byte cmd2) - throws FieldException, InvalidMessageTypeException { - return (makeStandardMessage(flags, cmd1, cmd2, -1)); - } - - /** - * Helper method to make standard message, possibly with group - * - * @param flags - * @param cmd1 - * @param cmd2 - * @param group (-1 if not a group message) - * @return standard message - * @throws FieldException - * @throws InvalidMessageTypeException - */ - public Msg makeStandardMessage(byte flags, byte cmd1, byte cmd2, int group) - throws FieldException, InvalidMessageTypeException { - Msg m = Msg.makeMessage("SendStandardMessage"); - InsteonAddress addr = null; - byte f = flags; - if (group != -1) { - f |= 0xc0; // mark message as group message - // and stash the group number into the address - addr = new InsteonAddress((byte) 0, (byte) 0, (byte) (group & 0xff)); - } else { - addr = getAddress(); - } - m.setAddress("toAddress", addr); - m.setByte("messageFlags", f); - m.setByte("command1", cmd1); - m.setByte("command2", cmd2); - return m; - } - - public Msg makeX10Message(byte rawX10, byte X10Flag) throws FieldException, InvalidMessageTypeException { - Msg m = Msg.makeMessage("SendX10Message"); - m.setByte("rawX10", rawX10); - m.setByte("X10Flag", X10Flag); - m.setQuietTime(300L); - return m; - } - - /** - * Helper method to make extended message - * - * @param flags - * @param cmd1 - * @param cmd2 - * @return extended message - * @throws FieldException - * @throws InvalidMessageTypeException - */ - public Msg makeExtendedMessage(byte flags, byte cmd1, byte cmd2) - throws FieldException, InvalidMessageTypeException { - return makeExtendedMessage(flags, cmd1, cmd2, new byte[] {}); - } - - /** - * Helper method to make extended message - * - * @param flags - * @param cmd1 - * @param cmd2 - * @param data array with userdata - * @return extended message - * @throws FieldException - * @throws InvalidMessageTypeException - */ - public Msg makeExtendedMessage(byte flags, byte cmd1, byte cmd2, byte[] data) - throws FieldException, InvalidMessageTypeException { - Msg m = Msg.makeMessage("SendExtendedMessage"); - m.setAddress("toAddress", getAddress()); - m.setByte("messageFlags", (byte) (((flags & 0xff) | 0x10) & 0xff)); - m.setByte("command1", cmd1); - m.setByte("command2", cmd2); - m.setUserData(data); - m.setCRC(); - return m; - } - - /** - * Helper method to make extended message, but with different CRC calculation - * - * @param flags - * @param cmd1 - * @param cmd2 - * @param data array with user data - * @return extended message - * @throws FieldException - * @throws InvalidMessageTypeException - */ - public Msg makeExtendedMessageCRC2(byte flags, byte cmd1, byte cmd2, byte[] data) - throws FieldException, InvalidMessageTypeException { - Msg m = Msg.makeMessage("SendExtendedMessage"); - m.setAddress("toAddress", getAddress()); - m.setByte("messageFlags", (byte) (((flags & 0xff) | 0x10) & 0xff)); - m.setByte("command1", cmd1); - m.setByte("command2", cmd2); - m.setUserData(data); - m.setCRC2(); - return m; - } - /** * Called by the RequestQueueManager when the queue has expired * diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceFeature.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceFeature.java index 91b474c96619d..0d20385e8afba 100644 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceFeature.java +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceFeature.java @@ -29,13 +29,13 @@ import org.openhab.binding.insteon.internal.device.feature.LegacyCommandHandler; import org.openhab.binding.insteon.internal.device.feature.LegacyFeatureTemplate; import org.openhab.binding.insteon.internal.device.feature.LegacyFeatureTemplateLoader; +import org.openhab.binding.insteon.internal.device.feature.LegacyFeatureTemplateLoader.ParsingException; import org.openhab.binding.insteon.internal.device.feature.LegacyMessageDispatcher; import org.openhab.binding.insteon.internal.device.feature.LegacyMessageHandler; import org.openhab.binding.insteon.internal.device.feature.LegacyPollHandler; import org.openhab.binding.insteon.internal.listener.LegacyFeatureListener; import org.openhab.binding.insteon.internal.listener.LegacyFeatureListener.StateChangeType; import org.openhab.binding.insteon.internal.transport.message.Msg; -import org.openhab.binding.insteon.internal.utils.Utils.ParsingException; import org.openhab.core.types.Command; import org.openhab.core.types.State; import org.slf4j.Logger; diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/OnLevel.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/OnLevel.java new file mode 100644 index 0000000000000..160450c26bb93 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/OnLevel.java @@ -0,0 +1,141 @@ +/** + * 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.List; + +import javax.measure.quantity.Temperature; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.FanLincFanMode; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.ThermostatFanMode; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.ThermostatSystemMode; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.VenstarSystemMode; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.ImperialUnits; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link OnLevel} represents on level format functions for Insteon products + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public class OnLevel { + /** + * Returns an on level string as a hex value based on a feature type + * + * @param string the on level string to use + * @param featureType the feature type + * @return the on level hex value if valid, otherwise -1 + */ + public static int getHexValue(String string, String featureType) { + try { + switch (featureType) { + case FEATURE_TYPE_GENERIC_DIMMER: + int level = Integer.parseInt(string); + return level >= 0 && level <= 100 ? (int) Math.round(level * 255 / 100.0) : -1; + case FEATURE_TYPE_GENERIC_SWITCH: + case FEATURE_TYPE_OUTLET_SWITCH: + case FEATURE_TYPE_KEYPAD_BUTTON: + return "OFF".equals(string) ? 0x00 : "ON".equals(string) ? 0xFF : -1; + case FEATURE_TYPE_FANLINC_FAN: + return FanLincFanMode.valueOf(string).getValue(); + case FEATURE_TYPE_THERMOSTAT_COOL_SETPOINT: + case FEATURE_TYPE_THERMOSTAT_HEAT_SETPOINT: + case FEATURE_TYPE_VENSTAR_COOL_SETPOINT: + case FEATURE_TYPE_VENSTAR_HEAT_SETPOINT: + double temperature = Double.parseDouble(string); + return temperature >= 0 && temperature <= 127.5 ? (int) Math.round(temperature * 2) : -1; + case FEATURE_TYPE_THERMOSTAT_FAN_MODE: + case FEATURE_TYPE_VENSTAR_FAN_MODE: + return ThermostatFanMode.valueOf(string).getValue(); + case FEATURE_TYPE_THERMOSTAT_SYSTEM_MODE: + return ThermostatSystemMode.valueOf(string).getValue(); + case FEATURE_TYPE_VENSTAR_SYSTEM_MODE: + return VenstarSystemMode.valueOf(string).getValue(); + } + } catch (IllegalArgumentException ignored) { + } + return -1; + } + + /** + * Returns an on level value as a state based on a feature type + * + * @param value the on level value to use + * @param featureType the feature type + * @return the on level state + */ + public static State getState(int value, String featureType) { + try { + switch (featureType) { + case FEATURE_TYPE_GENERIC_DIMMER: + return new PercentType((int) Math.round(value * 100 / 255.0)); + case FEATURE_TYPE_GENERIC_SWITCH: + case FEATURE_TYPE_OUTLET_SWITCH: + case FEATURE_TYPE_KEYPAD_BUTTON: + return OnOffType.from(value != 0x00); + case FEATURE_TYPE_FANLINC_FAN: + return new StringType(FanLincFanMode.valueOf(value).toString()); + case FEATURE_TYPE_THERMOSTAT_COOL_SETPOINT: + case FEATURE_TYPE_THERMOSTAT_HEAT_SETPOINT: + case FEATURE_TYPE_VENSTAR_COOL_SETPOINT: + case FEATURE_TYPE_VENSTAR_HEAT_SETPOINT: + return new QuantityType(Math.round(value * 0.5), ImperialUnits.FAHRENHEIT); + case FEATURE_TYPE_THERMOSTAT_FAN_MODE: + case FEATURE_TYPE_VENSTAR_FAN_MODE: + return new StringType(ThermostatFanMode.valueOf(value).toString()); + case FEATURE_TYPE_THERMOSTAT_SYSTEM_MODE: + return new StringType(ThermostatSystemMode.valueOf(value).toString()); + case FEATURE_TYPE_VENSTAR_SYSTEM_MODE: + return new StringType(VenstarSystemMode.valueOf(value).toString()); + } + } catch (IllegalArgumentException ignored) { + } + return UnDefType.NULL; + } + + /** + * Returns a list of supported on level values based on a feature type + * + * @param featureType the feature type + * @return the list of on level values + */ + public static List getSupportedValues(String featureType) { + switch (featureType) { + case FEATURE_TYPE_GENERIC_DIMMER: + return List.of("0", "25", "50", "75", "100"); + case FEATURE_TYPE_GENERIC_SWITCH: + case FEATURE_TYPE_OUTLET_SWITCH: + case FEATURE_TYPE_KEYPAD_BUTTON: + return List.of("ON", "OFF"); + case FEATURE_TYPE_FANLINC_FAN: + return FanLincFanMode.names(); + case FEATURE_TYPE_THERMOSTAT_FAN_MODE: + case FEATURE_TYPE_VENSTAR_FAN_MODE: + return ThermostatFanMode.names(); + case FEATURE_TYPE_THERMOSTAT_SYSTEM_MODE: + return ThermostatSystemMode.names(); + case FEATURE_TYPE_VENSTAR_SYSTEM_MODE: + return VenstarSystemMode.names(); + } + return List.of(); + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ProductData.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ProductData.java new file mode 100644 index 0000000000000..358c08328b670 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ProductData.java @@ -0,0 +1,265 @@ +/** + * 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.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.utils.HexUtils; + +/** + * The {@link ProductData} represents a device product data + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public class ProductData { + public static final int DEVICE_CATEGORY_UNKNOWN = 0xFF; + public static final int SUB_CATEGORY_UNKNOWN = 0xFF; + + private int deviceCategory = DEVICE_CATEGORY_UNKNOWN; + private int subCategory = SUB_CATEGORY_UNKNOWN; + private int productKey = 0; + private @Nullable String description; + private @Nullable String model; + private @Nullable String vendor; + private @Nullable String deviceType; + private int firstRecord = 0; + private int firmware = 0; + private int hardware = 0; + + public int getDeviceCategory() { + return deviceCategory; + } + + public int getSubCategory() { + return subCategory; + } + + public int getProductKey() { + return productKey; + } + + public @Nullable String getProductId() { + return deviceCategory == DEVICE_CATEGORY_UNKNOWN || subCategory == SUB_CATEGORY_UNKNOWN ? null + : HexUtils.getHexString(deviceCategory) + " " + HexUtils.getHexString(subCategory); + } + + public @Nullable String getDescription() { + return description; + } + + public @Nullable String getModel() { + return model; + } + + public @Nullable String getVendor() { + return vendor; + } + + public @Nullable DeviceType getDeviceType() { + return DeviceTypeRegistry.getInstance().getDeviceType(deviceType); + } + + public int getFirstRecordLocation() { + return firstRecord; + } + + public int getFirmwareVersion() { + return firmware; + } + + public int getHardwareVersion() { + return hardware; + } + + public @Nullable String getLabel() { + List properties = new ArrayList<>(); + if (vendor != null) { + properties.add("" + vendor); + } + if (model != null) { + properties.add("" + model); + } + if (description != null) { + properties.add("" + description); + } + return properties.isEmpty() ? null : String.join(" ", properties); + } + + public byte[] getRecordData() { + return new byte[] { (byte) deviceCategory, (byte) subCategory, (byte) firmware }; + } + + public void setDeviceCategory(int deviceCategory) { + this.deviceCategory = deviceCategory; + } + + public void setSubCategory(int subCategory) { + this.subCategory = subCategory; + } + + public void setProductKey(int productKey) { + this.productKey = productKey; + } + + public void setDescription(@Nullable String description) { + this.description = description; + } + + public void setModel(@Nullable String model) { + this.model = model; + } + + public void setVendor(@Nullable String vendor) { + this.vendor = vendor; + } + + public void setDeviceType(@Nullable String deviceType) { + this.deviceType = deviceType; + } + + public void setDeviceType(DeviceType deviceType) { + this.deviceType = deviceType.getName(); + } + + public void setFirstRecordLocation(int firstRecord) { + this.firstRecord = firstRecord; + } + + public void setFirmwareVersion(int firmware) { + this.firmware = firmware; + } + + public void setHardwareVersion(int hardware) { + this.hardware = hardware; + } + + public boolean update(ProductData productData) { + boolean deviceTypeUpdated = false; + // update device and sub category if unknown + if (deviceCategory == DEVICE_CATEGORY_UNKNOWN && subCategory == SUB_CATEGORY_UNKNOWN) { + deviceCategory = productData.deviceCategory; + subCategory = productData.subCategory; + } + // update device type if not defined already + if (deviceType == null) { + deviceType = productData.deviceType; + deviceTypeUpdated = productData.deviceType != null; + } + // update remaining properties if defined in given product data + if (productData.productKey != 0) { + productKey = productData.productKey; + } + if (productData.description != null) { + description = productData.description; + } + if (productData.model != null) { + model = productData.model; + } + if (productData.vendor != null) { + vendor = productData.vendor; + } + if (productData.firstRecord != 0) { + firstRecord = productData.firstRecord; + } + if (productData.firmware != 0) { + firmware = productData.firmware; + } + if (productData.hardware != 0) { + hardware = productData.hardware; + } + return deviceTypeUpdated; + } + + @Override + public String toString() { + List properties = new ArrayList<>(); + if (deviceCategory != DEVICE_CATEGORY_UNKNOWN) { + properties.add("deviceCategory:" + HexUtils.getHexString(deviceCategory)); + } + if (subCategory != SUB_CATEGORY_UNKNOWN) { + properties.add("subCategory:" + HexUtils.getHexString(subCategory)); + } + if (productKey != 0) { + properties.add("productKey:" + HexUtils.getHexString(productKey, 6)); + } + if (description != null) { + properties.add("description:" + description); + } + if (model != null) { + properties.add("model:" + model); + } + if (vendor != null) { + properties.add("vendor:" + vendor); + } + if (deviceType != null) { + properties.add("deviceType:" + deviceType); + } + if (firstRecord != 0) { + properties.add("firstRecord:" + HexUtils.getHexString(firstRecord)); + } + if (firmware != 0) { + properties.add("firmwareVersion:" + HexUtils.getHexString(firmware)); + } + if (hardware != 0) { + properties.add("hardwareVersion:" + HexUtils.getHexString(hardware)); + } + return properties.isEmpty() ? "undefined product data" : String.join("|", properties); + } + + /** + * Factory method for creating a ProductData for an Insteon product + * + * @param deviceCategory the Insteon device category + * @param subCategory the Insteon device subcategory + * @return the product data + */ + public static ProductData makeInsteonProduct(int deviceCategory, int subCategory) { + ProductData productData = new ProductData(); + productData.setDeviceCategory(deviceCategory); + productData.setSubCategory(subCategory); + return productData; + } + + /** + * Factory method for creating a ProductData for an Insteon product + * + * @param deviceCategory the Insteon device category + * @param subCategory the Insteon device subcategory + * @param srcData the source product data to use + * @return the product data + */ + public static ProductData makeInsteonProduct(int deviceCategory, int subCategory, @Nullable ProductData srcData) { + ProductData productData = makeInsteonProduct(deviceCategory, subCategory); + if (srcData != null) { + productData.update(srcData); + } + return productData; + } + + /** + * Factory method for creating a ProductData for a X10 product + * + * @param deviceType the X10 device type + * @return the product data + */ + public static ProductData makeX10Product(String deviceType) { + ProductData productData = new ProductData(); + productData.setDeviceType(deviceType); + productData.setDescription(deviceType.replace("_", " ")); + return productData; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ProductDataRegistry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ProductDataRegistry.java new file mode 100644 index 0000000000000..628568cb7fea2 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ProductDataRegistry.java @@ -0,0 +1,208 @@ +/** + * 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.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.utils.HexUtils; +import org.openhab.binding.insteon.internal.utils.ResourceLoader; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** + * The {@link ProductDataRegistry} represents the product data registry + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public class ProductDataRegistry extends ResourceLoader { + private static final ProductDataRegistry PRODUCT_DATA_REGISTRY = new ProductDataRegistry(); + private static final String RESOURCE_NAME = "/device_products.xml"; + + private Map products = new HashMap<>(); + + /** + * Returns the product data for a given dev/sub category + * + * @param deviceCategory device category to match + * @param subCategory device subcategory to match + * @return product data matching provided parameters + */ + public ProductData getProductData(int deviceCategory, int subCategory) { + int productId = getProductId(deviceCategory, subCategory); + if (!products.containsKey(productId)) { + logger.warn("unknown product for devCat:{} subCat:{} in device products xml file", + HexUtils.getHexString(deviceCategory), HexUtils.getHexString(subCategory)); + // fallback to matching product id using device category only + productId = getProductId(deviceCategory, ProductData.SUB_CATEGORY_UNKNOWN); + } + + return ProductData.makeInsteonProduct(deviceCategory, subCategory, products.get(productId)); + } + + /** + * Returns the device type for a given dev/sub category + * + * @param deviceCategory device category to match + * @param subCategory device subcategory to match + * @return device type matching provided parameters + */ + public @Nullable DeviceType getDeviceType(int deviceCategory, int subCategory) { + return getProductData(deviceCategory, subCategory).getDeviceType(); + } + + /** + * Returns product id based on dev/sub category + * + * @param deviceCategory device category to use + * @param subCategory device subcategory to use + * @return product key + */ + private int getProductId(int deviceCategory, int subCategory) { + return deviceCategory << 8 | subCategory; + } + + /** + * Returns known products + * + * @return currently known products + */ + public Map getProducts() { + return products; + } + + /** + * Initializes product data registry + */ + @Override + protected void initialize() { + super.initialize(); + + if (logger.isDebugEnabled()) { + logger.debug("loaded {} products", products.size()); + if (logger.isTraceEnabled()) { + products.values().stream().map(String::valueOf).forEach(logger::trace); + } + } + } + + /** + * Returns device product data resource name + */ + @Override + protected String getResourceName() { + return RESOURCE_NAME; + } + + /** + * Parses product data document + * + * @param element element to parse + * @throws SAXException + * @throws IOException + */ + @Override + protected void parseDocument(Element element) throws SAXException, IOException { + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + String nodeName = child.getNodeName(); + if ("product".equals(nodeName)) { + parseProduct(child); + } + } + } + } + + /** + * Parses product node + * + * @param element element to parse + * @throws SAXException + */ + private void parseProduct(Element element) throws SAXException { + int deviceCategory = getHexAttributeAsInteger(element, "devCat", ProductData.DEVICE_CATEGORY_UNKNOWN); + int subCategory = getHexAttributeAsInteger(element, "subCat", ProductData.SUB_CATEGORY_UNKNOWN); + int productKey = getHexAttributeAsInteger(element, "productKey", 0); + int firstRecord = getHexAttributeAsInteger(element, "firstRecord", 0); + if (deviceCategory == ProductData.DEVICE_CATEGORY_UNKNOWN) { + throw new SAXException("invalid product with no device category in device products xml file"); + } + int productId = getProductId(deviceCategory, subCategory); + if (products.containsKey(productId)) { + logger.warn("overwriting previous definition of product {}", products.get(productId)); + } + + ProductData productData = ProductData.makeInsteonProduct(deviceCategory, subCategory); + productData.setProductKey(productKey); + productData.setFirstRecordLocation(firstRecord); + + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE) { + Element child = (Element) node; + String nodeName = child.getNodeName(); + String textContent = child.getTextContent(); + if ("description".equals(nodeName)) { + productData.setDescription(textContent); + } else if ("model".equals(nodeName)) { + productData.setModel(textContent); + } else if ("vendor".equals(nodeName)) { + productData.setVendor(textContent); + } else if ("device-type".equals(nodeName)) { + parseDeviceType(child, productData); + } + } + } + products.put(productId, productData); + } + + /** + * Parses product device type element + * + * @param element element to parse + * @param productData product data to update + * @throws SAXException + */ + private void parseDeviceType(Element element, ProductData productData) throws SAXException { + String deviceType = element.getTextContent(); + if (deviceType == null) { + return; // undefined device type + } + if (DeviceTypeRegistry.getInstance().getDeviceType(deviceType) == null) { + throw new SAXException("invalid device type " + deviceType + " in device products xml file"); + } + productData.setDeviceType(deviceType); + } + + /** + * Singleton instance function + * + * @return ProductDataRegistry singleton reference + */ + public static synchronized ProductDataRegistry getInstance() { + if (PRODUCT_DATA_REGISTRY.getProducts().isEmpty()) { + PRODUCT_DATA_REGISTRY.initialize(); + } + return PRODUCT_DATA_REGISTRY; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RampRate.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RampRate.java new file mode 100644 index 0000000000000..7a9079ae1d6d4 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RampRate.java @@ -0,0 +1,144 @@ +/** + * 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.text.DecimalFormat; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link RampRate} represents a ramp rate for Insteon dimmer products + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public enum RampRate { + MIN_9(0x00, 540), + MIN_8(0x01, 480), + MIN_7(0x02, 420), + MIN_6(0x03, 360), + MIN_5(0x04, 300), + MIN_4_5(0x05, 270), + MIN_4(0x06, 240), + MIN_3_5(0x07, 210), + MIN_3(0x08, 180), + MIN_2_5(0x09, 150), + MIN_2(0x0A, 120), + MIN_1_5(0x0B, 90), + MIN_1(0x0C, 60), + SEC_47(0x0D, 47), + SEC_43(0x0E, 43), + SEC_38_5(0x0F, 38.5), + SEC_34(0x10, 34), + SEC_32(0x11, 32), + SEC_30(0x12, 30), + SEC_28(0x13, 28), + SEC_26(0x14, 26), + SEC_23_5(0x15, 23.5), + SEC_21_5(0x16, 21.5), + SEC_19(0x17, 19), + SEC_8_5(0x18, 8.5), + SLOW(0x19, 6.5), + SEC_4_5(0x1A, 4.5), + MEDIUM(0x1B, 2), + DEFAULT(0x1C, 0.5), + FAST(0x1D, 0.3), + SEC_0_2(0x1E, 0.2), + INSTANT(0x1F, 0.1); + + private static final Map VALUE_MAP = Arrays.stream(values()) + .collect(Collectors.toUnmodifiableMap(rate -> rate.value, Function.identity())); + + private final int value; + private final double time; + + private RampRate(int value, double time) { + this.value = value; + this.time = time; + } + + public int getValue() { + return value; + } + + public double getTimeInSeconds() { + return time; + } + + public long getTimeInMilliseconds() { + return (long) (time * 1000); + } + + @Override + public String toString() { + double time = getTimeInSeconds(); + String unit = "s"; + if (time >= 60) { + time /= 60; + unit = "min"; + } + return new DecimalFormat("0.#").format(time) + unit; + } + + /** + * Factory method for determining if a given feature type supports ramp rate + * + * @param featureType the feature type + * @return true if supported + */ + public static boolean supportsFeatureType(String featureType) { + return FEATURE_TYPE_GENERIC_DIMMER.equals(featureType); + } + + /** + * Factory method for getting a RampRate from a ramp rate value + * + * @param value the ramp rate value + * @return the ramp rate + */ + public static RampRate valueOf(int value) { + return VALUE_MAP.getOrDefault(value, RampRate.DEFAULT); + } + + /** + * Factory method for getting a RampRate from the closest ramp time + * + * @param time the ramp time + * @return the ramp rate + */ + public static RampRate fromTime(double time) { + return VALUE_MAP.values().stream().min(Comparator.comparingDouble(rate -> Math.abs(rate.time - time))).get(); + } + + /** + * Factory method for getting a RampRate from a ramp rate string + * + * @param string the ramp rate string + * @return the ramp rate + */ + public static @Nullable RampRate fromString(String string) { + try { + return fromTime(Double.parseDouble(string)); + } catch (NumberFormatException e) { + return VALUE_MAP.values().stream().filter(rate -> rate.toString().equals(string)).findAny().orElse(null); + } + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10.java deleted file mode 100644 index e887045b1d990..0000000000000 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10.java +++ /dev/null @@ -1,191 +0,0 @@ -/** - * 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.HashMap; -import java.util.Map; -import java.util.Map.Entry; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * This class has utilities related to the X10 protocol. - * - * @author Bernd Pfrommer - Initial contribution - * @author Rob Nielsen - Port to openHAB 2 insteon binding - */ -@NonNullByDefault -public class X10 { - /** - * Enumerates the X10 command codes. - * - * @author Bernd Pfrommer - openHAB 1 insteonplm binding - * - */ - public enum Command { - ALL_LIGHTS_OFF(0x6), - STATUS_OFF(0xE), - ON(0x2), - PRESET_DIM_1(0xA), - ALL_LIGHTS_ON(0x1), - HAIL_ACKNOWLEDGE(0x9), - BRIGHT(0x5), - STATUS_ON(0xD), - EXTENDED_CODE(0x9), - STATUS_REQUEST(0xF), - OFF(0x3), - PRESET_DIM_2(0xB), - ALL_UNITS_OFF(0x0), - HAIL_REQUEST(0x8), - DIM(0x4), - EXTENDED_DATA(0xC); - - private final byte code; - - Command(int b) { - code = (byte) b; - } - - public byte code() { - return code; - } - } - - /** - * converts house code to clear text - * - * @param c house code as per X10 spec - * @return clear text house code, i.e letter A-P - */ - public static String houseToString(byte c) { - String s = houseCodeToString.get(c & 0xff); - return (s == null) ? "X" : s; - } - - /** - * converts unit code to regular integer - * - * @param c unit code per X10 spec - * @return decoded integer, i.e. number 0-16 - */ - public static int unitToInt(byte c) { - Integer i = unitCodeToInt.get(c & 0xff); - return (i == null) ? -1 : i; - } - - /** - * Test if string has valid X10 address of form "H.U", e.g. A.10 - * - * @param s string to test - * @return true if is valid X10 address - */ - public static boolean isValidAddress(String s) { - String[] parts = s.split("\\."); - if (parts.length != 2) { - return false; - } - return parts[0].matches("[A-P]") && parts[1].matches("\\d{1,2}"); - } - - /** - * Turn clear text address ("A.10") to byte code - * - * @param addr clear text address - * @return byte that encodes house + unit code - */ - public static byte addressToByte(String addr) { - String[] parts = addr.split("\\."); - int ih = houseStringToCode(parts[0]); - int iu = unitStringToCode(parts[1]); - int itot = ih << 4 | iu; - return (byte) (itot & 0xff); - } - - /** - * converts String to house byte code - * - * @param s clear text house string - * @return coded house byte - */ - public static int houseStringToCode(String s) { - for (Entry entry : houseCodeToString.entrySet()) { - if (s.equals(entry.getValue())) { - return entry.getKey(); - } - } - return 0xf; - } - - /** - * converts unit string to unit code - * - * @param s string with clear text integer inside - * @return encoded unit byte - */ - public static int unitStringToCode(String s) { - try { - int i = Integer.parseInt(s); - for (Entry entry : unitCodeToInt.entrySet()) { - if (i == entry.getValue()) { - return entry.getKey(); - } - } - } catch (NumberFormatException e) { - } - return 0xf; - } - - /** - * Map between 4-bit X10 code and the house code. - */ - private static Map houseCodeToString = new HashMap<>(); - /** - * Map between 4-bit X10 code and the unit code. - */ - private static Map unitCodeToInt = new HashMap<>(); - - static { - houseCodeToString.put(0x6, "A"); - unitCodeToInt.put(0x6, 1); - houseCodeToString.put(0xe, "B"); - unitCodeToInt.put(0xe, 2); - houseCodeToString.put(0x2, "C"); - unitCodeToInt.put(0x2, 3); - houseCodeToString.put(0xa, "D"); - unitCodeToInt.put(0xa, 4); - houseCodeToString.put(0x1, "E"); - unitCodeToInt.put(0x1, 5); - houseCodeToString.put(0x9, "F"); - unitCodeToInt.put(0x9, 6); - houseCodeToString.put(0x5, "G"); - unitCodeToInt.put(0x5, 7); - houseCodeToString.put(0xd, "H"); - unitCodeToInt.put(0xd, 8); - houseCodeToString.put(0x7, "I"); - unitCodeToInt.put(0x7, 9); - houseCodeToString.put(0xf, "J"); - unitCodeToInt.put(0xf, 10); - houseCodeToString.put(0x3, "K"); - unitCodeToInt.put(0x3, 11); - houseCodeToString.put(0xb, "L"); - unitCodeToInt.put(0xb, 12); - houseCodeToString.put(0x0, "M"); - unitCodeToInt.put(0x0, 13); - houseCodeToString.put(0x8, "N"); - unitCodeToInt.put(0x8, 14); - houseCodeToString.put(0x4, "O"); - unitCodeToInt.put(0x4, 15); - houseCodeToString.put(0xc, "P"); - unitCodeToInt.put(0xc, 16); - } -} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Address.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Address.java new file mode 100644 index 0000000000000..d85edfab287d2 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Address.java @@ -0,0 +1,202 @@ +/** + * 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.Map; +import java.util.Map.Entry; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * THe {@link X10Address} represents an X10 address + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public class X10Address implements DeviceAddress { + private static final Map HOUSE_CODES = Map.ofEntries(Map.entry("A", 0x06), Map.entry("B", 0x0E), + Map.entry("C", 0x02), Map.entry("D", 0x0A), Map.entry("E", 0x01), Map.entry("F", 0x09), + Map.entry("G", 0x05), Map.entry("H", 0x0D), Map.entry("I", 0x07), Map.entry("J", 0x0F), + Map.entry("K", 0x03), Map.entry("L", 0x0B), Map.entry("M", 0x00), Map.entry("N", 0x08), + Map.entry("O", 0x04), Map.entry("P", 0x0C)); + private static final Map UNIT_CODES = Map.ofEntries(Map.entry(1, 0x06), Map.entry(2, 0x0E), + Map.entry(3, 0x02), Map.entry(4, 0x0A), Map.entry(5, 0x01), Map.entry(6, 0x09), Map.entry(7, 0x05), + Map.entry(8, 0x0D), Map.entry(9, 0x07), Map.entry(10, 0x0F), Map.entry(11, 0x03), Map.entry(12, 0x0B), + Map.entry(13, 0x00), Map.entry(14, 0x08), Map.entry(15, 0x04), Map.entry(16, 0x0C)); + + private final byte houseCode; + private final byte unitCode; + + public X10Address(byte address) { + this.houseCode = (byte) (address >> 4); + this.unitCode = (byte) (address & 0x0F); + } + + public X10Address(String house, int unit) throws IllegalArgumentException { + this.houseCode = (byte) houseStringToCode(house); + this.unitCode = (byte) unitIntToCode(unit); + } + + public X10Address(String address) throws IllegalArgumentException { + this.houseCode = (byte) houseStringToCode(address.substring(0, 1)); + this.unitCode = (byte) unitStringToCode(address.substring(1)); + } + + public byte getHouseCode() { + return houseCode; + } + + public byte getUnitCode() { + return unitCode; + } + + public byte getCode() { + return (byte) (houseCode << 4 | unitCode); + } + + @Override + public String toString() { + String house = houseCodeToString(houseCode); + int unit = unitCodeToInt(unitCode); + return house != null && unit != -1 ? house + unit : "NULL"; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + X10Address other = (X10Address) obj; + return houseCode == other.houseCode && unitCode == other.unitCode; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + houseCode; + result = prime * result + unitCode; + return result; + } + + /** + * Returns a house string as code + * + * @param house house string + * @return house string as code if defined, otherwise throw exception + * @throws IllegalArgumentException + */ + public static int houseStringToCode(String house) throws IllegalArgumentException { + int houseCode = HOUSE_CODES.getOrDefault(house, -1); + if (houseCode == -1) { + throw new IllegalArgumentException("Invalid X10 house code: " + house); + } + return houseCode; + } + + /** + * Returns an unit integer as code + * + * @param unit unit integer + * @return unit integer as code if defined, otherwise throw exception + * @throws IllegalArgumentException + */ + public static int unitIntToCode(int unit) throws IllegalArgumentException { + int unitCode = UNIT_CODES.getOrDefault(unit, -1); + if (unitCode == -1) { + throw new IllegalArgumentException("Invalid X10 unit code: " + unit); + } + return unitCode; + } + + /** + * Returns an unit string as code + * + * @param unit unit string + * @return unit string as code if defined, otherwise throw exception + * @throws IllegalArgumentException + */ + public static int unitStringToCode(String unit) throws IllegalArgumentException { + try { + return unitIntToCode(Integer.parseInt(unit)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid X10 unit code: " + unit); + } + } + + /** + * Returns a house code as string + * + * @param code house code + * @return house code as string if found, otherwise null + */ + public static @Nullable String houseCodeToString(byte code) { + return HOUSE_CODES.entrySet().stream().filter(entry -> entry.getValue() == code).map(Entry::getKey).findFirst() + .orElse(null); + } + + /** + * Returns a unit code as integer + * + * @param code unit code + * @return unit code as integer if found, otherwise -1 + */ + public static int unitCodeToInt(byte code) { + return UNIT_CODES.entrySet().stream().filter(entry -> entry.getValue() == code).map(Entry::getKey).findFirst() + .orElse(-1); + } + + /** + * Returns if a house code is valid + * + * @param house house code + * @return true if valid house code + */ + public static boolean isValidHouseCode(String house) { + return HOUSE_CODES.containsKey(house); + } + + /** + * Returns if a unit code is valid + * + * @param unit unit code + * @return true if valid unit code + */ + public static boolean isValidUnitCode(int unit) { + return UNIT_CODES.containsKey(unit); + } + + /** + * Returns if x10 address is valid + * + * @return true if address is valid + */ + public static boolean isValid(@Nullable String address) { + if (address == null) { + return false; + } + try { + new X10Address(address); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Command.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Command.java new file mode 100644 index 0000000000000..787240d2198a0 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Command.java @@ -0,0 +1,50 @@ +/** + * 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; + +/** + * The {@link X10Command} represents an X10 command + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public enum X10Command { + ALL_UNITS_OFF(0x00), + ALL_LIGHTS_ON(0x01), + ALL_LIGHTS_OFF(0x06), + ON(0x02), + OFF(0x03), + DIM(0x04), + BRIGHT(0x05), + EXTENDED_CODE(0x07), + HAIL_REQUEST(0x08), + HAIL_ACKNOWLEDGEMENT(0x09), + PRESET_DIM_1(0x0A), + PRESET_DIM_2(0x0B), + EXTENDED_DATA(0x0C), + STATUS_ON(0x0D), + STATUS_OFF(0x0E), + STATUS_REQUEST(0x0F); + + private final byte code; + + private X10Command(int code) { + this.code = (byte) code; + } + + public byte code() { + return code; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Device.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Device.java new file mode 100644 index 0000000000000..f25bb1bae681f --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Device.java @@ -0,0 +1,51 @@ +/** + * 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; +import org.openhab.binding.insteon.internal.handler.X10DeviceHandler; + +/** + * The {@link X10Device} represents an X10 device + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public class X10Device extends BaseDevice { + public X10Device(X10Address address) { + super(address); + } + + /** + * Factory method for creating a X10Device from a device address, modem and product data + * + * @param address the device address + * @param modem the device modem + * @param productData the device product data + * @return the newly created X10Device + */ + public static X10Device makeDevice(X10Address address, @Nullable InsteonModem modem, ProductData productData) { + X10Device device = new X10Device(address); + device.setModem(modem); + + DeviceType deviceType = productData.getDeviceType(); + if (deviceType != null) { + device.instantiateFeatures(deviceType); + device.setFlags(deviceType.getFlags()); + } + device.setProductData(productData); + + return device; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Flag.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Flag.java new file mode 100644 index 0000000000000..c978a00901946 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Flag.java @@ -0,0 +1,36 @@ +/** + * 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; + +/** + * The {@link X10Flag} represents an X10 flag + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public enum X10Flag { + ADDRESS(0x00), + COMMAND(0x80); + + private final byte code; + + private X10Flag(int code) { + this.code = (byte) code; + } + + public byte code() { + return code; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/CommandHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/CommandHandler.java new file mode 100644 index 0000000000000..029669eff2db9 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/CommandHandler.java @@ -0,0 +1,2350 @@ +/** + * 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.feature; + +import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*; + +import java.lang.reflect.InvocationTargetException; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import javax.measure.Unit; +import javax.measure.quantity.Dimensionless; +import javax.measure.quantity.Temperature; +import javax.measure.quantity.Time; + +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.DeviceFeature; +import org.openhab.binding.insteon.internal.device.InsteonAddress; +import org.openhab.binding.insteon.internal.device.ProductData; +import org.openhab.binding.insteon.internal.device.RampRate; +import org.openhab.binding.insteon.internal.device.X10Address; +import org.openhab.binding.insteon.internal.device.X10Command; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.FanLincFanMode; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.IOLincRelayMode; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.KeypadButtonConfig; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.KeypadButtonToggleMode; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.MicroModuleOpMode; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.SirenAlarmType; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.ThermostatFanMode; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.ThermostatSystemMode; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.ThermostatTemperatureFormat; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.ThermostatTimeFormat; +import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.VenstarSystemMode; +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.BinaryUtils; +import org.openhab.binding.insteon.internal.utils.HexUtils; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.IncreaseDecreaseType; +import org.openhab.core.library.types.NextPreviousType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.PlayPauseType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StopMoveType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.types.UpDownType; +import org.openhab.core.library.unit.ImperialUnits; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A command handler translates an openHAB command into a insteon message + * + * @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 abstract class CommandHandler extends FeatureBaseHandler { + private static final Set SUPPORTED_COMMAND_TYPES = Set.of("DecimalType", "IncreaseDecreaseType", + "OnOffType", "NextPreviousType", "PercentType", "PlayPauseType", "QuantityType", "RefreshType", + "RewindFastforwardType", "StopMoveType", "StringType", "UpDownType"); + + protected final Logger logger = LoggerFactory.getLogger(CommandHandler.class); + + /** + * Constructor + * + * @param f The DeviceFeature for which this command was intended. + * The openHAB commands are issued on an openhab item. The .items files bind + * an openHAB item to a DeviceFeature. + */ + public CommandHandler(DeviceFeature feature) { + super(feature); + } + + /** + * Returns handler id + * + * @return handler id based on command parameter + */ + public String getId() { + return getParameterAsString("command", "default"); + } + + /** + * Returns if handler can handle the openHAB command received + * + * @param cmd the openhab command received + * @return true if can handle + */ + public abstract boolean canHandle(Command cmd); + + /** + * Implements what to do when an openHAB command is received + * + * @param channelUID the channel uid that generated the command + * @param config the channel configuration that generated the command + * @param cmd the openhab command to handle + */ + public abstract void handleCommand(InsteonChannelConfiguration config, Command cmd); + + // + // + // ---------------- the various command handlers start here ------------------- + // + // + + /** + * Default command handler + */ + public static class DefaultCommandHandler extends CommandHandler { + DefaultCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return true; + } + + @Override + public void handleCommand(InsteonChannelConfiguration config, Command cmd) { + logger.warn("{}: command {}:{} is not supported", nm(), cmd.getClass().getSimpleName(), cmd); + } + } + + /** + * No-op command handler + */ + public static class NoOpCommandHandler extends CommandHandler { + NoOpCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return true; + } + + @Override + public void handleCommand(InsteonChannelConfiguration config, Command cmd) { + // do nothing, not even log + } + } + + /** + * Refresh command handler + */ + public static class RefreshCommandHandler extends CommandHandler { + RefreshCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return cmd instanceof RefreshType; + } + + @Override + public void handleCommand(InsteonChannelConfiguration config, Command cmd) { + feature.triggerPoll(0L); + } + } + + /** + * Custom abstract command handler based of parameters + */ + public abstract static class CustomCommandHandler extends CommandHandler { + CustomCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public void handleCommand(InsteonChannelConfiguration config, Command cmd) { + int cmd1 = getParameterAsInteger("cmd1", -1); + int cmd2 = getParameterAsInteger("cmd2", 0); + int ext = getParameterAsInteger("ext", 0); + if (cmd1 == -1) { + logger.warn("{}: handler misconfigured, no cmd1 parameter specified", nm()); + return; + } + if (ext < 0 || ext > 2) { + logger.warn("{}: handler misconfigured, invalid ext parameter specified", nm()); + return; + } + // determine data field based on parameter, default to cmd2 if is standard message + String field = getParameterAsString("field", ext == 0 ? "command2" : ""); + if (field.isEmpty()) { + logger.warn("{}: handler misconfigured, no field parameter specified", nm()); + return; + } + // determine cmd value and apply factor ratio based of parameters + int value = (int) Math.round(getValue(cmd) * getParameterAsInteger("factor", 1)); + if (value == -1) { + logger.debug("{}: unable to determine command value, ignoring request", nm()); + return; + } + try { + InsteonAddress address = getInsteonDevice().getAddress(); + boolean setCRC = getInsteonDevice().getInsteonEngine().supportsChecksum(); + Msg msg; + if (ext == 0) { + msg = Msg.makeStandardMessage(address, (byte) cmd1, (byte) cmd2); + } else { + // set userData1 to d1 parameter if defined, fallback to group parameter + byte[] data = { (byte) getParameterAsInteger("d1", getParameterAsInteger("group", 0)), + (byte) getParameterAsInteger("d2", 0), (byte) getParameterAsInteger("d3", 0) }; + msg = Msg.makeExtendedMessage(address, (byte) cmd1, (byte) cmd2, data, false); + } + // set field to clamped byte-size value + msg.setByte(field, (byte) Math.min(value, 0xFF)); + // set crc based on message type if supported + if (setCRC) { + if (ext == 1) { + msg.setCRC(); + } else if (ext == 2) { + msg.setCRC2(); + } + } + // send request + feature.sendRequest(msg); + if (logger.isDebugEnabled()) { + logger.debug("{}: sent {} {} request to {}", nm(), feature.getName(), HexUtils.getHexString(value), + address); + } + } catch (InvalidMessageTypeException e) { + logger.warn("{}: invalid message: ", nm(), e); + } catch (FieldException e) { + logger.warn("{}: command send message creation error ", nm(), e); + } + } + + protected abstract double getValue(Command cmd); + } + + /** + * Custom bitmask command handler based of parameters + */ + public static class CustomBitmaskCommandHandler extends CustomCommandHandler { + CustomBitmaskCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return cmd instanceof OnOffType; + } + + @Override + protected double getValue(Command cmd) { + return getBitmask(cmd); + } + + protected int getBitNumber() { + return getParameterAsInteger("bit", -1); + } + + protected @Nullable Boolean shouldSetBit(Command cmd) { + return OnOffType.ON.equals(cmd) ^ getParameterAsBoolean("inverted", false); + } + + protected int getBitmask(Command cmd) { + // get bit number based on parameter + int bit = getBitNumber(); + // get last bitmask message value received by this feature + int bitmask = feature.getLastMsgValueAsInteger(-1); + // determine if bit should be set + Boolean shouldSetBit = shouldSetBit(cmd); + // update last bitmask value specific bit based on cmd state, if defined and bit number valid + if (bit < 0 || bit > 7) { + logger.debug("{}: invalid bit number {} for {}", nm(), bit, feature.getName()); + } else if (bitmask == -1) { + logger.debug("{}: unable to determine last bitmask for {}", nm(), feature.getName()); + } else if (shouldSetBit == null) { + logger.debug("{}: unable to determine if bit should be set, ignoring request", nm()); + } else { + if (logger.isTraceEnabled()) { + logger.trace("{}: bitmask:{} bit:{} set:{}", nm(), BinaryUtils.getBinaryString(bitmask), bit, + shouldSetBit); + } + return BinaryUtils.setBit(bitmask, bit, shouldSetBit); + } + return -1; + } + } + + /** + * Custom on/off type command handler based of parameters + */ + public static class CustomOnOffCommandHandler extends CustomCommandHandler { + CustomOnOffCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return cmd instanceof OnOffType; + } + + @Override + protected double getValue(Command cmd) { + return OnOffType.OFF.equals(cmd) ? getParameterAsInteger("off", 0x00) : getParameterAsInteger("on", 0xFF); + } + } + + /** + * Custom decimal type command handler based of parameters + */ + public static class CustomDecimalCommandHandler extends CustomCommandHandler { + CustomDecimalCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return cmd instanceof DecimalType; + } + + @Override + protected double getValue(Command cmd) { + return ((DecimalType) cmd).doubleValue(); + } + } + + /** + * Custom percent type command handler based of parameters + */ + public static class CustomPercentCommandHandler extends CustomCommandHandler { + CustomPercentCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return cmd instanceof PercentType; + } + + @Override + protected double getValue(Command cmd) { + int minValue = getParameterAsInteger("min", 0x00); + int maxValue = getParameterAsInteger("max", 0xFF); + double value = ((PercentType) cmd).doubleValue(); + return Math.round(value * (maxValue - minValue) / 100.0) + minValue; + } + } + + /** + * Custom dimensionless quantity type command handler based of parameters + */ + public static class CustomDimensionlessCommandHandler extends CustomCommandHandler { + CustomDimensionlessCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return cmd instanceof QuantityType; + } + + @Override + protected double getValue(Command cmd) { + int minValue = getParameterAsInteger("min", 0); + int maxValue = getParameterAsInteger("max", 100); + @SuppressWarnings("unchecked") + double value = ((QuantityType) cmd).doubleValue(); + return Math.round(value * (maxValue - minValue) / 100.0) + minValue; + } + } + + /** + * Custom temperature quantity type command handler based of parameters + */ + public static class CustomTemperatureCommandHandler extends CustomCommandHandler { + CustomTemperatureCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return cmd instanceof QuantityType; + } + + @Override + protected double getValue(Command cmd) { + @SuppressWarnings("unchecked") + QuantityType temperature = (QuantityType) cmd; + Unit unit = getTemperatureUnit(); + double value = Objects.requireNonNullElse(temperature.toInvertibleUnit(unit), temperature).doubleValue(); + double increment = SIUnits.CELSIUS.equals(unit) ? 0.5 : 1; + return Math.round(value / increment) * increment; // round in increment based on temperature unit + } + + private Unit getTemperatureUnit() { + String scale = getParameterAsString("scale", ""); + switch (scale) { + case "celsius": + return SIUnits.CELSIUS; + case "fahrenheit": + return ImperialUnits.FAHRENHEIT; + default: + logger.debug("{}: no valid temperature scale parameter found, defaulting to: CELSIUS", nm()); + return SIUnits.CELSIUS; + } + } + } + + /** + * Custom time quantity type command handler based of parameters + */ + public static class CustomTimeCommandHandler extends CustomCommandHandler { + CustomTimeCommandHandler(DeviceFeature feature) { + super(feature); + } + + @Override + public boolean canHandle(Command cmd) { + return cmd instanceof QuantityType; + } + + @Override + protected double getValue(Command cmd) { + @SuppressWarnings("unchecked") + QuantityType