diff --git a/CODEOWNERS b/CODEOWNERS index ae0d5d1a3c958..f2eb395a8a376 100755 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -164,7 +164,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 e033abe0beb66..4bee1a400ea92 100644 --- a/bundles/org.openhab.binding.insteon/README.md +++ b/bundles/org.openhab.binding.insteon/README.md @@ -1,266 +1,555 @@ # 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). + +**Important note as of openHAB 4.3.0** + +The binding has been rewritten 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. +You can follow the [migration guide](#migration-guide). + +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 communicates with Insteon devices. | +| legacy-device | Thing | An Insteon or X10 device such as a switch, dimmer, keypad, sensor, 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 - -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. | +For bridge things, if the poll interval is too short, it will result in sluggish performance and 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. + +### `hub1` + +| 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. | +| 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. | + +>NOTE: Use this bridge to connect to a networked PLM via ser2net. + +### `hub2` + +| 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. | +| 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. | + +### `plm` + +| 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. | +| 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. | + +### `device` + +| 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. + +### `scene` + +| 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` + +| 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 | +
+ +### `network` + +| Parameter | Default | Required | Description | +| ------------------------- | :-----: | :------: | --------------------------------------- | +| port | | Yes | Port configuration. | +| devicePollIntervalSeconds | 300 | No | Poll interval of devices in seconds. | +| additionalDevices | | No | File with additional device types. | +| additionalFeatures | | No | File with additional feature templates. | >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 | +
+ Port configuration examples + + | Modem Type | Port Configuration | + | --------------------- | --------------------------------------------------------------------------------------------------------------- | + | Hub (2245-222) | `/hub2/my_user_name:my_password@192.168.1.100:25105,poll_time=1000` | + | Legacy Hub (2242-222) | `/hub/192.168.1.100:9761` | + | PLM | `/dev/ttyS0` or `/dev/ttyUSB0` (Linux)
`COM1` (Windows)
`/tcp/192.168.1.100:9761` (Networked via ser2net) | + | Smartenit ZBPLM | `/dev/ttyUSB0,baudRate=115200` (Linux) | +
+ +### `legacy-device` + +| Parameter | Required | Description | +| ------------ | :------: | ------------------------------------------------------------ | +| address | Yes | Device address. Example: `12.34.56` (Insteon) or `A.1` (X10) | +| productKey | Yes | Product key used to identify the model of the device. | +| deviceConfig | No | Optional JSON object with device specific configuration. | + +
+ 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 | +| --------------------- | -------------------- | :---------: | ---------------------------- | +| 3-way-mode | Switch | R/W | 3-Way Toggle Mode | +| ac-delay | Number:Time | R/W | AC Delay | +| alert-delay | Switch | R/W | Alert Delay | +| alert-duration | Number:Time | R/W | Alert Duration | +| alert-type | String | R/W | Alert Type | +| armed | Switch | R/W | Armed | +| backlight-duration | Number:Time | R/W | Back Light Duration | +| battery-level | Number:Dimensionless | R | Battery Level | +| battery-powered | Switch | R | Battery Powered | +| beep | Switch | W | Beep | +| button-a | Switch | R/W | Button A | +| button-b | Switch | R/W | Button B | +| button-c | Switch | R/W | Button C | +| button-d | Switch | R/W | Button D | +| button-e | Switch | R/W | Button E | +| button-f | Switch | R/W | Button F | +| button-g | Switch | R/W | Button G | +| button-h | Switch | R/W | Button H | +| button-beep | Switch | R/W | Beep on Button Press | +| button-config | String | R/W | Button Config | +| button-lock | Switch | R/W | Button Lock | +| carbon-monoxide-alarm | Switch | R | Carbon Monoxide Alarm | +| contact | Contact | R | Contact Sensor | +| cool-setpoint | Number:Temperature | R/W | Cool Setpoint | +| daytime | Switch | R | Daytime | +| dehumidify-setpoint | Number:Dimensionless | R/W | Dehumidify Setpoint | +| dimmer | Dimmer | R/W | Dimmer | +| energy-offset | Number:Temperature | R/W | Energy Temperature Offset | +| energy-reset | Switch | W | Energy Usage Reset | +| energy-saving | Switch | R | Energy Saving Mode | +| energy-usage | Number:Energy | R | Energy Usage | +| fan-mode | String | R/W | Fan Mode | +| fan-speed | String | R/W | Fan Speed | +| fan-state | Switch | R | Fan State | +| fast-on-off | Switch | W | Fast On/Off | +| heartbeat-interval | Number:Time | R/W | Heartbeat Interval | +| heartbeat-on-off | Switch | R/W | Heartbeat Enabled | +| heat-setpoint | Number:Temperature | R/W | Heat Setpoint | +| humidifier-state | String | R | Humidifier State | +| humidify-setpoint | Number:Dimensionless | R/W | Humidify Setpoint | +| humidity | Number:Dimensionless | R | Ambient Humidity | +| last-heard-from | DateTime | R | Last Heard From | +| leak | Switch | R | Leak Sensor | +| led-brightness | Dimmer | R/W | LED Brightness Level | +| led-on-off | Switch | R/W | LED Enabled | +| led-traffic | Switch | R/W | LED Traffic Blinking | +| light-level | Number:Dimensionless | R | Ambient Light Level | +| load | Switch | R | Load Sensor | +| load-sense | Switch | R/W | Load Sense | +| load-sense-bottom | Switch | R/W | Load Sense Bottom Outlet | +| load-sense-top | Switch | R/W | Load Sense Top Outlet | +| lock | Switch | R/W | Lock | +| low-battery | Switch | R | Low Battery Alert | +| malfunction | Switch | R | Malfunction Alert | +| manual-change | Rollershutter | W | Manual Change | +| momentary-duration | Number:Time | R/W | Momentary Duration | +| monitor-mode | Switch | R/W | Monitor Mode | +| motion | Switch | R | Motion Sensor | +| on-level | Dimmer | R/W | On Level | +| operation-mode | String | R/W | Switch Operation Mode | +| outlet-bottom | Switch | R/W | Bottom Outlet | +| outlet-top | Switch | R/W | Top Outlet | +| power-usage | Number:Power | R | Power Usage | +| program1 | Player | R/W | Program 1 | +| program2 | Player | R/W | Program 2 | +| program3 | Player | R/W | Program 3 | +| program4 | Player | R/W | Program 4 | +| program-lock | Switch | R/W | Local Programming Lock | +| pump | Switch | R/W | Pump Control | +| ramp-rate | Number:Time | R/W | Ramp Rate | +| relay-mode | String | R/W | Output Relay Mode | +| relay-sensor-follow | Switch | R/W | Output Relay Sensor Follow | +| resume-dim | Switch | R/W | Resume Dim Level | +| reverse-direction | Switch | R/W | Reverse Motor Direction | +| rollershutter | Rollershutter | R/W | Rollershutter | +| scene | Switch | R/W | Scene | +| siren | Switch | R/W | Siren | +| smoke-alarm | Switch | R | Smoke Alarm | +| stage1-duration | Number:Time | R/W | Stage 1 Duration | +| stay-awake | Switch | R/W | Stay Awake for Extended Time | +| switch | Switch | R/W | Switch | +| sync-time | Switch | W | Synchronize Time | +| system-mode | String | R/W | System Mode | +| system-state | String | R | System State | +| tamper-switch | Contact | R | Tamper Switch | +| temperature | Number:Temperature | R | Ambient Temperature | +| temperature-scale | String | R/W | Temperature Scale | +| test-alarm | Switch | R | Test Alarm | +| time-format | String | R/W | Time Format | +| toggle-mode-button-a | String | R/W | Toggle Mode Button A | +| toggle-mode-button-b | String | R/W | Toggle Mode Button B | +| toggle-mode-button-c | String | R/W | Toggle Mode Button C | +| toggle-mode-button-d | String | R/W | Toggle Mode Button D | +| toggle-mode-button-e | String | R/W | Toggle Mode Button E | +| toggle-mode-button-f | String | R/W | Toggle Mode Button F | +| toggle-mode-button-g | String | R/W | Toggle Mode Button G | +| toggle-mode-button-h | 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 | +| ------------------- | ------------------- | +| event-button | Event Button | +| event-button-a | Event Button A | +| event-button-b | Event Button B | +| event-button-c | Event Button C | +| event-button-d | Event Button D | +| event-button-e | Event Button E | +| event-button-f | Event Button F | +| event-button-g | Event Button G | +| event-button-h | Event Button H | +| event-button-main | Event Button Main | +| event-button-bottom | Event Button Bottom | +| event-button-top | Event Button Top | +| im-event-button | 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 Setpoint | + | 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 Setpoint | + | 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 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:dimmer" } +Switch keypadA { channel="insteon:device:home:22f8a8:button-a" } +Switch keypadB { channel="insteon:device:home:22f8a8:button-b" } +Switch keypadC { channel="insteon:device:home:22f8a8:button-c" } +Switch keypadD { channel="insteon:device:home:22f8a8:button-d" } +Switch scene42 { channel="insteon:scene:home:scene42:scene" } +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 -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. + ```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 + ``` + +
## Insteon Groups and Scenes @@ -268,93 +557,303 @@ 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 related device synchronization 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 thermostatCoolSetpoint "cool setpoint [%.1f °F]" { channel="insteon:device:home:32f422:cool-setpoint" } +Number:Temperature thermostatHeatSetpoint "heat setpoint [%.1f °F]" { channel="insteon:device:home:32f422:heat-setpoint" } ``` -### 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: ```java -Dimmer kitchenChandelier "kitchen chandelier" { channel="insteon:device:home:AABBCC:dimmer" } +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 `on-level` and `ramp-rate` channels. -#### Things +Alternatively, these settings can be overridden using the `dimmer` channel parameters `onLevel` and `rampRate`. +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 triggered 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. + +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:button-a"} +Switch keypadSwitchB "button B" { channel="insteon:device:home:aabbcc:button-b"} +Switch keypadSwitchC "button C" { channel="insteon:device:home:aabbcc:button-c"} +Switch keypadSwitchD "button D" { channel="insteon:device:home:aabbcc:button-d"} +``` + +
+ Legacy + + ```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: + +```java +rule "Main Button Off Event" +when + Channel 'insteon:device:home:aabbcc:event-button-main' triggered PRESSED_OFF +then + // do something +end + +rule "Main Button Fast On/Off Events" +when + Channel 'insteon:device:home:aabbcc:event-button-main' triggered DOUBLE_PRESSED_ON or + Channel 'insteon:device:home:aabbcc:event-button-main' triggered DOUBLE_PRESSED_OFF +then + // do something +end + +rule "Main Button Manual Change Stop Event" +when + Channel 'insteon:device:home:aabbcc:event-button-main' triggered RELEASED +then + // do something +end + +rule "Keypad Button A On Event" +when + Channel 'insteon:device:home:aabbcc:event-button-a' 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:button-a" } ``` -Setting a maximum level does not affect manual turning on or dimming a switch. +
+ Legacy + + ```java + Dimmer keypadDimmer "main dimmer" { channel="insteon:device:home:AABBCC:dimmer" } + Switch keypadDimmerButtonA "button A" { channel="insteon:device:home:AABBCC:buttonA" } + ``` + +
+ +##### 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:outlet-top" } +Switch outletBottom "Outlet Bottom" { channel="insteon:device:home:aabbcc:outlet-bottom" } ``` -This will give you individual control of each outlet. +
+ Legacy + + ```java + Switch outletTop "Outlet Top" { channel="insteon:device:home:AABBCC:topOutlet" } + Switch outletBottom "Outlet Bottom" { channel="insteon:device:home:AABBCC:bottomOutlet" } + ``` + +
### Mini Remotes @@ -370,82 +869,120 @@ 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: + +##### Rules ```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" } +rule "Mini Remote Button A Pressed On" +when + Channel 'insteon:device:home:mini-remote:event-button-a' triggered PRESSED_ON +then + // do something +end ``` -**Sitemap** -This goes into the sitemap file: +### Motion Sensors -```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"] +Link such that the modem is a responder to the motion sensor. + +##### Items + +```java +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:battery-level" } +Number:Dimensionless motionSensorLightLevel "light level [%.1f %%]" { channel="insteon:device:home:aabbcc:light-level" } ``` -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. +
+ Legacy -### Motion Sensors + ```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" } + ``` -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 +and create a file "motion.map" in the transforms directory with these entries: -```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" } +```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:tamper-switch" } +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. +
+ Legacy -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: + ```java + 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" } + ``` -#### 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 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. -``` +The 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. + +
+ Legacy -The temperature can be calculated in Fahrenheit using the following formulas: + 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: - 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:battery-level" } +``` + +
+ Legacy + + ```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 +990,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,336 +1029,517 @@ 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": - -```text -OPEN=open -CLOSED=closed --=unknown -``` - -**Items** -Along with this into your .items file: +##### Items ```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" } +Switch garageDoorOpener "door opener" { channel="insteon:device:home:aabbcc:switch" } +Contact garageDoorContact "door contact [MAP(contact.map):%s]" { channel="insteon:device:home:aabbcc:contact" } ``` -**Sitemap** -To make it visible in the GUI, put this into your sitemap file: +and create a file "contact.map" in the transforms directory with these entries: -```perl -Switch item=garageDoorOpener label="garage door opener" mappings=[ ON="OPEN/CLOSE"] -Text item=garageDoorContact +```text +OPEN=open +CLOSED=closed +-=unknown ``` -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. - > 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. -### Keypads +### Fan Controllers -Before you attempt to configure the keypads, please familiarize yourself with the concept of an Insteon group. +Here is an example configuration for a FanLinc module, which has a dimmable light and a variable speed 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. +##### Items -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: +```java +Dimmer fanLincDimmer "dimmer [%d %%]" { channel="insteon:device:home:aabbcc:dimmer" } +String fanLincFan "fan speed" { channel="insteon:device:home:aabbcc:fan-speed" } +``` -| Group | Button Number | 2487S Label | -|-------|---------------|-------------| -| 0x01 | 1 | (Load) | -| 0x03 | 3 | A | -| 0x04 | 4 | B | -| 0x05 | 5 | C | -| 0x06 | 6 | D | +
+ Legacy -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). + ```java + Dimmer fanLincDimmer "dimmer [%d %%]" { channel="insteon:device:home:AABBCC:lightDimmer" } + Number fanLincFan "fan" { channel="insteon:device:home:AABBCC:fan"} + ``` -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. +
-#### Keypad Switches +##### Sitemap -##### Items +```perl +Slider item=fanLincDimmer switchSupport +Switch item=fanLincFan mappings=[ OFF="OFF", LOW="LOW", MEDIUM="MEDIUM", HIGH="HIGH" ] +``` -Here is a simple example, just using the load (main) switch: +### Power Meters -```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" } -``` +The iMeter Solo reports both energy and power usage, 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. -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: +See the example below: -###### Things +##### Items ```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" ] - } -} +Number:Power iMeterPower "power [%d W]" { channel="insteon:device:home:aabbcc:power-usage" } +Number:Energy iMeterEnergy "energy [%.04f kWh]" { channel="insteon:device:home:aabbcc:energy-usage" } +Switch iMeterReset "reset" { channel="insteon:device:home:aabbcc:reset" } ``` -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". +
+ Legacy + + ```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" } + ``` + +
+ +### Sirens + +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 alert delay and duration are only used for the siren arming behavior. -###### Items +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 sirenAlertDelay "alert delay" { channel="insteon:device:home:aabbcc:alert-delay" } +Number:Time sirenAlertDuration "alert duration [%d s]" { channel="insteon:device:home:aabbcc:alert-duration" } +String sirenAlertType "alert type [%s]" { channel="insteon:device:home:aabbcc:alert-type" } ``` ##### 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=sirenAlertDelay +Setpoint item=sirenAlertDuration minValue=0 maxValue=127 step=1 +Switch item=sirenAlertType mappings=[ CHIME="CHIME", LOUD_SIREN="LOUD SIREN" ] ``` -#### Keypad Dimmers +### Smoke Detectors -The keypad dimmers are like keypad switches, except that the main load is dimmable. +The smoke bridge monitors First Alert ONELINK smoke and carbon monoxide detectors. + +Here is an example configuration for a smoke bridge: ##### Items ```java -Dimmer keypadDimmer "dimmer" { channel="insteon:device:home:AABBCC:loadDimmer" } -Switch keypadDimmerButtonA "keypad dimmer button A [%d %%]" { channel="insteon:device:home:AABBCC:keypadButtonA" } +Switch smokeAlarm "smoke alarm" { channel="insteon:device:home:aabbcc:smoke-alarm" } +Switch carbonMonoxideAlarm "carbon monoxide alarm" { channel="insteon:device:home:aabbcc:carbon-monoxide-alarm" } +Switch lowBattery "low battery" { channel="insteon:device:home:aabbcc:low-battery" } ``` -##### Sitemap +### Sprinklers -```perl -Slider item=keypadDimmer switchSupport -Switch item=keypadDimmerButtonA label="buttonA" +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 +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 thermostatCoolSetpoint "cool setpoint [%.1f °F]" { channel="insteon:device:home:aabbcc:cool-setpoint" } +Number:Temperature thermostatHeatSetpoint "heat setpoint [%.1f °F]" { channel="insteon:device:home:aabbcc:heat-setpoint" } +String thermostatSystemMode "system mode [%s]" { channel="insteon:device:home:aabbcc:system-mode" } +String thermostatSystemState "system state [%s]" { channel="insteon:device:home:aabbcc:system-state" } +String thermostatFanMode "fan mode [%s]" { channel="insteon:device:home:aabbcc:fan-mode" } +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:ac-delay" } +Number:Time thermostatBacklight "backlight [%d sec]" { channel="insteon:device:home:aabbcc:backlight-duration" } +Number:Time thermostatStage1 "A/C stage 1 time [%d min]" { channel="insteon:device:home:aabbcc:stage1-duration" } +Number:Dimensionless thermostatDehumidifySetpoint "dehumidify setpoint [%d %%]" { channel="insteon:device:home:aabbcc:dehumidify-setpoint" } +Number:Dimensionless thermostatHumidifySetpoint "humidify setpoint [%d %%]" { channel="insteon:device:home:aabbcc:humidify-setpoint" } +String thermostatTemperatureScale "temperature scale [%s]" { channel="insteon:device:home:aabbcc:temperature-scale" } +String thermostatTimeFormat "time format [%s]" { channel="insteon:device:home:aabbcc:time=format" } ``` -#### 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", ALWAYS_ON="ALWAYS ON" ] +Setpoint item=thermostatACDelay minValue=2 maxValue=20 step=1 +Setpoint item=thermostatBacklight minValue=0 maxValue=100 step=1 +Setpoint item=thermostatDehumidifySetpoint minValue=20 maxValue=90 step=1 +Setpoint item=thermostatHumidifySetpoint minValue=0 maxValue=79 step=1 +Setpoint item=thermostatStage1 minValue=1 maxValue=60 step=1 +Switch item=thermostatTemperatureScale 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 + +The binding can trigger scenes by commanding the modem to send broadcasts to a given Insteon group. -#### Items +### Things ```java -Dimmer fanLincDimmer "fanlinc dimmer [%d %%]" { channel="insteon:device:home:AABBCC:lightDimmer" } -Number fanLincFan "fanlinc fan" { channel="insteon:device:home:AABBCC:fan"} +Bridge insteon:plm:home [serialPort="/dev/ttyUSB0"] { + Thing scene scene42 [group=42] +} ``` -#### Sitemap +### Items + +```java +Switch scene "scene" { channel="insteon:scene:home:scene42:scene" } +Switch sceneFastOnOff "fast on/off" { channel="insteon:scene:home:scene42:fast-on-off" } +Rollershutter sceneManualChange "manual change" { channel="insteon:scene:home:scene42:manual-change" } +``` + +### Sitemap ```perl -Slider item=fanLincDimmer switchSupport -Switch item=fanLincFan label="fan speed" mappings=[ 0="OFF", 1="LOW", 2="MEDIUM", 3="HIGH"] +Switch item=scene +Switch item=sceneFastOnOff mappings=[ ON="ON", OFF="OFF" ] +Switch item=sceneManualChange mappings=[ UP="UP", DOWN="DOWN", STOP="STOP" ] ``` -### X10 Devices +Sending `ON` command to `scene` will cause the modem to send a broadcast message to group 42, and all devices that are configured to respond to it should react. +The current state of a scene is published on the `scene` 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]}"] + } + ``` + +### Items + + ```java + Switch broadcastOnOff "group on/off" { channel="insteon:device:home:AABBCC:broadcastOnOff#2" } + ``` -## Direct Sending of Group Broadcasts (Triggering Scenes) + 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. -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: +
+ +## 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. - -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 -### Things (device Config) +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 `stay-awake` 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. -```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 `heartbeat-interval` 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 `heartbeat-on-off` 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"] } -} -``` + ``` + +
+ +## Triggered Events -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: +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: ```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"] +rule "Dimmer Paddle Events" +when + Channel 'insteon:device:home:dimmer:event-button' 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:event-button-a' 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. +## Migration Guide + +Here are the recommended steps to follow when migrating from the legacy implementation: + +- Create a new bridge matching your modem type. +This will automatically disable the legacy network bridge with the same configuration to prevent having two bridges connected to the same modem. + +- Once your devices are discovered, they will show in your inbox. + - Add the discovered things. + - Connect the new things to your existing semantic models. + - Link the new channels to your existing items. + - Update your relevant rules. + +- For battery powered devices, press on their SET button to speed up the discovery process. +Otherwise you may have to wait until the next time these devices send a heartbeat message which can take up to 24 hours. + +- For scenes, you can either enable scene discovery and add the discovered things, or just manually add specific scene things based on your existing environment. +Enabling scene discovery might generate a considerable amount of things in your inbox depending on the number of scenes configured in your modem. + +- If you have rules to send commands to synchronize the state between related devices, you can enable the device synchronization feature on the bridge instead. +This will synchronize related devices automatically based on their all-link database. + +- If you need to re-enable the legacy bridge, simply disable the new bridge and enable the legacy one again. + +- Once you finished updating your environment, you can remove the legacy bridge and things, which may need to be forced deleted since their bridge would be disabled. ## 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. +### Debug Console Commands + +To log message events between a device and the modem to a file: + +```shell +# Single device monitor +openhab> insteon debug startMonitoring AA.BB.CC +# All devices monitor +openhab> insteon debug startMonitoring --all +``` + +To send a message to a device or broadcast group: + +```shell +# Standard message to a device +openhab> insteon debug sendStandardMessage AA.BB.CC 11 FF +# Broadcast message to a group +openhab> insteon debug sendBroadcastMessage 42 13 00 +``` + ### Device Permissions / Linux Device Locks 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 +1548,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: +
-```ini -additionalDevices="/usr/local/openhab/rt/my_own_devices.xml" -``` +### Adding New Legacy Device Types (Using Existing Device Features) + + 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: -Where the `my_own_devices.xml` file defines a new device like this: + ```ini + additionalDevices="/usr/local/openhab/rt/my-own-devices.xml" + ``` -```xml - - + Where the `my-own-devices.xml` file defines a new device like this: + + ```xml + + 2456-D3 LampLinc V2 GenericDimmer GenericLastTime - - -``` - -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. - -### Adding New Device Features - -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: - -```ini -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 `device_types.xml` file (or from `my_own_devices.xml`): - -```xml - - - DefaultDispatcher - NoOpMsgHandler - NoOpMsgHandler - NoOpMsgHandler - NoOpMsgHandler - LightStateSwitchHandler - IOLincOnOffCommandHandler - DefaultPollHandler - - -``` + + + ``` + + 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. + +### Adding New Legacy Device Features + + 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: + + ```ini + 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 index 10f431d7bce67..526bb8ddc3f0a 100644 --- 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 @@ -12,7 +12,13 @@ */ 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; /** @@ -20,93 +26,85 @@ * 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_SCALE = "temperatureScale"; + 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 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 specific device types + public static final String DEVICE_TYPE_CLIMATE_CONTROL_VENSTAR_THERMOSTAT = "ClimateControl_VenstarThermostat"; - // List of all Channel ids - public static final String AC_DELAY = "acDelay"; - public static final String BACKLIGHT_DURATION = "backlightDuration"; - public static final String BATTERY_LEVEL = "batteryLevel"; - public static final String BATTERY_PERCENT = "batteryPercent"; - public static final String BATTERY_WATERMARK_LEVEL = "batteryWatermarkLevel"; - public static final String BEEP = "beep"; - public static final String BOTTOM_OUTLET = "bottomOutlet"; - public static final String BUTTON_A = "buttonA"; - public static final String BUTTON_B = "buttonB"; - public static final String BUTTON_C = "buttonC"; - public static final String BUTTON_D = "buttonD"; - public static final String BUTTON_E = "buttonE"; - public static final String BUTTON_F = "buttonF"; - public static final String BUTTON_G = "buttonG"; - public static final String BUTTON_H = "buttonH"; - public static final String BROADCAST_ON_OFF = "broadcastOnOff"; - public static final String CONTACT = "contact"; - public static final String COOL_SET_POINT = "coolSetPoint"; - public static final String DIMMER = "dimmer"; - public static final String FAN = "fan"; - public static final String FAN_MODE = "fanMode"; - public static final String FAST_ON_OFF = "fastOnOff"; - public static final String FAST_ON_OFF_BUTTON_A = "fastOnOffButtonA"; - public static final String FAST_ON_OFF_BUTTON_B = "fastOnOffButtonB"; - public static final String FAST_ON_OFF_BUTTON_C = "fastOnOffButtonC"; - public static final String FAST_ON_OFF_BUTTON_D = "fastOnOffButtonD"; - public static final String FAST_ON_OFF_BUTTON_E = "fastOnOffButtonE"; - public static final String FAST_ON_OFF_BUTTON_F = "fastOnOffButtonF"; - public static final String FAST_ON_OFF_BUTTON_G = "fastOnOffButtonG"; - public static final String FAST_ON_OFF_BUTTON_H = "fastOnOffButtonH"; - public static final String HEAT_SET_POINT = "heatSetPoint"; - public static final String HUMIDITY = "humidity"; - public static final String HUMIDITY_HIGH = "humidityHigh"; - public static final String HUMIDITY_LOW = "humidityLow"; - public static final String IS_COOLING = "isCooling"; - public static final String IS_HEATING = "isHeating"; - public static final String KEYPAD_BUTTON_A = "keypadButtonA"; - public static final String KEYPAD_BUTTON_B = "keypadButtonB"; - public static final String KEYPAD_BUTTON_C = "keypadButtonC"; - public static final String KEYPAD_BUTTON_D = "keypadButtonD"; - public static final String KEYPAD_BUTTON_E = "keypadButtonE"; - public static final String KEYPAD_BUTTON_F = "keypadButtonF"; - public static final String KEYPAD_BUTTON_G = "keypadButtonG"; - public static final String KEYPAD_BUTTON_H = "keypadButtonH"; - public static final String KWH = "kWh"; - public static final String LAST_HEARD_FROM = "lastHeardFrom"; - public static final String LED_BRIGHTNESS = "ledBrightness"; - public static final String LED_ONOFF = "ledOnOff"; - public static final String LIGHT_DIMMER = "lightDimmer"; - public static final String LIGHT_LEVEL = "lightLevel"; - public static final String LIGHT_LEVEL_ABOVE_THRESHOLD = "lightLevelAboveThreshold"; - public static final String LOAD_DIMMER = "loadDimmer"; - public static final String LOAD_SWITCH = "loadSwitch"; - public static final String LOAD_SWITCH_FAST_ON_OFF = "loadSwitchFastOnOff"; - public static final String LOAD_SWITCH_MANUAL_CHANGE = "loadSwitchManualChange"; - public static final String LOWBATTERY = "lowBattery"; - public static final String MANUAL_CHANGE = "manualChange"; - public static final String MANUAL_CHANGE_BUTTON_A = "manualChangeButtonA"; - public static final String MANUAL_CHANGE_BUTTON_B = "manualChangeButtonB"; - public static final String MANUAL_CHANGE_BUTTON_C = "manualChangeButtonC"; - public static final String MANUAL_CHANGE_BUTTON_D = "manualChangeButtonD"; - public static final String MANUAL_CHANGE_BUTTON_E = "manualChangeButtonE"; - public static final String MANUAL_CHANGE_BUTTON_F = "manualChangeButtonF"; - public static final String MANUAL_CHANGE_BUTTON_G = "manualChangeButtonG"; - public static final String MANUAL_CHANGE_BUTTON_H = "manualChangeButtonH"; - public static final String NOTIFICATION = "notification"; - public static final String ON_LEVEL = "onLevel"; - public static final String RAMP_DIMMER = "rampDimmer"; - public static final String RAMP_RATE = "rampRate"; - public static final String RESET = "reset"; - public static final String STAGE1_DURATION = "stage1Duration"; - public static final String SWITCH = "switch"; - public static final String SYSTEM_MODE = "systemMode"; - public static final String TAMPER_SWITCH = "tamperSwitch"; - public static final String TEMPERATURE = "temperature"; - public static final String TEMPERATURE_LEVEL = "temperatureLevel"; - public static final String TOP_OUTLET = "topOutlet"; - public static final String UPDATE = "update"; - public static final String WATTS = "watts"; + // 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 d31796854323b..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 @@ -14,29 +14,34 @@ 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.InsteonDeviceDiscoveryService; +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.InsteonNetworkHandler; +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,40 +84,42 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); - if (NETWORK_THING_TYPE.equals(thingTypeUID)) { - InsteonNetworkHandler insteonNetworkHandler = new InsteonNetworkHandler((Bridge) thing, serialPortManager); - registerServices(insteonNetworkHandler); - - return insteonNetworkHandler; - } else if (DEVICE_THING_TYPE.equals(thingTypeUID)) { - return new InsteonDeviceHandler(thing); + 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 InsteonNetworkHandler) { - 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(InsteonNetworkHandler handler) { - this.serviceRegs.put(handler.getThing().getUID(), - bundleContext.registerService(InsteonNetworkHandler.class.getName(), handler, new Hashtable<>())); - - InsteonDeviceDiscoveryService discoveryService = new InsteonDeviceDiscoveryService(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/InsteonBinding.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBinding.java similarity index 50% rename from bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonBinding.java rename to bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBinding.java index 59b43d5413801..fc1ae270b7515 100644 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonBinding.java +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBinding.java @@ -12,7 +12,6 @@ */ package org.openhab.binding.insteon.internal; -import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -26,38 +25,36 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; -import javax.xml.parsers.ParserConfigurationException; - 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.config.InsteonNetworkConfiguration; -import org.openhab.binding.insteon.internal.device.DeviceFeature; -import org.openhab.binding.insteon.internal.device.DeviceFeatureListener; -import org.openhab.binding.insteon.internal.device.DeviceType; -import org.openhab.binding.insteon.internal.device.DeviceTypeLoader; +import org.openhab.binding.insteon.internal.config.InsteonLegacyChannelConfiguration; +import org.openhab.binding.insteon.internal.config.InsteonLegacyNetworkConfiguration; +import org.openhab.binding.insteon.internal.device.DeviceAddress; import org.openhab.binding.insteon.internal.device.InsteonAddress; -import org.openhab.binding.insteon.internal.device.InsteonDevice; -import org.openhab.binding.insteon.internal.device.InsteonDevice.DeviceStatus; -import org.openhab.binding.insteon.internal.device.RequestQueueManager; -import org.openhab.binding.insteon.internal.driver.Driver; -import org.openhab.binding.insteon.internal.driver.DriverListener; -import org.openhab.binding.insteon.internal.driver.ModemDBEntry; -import org.openhab.binding.insteon.internal.driver.Poller; -import org.openhab.binding.insteon.internal.driver.Port; -import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler; -import org.openhab.binding.insteon.internal.handler.InsteonNetworkHandler; -import org.openhab.binding.insteon.internal.message.FieldException; -import org.openhab.binding.insteon.internal.message.Msg; -import org.openhab.binding.insteon.internal.message.MsgListener; -import org.openhab.binding.insteon.internal.utils.Utils; +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.device.LegacyPollManager; +import org.openhab.binding.insteon.internal.device.LegacyRequestManager; +import org.openhab.binding.insteon.internal.device.X10Address; +import org.openhab.binding.insteon.internal.device.database.LegacyModemDBEntry; +import org.openhab.binding.insteon.internal.device.feature.LegacyFeatureListener; +import org.openhab.binding.insteon.internal.device.feature.LegacyFeatureTemplateLoader; +import org.openhab.binding.insteon.internal.handler.InsteonLegacyNetworkHandler; +import org.openhab.binding.insteon.internal.transport.LegacyDriver; +import org.openhab.binding.insteon.internal.transport.LegacyDriverListener; +import org.openhab.binding.insteon.internal.transport.LegacyPort; +import org.openhab.binding.insteon.internal.transport.LegacyPortListener; +import org.openhab.binding.insteon.internal.transport.message.FieldException; +import org.openhab.binding.insteon.internal.transport.message.Msg; import org.openhab.core.io.transport.serial.SerialPortManager; import org.openhab.core.thing.ChannelUID; import org.openhab.core.types.Command; import org.openhab.core.types.State; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.xml.sax.SAXException; /** * A majority of the code in this file is from the openHAB 1 binding @@ -104,34 +101,34 @@ * @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 InsteonBinding { +public class InsteonLegacyBinding implements LegacyDriverListener, LegacyPortListener { private static final int DEAD_DEVICE_COUNT = 10; - private final Logger logger = LoggerFactory.getLogger(InsteonBinding.class); + private final Logger logger = LoggerFactory.getLogger(InsteonLegacyBinding.class); - private Driver driver; - private Map devices = new ConcurrentHashMap<>(); - private Map bindingConfigs = new ConcurrentHashMap<>(); - private PortListener portListener = new PortListener(); + private LegacyDriver driver; + private Map devices = new ConcurrentHashMap<>(); + private Map bindingConfigs = new ConcurrentHashMap<>(); private int devicePollIntervalMilliseconds = 300000; private int deadDeviceTimeout = -1; private boolean driverInitialized = false; private int messagesReceived = 0; private boolean isActive = false; // state of binding private int x10HouseUnit = -1; - private InsteonNetworkHandler handler; + private InsteonLegacyNetworkHandler handler; - public InsteonBinding(InsteonNetworkHandler handler, InsteonNetworkConfiguration config, + public InsteonLegacyBinding(InsteonLegacyNetworkHandler handler, InsteonLegacyNetworkConfiguration config, 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 Driver(port, portListener, serialPortManager, scheduler); - driver.addMsgListener(portListener); + driver = new LegacyDriver(config, this, serialPortManager, scheduler); + driver.addPortListener(this); Integer devicePollIntervalSeconds = config.getDevicePollIntervalSeconds(); if (devicePollIntervalSeconds != null) { @@ -141,30 +138,21 @@ public InsteonBinding(InsteonNetworkHandler handler, InsteonNetworkConfiguration String additionalDevices = config.getAdditionalDevices(); if (additionalDevices != null) { - try { - DeviceTypeLoader instance = DeviceTypeLoader.instance(); - if (instance != null) { - instance.loadDeviceTypesXML(additionalDevices); - logger.debug("read additional device definitions from {}", additionalDevices); - } else { - logger.warn("device type loader instance is null"); - } - } catch (ParserConfigurationException | SAXException | IOException e) { - logger.warn("error reading additional devices from {}", additionalDevices, e); - } + logger.debug("loading additional device types from {}", additionalDevices); + LegacyDeviceTypeLoader.instance().loadDocument(additionalDevices); } String additionalFeatures = config.getAdditionalFeatures(); if (additionalFeatures != null) { - logger.debug("reading additional feature templates from {}", additionalFeatures); - DeviceFeature.readFeatureTemplates(additionalFeatures); + logger.debug("loading additional feature templates from {}", additionalFeatures); + LegacyFeatureTemplateLoader.instance().loadDocument(additionalFeatures); } deadDeviceTimeout = devicePollIntervalMilliseconds * DEAD_DEVICE_COUNT; logger.debug("dead device timeout set to {} seconds", deadDeviceTimeout / 1000); } - public Driver getDriver() { + public LegacyDriver getDriver() { return driver; } @@ -188,41 +176,41 @@ public void sendCommand(String channelName, Command command) { return; } - InsteonChannelConfiguration bindingConfig = bindingConfigs.get(channelName); + InsteonLegacyChannelConfiguration bindingConfig = bindingConfigs.get(channelName); if (bindingConfig == null) { logger.warn("unable to find binding config for channel {}", channelName); return; } - InsteonDevice dev = getDevice(bindingConfig.getAddress()); - if (dev == null) { + LegacyDevice device = getDevice(bindingConfig.getAddress()); + if (device == null) { logger.warn("no device found with insteon address {}", bindingConfig.getAddress()); return; } - dev.processCommand(driver, bindingConfig, command); + device.processCommand(driver, bindingConfig, command); logger.debug("found binding config for channel {}", channelName); } - public void addFeatureListener(InsteonChannelConfiguration bindingConfig) { + public void addFeatureListener(InsteonLegacyChannelConfiguration bindingConfig) { logger.debug("adding listener for channel {}", bindingConfig.getChannelName()); - InsteonAddress address = bindingConfig.getAddress(); - InsteonDevice dev = getDevice(address); - if (dev == null) { + DeviceAddress address = bindingConfig.getAddress(); + LegacyDevice device = getDevice(address); + if (device == null) { logger.warn("device for address {} is null", address); return; } @Nullable - DeviceFeature f = dev.getFeature(bindingConfig.getFeature()); - if (f == null || f.isFeatureGroup()) { + LegacyDeviceFeature feature = device.getFeature(bindingConfig.getFeature()); + if (feature == null || feature.isFeatureGroup()) { StringBuilder buf = new StringBuilder(); - ArrayList names = new ArrayList<>(dev.getFeatures().keySet()); + ArrayList names = new ArrayList<>(device.getFeatures().keySet()); Collections.sort(names); for (String name : names) { - DeviceFeature feature = dev.getFeature(name); - if (feature != null && !feature.isFeatureGroup()) { + LegacyDeviceFeature f = device.getFeature(name); + if (f != null && !f.isFeatureGroup()) { if (buf.length() > 0) { buf.append(", "); } @@ -236,10 +224,10 @@ public void addFeatureListener(InsteonChannelConfiguration bindingConfig) { return; } - DeviceFeatureListener fl = new DeviceFeatureListener(this, bindingConfig.getChannelUID(), + LegacyFeatureListener listener = new LegacyFeatureListener(this, bindingConfig.getChannelUID(), bindingConfig.getChannelName()); - fl.setParameters(bindingConfig.getParameters()); - f.addListener(fl); + listener.setParameters(bindingConfig.getParameters()); + feature.addListener(listener); bindingConfigs.put(bindingConfig.getChannelName(), bindingConfig); } @@ -249,11 +237,11 @@ public void removeFeatureListener(ChannelUID channelUID) { logger.debug("removing listener for channel {}", channelName); - for (Iterator> it = devices.entrySet().iterator(); it.hasNext();) { - InsteonDevice dev = it.next().getValue(); - boolean removedListener = dev.removeFeatureListener(channelName); + for (Iterator> it = devices.entrySet().iterator(); it.hasNext();) { + LegacyDevice device = it.next().getValue(); + boolean removedListener = device.removeFeatureListener(channelName); if (removedListener) { - logger.trace("removed feature listener {} from dev {}", channelName, dev); + logger.trace("removed feature listener {} from device {}", channelName, device); } } } @@ -262,47 +250,43 @@ public void updateFeatureState(ChannelUID channelUID, State state) { handler.updateState(channelUID, state); } - public @Nullable InsteonDevice makeNewDevice(InsteonAddress addr, String productKey, + public @Nullable LegacyDevice makeNewDevice(DeviceAddress address, String productKey, Map deviceConfigMap) { - DeviceTypeLoader instance = DeviceTypeLoader.instance(); - if (instance == null) { - return null; - } - DeviceType dt = instance.getDeviceType(productKey); - if (dt == null) { + LegacyDeviceType deviceType = LegacyDeviceTypeLoader.instance().getDeviceType(productKey); + if (deviceType == null) { return null; } - InsteonDevice dev = InsteonDevice.makeDevice(dt); - dev.setAddress(addr); - dev.setProductKey(productKey); - dev.setDriver(driver); - dev.setIsModem(productKey.equals(InsteonDeviceHandler.PLM_PRODUCT_KEY)); - dev.setDeviceConfigMap(deviceConfigMap); - if (!dev.hasValidPollingInterval()) { - dev.setPollInterval(devicePollIntervalMilliseconds); + LegacyDevice device = LegacyDevice.makeDevice(deviceType); + device.setAddress(address); + device.setProductKey(productKey); + device.setDriver(driver); + device.setIsModem(productKey.equals(InsteonLegacyBindingConstants.PLM_PRODUCT_KEY)); + device.setDeviceConfigMap(deviceConfigMap); + if (!device.hasValidPollingInterval()) { + device.setPollInterval(devicePollIntervalMilliseconds); } - if (driver.isModemDBComplete() && dev.getStatus() != DeviceStatus.POLLING) { - int ndev = checkIfInModemDatabase(dev); - if (dev.hasModemDBEntry()) { - dev.setStatus(DeviceStatus.POLLING); - Poller.instance().startPolling(dev, ndev); + if (driver.isModemDBComplete() && device.getStatus() != DeviceStatus.POLLING) { + int ndev = checkIfInModemDatabase(device); + if (device.hasModemDBEntry()) { + device.setStatus(DeviceStatus.POLLING); + LegacyPollManager.instance().startPolling(device, ndev); } } - devices.put(addr, dev); + devices.put(address, device); handler.insteonDeviceWasCreated(); - return (dev); + return device; } - public void removeDevice(InsteonAddress addr) { - InsteonDevice dev = devices.remove(addr); - if (dev == null) { + public void removeDevice(DeviceAddress address) { + LegacyDevice device = devices.remove(address); + if (device == null) { return; } - if (dev.getStatus() == DeviceStatus.POLLING) { - Poller.instance().stopPolling(dev); + if (device.getStatus() == DeviceStatus.POLLING) { + LegacyPollManager.instance().stopPolling(device); } } @@ -310,22 +294,24 @@ public void removeDevice(InsteonAddress addr) { * Checks if a device is in the modem link database, and, if the database * is complete, logs a warning if the device is not present * - * @param dev The device to search for in the modem database + * @param device The device to search for in the modem database * @return number of devices in modem database */ - private int checkIfInModemDatabase(InsteonDevice dev) { + private int checkIfInModemDatabase(LegacyDevice device) { try { - InsteonAddress addr = dev.getAddress(); - Map dbes = driver.lockModemDBEntries(); - if (dbes.containsKey(addr)) { - if (!dev.hasModemDBEntry()) { - logger.debug("device {} found in the modem database and {}.", addr, getLinkInfo(dbes, addr, true)); - dev.setHasModemDBEntry(true); - } - } else { - if (driver.isModemDBComplete() && !addr.isX10()) { - logger.warn("device {} not found in the modem database. Did you forget to link?", addr); - handler.deviceNotLinked(addr); + Map dbes = driver.lockModemDBEntries(); + if (device.getAddress() instanceof InsteonAddress address) { + if (dbes.containsKey(address)) { + if (!device.hasModemDBEntry()) { + logger.debug("device {} found in the modem database and {}.", address, + getLinkInfo(dbes, address, true)); + device.setHasModemDBEntry(true); + } + } else { + if (driver.isModemDBComplete()) { + logger.warn("device {} not found in the modem database. Did you forget to link?", address); + handler.deviceNotLinked(address); + } } } return dbes.size(); @@ -337,10 +323,9 @@ private int checkIfInModemDatabase(InsteonDevice dev) { public Map getDatabaseInfo() { try { Map databaseInfo = new HashMap<>(); - Map dbes = driver.lockModemDBEntries(); - for (InsteonAddress addr : dbes.keySet()) { - String a = addr.toString(); - databaseInfo.put(a, a + ": " + getLinkInfo(dbes, addr, false)); + Map dbes = driver.lockModemDBEntries(); + for (InsteonAddress address : dbes.keySet()) { + databaseInfo.put(address.toString(), address + ": " + getLinkInfo(dbes, address, false)); } return databaseInfo; @@ -365,50 +350,49 @@ public void shutdown() { logger.debug("shutting down Insteon bridge"); driver.stop(); devices.clear(); - RequestQueueManager.destroyInstance(); - Poller.instance().stop(); + LegacyRequestManager.destroyInstance(); + LegacyPollManager.instance().stop(); isActive = false; } /** * Method to find a device by address * - * @param aAddr the insteon address to search for + * @param address the insteon address to search for * @return reference to the device, or null if not found */ - public @Nullable InsteonDevice getDevice(@Nullable InsteonAddress aAddr) { - InsteonDevice dev = (aAddr == null) ? null : devices.get(aAddr); - return (dev); + public @Nullable LegacyDevice getDevice(@Nullable DeviceAddress address) { + return address == null ? null : devices.get(address); } - private String getLinkInfo(Map dbes, InsteonAddress a, boolean prefix) { - ModemDBEntry dbe = dbes.get(a); + private String getLinkInfo(Map dbes, InsteonAddress address, boolean prefix) { + LegacyModemDBEntry dbe = dbes.get(address); if (dbe == null) { return ""; } List controls = dbe.getControls(); List responds = dbe.getRespondsTo(); - Port port = dbe.getPort(); + LegacyPort port = dbe.getPort(); if (port == null) { return ""; } - String deviceName = port.getDeviceName(); - String s = deviceName.startsWith("/hub") ? "hub" : "plm"; + String portName = port.getName(); + String modemType = portName.startsWith("/hub") ? "hub" : "plm"; StringBuilder buf = new StringBuilder(); - if (port.isModem(a)) { + if (port.isModem(address)) { if (prefix) { buf.append("it is the "); } - buf.append(s); + buf.append(modemType); buf.append(" ("); - buf.append(Utils.redactPassword(deviceName)); + buf.append(portName); buf.append(")"); } else { if (prefix) { buf.append("the "); } - buf.append(s); + buf.append(modemType); buf.append(" controls groups ("); buf.append(toGroupString(controls)); buf.append(") and responds to groups ("); @@ -443,136 +427,124 @@ public int compare(Byte b1, Byte b2) { public void logDeviceStatistics() { String msg = String.format("devices: %3d configured, %3d polling, msgs received: %5d", devices.size(), - Poller.instance().getSizeOfQueue(), messagesReceived); + LegacyPollManager.instance().getSizeOfQueue(), messagesReceived); logger.debug("{}", msg); messagesReceived = 0; - for (InsteonDevice dev : devices.values()) { - if (dev.isModem()) { + for (LegacyDevice device : devices.values()) { + if (device.isModem()) { continue; } - if (deadDeviceTimeout > 0 && dev.getPollOverDueTime() > deadDeviceTimeout) { - logger.debug("device {} has not responded to polls for {} sec", dev.toString(), - dev.getPollOverDueTime() / 3600); + if (deadDeviceTimeout > 0 && device.getPollOverDueTime() > deadDeviceTimeout) { + logger.debug("device {} has not responded to polls for {} sec", device.toString(), + device.getPollOverDueTime() / 3600); } } } - /** - * Handles messages that come in from the ports. - * Will only process one message at a time. - */ - private class PortListener implements MsgListener, DriverListener { - @Override - public void msg(Msg msg) { - if (msg.isEcho() || msg.isPureNack()) { - return; - } - messagesReceived++; - logger.debug("got msg: {}", msg); + @Override + public void msg(Msg msg) { + if (msg.isEcho() || msg.isPureNack()) { + return; + } + messagesReceived++; + logger.debug("got msg: {}", msg); + try { if (msg.isX10()) { handleX10Message(msg); - } else { + } else if (msg.isInsteon()) { handleInsteonMessage(msg); } + } catch (FieldException e) { + logger.warn("got bad message: {}", msg, e); } + } - @Override - public void driverCompletelyInitialized() { - List missing = new ArrayList<>(); - try { - Map dbes = driver.lockModemDBEntries(); - logger.debug("modem database has {} entries!", dbes.size()); - if (dbes.isEmpty()) { - logger.warn("the modem link database is empty!"); - } - for (InsteonAddress k : dbes.keySet()) { - logger.debug("modem db entry: {}", k); - } - Set addrs = new HashSet<>(); - for (InsteonDevice dev : devices.values()) { - InsteonAddress a = dev.getAddress(); - if (!dbes.containsKey(a)) { - if (!a.isX10()) { - logger.warn("device {} not found in the modem database. Did you forget to link?", a); - handler.deviceNotLinked(a); - } + @Override + public void driverCompletelyInitialized() { + List missing = new ArrayList<>(); + try { + Map dbes = driver.lockModemDBEntries(); + logger.debug("modem database has {} entries!", dbes.size()); + if (dbes.isEmpty()) { + logger.warn("the modem link database is empty!"); + } + for (InsteonAddress address : dbes.keySet()) { + logger.debug("modem db entry: {}", address); + } + Set addrs = new HashSet<>(); + for (LegacyDevice device : devices.values()) { + if (device.getAddress() instanceof InsteonAddress address) { + if (!dbes.containsKey(address)) { + logger.warn("device {} not found in the modem database. Did you forget to link?", address); + handler.deviceNotLinked(address); } 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 (!device.hasModemDBEntry()) { + addrs.add(address); + logger.debug("device {} found in the modem database and {}.", address, + getLinkInfo(dbes, address, true)); + device.setHasModemDBEntry(true); } - if (dev.getStatus() != DeviceStatus.POLLING) { - Poller.instance().startPolling(dev, dbes.size()); + if (device.getStatus() != DeviceStatus.POLLING) { + LegacyPollManager.instance().startPolling(device, dbes.size()); } } } + } - for (InsteonAddress k : dbes.keySet()) { - if (!addrs.contains(k)) { - logger.debug("device {} found in the modem database, but is not configured as a thing and {}.", - k, getLinkInfo(dbes, k, true)); + for (InsteonAddress address : dbes.keySet()) { + if (!addrs.contains(address)) { + logger.debug("device {} found in the modem database, but is not configured as a thing and {}.", + address, getLinkInfo(dbes, address, true)); - missing.add(k.toString()); - } + missing.add(address); } - } finally { - driver.unlockModemDBEntries(); } - - if (!missing.isEmpty()) { - handler.addMissingDevices(missing); - } - - driverInitialized = true; + } finally { + driver.unlockModemDBEntries(); } - @Override - public void disconnected() { - handler.bindingDisconnected(); + if (!missing.isEmpty()) { + handler.addMissingDevices(missing); } - private void handleInsteonMessage(Msg msg) { - InsteonAddress toAddr = msg.getAddr("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; - } - handleMessage(fromAddr, msg); + driverInitialized = true; + } + + @Override + public void disconnected() { + handler.bindingDisconnected(); + } + + 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.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; - } - } catch (FieldException e) { - logger.warn("got bad X10 message: {}", msg, e); - return; + 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); } + } else if (x10Flag == 0) { + // what unit the next cmd will apply to + x10HouseUnit = rawX10 & 0xFF; } + } - private void handleMessage(InsteonAddress fromAddr, Msg msg) { - InsteonDevice dev = getDevice(fromAddr); - if (dev == null) { - logger.debug("dropping message from unknown device with address {}", fromAddr); - } else { - dev.handleMessage(msg); - } + private void handleMessage(DeviceAddress fromAddr, Msg msg) { + LegacyDevice device = getDevice(fromAddr); + if (device == null) { + logger.debug("dropping message from unknown device with address {}", fromAddr); + } else { + device.handleMessage(msg); } } } 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 new file mode 100644 index 0000000000000..fc21dc1b0ec55 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBindingConstants.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; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * 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 { + // List of all Channel ids + public static final String AC_DELAY = "acDelay"; + public static final String BACKLIGHT_DURATION = "backlightDuration"; + public static final String BATTERY_LEVEL = "batteryLevel"; + public static final String BATTERY_PERCENT = "batteryPercent"; + public static final String BATTERY_WATERMARK_LEVEL = "batteryWatermarkLevel"; + public static final String BEEP = "beep"; + public static final String BOTTOM_OUTLET = "bottomOutlet"; + public static final String BUTTON_A = "buttonA"; + public static final String BUTTON_B = "buttonB"; + public static final String BUTTON_C = "buttonC"; + public static final String BUTTON_D = "buttonD"; + public static final String BUTTON_E = "buttonE"; + public static final String BUTTON_F = "buttonF"; + public static final String BUTTON_G = "buttonG"; + public static final String BUTTON_H = "buttonH"; + public static final String BROADCAST_ON_OFF = "broadcastOnOff"; + public static final String CONTACT = "contact"; + public static final String COOL_SET_POINT = "coolSetPoint"; + public static final String DIMMER = "dimmer"; + public static final String FAN = "fan"; + public static final String FAN_MODE = "fanMode"; + public static final String FAST_ON_OFF = "fastOnOff"; + public static final String FAST_ON_OFF_BUTTON_A = "fastOnOffButtonA"; + public static final String FAST_ON_OFF_BUTTON_B = "fastOnOffButtonB"; + public static final String FAST_ON_OFF_BUTTON_C = "fastOnOffButtonC"; + public static final String FAST_ON_OFF_BUTTON_D = "fastOnOffButtonD"; + public static final String FAST_ON_OFF_BUTTON_E = "fastOnOffButtonE"; + public static final String FAST_ON_OFF_BUTTON_F = "fastOnOffButtonF"; + public static final String FAST_ON_OFF_BUTTON_G = "fastOnOffButtonG"; + public static final String FAST_ON_OFF_BUTTON_H = "fastOnOffButtonH"; + public static final String HEAT_SET_POINT = "heatSetPoint"; + public static final String HUMIDITY = "humidity"; + public static final String HUMIDITY_HIGH = "humidityHigh"; + public static final String HUMIDITY_LOW = "humidityLow"; + public static final String IS_COOLING = "isCooling"; + public static final String IS_HEATING = "isHeating"; + public static final String KEYPAD_BUTTON_A = "keypadButtonA"; + public static final String KEYPAD_BUTTON_B = "keypadButtonB"; + public static final String KEYPAD_BUTTON_C = "keypadButtonC"; + public static final String KEYPAD_BUTTON_D = "keypadButtonD"; + public static final String KEYPAD_BUTTON_E = "keypadButtonE"; + public static final String KEYPAD_BUTTON_F = "keypadButtonF"; + public static final String KEYPAD_BUTTON_G = "keypadButtonG"; + public static final String KEYPAD_BUTTON_H = "keypadButtonH"; + public static final String KWH = "kWh"; + public static final String LAST_HEARD_FROM = "lastHeardFrom"; + public static final String LED_BRIGHTNESS = "ledBrightness"; + public static final String LED_ONOFF = "ledOnOff"; + public static final String LIGHT_DIMMER = "lightDimmer"; + public static final String LIGHT_LEVEL = "lightLevel"; + public static final String LIGHT_LEVEL_ABOVE_THRESHOLD = "lightLevelAboveThreshold"; + public static final String LOAD_DIMMER = "loadDimmer"; + public static final String LOAD_SWITCH = "loadSwitch"; + public static final String LOAD_SWITCH_FAST_ON_OFF = "loadSwitchFastOnOff"; + public static final String LOAD_SWITCH_MANUAL_CHANGE = "loadSwitchManualChange"; + public static final String LOWBATTERY = "lowBattery"; + public static final String MANUAL_CHANGE = "manualChange"; + public static final String MANUAL_CHANGE_BUTTON_A = "manualChangeButtonA"; + public static final String MANUAL_CHANGE_BUTTON_B = "manualChangeButtonB"; + public static final String MANUAL_CHANGE_BUTTON_C = "manualChangeButtonC"; + public static final String MANUAL_CHANGE_BUTTON_D = "manualChangeButtonD"; + public static final String MANUAL_CHANGE_BUTTON_E = "manualChangeButtonE"; + public static final String MANUAL_CHANGE_BUTTON_F = "manualChangeButtonF"; + public static final String MANUAL_CHANGE_BUTTON_G = "manualChangeButtonG"; + public static final String MANUAL_CHANGE_BUTTON_H = "manualChangeButtonH"; + public static final String NOTIFICATION = "notification"; + public static final String ON_LEVEL = "onLevel"; + public static final String RAMP_DIMMER = "rampDimmer"; + public static final String RAMP_RATE = "rampRate"; + public static final String RESET = "reset"; + public static final String STAGE1_DURATION = "stage1Duration"; + public static final String SWITCH = "switch"; + public static final String SYSTEM_MODE = "systemMode"; + public static final String TAMPER_SWITCH = "tamperSwitch"; + public static final String TEMPERATURE = "temperature"; + public static final String TEMPERATURE_LEVEL = "temperatureLevel"; + 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/InsteonResourceLoader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonResourceLoader.java new file mode 100644 index 0000000000000..78a9a8eb9f322 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonResourceLoader.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; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.insteon.internal.utils.HexUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.xml.sax.SAXException; + +/** + * The {@link InsteonResourceLoader} represents an abstract Insteon resource loader + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public abstract class InsteonResourceLoader { + protected final Logger logger = LoggerFactory.getLogger(getClass()); + + private final String name; + + protected InsteonResourceLoader(String name) { + this.name = name; + } + + protected void initialize() { + InputStream stream = getClass().getResourceAsStream(name); + if (stream != null) { + loadDocument(stream); + } else { + logger.warn("Resource stream {} cannot be found.", name); + } + } + + public void loadDocument(String filename) { + try { + InputStream stream = new FileInputStream(filename); + loadDocument(stream); + } catch (FileNotFoundException e) { + logger.warn("xml document {} not found", filename); + } + } + + protected void loadDocument(InputStream stream) { + try { + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + // see https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html + dbFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); + dbFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + dbFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + dbFactory.setXIncludeAware(false); + dbFactory.setExpandEntityReferences(false); + DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); + Document doc = dBuilder.parse(stream); + doc.getDocumentElement().normalize(); + + parseDocument(doc.getDocumentElement()); + } catch (ParserConfigurationException e) { + logger.warn("parser config error when loading xml document:", e); + } catch (SAXException e) { + logger.warn("SAX exception when loading xml document:", e); + } catch (IOException e) { + logger.warn("I/O exception when loading xml document:", e); + } + } + + protected abstract void parseDocument(Element element) throws SAXException; + + protected Map getFlags(Element element) { + NamedNodeMap attributes = element.getAttributes(); + Map flags = new HashMap<>(); + for (int i = 0; i < attributes.getLength(); i++) { + Node attribute = attributes.item(i); + String nodeName = attribute.getNodeName(); + String nodeValue = attribute.getNodeValue(); + if ("true".equals(nodeValue) || "false".equals(nodeValue)) { + flags.put(nodeName, "true".equals(nodeValue)); + } + } + return flags; + } + + protected Map getParameters(Element element, List excludedAttrs) { + NamedNodeMap attributes = element.getAttributes(); + Map params = new HashMap<>(); + for (int i = 0; i < attributes.getLength(); i++) { + Node attribute = attributes.item(i); + String nodeName = attribute.getNodeName(); + String nodeValue = attribute.getNodeValue(); + if (!excludedAttrs.contains(nodeName)) { + params.put(nodeName, nodeValue); + } + } + return params; + } + + protected int getAttributeAsInteger(Element element, String name) throws SAXException { + try { + return Integer.parseInt(element.getAttribute(name)); + } catch (NumberFormatException e) { + throw new SAXException("invalid integer attribute " + name); + } + } + + protected int getAttributeAsInteger(Element element, String name, int defaultValue) throws SAXException { + return element.hasAttribute(name) ? getAttributeAsInteger(element, name) : defaultValue; + } + + protected int getHexAttributeAsInteger(Element element, String name) throws SAXException { + try { + return HexUtils.toInteger(element.getAttribute(name)); + } catch (NumberFormatException e) { + throw new SAXException("invalid hex attribute " + name); + } + } + + protected int getHexAttributeAsInteger(Element element, String name, int defaultValue) throws SAXException { + return element.hasAttribute(name) ? getHexAttributeAsInteger(element, name) : defaultValue; + } + + protected byte getHexAttributeAsByte(Element element, String name) throws SAXException { + return (byte) (getHexAttributeAsInteger(element, name) & 0xFF); + } + + protected byte getHexAttributeAsByte(Element element, String name, byte defaultValue) throws SAXException { + return element.hasAttribute(name) ? getHexAttributeAsByte(element, name) : defaultValue; + } +} 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/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..d35e495b61c30 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/DebugCommand.java @@ -0,0 +1,479 @@ +/** + * 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.transport.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); + boolean setCRC = getInsteonEngine(args[1]).supportsChecksum(); + if (messageType == MessageType.EXTENDED) { + msg = Msg.makeExtendedMessage(address, cmd1, cmd2, data, setCRC); + } 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..7538386516454 --- /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.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.database.LinkDBRecord; +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..7c068067fa294 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonCommand.java @@ -0,0 +1,174 @@ +/** + * 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.InsteonEngine; +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().getInsteonScene(Integer.parseInt(thingId)); + } else { + return getInsteonSceneHandlers().filter(handler -> handler.getThingId().equals(thingId)) + .map(InsteonSceneHandler::getScene).findFirst().orElse(null); + } + } + + protected InsteonEngine getInsteonEngine(String thingId) { + InsteonDevice device = getInsteonDevice(thingId); + return device != null ? device.getInsteonEngine() : InsteonEngine.UNKNOWN; + } +} 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 index 6dc10918cf9a1..a28af4f1a2fe4 100644 --- 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 @@ -12,336 +12,148 @@ */ package org.openhab.binding.insteon.internal.command; -import java.text.SimpleDateFormat; +import java.lang.reflect.InvocationTargetException; import java.util.Arrays; -import java.util.Date; -import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Set; +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.InsteonBinding; -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.handler.InsteonNetworkHandler; -import org.openhab.binding.insteon.internal.message.FieldException; -import org.openhab.binding.insteon.internal.message.InvalidMessageTypeException; -import org.openhab.binding.insteon.internal.message.Msg; -import org.openhab.binding.insteon.internal.message.MsgListener; -import org.openhab.binding.insteon.internal.utils.Utils; +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; -import org.osgi.service.component.annotations.ReferenceCardinality; -import org.osgi.service.component.annotations.ReferencePolicy; -import org.osgi.service.component.annotations.ReferencePolicyOption; /** * - * Console commands for the Insteon binding + * The {@link InsteonCommandExtension} is responsible for handling console commands * - * @author Rob Nielsen - Initial contribution + * @author Jeremy Setton - Initial contribution */ @NonNullByDefault @Component(service = ConsoleCommandExtension.class) -public class InsteonCommandExtension extends AbstractConsoleCommandExtension implements MsgListener { - 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"; - private static final String DISPLAY_MONITORED = "display_monitored"; - private static final String START_MONITORING = "start_monitoring"; - private static final String STOP_MONITORING = "stop_monitoring"; - private static final String SEND_STANDARD_MESSAGE = "send_standard_message"; - private static final String SEND_EXTENDED_MESSAGE = "send_extended_message"; - private static final String SEND_EXTENDED_MESSAGE_2 = "send_extended_message_2"; +public class InsteonCommandExtension extends AbstractConsoleCommandExtension implements ConsoleCommandCompleter { - private enum MessageType { - STANDARD, - EXTENDED, - EXTENDED_2 - } + private static final List> SUBCMD_CLASSES = List.of(ModemCommand.class, + DeviceCommand.class, SceneCommand.class, ChannelCommand.class, DebugCommand.class); - @Nullable - private InsteonNetworkHandler handler; - @Nullable - private Console console; - private boolean monitoring = false; - private boolean monitorAllDevices = false; - private Set monitoredAddresses = new HashSet<>(); + private final ThingRegistry thingRegistry; + private final InsteonLegacyCommandExtension legacyCommandExtension; + private final Map subCommands; - public InsteonCommandExtension() { - super("insteon", "Interact with the Insteon integration."); - } + private @Nullable InsteonBridgeHandler handler; - @Override - public void execute(String[] args, Console console) { - if (args.length > 0) { - InsteonNetworkHandler handler = this.handler; // fix eclipse warnings about nullable - if (handler == null) { - console.println("No Insteon network bridge configured."); - } else { - switch (args[0]) { - case DISPLAY_DEVICES: - if (args.length == 1) { - handler.displayDevices(console); - } else { - printUsage(console); - } - break; - case DISPLAY_CHANNELS: - if (args.length == 1) { - handler.displayChannels(console); - } else { - printUsage(console); - } - break; - case DISPLAY_LOCAL_DATABASE: - if (args.length == 1) { - handler.displayLocalDatabase(console); - } else { - printUsage(console); - } - break; - case DISPLAY_MONITORED: - if (args.length == 1) { - displayMonitoredDevices(console); - } else { - printUsage(console); - } - break; - case START_MONITORING: - if (args.length == 2) { - startMonitoring(console, args[1]); - } else { - printUsage(console); - } - break; - case STOP_MONITORING: - if (args.length == 2) { - stopMonitoring(console, args[1]); - } else { - printUsage(console); - } - break; - case SEND_STANDARD_MESSAGE: - if (args.length == 5) { - sendMessage(console, MessageType.STANDARD, args); - } else { - printUsage(console); - } - break; - case SEND_EXTENDED_MESSAGE: - if (args.length >= 5 && args.length <= 18) { - sendMessage(console, MessageType.EXTENDED, args); - } else { - printUsage(console); - } - break; - case SEND_EXTENDED_MESSAGE_2: - if (args.length >= 5 && args.length <= 17) { - sendMessage(console, MessageType.EXTENDED_2, args); - } else { - printUsage(console); - } - break; - default: - console.println("Unknown command '" + args[0] + "'"); - printUsage(console); - break; - } - } - } else { - printUsage(console); - } - } + @Activate + public InsteonCommandExtension(final @Reference ThingRegistry thingRegistry) { + super(InsteonBindingConstants.BINDING_ID, "Interact with the Insteon integration."); + this.thingRegistry = thingRegistry; - @Override - public List getUsages() { - return Arrays.asList(new String[] { - buildCommandUsage(DISPLAY_DEVICES, "display devices that are online, along with available channels"), - buildCommandUsage(DISPLAY_CHANNELS, - "display 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", - "start displaying messages received from device(s)"), - buildCommandUsage(STOP_MONITORING + " all|address", "stop displaying messages received from device(s)"), - buildCommandUsage(SEND_STANDARD_MESSAGE + " address flags cmd1 cmd2", - "send standard message to a device"), - 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") }); + 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 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()); - } + public List getUsages() { + if (legacyCommandExtension.isAvailable()) { + return legacyCommandExtension.getUsages(); } + return subCommands.values().stream().map(cmd -> buildCommandUsage(cmd.getSubCommand(), cmd.getDescription())) + .toList(); } - @Reference(cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC, policyOption = ReferencePolicyOption.GREEDY) - public void setInsteonNetworkHandler(InsteonNetworkHandler handler) { - this.handler = handler; - } - - public void unsetInsteonNetworkHandler(InsteonNetworkHandler handler) { - this.handler = null; + @Override + public @Nullable ConsoleCommandCompleter getCompleter() { + return this; } - private void displayMonitoredDevices(Console console) { - if (!monitoredAddresses.isEmpty()) { - StringBuilder builder = new StringBuilder(); - for (InsteonAddress insteonAddress : monitoredAddresses) { - if (builder.length() == 0) { - builder = new StringBuilder("The individual device(s) "); - } else { - builder.append(", "); - } - builder.append(insteonAddress); - } - console.println(builder.append(" are monitored").toString()); - } else if (monitorAllDevices) { - console.println("All devices are monitored."); - } else { - console.println("Not mointoring any devices."); + @Override + public void execute(String[] args, Console console) { + if (legacyCommandExtension.isAvailable()) { + legacyCommandExtension.execute(args, console); + return; } - } - private void startMonitoring(Console console, String addr) { - if ("all".equalsIgnoreCase(addr)) { - if (!monitorAllDevices) { - monitorAllDevices = true; - monitoredAddresses.clear(); - console.println("Started monitoring all devices."); - } else { - console.println("Already monitoring all devices."); - } - } else { - try { - if (monitorAllDevices) { - console.println("Already monitoring all devices."); - } else if (monitoredAddresses.add(new InsteonAddress(addr))) { - console.println("Started monitoring the device " + addr + "."); - } else { - console.println("Already monitoring the device " + addr + "."); - } - } catch (IllegalArgumentException e) { - console.println("Invalid device address" + addr + "."); - return; - } + InsteonBridgeHandler handler = getBridgeHandler(); + if (handler == null) { + console.println("No Insteon bridge configured or enabled."); + return; } - if (!monitoring) { - getInsteonBinding().getDriver().addMsgListener(this); - - this.console = console; - monitoring = true; + if (handler.getModem() == null) { + console.println("Insteon bridge " + handler.getThing().getUID() + " not initialized yet."); + return; } - } - private void stopMonitoring(Console console, String addr) { - if (!monitoring) { - console.println("Not mointoring any devices."); + if (args.length == 0) { + printUsage(console); return; } - if ("all".equalsIgnoreCase(addr)) { - if (monitorAllDevices) { - monitorAllDevices = false; - console.println("Stopped monitoring all devices."); - } else { - console.println("Not monitoring all devices."); - } + InsteonCommand command = subCommands.get(args[0]); + if (command != null) { + command.execute(Arrays.copyOfRange(args, 1, args.length), console); } else { - try { - if (monitorAllDevices) { - console.println("Not monitoring individual devices."); - } else if (monitoredAddresses.remove(new InsteonAddress(addr))) { - console.println("Stopped monitoring the device " + addr + "."); - } else { - console.println("Not monitoring the device " + addr + "."); - return; - } - } catch (IllegalArgumentException e) { - console.println("Invalid address device address " + addr + "."); - return; - } - } - - if (!monitorAllDevices && monitoredAddresses.isEmpty()) { - getInsteonBinding().getDriver().removeListener(this); - this.console = null; - monitoring = false; + console.println("Unknown command '" + args[0] + "'"); + printUsage(console); } } - private void sendMessage(Console console, MessageType messageType, String[] args) { - InsteonDevice device = new InsteonDevice(); - device.setDriver(getInsteonBinding().getDriver()); - - try { - device.setAddress(new InsteonAddress(args[1])); - } catch (IllegalArgumentException e) { - console.println("Invalid device address" + args[1] + "."); - return; - } + @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); + } - StringBuilder builder = new StringBuilder(); - for (int i = 2; i < args.length; i++) { - if (!args[i].matches("\\p{XDigit}{1,2}")) { - if (builder.length() > 0) { - builder.append(", "); - } - builder.append(args[i]); + ConsoleCommandCompleter completer = subCommands.get(args[0]); + if (completer != null) { + return completer.complete(Arrays.copyOfRange(args, 1, args.length), cursorArgumentIndex - 1, + cursorPosition, candidates); } } - if (builder.length() != 0) { - builder.append(" is not a valid hexadecimal byte."); - console.print(builder.toString()); - return; + return false; + } + + public @Nullable InsteonBridgeHandler getBridgeHandler() { + InsteonBridgeHandler handler = this.handler; + if (handler == null || !handler.getThing().isEnabled()) { + return getBridgeHandlers().findFirst().orElse(null); } + return handler; + } - try { - byte flags = (byte) Utils.fromHexString(args[2]); - byte cmd1 = (byte) Utils.fromHexString(args[3]); - byte cmd2 = (byte) Utils.fromHexString(args[4]); - Msg msg; - if (messageType == MessageType.STANDARD) { - msg = device.makeStandardMessage(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]); - } + public Stream getBridgeHandlers() { + return thingRegistry.getAll().stream().filter(Thing::isEnabled).map(Thing::getHandler) + .filter(InsteonBridgeHandler.class::isInstance).map(InsteonBridgeHandler.class::cast); + } - if (messageType == MessageType.EXTENDED) { - msg = device.makeExtendedMessage(flags, cmd1, cmd2, data); - } else { - msg = device.makeExtendedMessageCRC2(flags, cmd1, cmd2, data); - } - } - device.enqueueMessage(msg, new DeviceFeature(device, "console")); - } catch (FieldException | InvalidMessageTypeException e) { - console.println("Error while trying to create message."); - } + public void setBridgeHandler(InsteonBridgeHandler handler) { + this.handler = handler; } - private InsteonBinding getInsteonBinding() { - InsteonNetworkHandler handler = this.handler; - if (handler == null) { - throw new IllegalArgumentException("No Insteon network bridge configured."); + private @Nullable InsteonCommand instantiateCommand(Class clazz) { + try { + return clazz.getDeclaredConstructor(InsteonCommandExtension.class).newInstance(this); + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException + | InvocationTargetException e) { + return null; } - - return handler.getInsteonBinding(); } } 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 new file mode 100644 index 0000000000000..0e00faba5186d --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonLegacyCommandExtension.java @@ -0,0 +1,352 @@ +/** + * 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.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +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; +import org.openhab.binding.insteon.internal.device.LegacyDeviceFeature; +import org.openhab.binding.insteon.internal.handler.InsteonLegacyNetworkHandler; +import org.openhab.binding.insteon.internal.transport.LegacyPortListener; +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.openhab.core.io.console.Console; +import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; + +/** + * + * The {@link InsteonLegacyCommandExtension} is responsible for handling legacy console commands + * + * @author Rob Nielsen - Initial contribution + * @author Jeremy Setton - Rewrite insteon binding + */ +@NonNullByDefault +public class InsteonLegacyCommandExtension extends AbstractConsoleCommandExtension implements LegacyPortListener { + 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"; + private static final String DISPLAY_MONITORED = "display_monitored"; + private static final String START_MONITORING = "start_monitoring"; + private static final String STOP_MONITORING = "stop_monitoring"; + private static final String SEND_STANDARD_MESSAGE = "send_standard_message"; + private static final String SEND_EXTENDED_MESSAGE = "send_extended_message"; + private static final String SEND_EXTENDED_MESSAGE_2 = "send_extended_message_2"; + + private enum MessageType { + STANDARD, + EXTENDED, + EXTENDED_2 + } + + private final ThingRegistry thingRegistry; + + @Nullable + private Console console; + private boolean monitoring = false; + private boolean monitorAllDevices = false; + private Set monitoredAddresses = new HashSet<>(); + + 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 = getLegacyNetworkHandler(); + if (handler == null) { + console.println("No Insteon legacy network bridge configured."); + } else { + switch (args[0]) { + case DISPLAY_DEVICES: + if (args.length == 1) { + handler.displayDevices(console); + } else { + printUsage(console); + } + break; + case DISPLAY_CHANNELS: + if (args.length == 1) { + handler.displayChannels(console); + } else { + printUsage(console); + } + break; + case DISPLAY_LOCAL_DATABASE: + if (args.length == 1) { + handler.displayLocalDatabase(console); + } else { + printUsage(console); + } + break; + case DISPLAY_MONITORED: + if (args.length == 1) { + displayMonitoredDevices(console); + } else { + printUsage(console); + } + break; + case START_MONITORING: + if (args.length == 2) { + startMonitoring(console, args[1]); + } else { + printUsage(console); + } + break; + case STOP_MONITORING: + if (args.length == 2) { + stopMonitoring(console, args[1]); + } else { + printUsage(console); + } + break; + case SEND_STANDARD_MESSAGE: + if (args.length == 5) { + sendMessage(console, MessageType.STANDARD, args); + } else { + printUsage(console); + } + break; + case SEND_EXTENDED_MESSAGE: + if (args.length >= 5 && args.length <= 18) { + sendMessage(console, MessageType.EXTENDED, args); + } else { + printUsage(console); + } + break; + case SEND_EXTENDED_MESSAGE_2: + if (args.length >= 5 && args.length <= 17) { + sendMessage(console, MessageType.EXTENDED_2, args); + } else { + printUsage(console); + } + break; + default: + console.println("Unknown command '" + args[0] + "'"); + printUsage(console); + break; + } + } + } else { + printUsage(console); + } + } + + @Override + public List getUsages() { + return List.of( + buildCommandUsage(DISPLAY_DEVICES, + "display legacy devices that are online, along with available channels"), + buildCommandUsage(DISPLAY_CHANNELS, + "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", + "start displaying messages received from device(s)"), + buildCommandUsage(STOP_MONITORING + " all|address", "stop displaying messages received from device(s)"), + buildCommandUsage(SEND_STANDARD_MESSAGE + " address flags cmd1 cmd2", + "send standard message to a device"), + 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")); + } + + @Override + public void msg(Msg msg) { + 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 + } + } + + public boolean isAvailable() { + return getLegacyNetworkHandler() != null; + } + + private void displayMonitoredDevices(Console console) { + if (!monitoredAddresses.isEmpty()) { + StringBuilder builder = new StringBuilder(); + for (InsteonAddress insteonAddress : monitoredAddresses) { + if (builder.length() == 0) { + builder = new StringBuilder("The individual device(s) "); + } else { + builder.append(", "); + } + builder.append(insteonAddress); + } + console.println(builder.append(" are monitored").toString()); + } else if (monitorAllDevices) { + console.println("All devices are monitored."); + } else { + console.println("Not monitoring any devices."); + } + } + + private void startMonitoring(Console console, String addr) { + if ("all".equalsIgnoreCase(addr)) { + if (!monitorAllDevices) { + monitorAllDevices = true; + monitoredAddresses.clear(); + console.println("Started monitoring all devices."); + } else { + console.println("Already monitoring all devices."); + } + } else { + try { + if (monitorAllDevices) { + console.println("Already monitoring all devices."); + } else if (monitoredAddresses.add(new InsteonAddress(addr))) { + console.println("Started monitoring the device " + addr + "."); + } else { + console.println("Already monitoring the device " + addr + "."); + } + } catch (IllegalArgumentException e) { + console.println("Invalid device address" + addr + "."); + return; + } + } + + if (!monitoring) { + getInsteonBinding().getDriver().addPortListener(this); + + this.console = console; + monitoring = true; + } + } + + private void stopMonitoring(Console console, String addr) { + if (!monitoring) { + console.println("Not monitoring any devices."); + return; + } + + if ("all".equalsIgnoreCase(addr)) { + if (monitorAllDevices) { + monitorAllDevices = false; + console.println("Stopped monitoring all devices."); + } else { + console.println("Not monitoring all devices."); + } + } else { + try { + if (monitorAllDevices) { + console.println("Not monitoring individual devices."); + } else if (monitoredAddresses.remove(new InsteonAddress(addr))) { + console.println("Stopped monitoring the device " + addr + "."); + } else { + console.println("Not monitoring the device " + addr + "."); + return; + } + } catch (IllegalArgumentException e) { + console.println("Invalid address device address " + addr + "."); + return; + } + } + + if (!monitorAllDevices && monitoredAddresses.isEmpty()) { + getInsteonBinding().getDriver().removePortListener(this); + this.console = null; + monitoring = false; + } + } + + private void sendMessage(Console console, MessageType messageType, String[] args) { + LegacyDevice device = new LegacyDevice(); + device.setDriver(getInsteonBinding().getDriver()); + + try { + device.setAddress(new InsteonAddress(args[1])); + } catch (IllegalArgumentException e) { + console.println("Invalid device address" + args[1] + "."); + return; + } + + StringBuilder builder = new StringBuilder(); + for (int i = 2; i < args.length; i++) { + if (!args[i].matches("\\p{XDigit}{1,2}")) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(args[i]); + } + } + if (builder.length() != 0) { + builder.append(" is not a valid hexadecimal byte."); + console.print(builder.toString()); + return; + } + + try { + 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 = 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) HexUtils.toInteger(args[i + 5]); + } + + msg = Msg.makeExtendedMessage(address, flags, cmd1, cmd2, data, false); + if (messageType == MessageType.EXTENDED) { + msg.setCRC(); + } else { + msg.setCRC2(); + } + } + device.enqueueMessage(msg, new LegacyDeviceFeature(device, "console")); + } catch (FieldException | InvalidMessageTypeException e) { + console.println("Error while trying to create message."); + } + } + + 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 = getLegacyNetworkHandler(); + if (handler == null) { + 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..089338bb07306 --- /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.device.InsteonAddress; +import org.openhab.binding.insteon.internal.device.ProductData; +import org.openhab.binding.insteon.internal.device.database.ModemDBEntry; +import org.openhab.binding.insteon.internal.device.database.ModemDBRecord; +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 index b0c73e97af197..07397bc564d8e 100644 --- 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 @@ -12,60 +12,62 @@ */ package org.openhab.binding.insteon.internal.config; -import java.util.Map; - import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.insteon.internal.device.InsteonAddress; -import org.openhab.core.thing.ChannelUID; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.device.RampRate; /** * - * This file contains config information needed for each channel + * The {@link InsteonChannelConfiguration} is the configuration for an insteon channel. * - * @author Rob Nielsen - Initial contribution + * @author Jeremy Setton - Initial contribution */ @NonNullByDefault public class InsteonChannelConfiguration { - private final ChannelUID channelUID; - private final String channelName; - private final InsteonAddress address; - private final String feature; - private final String productKey; - private final Map parameters; - - public InsteonChannelConfiguration(ChannelUID channelUID, String feature, InsteonAddress address, String productKey, - Map parameters) { - this.channelUID = channelUID; - this.feature = feature; - this.address = address; - this.productKey = productKey; - this.parameters = parameters; - - this.channelName = channelUID.getAsString(); - } + private int group = -1; + private int onLevel = -1; + private double rampRate = -1; + private boolean original = true; - public ChannelUID getChannelUID() { - return channelUID; + public int getGroup() { + return group; } - public String getChannelName() { - return channelName; + public int getOnLevel() { + return onLevel; } - public InsteonAddress getAddress() { - return address; + public @Nullable RampRate getRampRate() { + return rampRate != -1 ? RampRate.fromTime(rampRate) : null; } - public String getFeature() { - return feature; + public boolean isOriginal() { + return original; } - public String getProductKey() { - return productKey; + @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 Map getParameters() { - return parameters; + 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 index d1528907e4af1..cafd9efd1d988 100644 --- 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 @@ -13,34 +13,25 @@ package org.openhab.binding.insteon.internal.config; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; /** - * The {@link InsteonDeviceConfiguration} class contains fields mapping thing configuration parameters. + * The {@link InsteonDeviceConfiguration} is the configuration for an insteon device thing. * - * @author Rob Nielsen - Initial contribution + * @author Jeremy Setton - Initial contribution */ @NonNullByDefault public class InsteonDeviceConfiguration { - // required parameter private String address = ""; - // required parameter - private String productKey = ""; - - // optional parameter - private @Nullable String deviceConfig; - public String getAddress() { return address; } - public String getProductKey() { - return productKey; - } - - public @Nullable String getDeviceConfig() { - return deviceConfig; + @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 new file mode 100644 index 0000000000000..438c285f7eb3b --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyChannelConfiguration.java @@ -0,0 +1,76 @@ +/** + * 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 java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.device.DeviceAddress; +import org.openhab.core.thing.ChannelUID; + +/** + * 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 DeviceAddress address; + private final String feature; + private final String productKey; + private final Map parameters; + + public InsteonLegacyChannelConfiguration(ChannelUID channelUID, String feature, DeviceAddress address, + String productKey, Map parameters) { + this.channelUID = channelUID; + this.feature = feature; + this.address = address; + this.productKey = productKey; + this.parameters = parameters; + + this.channelName = channelUID.getAsString(); + } + + public ChannelUID getChannelUID() { + return channelUID; + } + + public String getChannelName() { + return channelName; + } + + public DeviceAddress getAddress() { + return address; + } + + public String getFeature() { + return feature; + } + + public String getProductKey() { + return productKey; + } + + 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 new file mode 100644 index 0000000000000..b8deb23b9cb59 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyDeviceConfiguration.java @@ -0,0 +1,41 @@ +/** + * 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 InsteonLegacyDeviceConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Rob Nielsen - Initial contribution + */ +@NonNullByDefault +public class InsteonLegacyDeviceConfiguration { + + private String address = ""; + private String productKey = ""; + private @Nullable String deviceConfig; + + public String getAddress() { + return address; + } + + public String getProductKey() { + return productKey; + } + + public @Nullable String getDeviceConfig() { + return deviceConfig; + } +} 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 new file mode 100644 index 0000000000000..96f03d525bd56 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyNetworkConfiguration.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 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; + +/** + * 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+))?"); + + 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; + } + + public @Nullable String getAdditionalDevices() { + return additionalDevices; + } + + 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/InsteonNetworkConfiguration.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonNetworkConfiguration.java deleted file mode 100644 index 5d24fd7553707..0000000000000 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonNetworkConfiguration.java +++ /dev/null @@ -1,50 +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.config; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; - -/** - * The {@link InsteonNetworkConfiguration} class contains fields mapping thing configuration parameters. - * - * @author Rob Nielsen - Initial contribution - */ -@NonNullByDefault -public class InsteonNetworkConfiguration { - - // required parameter - private String port = ""; - - private @Nullable Integer devicePollIntervalSeconds; - - private @Nullable String additionalDevices; - - private @Nullable String additionalFeatures; - - public String getPort() { - return port; - } - - public @Nullable Integer getDevicePollIntervalSeconds() { - return devicePollIntervalSeconds; - } - - public @Nullable String getAdditionalDevices() { - return additionalDevices; - } - - public @Nullable String getAdditionalFeatures() { - return additionalFeatures; - } -} 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/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..2f7af2bed3253 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/BaseDevice.java @@ -0,0 +1,645 @@ +/** + * 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.InsteonThingHandler; +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 InsteonThingHandler> + 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) { + 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) { + 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) { + 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() { + 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 { + 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) { + 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 -> { + 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) { + 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) { + 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() { + long now = System.currentTimeMillis(); + // wait for feature queried to complete + long waitTime = checkFeatureQueried(now); + if (waitTime > 0) { + return waitTime; + } + + synchronized (requestQueue) { + // 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 = now; + // set feature queried for non-broadcast request message + if (!msg.isAllLinkBroadcast()) { + logger.trace("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 { + logger.trace("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(now + quietTime, nextExpTime); + logger.trace("next request queue processed in {} msec, quiettime {} msec", nextTime - now, quietTime); + return nextTime; + } + } + + /** + * Checks feature queried status + * + * @param now the current time + * @return wait time if necessary otherwise 0 + */ + private long checkFeatureQueried(long now) { + DeviceFeature feature = getFeatureQueried(); + if (feature != null) { + QueryStatus queryStatus = feature.getQueryStatus(); + switch (queryStatus) { + case QUERY_QUEUED: + // wait for feature queried request to be sent + long maxQueueTime = lastRequestQueued + REQUEST_QUEUE_TIMEOUT; + if (maxQueueTime > now) { + logger.trace("still waiting for {} query to be sent to {} for another {} msec", + feature.getName(), address, maxQueueTime - now); + return now + 1000L; // retry in 1000 ms + } + 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) { + logger.trace("still waiting for {} query reply from {} for another {} msec", feature.getName(), + address, maxAckTime - now); + return now + 500L; // retry in 500 ms + } + 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: + logger.debug("unexpected feature {} query status {} for {}", feature.getName(), queryStatus, + address); + } + // reset feature queried otheriwse + setFeatureQueried(null); + } + return 0L; + } + + /** + * 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 { + 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() { + 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..a10413acaf826 --- /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.device.database.LinkDBRecord; +import org.openhab.binding.insteon.internal.device.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/DeviceCache.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceCache.java new file mode 100644 index 0000000000000..136b40178621e --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceCache.java @@ -0,0 +1,142 @@ +/** + * 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.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.device.database.DatabaseCache; +import org.openhab.binding.insteon.internal.device.database.LinkDB; +import org.openhab.binding.insteon.internal.device.database.ModemDB; +import org.openhab.binding.insteon.internal.device.feature.FeatureCache; + +/** + * 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/device/DeviceFeature.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceFeature.java index c31ce17a11852..7d36b01e22924 100644 --- 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 @@ -12,25 +12,37 @@ */ package org.openhab.binding.insteon.internal.device; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; +import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*; + import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; 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.DeviceFeatureListener.StateChangeType; -import org.openhab.binding.insteon.internal.message.Msg; -import org.openhab.binding.insteon.internal.utils.Utils.ParsingException; +import org.openhab.binding.insteon.internal.device.feature.CommandHandler; +import org.openhab.binding.insteon.internal.device.feature.FeatureListener; +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.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; @@ -48,8 +60,8 @@ * 3) CommandHandler: translates commands from the openhab bus into an Insteon message. * 4) PollHandler: creates an Insteon message to query the DeviceFeature * - * Lastly, DeviceFeatureListeners can register with the DeviceFeature to get notifications when - * the state of a feature has changed. In practice, a DeviceFeatureListener corresponds to an + * 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. @@ -61,386 +73,667 @@ * @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 enum QueryStatus { + public static enum QueryStatus { NEVER_QUERIED, - QUERY_PENDING, - QUERY_ANSWERED - } - - private static final Logger logger = LoggerFactory.getLogger(DeviceFeature.class); - - private static Map features = new HashMap<>(); - - private InsteonDevice device = new InsteonDevice(); - private String name = "INVALID_FEATURE_NAME"; - private boolean isStatus = false; - private int directAckTimeout = 6000; - private QueryStatus queryStatus = QueryStatus.NEVER_QUERIED; - - private MessageHandler defaultMsgHandler = new MessageHandler.DefaultMsgHandler(this); - private CommandHandler defaultCommandHandler = new CommandHandler.WarnCommandHandler(this); - private @Nullable PollHandler pollHandler = null; - private @Nullable MessageDispatcher dispatcher = null; - - private Map msgHandlers = new HashMap<>(); - private Map, @Nullable CommandHandler> commandHandlers = new HashMap<>(); - private List listeners = new ArrayList<>(); + 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 device Insteon device to which this feature belongs - * @param name descriptive name for that feature - */ - public DeviceFeature(InsteonDevice device, String name) { - this.name = name; - setDevice(device); - } - - /** - * Constructor - * - * @param name descriptive name of the feature + * @param name feature name + * @param type feature type + * @param device feature device */ - public DeviceFeature(String name) { + public DeviceFeature(String name, String type, Device device) { this.name = name; + this.type = type; + this.device = device; } - // various simple getters 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 InsteonDevice getDevice() { - return device; + public synchronized State getState() { + return state; } - public boolean isFeatureGroup() { + 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 isStatus; + return getParameterAsBoolean("status", false); + } + + public int getGroup() { + return getParameterAsInteger("group", 1); } - public int getDirectAckTimeout() { - return directAckTimeout; + 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 Map getMsgHandlers() { - return this.msgHandlers; + 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() { - return (connectedFeatures); + synchronized (connectedFeatures) { + return connectedFeatures; + } } - // various simple setters - public void setStatusFeature(boolean f) { - isStatus = f; + public boolean hasControllerFeatures() { + return isControllerFeature() || getConnectedFeatures().stream().anyMatch(DeviceFeature::hasControllerFeatures); } - public void setPollHandler(@Nullable PollHandler h) { - pollHandler = h; + public boolean hasResponderFeatures() { + return isResponderFeature() || getConnectedFeatures().stream().anyMatch(DeviceFeature::hasResponderFeatures); } - public void setDevice(InsteonDevice d) { - device = d; + public boolean hasListeners() { + return !listeners.isEmpty() || getConnectedFeatures().stream().anyMatch(DeviceFeature::hasListeners); } - public void setMessageDispatcher(@Nullable MessageDispatcher md) { - dispatcher = md; + public void setMessageDispatcher(@Nullable MessageDispatcher dispatcher) { + this.dispatcher = dispatcher; } - public void setDefaultCommandHandler(CommandHandler ch) { - defaultCommandHandler = ch; + public void setPollHandler(@Nullable PollHandler pollHandler) { + this.pollHandler = pollHandler; } - public void setDefaultMsgHandler(MessageHandler mh) { - defaultMsgHandler = mh; + public void setDefaultCommandHandler(CommandHandler defaultCommandHandler) { + this.defaultCommandHandler = defaultCommandHandler; } - public synchronized void setQueryStatus(QueryStatus status) { - logger.trace("{} set query status to: {}", name, status); - queryStatus = status; + public void setDefaultMsgHandler(MessageHandler defaultMsgHandler) { + this.defaultMsgHandler = defaultMsgHandler; } - public void setTimeout(@Nullable String s) { - if (s != null && !s.isEmpty()) { - try { - directAckTimeout = Integer.parseInt(s); - logger.trace("ack timeout set to {}", directAckTimeout); - } catch (NumberFormatException e) { - logger.warn("invalid number for timeout: {}", s); + public void setGroupFeature(DeviceFeature groupFeature) { + this.groupFeature = groupFeature; + } + + public synchronized void setLastMsgValue(double lastMsgValue) { + 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) { + logger.trace("{}:{} setting query status to: {}", device.getAddress(), name, queryStatus); + this.queryStatus = queryStatus; + } + + public synchronized void setState(State state) { + 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); } } } - /** - * Add a listener (item) to a device feature - * - * @param l the listener - */ - public void addListener(DeviceFeatureListener l) { - synchronized (listeners) { - for (DeviceFeatureListener m : listeners) { - if (m.getItemName().equals(l.getItemName())) { - return; - } + 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); } - listeners.add(l); } } + 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); + } + /** - * Adds a connected feature such that this DeviceFeature can - * act as a feature group + * Returns if a message is a successful response queried by this feature * - * @param f the device feature related to this feature + * @param msg the message to check + * @return true if my direct ack */ - public void addConnectedFeature(DeviceFeature f) { - connectedFeatures.add(f); + public boolean isMyDirectAck(Msg msg) { + return msg.isAckOfDirect() && !msg.isReplayed() && getQueryStatus() == QueryStatus.QUERY_ACKED; } - public boolean hasListeners() { - if (!listeners.isEmpty()) { - return true; - } - for (DeviceFeature f : connectedFeatures) { - if (f.hasListeners()) { - return true; + /** + * 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; } /** - * removes a DeviceFeatureListener from this feature + * Returns if a message is a response queried by this feature * - * @param aItemName name of the item to remove as listener - * @return true if a listener was removed + * @param msg the message to check + * @return true if my direct ack or nack */ - public boolean removeListener(String aItemName) { - boolean listenerRemoved = false; - synchronized (listeners) { - for (Iterator it = listeners.iterator(); it.hasNext();) { - DeviceFeatureListener fl = it.next(); - if (fl.getItemName().equals(aItemName)) { - it.remove(); - listenerRemoved = true; - } - } - } - return listenerRemoved; + public boolean isMyDirectAckOrNack(Msg msg) { + return isMyDirectAck(msg) || isMyDirectNack(msg); } - public boolean isReferencedByItem(String aItemName) { - synchronized (listeners) { - for (DeviceFeatureListener fl : listeners) { - if (fl.getItemName().equals(aItemName)) { - return true; - } - } - } - return false; + /** + * 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; } /** - * Called when message is incoming. Dispatches message according to message dispatcher + * Handles message according to message dispatcher * - * @param msg The message to dispatch + * @param msg the message to dispatch * @return true if dispatch successful */ public boolean handleMessage(Msg msg) { - MessageDispatcher dispatcher = this.dispatcher; + MessageDispatcher dispatcher = getMsgDispatcher(); if (dispatcher == null) { - logger.warn("{} no dispatcher for msg {}", name, msg); + logger.warn("{}:{} no dispatcher for msg {}", device.getAddress(), name, msg); return false; } + logger.trace("{}:{} handling message using dispatcher {}", device.getAddress(), name, + dispatcher.getClass().getSimpleName()); return dispatcher.dispatch(msg); } /** - * Called when an openhab command arrives for this device feature + * 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 c the binding config of the item which sends the command - * @param cmd the command to be exectued + * @param config the channel config of the item which sends the command + * @param cmd the command to be executed */ - public void handleCommand(InsteonChannelConfiguration c, Command cmd) { - Class key = cmd.getClass(); - CommandHandler h = commandHandlers.containsKey(key) ? commandHandlers.get(key) : defaultCommandHandler; - if (h != null) { - logger.trace("{} uses {} to handle command {} for {}", getName(), h.getClass().getSimpleName(), - key.getSimpleName(), getDevice().getAddress()); - h.handleCommand(c, cmd, getDevice()); + public void handleCommand(InsteonChannelConfiguration config, Command cmd) { + String cmdType = cmd.getClass().getSimpleName(); + CommandHandler cmdHandler = getOrDefaultCommandHandler(cmdType); + if (!cmdHandler.canHandle(cmd)) { + logger.debug("{}:{} command {}:{} cannot be handled by {}", device.getAddress(), name, cmdType, cmd, + cmdHandler.getClass().getSimpleName()); + return; } + logger.trace("{}:{} handling command {}:{} using handler {}", device.getAddress(), name, cmdType, cmd, + cmdHandler.getClass().getSimpleName()); + cmdHandler.handleCommand(config, cmd); } /** - * Make a poll message using the configured poll message handler + * Makes a poll message using the configured poll message handler * * @return the poll message */ public @Nullable Msg makePollMsg() { - PollHandler pollHandler = this.pollHandler; + PollHandler pollHandler = getPollHandler(); if (pollHandler == null) { return null; } - logger.trace("{} making poll msg for {} using handler {}", getName(), getDevice().getAddress(), + logger.trace("{}:{} making poll msg using handler {}", device.getAddress(), name, pollHandler.getClass().getSimpleName()); - return pollHandler.makeMsg(device); + return pollHandler.makeMsg(); } /** - * Publish new state to all device feature listeners, but give them - * additional dataKey and dataValue information so they can decide - * whether to publish the data to the bus. + * Sends request message to device * - * @param newState state to be published - * @param changeType what kind of changes to publish - * @param dataKey the key on which to filter - * @param dataValue the value that must be matched + * @param msg request message to send */ - public void publish(State newState, StateChangeType changeType, String dataKey, String dataValue) { - logger.debug("{}:{} publishing: {}", this.getDevice().getAddress(), getName(), newState); - synchronized (listeners) { - for (DeviceFeatureListener listener : listeners) { - listener.stateChanged(newState, changeType, dataKey, dataValue); - } - } + public void sendRequest(Msg msg) { + device.sendMessage(msg, this, 0L); } /** - * Publish new state to all device feature listeners + * Updates the state for this feature * - * @param newState state to be published - * @param changeType what kind of changes to publish + * @param state the state to update */ - public void publish(State newState, StateChangeType changeType) { - logger.debug("{}:{} publishing: {}", this.getDevice().getAddress(), getName(), newState); - synchronized (listeners) { - for (DeviceFeatureListener listener : listeners) { - listener.stateChanged(newState, changeType); - } + 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)); } /** - * Poll all device feature listeners for related devices + * 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 pollRelatedDevices() { - synchronized (listeners) { - for (DeviceFeatureListener listener : listeners) { - listener.pollRelatedDevices(); - } + 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) { + 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) { + 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); } } /** - * Adds a message handler to this device feature. + * Returns the poll delay for this feature * - * @param cm1 The insteon cmd1 of the incoming message for which the handler should be used - * @param handler the handler to invoke + * @return the poll delay based on device ramp rate if supported and available, otherwise 0 */ - public void addMessageHandler(int cm1, @Nullable MessageHandler handler) { - synchronized (msgHandlers) { - msgHandlers.put(cm1, handler); + 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; } /** - * Adds a command handler to this device feature + * Executes the polling of this feature * - * @param c the command for which this handler is invoked - * @param handler the handler to call + * @param delay scheduling delay (in milliseconds) + * @return poll message */ - public void addCommandHandler(Class c, @Nullable CommandHandler handler) { - synchronized (commandHandlers) { - commandHandlers.put(c, handler); + public @Nullable Msg doPoll(long delay) { + Msg msg = makePollMsg(); + if (msg != null) { + device.sendMessage(msg, this, delay); } + return msg; } /** - * Turn DeviceFeature into String + * Polls related devices to this feature + * + * @param delay scheduling delay (in milliseconds) */ - @Override - public String toString() { - return name + "(" + listeners.size() + ":" + commandHandlers.size() + ":" + msgHandlers.size() + ")"; + public void pollRelatedDevices(long delay) { + if (device instanceof InsteonDevice insteonDevice) { + insteonDevice.pollRelatedDevices(getGroup(), delay); + } } /** - * Factory method for creating DeviceFeatures. + * Polls related devices to a broadcast group * - * @param s The name of the device feature to create. - * @return The newly created DeviceFeature, or null if requested DeviceFeature does not exist. + * @param group broadcast group + * @param delay scheduling delay (in milliseconds) */ - @Nullable - public static DeviceFeature makeDeviceFeature(String s) { - DeviceFeature f = null; - synchronized (features) { - FeatureTemplate ft = features.get(s); - if (ft != null) { - f = ft.build(); - } else { - logger.warn("unimplemented feature requested: {}", s); - } + 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); } - return f; } /** - * Reads the features templates from an input stream and puts them in global map + * Adjusts related devices to this feature * - * @param input the input stream from which to read the feature templates + * @param config the channel config + * @param cmd the command to adjust to */ - public static void readFeatureTemplates(InputStream input) { - try { - List featureTemplates = FeatureTemplateLoader.readTemplates(input); - synchronized (features) { - for (FeatureTemplate f : featureTemplates) { - features.put(f.getName(), f); - } - } - } catch (IOException e) { - logger.warn("IOException while reading device features", e); - } catch (ParsingException e) { - logger.warn("Parsing exception while reading device features", e); + public void adjustRelatedDevices(InsteonChannelConfiguration config, Command cmd) { + if (device instanceof InsteonDevice insteonDevice) { + insteonDevice.adjustRelatedDevices(getGroup(), config, cmd); } } /** - * Reads the feature templates from a file and adds them to a global map + * Returns broadcast group for this feature * - * @param file name of the file to read from + * @param config the channel config + * @return the broadcast group if found, otherwise -1 */ - public static void readFeatureTemplates(String file) { - try { - FileInputStream fis = new FileInputStream(file); - readFeatureTemplates(fis); - } catch (FileNotFoundException e) { - logger.warn("cannot read feature templates from file {} ", file, e); + 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; } /** - * static initializer + * 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. */ - static { - // read features from xml file and store them in a map - InputStream input = DeviceFeature.class.getResourceAsStream("/device_features.xml"); - Objects.requireNonNull(input); - readFeatureTemplates(input); + 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 index 58f069fd90768..7e08ba400284c 100644 --- 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 @@ -14,135 +14,123 @@ 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 DeviceType class holds device type definitions that are read from - * an xml file. + * 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 productKey; - private String model = ""; - private String description = ""; - private Map features = new HashMap<>(); - private Map featureGroups = new HashMap<>(); + private String name; + private Map flags = new HashMap<>(); + private Map features = new LinkedHashMap<>(); + private Map links = new LinkedHashMap<>(); /** * Constructor * - * @param aProductKey the product key for this device type + * @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 aProductKey) { - productKey = aProductKey; + public DeviceType(String name, Map flags, Map features, + Map links) { + this.name = name; + this.flags = flags; + this.features = features; + this.links = links; } /** - * Get supported features + * Returns name * - * @return all features that this device type supports - */ - public Map getFeatures() { - return features; - } - - /** - * Get all feature groups - * - * @return all feature groups of this device type + * @return the name for this device type */ - public Map getFeatureGroups() { - return featureGroups; + public String getName() { + return name; } /** - * Sets the descriptive model string + * Returns flags * - * @param aModel descriptive model string + * @return all flags for this device type */ - public void setModel(String aModel) { - model = aModel; + public Map getFlags() { + return flags; } /** - * Sets free text description + * Returns supported features * - * @param aDesc free text description + * @return all features that this device type supports */ - public void setDescription(String aDesc) { - description = aDesc; + public List getFeatures() { + return features.values().stream().toList(); } /** - * Adds feature to this device type + * Returns supported feature groups * - * @param aKey the key (e.g. "switch") under which this feature can be referenced in the item binding config - * @param aFeatureName the name (e.g. "GenericSwitch") under which the feature has been defined - * @return false if feature was already there + * @return all feature groups that this device type supports */ - public boolean addFeature(String aKey, String aFeatureName) { - if (features.containsKey(aKey)) { - return false; - } - features.put(aKey, aFeatureName); - return true; + public List getFeatureGroups() { + return features.values().stream().filter(FeatureEntry::hasConnectedFeatures).toList(); } /** - * Adds feature group to device type + * Returns default links * - * @param aKey name of the feature group, which acts as key for lookup later - * @param fg feature group to add - * @return true if add succeeded, false if group was already there + * @return all default links for this device type */ - public boolean addFeatureGroup(String aKey, FeatureGroup fg) { - if (features.containsKey(aKey)) { - return false; - } - featureGroups.put(aKey, fg); - return true; + public Map getDefaultLinks() { + return links; } @Override public String toString() { - String s = "pk:" + productKey + "|model:" + model + "|desc:" + description + "|features"; - for (Entry f : features.entrySet()) { - s += ":" + f.getKey() + "=" + f.getValue(); + 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(",")); } - s += "|groups"; - for (Entry f : featureGroups.entrySet()) { - s += ":" + f.getKey() + "=" + f.getValue(); + if (!links.isEmpty()) { + s += "|default-links:" + + links.values().stream().map(DefaultLinkEntry::toString).collect(Collectors.joining(",")); } return s; } /** - * Class that reflects feature group association - * - * @author Bernd Pfrommer - Initial contribution + * Class that reflects a feature entry */ - public static class FeatureGroup { + public static class FeatureEntry { private String name; private String type; - private ArrayList fgFeatures = new ArrayList<>(); + private Map parameters; + private List connectedFeatures = new ArrayList<>(); - FeatureGroup(String name, String type) { + public FeatureEntry(String name, String type, Map parameters) { this.name = name; this.type = type; - } - - public void addFeature(String f) { - fgFeatures.add(f); - } - - public ArrayList getFeatures() { - return fgFeatures; + this.parameters = parameters; } public String getName() { @@ -153,13 +141,127 @@ 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 = ""; - for (String g : fgFeatures) { - s += g + ","; + String s = name + "->" + type; + if (!connectedFeatures.isEmpty()) { + s += "|connectedFeatures:" + connectedFeatures; } - return (s.replaceAll(",$", "")); + 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/DeviceTypeLoader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceTypeLoader.java deleted file mode 100644 index 52af9488ef465..0000000000000 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceTypeLoader.java +++ /dev/null @@ -1,220 +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.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.HashMap; -import java.util.Map; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.insteon.internal.device.DeviceType.FeatureGroup; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; - -/** - * Reads the device types from an xml file. - * - * @author Daniel Pfrommer - Initial contribution - * @author Bernd Pfrommer - openHAB 1 insteonplm binding - * @author Rob Nielsen - Port to openHAB 2 insteon binding - */ -@NonNullByDefault -public class DeviceTypeLoader { - private static final Logger logger = LoggerFactory.getLogger(DeviceTypeLoader.class); - private Map deviceTypes = new HashMap<>(); - private static DeviceTypeLoader deviceTypeLoader = new DeviceTypeLoader(); - - private DeviceTypeLoader() { - } // private so nobody can call it - - /** - * Finds the device type for a given product key - * - * @param aProdKey product key to search for - * @return the device type, or null if not found - */ - public @Nullable DeviceType getDeviceType(String aProdKey) { - return (deviceTypes.get(aProdKey)); - } - - /** - * Must call loadDeviceTypesXML() before calling this function! - * - * @return currently known device types - */ - public Map getDeviceTypes() { - return (deviceTypes); - } - - /** - * Reads the device types from input stream and stores them in memory for - * later access. - * - * @param in the input stream from which to read - */ - public void loadDeviceTypesXML(InputStream in) throws ParserConfigurationException, SAXException, IOException { - DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); - // see https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html - dbFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); - dbFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - dbFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - dbFactory.setXIncludeAware(false); - dbFactory.setExpandEntityReferences(false); - DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); - Document doc = dBuilder.parse(in); - doc.getDocumentElement().normalize(); - Node root = doc.getDocumentElement(); - NodeList nodes = root.getChildNodes(); - for (int i = 0; i < nodes.getLength(); i++) { - Node node = nodes.item(i); - if (node.getNodeType() == Node.ELEMENT_NODE && "device".equals(node.getNodeName())) { - processDevice((Element) node); - } - } - } - - /** - * Reads the device types from file and stores them in memory for later access. - * - * @param aFileName The name of the file to read from - * @throws ParserConfigurationException - * @throws SAXException - * @throws IOException - */ - public void loadDeviceTypesXML(String aFileName) throws ParserConfigurationException, SAXException, IOException { - File file = new File(aFileName); - InputStream in = new FileInputStream(file); - loadDeviceTypesXML(in); - } - - /** - * Process device node - * - * @param e name of the element to process - * @throws SAXException - */ - private void processDevice(Element e) throws SAXException { - String productKey = e.getAttribute("productKey"); - if ("".equals(productKey)) { - throw new SAXException("device in device_types file has no product key!"); - } - if (deviceTypes.containsKey(productKey)) { - logger.warn("overwriting previous definition of device {}", productKey); - deviceTypes.remove(productKey); - } - DeviceType devType = new DeviceType(productKey); - - NodeList nodes = e.getChildNodes(); - for (int i = 0; i < nodes.getLength(); i++) { - Node node = nodes.item(i); - if (node.getNodeType() != Node.ELEMENT_NODE) { - continue; - } - Element subElement = (Element) node; - String nodeName = subElement.getNodeName(); - if ("model".equals(nodeName)) { - devType.setModel(subElement.getTextContent()); - } else if ("description".equals(nodeName)) { - devType.setDescription(subElement.getTextContent()); - } else if ("feature".equals(nodeName)) { - processFeature(devType, subElement); - } else if ("feature_group".equals(nodeName)) { - processFeatureGroup(devType, subElement); - } - deviceTypes.put(productKey, devType); - } - } - - private String processFeature(DeviceType devType, Element e) throws SAXException { - String name = e.getAttribute("name"); - if ("".equals(name)) { - throw new SAXException("feature " + e.getNodeName() + " has feature without name!"); - } - if (!name.equals(name.toLowerCase())) { - throw new SAXException("feature name '" + name + "' must be lower case"); - } - if (!devType.addFeature(name, e.getTextContent())) { - throw new SAXException("duplicate feature: " + name); - } - return (name); - } - - private String processFeatureGroup(DeviceType devType, Element e) throws SAXException { - String name = e.getAttribute("name"); - if ("".equals(name)) { - throw new SAXException("feature group " + e.getNodeName() + " has no name attr!"); - } - String type = e.getAttribute("type"); - if ("".equals(type)) { - throw new SAXException("feature group " + e.getNodeName() + " has no type attr!"); - } - FeatureGroup fg = new FeatureGroup(name, type); - NodeList nodes = e.getChildNodes(); - for (int i = 0; i < nodes.getLength(); i++) { - Node node = nodes.item(i); - if (node.getNodeType() != Node.ELEMENT_NODE) { - continue; - } - Element subElement = (Element) node; - String nodeName = subElement.getNodeName(); - if ("feature".equals(nodeName)) { - fg.addFeature(processFeature(devType, subElement)); - } else if ("feature_group".equals(nodeName)) { - fg.addFeature(processFeatureGroup(devType, subElement)); - } - } - if (!devType.addFeatureGroup(name, fg)) { - throw new SAXException("duplicate feature group " + name); - } - return (name); - } - - /** - * Singleton instance function, creates DeviceTypeLoader - * - * @return DeviceTypeLoader singleton reference - */ - @Nullable - public static synchronized DeviceTypeLoader instance() { - if (deviceTypeLoader.getDeviceTypes().isEmpty()) { - InputStream input = DeviceTypeLoader.class.getResourceAsStream("/device_types.xml"); - try { - if (input != null) { - deviceTypeLoader.loadDeviceTypesXML(input); - } else { - logger.warn("Resource stream is null, cannot read xml file."); - } - } catch (ParserConfigurationException e) { - logger.warn("parser config error when reading device types xml file: ", e); - } catch (SAXException e) { - logger.warn("SAX exception when reading device types xml file: ", e); - } catch (IOException e) { - logger.warn("I/O exception when reading device types xml file: ", e); - } - } - return deviceTypeLoader; - } -} 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..046f878d48b2c --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceTypeRegistry.java @@ -0,0 +1,309 @@ +/** + * 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.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.InsteonResourceLoader; +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.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 InsteonResourceLoader { + 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<>(); + + private DeviceTypeRegistry() { + super(RESOURCE_NAME); + } + + /** + * 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(); + + logger.debug("loaded {} device types", deviceTypes.size()); + if (logger.isTraceEnabled()) { + deviceTypes.values().stream().map(String::valueOf).forEach(logger::trace); + } + } + + /** + * Parses device type document + * + * @param element element to parse + * @throws SAXException + */ + @Override + protected void parseDocument(Element element) throws SAXException { + 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 (name.isEmpty()) { + 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 (name.isEmpty()) { + 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 (name.isEmpty()) { + throw new SAXException("undefined feature group name"); + } + String type = element.getAttribute("type"); + if (type.isEmpty()) { + 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 (name.isEmpty()) { + 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 (name.isEmpty()) { + 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/FeatureTemplateLoader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/FeatureTemplateLoader.java deleted file mode 100644 index 7a05a4768e815..0000000000000 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/FeatureTemplateLoader.java +++ /dev/null @@ -1,171 +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.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.insteon.internal.utils.Utils; -import org.openhab.binding.insteon.internal.utils.Utils.ParsingException; -import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.IncreaseDecreaseType; -import org.openhab.core.library.types.OnOffType; -import org.openhab.core.library.types.PercentType; -import org.openhab.core.types.Command; -import org.w3c.dom.DOMException; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.NamedNodeMap; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; - -/** - * Class that loads the device feature templates from an xml stream - * - * @author Daniel Pfrommer - Initial contribution - * @author Rob Nielsen - Port to openHAB 2 insteon binding - */ -@NonNullByDefault -public class FeatureTemplateLoader { - public static List readTemplates(InputStream input) throws IOException, ParsingException { - List features = new ArrayList<>(); - try { - DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); - // see https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html - dbFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); - dbFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - dbFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); - dbFactory.setXIncludeAware(false); - dbFactory.setExpandEntityReferences(false); - DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); - // Parse it! - Document doc = dBuilder.parse(input); - doc.getDocumentElement().normalize(); - - Element root = doc.getDocumentElement(); - - NodeList nodes = root.getChildNodes(); - - for (int i = 0; i < nodes.getLength(); i++) { - Node node = nodes.item(i); - if (node.getNodeType() == Node.ELEMENT_NODE) { - Element e = (Element) node; - if ("feature".equals(e.getTagName())) { - features.add(parseFeature(e)); - } - } - } - } catch (SAXException e) { - throw new ParsingException("Failed to parse XML!", e); - } catch (ParserConfigurationException e) { - throw new ParsingException("Got parser config exception! ", e); - } - return features; - } - - private static FeatureTemplate parseFeature(Element e) throws ParsingException { - String name = e.getAttribute("name"); - boolean statusFeature = "true".equals(e.getAttribute("statusFeature")); - FeatureTemplate feature = new FeatureTemplate(name, statusFeature, e.getAttribute("timeout")); - - NodeList nodes = e.getChildNodes(); - - for (int i = 0; i < nodes.getLength(); i++) { - Node node = nodes.item(i); - if (node.getNodeType() == Node.ELEMENT_NODE) { - Element child = (Element) node; - if ("message-handler".equals(child.getTagName())) { - parseMessageHandler(child, feature); - } else if ("command-handler".equals(child.getTagName())) { - parseCommandHandler(child, feature); - } else if ("message-dispatcher".equals(child.getTagName())) { - parseMessageDispatcher(child, feature); - } else if ("poll-handler".equals(child.getTagName())) { - parsePollHandler(child, feature); - } - } - } - - return feature; - } - - private static HandlerEntry makeHandlerEntry(Element e) throws ParsingException { - String handler = e.getTextContent(); - if (handler == null) { - throw new ParsingException("Could not find Handler for: " + e.getTextContent()); - } - - NamedNodeMap attributes = e.getAttributes(); - Map params = new HashMap<>(); - for (int i = 0; i < attributes.getLength(); i++) { - Node n = attributes.item(i); - params.put(n.getNodeName(), n.getNodeValue()); - } - return new HandlerEntry(handler, params); - } - - private static void parseMessageHandler(Element e, FeatureTemplate f) throws DOMException, ParsingException { - HandlerEntry he = makeHandlerEntry(e); - if ("true".equals(e.getAttribute("default"))) { - f.setDefaultMessageHandler(he); - } else { - String attr = e.getAttribute("cmd"); - int command = (attr == null) ? 0 : Utils.from0xHexString(attr); - f.addMessageHandler(command, he); - } - } - - private static void parseCommandHandler(Element e, FeatureTemplate f) throws ParsingException { - HandlerEntry he = makeHandlerEntry(e); - if ("true".equals(e.getAttribute("default"))) { - f.setDefaultCommandHandler(he); - } else { - Class command = parseCommandClass(e.getAttribute("command")); - f.addCommandHandler(command, he); - } - } - - private static void parseMessageDispatcher(Element e, FeatureTemplate f) throws DOMException, ParsingException { - HandlerEntry he = makeHandlerEntry(e); - f.setMessageDispatcher(he); - } - - private static void parsePollHandler(Element e, FeatureTemplate f) throws ParsingException { - HandlerEntry he = makeHandlerEntry(e); - f.setPollHandler(he); - } - - private static Class parseCommandClass(String c) throws ParsingException { - if ("OnOffType".equals(c)) { - return OnOffType.class; - } else if ("PercentType".equals(c)) { - return PercentType.class; - } else if ("DecimalType".equals(c)) { - return DecimalType.class; - } else if ("IncreaseDecreaseType".equals(c)) { - return IncreaseDecreaseType.class; - } else { - throw new ParsingException("Unknown Command Type"); - } - } -} 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 index db4430f3f8448..c5e5de007ef4e 100644 --- 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 @@ -12,644 +12,980 @@ */ package org.openhab.binding.insteon.internal.device; +import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*; + import java.io.IOException; -import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.Map.Entry; +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.device.DeviceType.FeatureGroup; -import org.openhab.binding.insteon.internal.device.GroupMessageStateMachine.GroupMessage; -import org.openhab.binding.insteon.internal.driver.Driver; -import org.openhab.binding.insteon.internal.message.FieldException; -import org.openhab.binding.insteon.internal.message.InvalidMessageTypeException; -import org.openhab.binding.insteon.internal.message.Msg; +import org.openhab.binding.insteon.internal.device.DeviceFeature.QueryStatus; +import org.openhab.binding.insteon.internal.device.database.LinkDB; +import org.openhab.binding.insteon.internal.device.database.LinkDBChange; +import org.openhab.binding.insteon.internal.device.database.LinkDBRecord; +import org.openhab.binding.insteon.internal.device.database.ModemDB; +import org.openhab.binding.insteon.internal.device.database.ModemDBChange; +import org.openhab.binding.insteon.internal.device.database.ModemDBEntry; +import org.openhab.binding.insteon.internal.device.database.ModemDBRecord; +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.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; /** - * The InsteonDevice class holds known per-device state of a single Insteon device, - * including the address, what port(modem) to reach it on etc. - * Note that some Insteon devices de facto consist of two devices (let's say - * a relay and a sensor), but operate under the same address. Such devices will - * be represented just by a single InsteonDevice. Their different personalities - * will then be represented by DeviceFeatures. + * 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 { - private final Logger logger = LoggerFactory.getLogger(InsteonDevice.class); - - public enum DeviceStatus { - INITIALIZED, - POLLING - } - - /** need to wait after query to avoid misinterpretation of duplicate replies */ - private static final int QUIET_TIME_DIRECT_MESSAGE = 2000; - /** how far to space out poll messages */ - private static final int TIME_BETWEEN_POLL_MESSAGES = 1500; - - private InsteonAddress address = new InsteonAddress(); - private long pollInterval = -1L; // in milliseconds - private @Nullable Driver driver = null; - private Map features = new HashMap<>(); - private @Nullable String productKey = null; - private volatile long lastTimePolled = 0L; - private volatile long lastMsgReceived = 0L; - private boolean isModem = false; - private PriorityQueue<@Nullable QEntry> mrequestQueue = new PriorityQueue<>(); - private @Nullable DeviceFeature featureQueried = null; - private long lastQueryTime = 0L; - private boolean hasModemDBEntry = false; - private DeviceStatus status = DeviceStatus.INITIALIZED; +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 Map deviceConfigMap = new HashMap<>(); + private volatile int failedMsgCount = 0; + private volatile long lastMsgReceived = 0L; - /** - * Constructor - */ public InsteonDevice() { - lastMsgReceived = System.currentTimeMillis(); + super(InsteonAddress.UNKNOWN); + this.linkDB = new LinkDB(this); } - // --------------------- simple getters ----------------------------- - - public boolean hasProductKey() { - return productKey != null; + public InsteonEngine getInsteonEngine() { + return engine; } - public @Nullable String getProductKey() { - return productKey; + public LinkDB getLinkDB() { + return linkDB; } - public boolean hasModemDBEntry() { - return hasModemDBEntry; + public @Nullable DefaultLink getDefaultLink(String name) { + synchronized (defaultLinks) { + return defaultLinks.get(name); + } } - public DeviceStatus getStatus() { - return status; + public List getDefaultLinks() { + synchronized (defaultLinks) { + return defaultLinks.values().stream().toList(); + } } - public InsteonAddress getAddress() { - return (address); + public List getStoredMessages() { + synchronized (storedMessages) { + return storedMessages; + } } - public @Nullable Driver getDriver() { - return driver; + public List getControllerFeatures() { + return getFeatures().stream().filter(DeviceFeature::isControllerFeature).toList(); } - public long getPollInterval() { - return pollInterval; + public List getResponderFeatures() { + return getFeatures().stream().filter(DeviceFeature::isResponderFeature).toList(); } - public boolean isModem() { - return isModem; + public List getControllerOrResponderFeatures() { + return getFeatures().stream().filter(DeviceFeature::isControllerOrResponderFeature).toList(); } - public @Nullable DeviceFeature getFeature(String f) { - return features.get(f); + public List getFeatures(String type) { + return getFeatures().stream().filter(feature -> feature.getType().equals(type)).toList(); } - public Map getFeatures() { - return features; + public @Nullable DeviceFeature getFeature(String type, int group) { + return getFeatures().stream().filter(feature -> feature.getType().equals(type) && feature.getGroup() == group) + .findFirst().orElse(null); } - public byte getX10HouseCode() { - return (address.getX10HouseCode()); + public double getLastMsgValueAsDouble(String type, int group, double defaultValue) { + return Optional.ofNullable(getFeature(type, group)).map(DeviceFeature::getLastMsgValue).map(Double::doubleValue) + .orElse(defaultValue); } - public byte getX10UnitCode() { - return (address.getX10UnitCode()); + public int getLastMsgValueAsInteger(String type, int group, int defaultValue) { + return Optional.ofNullable(getFeature(type, group)).map(DeviceFeature::getLastMsgValue).map(Double::intValue) + .orElse(defaultValue); } - public boolean hasProductKey(String key) { - String productKey = this.productKey; - return productKey != null && productKey.equals(key); + public @Nullable State getFeatureState(String type, int group) { + return Optional.ofNullable(getFeature(type, group)).map(DeviceFeature::getState).orElse(null); } - public boolean hasValidPollingInterval() { - return (pollInterval > 0); + public boolean isResponding() { + return failedMsgCount < FAILED_MSG_COUNT_THRESHOLD; } - public long getPollOverDueTime() { - return (lastTimePolled - lastMsgReceived); + public boolean isBatteryPowered() { + return getFlag("batteryPowered", false); } - public boolean hasAnyListeners() { - synchronized (features) { - for (DeviceFeature f : features.values()) { - if (f.hasListeners()) { - return true; - } - } - } - return false; + public boolean isDeviceSyncEnabled() { + return getFlag("deviceSyncEnabled", false); } - // --------------------- simple setters ----------------------------- - public void setStatus(DeviceStatus aI) { - status = aI; + public boolean hasModemDBEntry() { + return getFlag("modemDBEntry", false); } - public void setHasModemDBEntry(boolean b) { - hasModemDBEntry = b; + public void setInsteonEngine(InsteonEngine engine) { + logger.trace("setting insteon engine for {} to {}", address, engine); + this.engine = engine; + // notify properties changed + propertiesChanged(false); } - public void setAddress(InsteonAddress ia) { - address = ia; + public void setHasModemDBEntry(boolean value) { + setFlag("modemDBEntry", value); + // notify status changed + statusChanged(); } - public void setDriver(Driver d) { - driver = d; + public void setIsDeviceSyncEnabled(boolean value) { + setFlag("deviceSyncEnabled", value); } - public void setIsModem(boolean f) { - isModem = f; + /** + * 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; } - public void setProductKey(String pk) { - productKey = pk; + /** + * 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))); } - public void setPollInterval(long pi) { - logger.trace("setting poll interval for {} to {} ", address, pi); - if (pi > 0) { - pollInterval = pi; + /** + * 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; } - public void setFeatureQueried(@Nullable DeviceFeature f) { - synchronized (mrequestQueue) { - featureQueried = f; + /** + * 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; + } } } - public void setDeviceConfigMap(Map deviceConfigMap) { - this.deviceConfigMap = deviceConfigMap; + /** + * 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); + logger.trace("{} created group {} state", address, group); + } + if (stateMachine.getLastCommand() == cmd1 && stateMachine.getLastTimestamp() == timestamp) { + logger.trace("{} using previous group {} state for {}", address, group, type); + return stateMachine.isDuplicate(); + } else { + logger.trace("{} updating group {} state to {}", address, group, type); + return stateMachine.update(address, group, cmd1, timestamp, type); + } + } } - public Map getDeviceConfigMap() { - return deviceConfigMap; + /** + * Returns if device is pollable + * + * @return true if parent pollable and not battery powered + */ + @Override + public boolean isPollable() { + return super.isPollable() && !isBatteryPowered(); } - public @Nullable DeviceFeature getFeatureQueried() { - synchronized (mrequestQueue) { - return (featureQueried); + /** + * 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); } /** - * Removes feature listener from this device + * Schedules polling for this device * - * @param aItemName name of the feature listener to remove - * @return true if a feature listener was successfully removed - */ - public boolean removeFeatureListener(String aItemName) { - boolean removedListener = false; - synchronized (features) { - for (Iterator> it = features.entrySet().iterator(); it.hasNext();) { - DeviceFeature f = it.next().getValue(); - if (f.removeListener(aItemName)) { - removedListener = true; - } + * @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 removedListener; + return spacing; } /** - * Invoked to process an openHAB command + * Polls all responder features for this device * - * @param driver The driver to use - * @param c The item configuration - * @param command The actual command to execute - */ - public void processCommand(Driver driver, InsteonChannelConfiguration c, Command command) { - logger.debug("processing command {} features: {}", command, features.size()); - synchronized (features) { - for (DeviceFeature i : features.values()) { - if (i.isReferencedByItem(c.getChannelName())) { - i.handleCommand(c, command); - } - } - } + * @param delay scheduling delay (in milliseconds) + */ + public void pollResponders(long delay) { + schedulePoll(delay, DeviceFeature::hasResponderFeatures); } /** - * Execute poll on this device: create an array of messages, - * add them to the request queue, and schedule the queue - * for processing. + * 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 doPoll(long delay) { - long now = System.currentTimeMillis(); - List l = new ArrayList<>(); - synchronized (features) { - int spacing = 0; - for (DeviceFeature i : features.values()) { - if (i.hasListeners()) { - Msg m = i.makePollMsg(); - if (m != null) { - l.add(new QEntry(i, m, now + delay + spacing)); - spacing += TIME_BETWEEN_POLL_MESSAGES; - } - } - } - } - if (l.isEmpty()) { + 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; } - synchronized (mrequestQueue) { - for (QEntry e : l) { - mrequestQueue.add(e); - } + // 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 -> { + logger.debug("polling related device {} to controller {} group {}", device.getAddress(), + address, group); + device.pollResponders(address, group, delay); + }); } - RequestQueueManager instance = RequestQueueManager.instance(); - if (instance != null) { - instance.addQueue(this, now + delay); - } else { - logger.warn("request queue manager is null"); + } + + /** + * 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 -> { + logger.debug("adjusting related device {} to controller {} group {}", device.getAddress(), + address, group); + device.adjustResponders(address, group, config, cmd); + }); } + } - if (!l.isEmpty()) { - lastTimePolled = now; + /** + * 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) { + logger.trace("replaying msg: {}", msg); + msg.setIsReplayed(true); + handleMessage(msg); } } /** - * Handle incoming message for this device by forwarding + * 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) { - lastMsgReceived = System.currentTimeMillis(); - synchronized (features) { - // first update all features that are - // not status features - for (DeviceFeature f : features.values()) { - if (!f.isStatusFeature()) { - logger.debug("----- applying message to feature: {}", f.getName()); - if (f.handleMessage(msg)) { - // handled a reply to a query, - // mark it as processed - logger.trace("handled reply of direct: {}", f); - setFeatureQueried(null); - break; - } - } + // 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()) { + logger.debug("storing message for unknown device {}", address); + + synchronized (storedMessages) { + storedMessages.add(msg); } - // then update all the status features, - // e.g. when the device was last updated - for (DeviceFeature f : features.values()) { - if (f.isStatusFeature()) { - f.handleMessage(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 -> { + 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 -> { + 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); + logger.trace("enqueuing deferred request for {}", feature.getName()); + addDeviceRequest(msg, feature, delay); } } } } /** - * Helper method to make standard message + * Adds deferred request * - * @param flags - * @param cmd1 - * @param cmd2 - * @return standard message - * @throws FieldException - * @throws InvalidMessageTypeException + * @param request device request to add */ - public Msg makeStandardMessage(byte flags, byte cmd1, byte cmd2) - throws FieldException, InvalidMessageTypeException { - return (makeStandardMessage(flags, cmd1, cmd2, -1)); + private void addDeferredRequest(Msg msg, DeviceFeature feature) { + 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) { + logger.trace("overwriting existing deferred request for {}: {}", feature.getName(), msg); + deferredQueue.remove(prevRequest); + deferredQueueHash.remove(msg); + } + deferredQueue.add(request); + deferredQueueHash.put(msg, request); + } } /** - * Helper method to make standard message, possibly with group + * Clears request queue + */ + @Override + protected void clearRequestQueue() { + super.clearRequestQueue(); + + synchronized (deferredQueue) { + deferredQueue.clear(); + deferredQueueHash.clear(); + } + } + + /** + * Updates product data for this device * - * @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)); + * @param newData the new product data to use + */ + public void updateProductData(ProductData newData) { + ProductData productData = getProductData(); + if (productData == null) { + setProductData(newData); + propertiesChanged(true); } else { - addr = getAddress(); + logger.trace("updating product data for {} to {}", address, newData); + if (productData.update(newData)) { + propertiesChanged(true); + } else { + propertiesChanged(false); + resetFeaturesQueryStatus(); + } } - 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; + /** + * 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)) { + logger.trace("updating device type from {} to {} for {}", + currentType != null ? currentType.getName() : "undefined", newType.getName(), address); + productData.setDeviceType(newType); + propertiesChanged(true); + } } /** - * Helper method to make extended message - * - * @param flags - * @param cmd1 - * @param cmd2 - * @return extended message - * @throws FieldException - * @throws InvalidMessageTypeException + * Updates the default links */ - public Msg makeExtendedMessage(byte flags, byte cmd1, byte cmd2) - throws FieldException, InvalidMessageTypeException { - return makeExtendedMessage(flags, cmd1, cmd2, new byte[] {}); + 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)); + }); } /** - * 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 + * Adds a default link for this device * - * @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 + * @param link the default link to add + */ + private void addDefaultLink(DefaultLink link) { + 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 * - * @param timeNow - * @return time when to schedule the next message (timeNow + quietTime) + * @return map of missing link db records based on default links */ - public long processRequestQueue(long timeNow) { - synchronized (mrequestQueue) { - if (mrequestQueue.isEmpty()) { - return 0L; - } - DeviceFeature featureQueried = this.featureQueried; - if (featureQueried != null) { - // A feature has been queried, but - // the response has not been digested yet. - // Must wait for the query to be processed. - long dt = timeNow - (lastQueryTime + featureQueried.getDirectAckTimeout()); - if (dt < 0) { - logger.debug("still waiting for query reply from {} for another {} usec", address, -dt); - return (timeNow + 2000L); // retry soon - } else { - logger.debug("gave up waiting for query reply from device {}", address); + 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)); } } - QEntry qe = mrequestQueue.poll(); // take it off the queue! - if (qe == null) { - return 0L; - } - if (!qe.getMsg().isBroadcast()) { - logger.debug("qe taken off direct: {} {}", qe.getFeature(), qe.getMsg()); - lastQueryTime = timeNow; - // mark feature as pending - qe.getFeature().setQueryStatus(DeviceFeature.QueryStatus.QUERY_PENDING); - // also mark this queue as pending so there is no doubt - this.featureQueried = qe.getFeature(); - } else { - logger.debug("qe taken off bcast: {} {}", qe.getFeature(), qe.getMsg()); - } - long quietTime = qe.getMsg().getQuietTime(); - qe.getMsg().setQuietTime(500L); // rate limiting downstream! - try { - writeMessage(qe.getMsg()); - } catch (IOException e) { - logger.warn("message write failed for msg {}", qe.getMsg(), e); - } - // figure out when the request queue should be checked next - QEntry qnext = mrequestQueue.peek(); - long nextExpTime = (qnext == null ? 0L : qnext.getExpirationTime()); - long nextTime = Math.max(timeNow + quietTime, nextExpTime); - logger.debug("next request queue processed in {} msec, quiettime = {}", nextTime - timeNow, quietTime); - return (nextTime); } + return links; } /** - * Enqueues message to be sent at the next possible time + * Returns a map of missing modem links for this device * - * @param m message to be sent - * @param f device feature that sent this message (so we can associate the response message with it) + * @return map of missing modem db records based on default links */ - public void enqueueMessage(Msg m, DeviceFeature f) { - enqueueDelayedMessage(m, f, 0); + 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; } /** - * Enqueues message to be sent after a delay + * Returns a set of missing links for this device * - * @param m message to be sent - * @param f device feature that sent this message (so we can associate the response message with it) - * @param delay time (in milliseconds) to delay before enqueuing message - */ - public void enqueueDelayedMessage(Msg m, DeviceFeature f, long delay) { - long now = System.currentTimeMillis(); - synchronized (mrequestQueue) { - mrequestQueue.add(new QEntry(f, m, now + delay)); - } - if (!m.isBroadcast()) { - m.setQuietTime(QUIET_TIME_DIRECT_MESSAGE); - } - logger.trace("enqueing direct message with delay {}", delay); - RequestQueueManager instance = RequestQueueManager.instance(); - if (instance != null) { - instance.addQueue(this, now + delay); - } else { - logger.warn("request queue manger instance is null"); + * @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); } } - private void writeMessage(Msg m) throws IOException { - Driver driver = this.driver; - if (driver != null) { - driver.writeMessage(m); + /** + * 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()) { + logger.debug("no missing default links from link db to add for {}", address); + } else { + 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); + } + }); } } - private void instantiateFeatures(DeviceType dt) { - for (Entry fe : dt.getFeatures().entrySet()) { - DeviceFeature f = DeviceFeature.makeDeviceFeature(fe.getValue()); - if (f == null) { - logger.warn("device type {} references unknown feature: {}", dt, fe.getValue()); - } else { - addFeature(fe.getKey(), f); - } + /** + * Adds missing links to modem db for this device + */ + public void addMissingModemLinks() { + InsteonModem modem = getModem(); + if (modem == null || getDefaultLinks().isEmpty()) { + return; } - for (Entry fe : dt.getFeatureGroups().entrySet()) { - FeatureGroup fg = fe.getValue(); - @Nullable - DeviceFeature f = DeviceFeature.makeDeviceFeature(fg.getType()); - if (f == null) { - logger.warn("device type {} references unknown feature group: {}", dt, fg.getType()); - } else { - addFeature(fe.getKey(), f); - connectFeatures(fe.getKey(), f, fg.getFeatures()); - } + List changes = getMissingModemLinks().values().stream().distinct().toList(); + if (changes.isEmpty()) { + logger.debug("no missing default links from modem db to add for {}", address); + } else { + logger.trace("adding missing default links to modem db for {}", address); + ModemDB modemDB = modem.getDB(); + modemDB.clearChanges(); + changes.forEach(modemDB::addChange); + modemDB.update(); } } - private void connectFeatures(String gn, DeviceFeature fg, ArrayList fgFeatures) { - for (String fs : fgFeatures) { - @Nullable - DeviceFeature f = features.get(fs); - if (f == null) { - logger.warn("feature group {} references unknown feature {}", gn, fs); - } else { - logger.debug("{} connected feature: {}", gn, f); - fg.addConnectedFeature(f); + /** + * 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.updateBit(offMask, bit, buttonGroup != group); + } + onMaskFeature.handleCommand(new DecimalType(onMask)); + offMaskFeature.handleCommand(new DecimalType(offMask)); } } } - private void addFeature(String name, DeviceFeature f) { - f.setDevice(this); - synchronized (features) { - features.put(name, f); + /** + * 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)); + } } } /** - * Get the state of the state machine that suppresses duplicates for group messages. - * The state machine is advance the first time it is called for a message, - * otherwise return the current state. + * Sets keypad button toggle mode * - * @param group the insteon group of the broadcast message - * @param a the type of group message came in (action etc) - * @param cmd1 cmd1 from the message received - * @return true if this is message is NOT a duplicate - */ - public boolean getGroupState(int group, GroupMessage a, byte cmd1) { - GroupMessageStateMachine m = groupState.get(group); - if (m == null) { - m = new GroupMessageStateMachine(); - groupState.put(group, m); - logger.trace("{} created group {} state", address, group); - } else { - if (lastMsgReceived <= m.getLastUpdated()) { - logger.trace("{} using previous group {} state for {}", address, group, a); - return m.getPublish(); + * @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.updateBit(nonToggleMask, bit, mode != KeypadButtonToggleMode.TOGGLE); + alwaysOnOffMask = BinaryUtils.updateBit(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); } - logger.trace("{} updating group {} state to {}", address, group, a); - return (m.action(a, address, group, cmd1)); + if (!hasModemDBEntry()) { + logger.debug("device {} found in the modem database.", address); + setHasModemDBEntry(true); + } + + if (isPollable()) { + startPolling(); + } + + updateDefaultLinks(); } + /** + * Refreshes this device + */ @Override - public String toString() { - String s = address.toString(); - for (Entry f : features.entrySet()) { - s += "|" + f.getKey() + "->" + f.getValue().toString(); + public void refresh() { + initialize(); + + super.refresh(); + } + + /** + * Resets heartbeat monitor + */ + public void resetHeartbeatMonitor() { + InsteonDeviceHandler handler = getHandler(); + if (handler != null) { + handler.resetHeartbeatMonitor(); } - return s; } /** - * Factory method - * - * @param dt device type after which to model the device - * @return newly created device + * Notifies that the link db has been updated for this device */ - public static InsteonDevice makeDevice(DeviceType dt) { - InsteonDevice dev = new InsteonDevice(); - dev.instantiateFeatures(dt); - return dev; + public void linkDBUpdated() { + 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); + } } /** - * Queue entry helper class + * Notifies that the properties have changed for this device * - * @author Bernd Pfrommer - Initial contribution + * @param reset if the device should be reset */ - public static class QEntry implements Comparable { - private DeviceFeature feature; - private Msg msg; - private long expirationTime; + public void propertiesChanged(boolean reset) { + logger.trace("properties for {} has changed", address); - public DeviceFeature getFeature() { - return feature; + InsteonDeviceHandler handler = getHandler(); + if (handler != null) { + if (reset) { + handler.reset(this); + } else { + handler.updateProperties(this); + } } + } - public Msg getMsg() { - return msg; - } + /** + * Notifies that the status has changed for this device + */ + public void statusChanged() { + logger.trace("status for {} has changed", address); - public long getExpirationTime() { - return expirationTime; + InsteonDeviceHandler handler = getHandler(); + if (handler != null) { + handler.updateStatus(); } + } - QEntry(DeviceFeature f, Msg m, long t) { - feature = f; - msg = m; - expirationTime = t; + /** + * 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); } - @Override - public int compareTo(QEntry a) { - return (int) (expirationTime - a.expirationTime); - } + 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..a733f3860d0fc --- /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, true); + + 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..b41212d553a21 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonModem.java @@ -0,0 +1,522 @@ +/** + * 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.device.database.DatabaseManager; +import org.openhab.binding.insteon.internal.device.database.ModemDB; +import org.openhab.binding.insteon.internal.handler.InsteonBridgeHandler; +import org.openhab.binding.insteon.internal.transport.Port; +import org.openhab.binding.insteon.internal.transport.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.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(scheduler); + this.requester = new RequestManager(scheduler); + } + + @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 Scene 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 InsteonScene getInsteonScene(int group) { + return (InsteonScene) getScene(group); + } + + public List getInsteonScenes() { + return getScenes().stream().filter(InsteonScene.class::isInstance).map(InsteonScene.class::cast).toList(); + } + + public @Nullable ProductData getProductData(DeviceAddress address) { + Device device = getDevice(address); + if (device != null && device.getProductData() != null) { + return device.getProductData(); + } else if (address instanceof InsteonAddress insteonAddress) { + return modemDB.getProductData(insteonAddress); + } + return null; + } + + 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) { + getInsteonScenes().stream().filter(scene -> scene.getDevices().contains(device.getAddress())) + .forEach(scene -> scene.deleteEntries(device.getAddress())); + } + + public void updateSceneEntries(InsteonDevice device) { + getInsteonScenes().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; + + logger.debug("modem discovered: {}", this); + + InsteonBridgeHandler handler = getHandler(); + if (handler != null) { + handler.modemDiscovered(this); + } + } + + public void logDeviceStatistics() { + logger.debug("devices: {} configured, {} polling, msgs received: {}", getDevices().size(), + getPollManager().getSizeOfQueue(), msgsReceived); + msgsReceived = 0; + } + + private void logDevicesAndScenes() { + 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 -> { + 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(Scene::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; + } + 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 = getInsteonScene(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; + } + 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() { + 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() { + 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) { + 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..33c19aab860e8 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonScene.java @@ -0,0 +1,428 @@ +/** + * 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.device.database.LinkDBRecord; +import org.openhab.binding.insteon.internal.device.feature.FeatureListener; +import org.openhab.binding.insteon.internal.handler.InsteonSceneHandler; +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 implements Scene { + 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; + } + + @Override + 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) { + 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) { + 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) { + 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(); + + 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() { + 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()) { + logger.debug("scene {} found in the modem database.", group); + setHasModemDBEntry(true); + } + + updateEntries(); + } + + /** + * Refreshes this scene + */ + @Override + public void refresh() { + 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 new file mode 100644 index 0000000000000..4d7db0357b335 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDevice.java @@ -0,0 +1,529 @@ +/** + * 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.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.PriorityQueue; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.config.InsteonLegacyChannelConfiguration; +import org.openhab.binding.insteon.internal.device.LegacyDeviceType.FeatureGroup; +import org.openhab.binding.insteon.internal.transport.LegacyDriver; +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; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The InsteonDevice class holds known per-device state of a single Insteon device, + * including the address, what port(modem) to reach it on etc. + * Note that some Insteon devices de facto consist of two devices (let's say + * a relay and a sensor), but operate under the same address. Such devices will + * be represented just by a single InsteonDevice. Their different personalities + * will then be represented by DeviceFeatures. + * + * @author Bernd Pfrommer - Initial contribution + * @author Rob Nielsen - Port to openHAB 2 insteon binding + */ +@NonNullByDefault +public class LegacyDevice { + private final Logger logger = LoggerFactory.getLogger(LegacyDevice.class); + + public enum DeviceStatus { + INITIALIZED, + POLLING + } + + /** need to wait after query to avoid misinterpretation of duplicate replies */ + private static final int QUIET_TIME_DIRECT_MESSAGE = 2000; + /** how far to space out poll messages */ + private static final int TIME_BETWEEN_POLL_MESSAGES = 1500; + + private DeviceAddress address = InsteonAddress.UNKNOWN; + private long pollInterval = -1L; // in milliseconds + private @Nullable LegacyDriver driver = null; + private Map features = new HashMap<>(); + private @Nullable String productKey = null; + private volatile long lastTimePolled = 0L; + private volatile long lastMsgReceived = 0L; + private boolean isModem = false; + private PriorityQueue<@Nullable QEntry> mrequestQueue = new PriorityQueue<>(); + private @Nullable LegacyDeviceFeature featureQueried = null; + private long lastQueryTime = 0L; + private boolean hasModemDBEntry = false; + private DeviceStatus status = DeviceStatus.INITIALIZED; + private Map groupState = new HashMap<>(); + private Map deviceConfigMap = new HashMap<>(); + + /** + * Constructor + */ + public LegacyDevice() { + lastMsgReceived = System.currentTimeMillis(); + } + + public boolean hasProductKey() { + return productKey != null; + } + + public @Nullable String getProductKey() { + return productKey; + } + + public boolean hasModemDBEntry() { + return hasModemDBEntry; + } + + public DeviceStatus getStatus() { + return status; + } + + public DeviceAddress getAddress() { + return address; + } + + public @Nullable LegacyDriver getDriver() { + return driver; + } + + public long getPollInterval() { + return pollInterval; + } + + public boolean isModem() { + return isModem; + } + + public @Nullable LegacyDeviceFeature getFeature(String name) { + return features.get(name); + } + + public Map getFeatures() { + return features; + } + + public boolean hasProductKey(String key) { + String productKey = this.productKey; + return productKey != null && productKey.equals(key); + } + + public boolean hasValidPollingInterval() { + return pollInterval > 0; + } + + public long getPollOverDueTime() { + return lastTimePolled - lastMsgReceived; + } + + public boolean hasAnyListeners() { + synchronized (features) { + for (LegacyDeviceFeature feature : features.values()) { + if (feature.hasListeners()) { + return true; + } + } + } + return false; + } + + public void setStatus(DeviceStatus status) { + this.status = status; + } + + public void setHasModemDBEntry(boolean hasModemDBEntry) { + this.hasModemDBEntry = hasModemDBEntry; + } + + public void setAddress(DeviceAddress address) { + this.address = address; + } + + public void setDriver(LegacyDriver driver) { + this.driver = driver; + } + + public void setIsModem(boolean isModem) { + this.isModem = isModem; + } + + public void setProductKey(String productKey) { + this.productKey = productKey; + } + + public void setPollInterval(long pollInterval) { + logger.trace("setting poll interval for {} to {} ", address, pollInterval); + if (pollInterval > 0) { + this.pollInterval = pollInterval; + } + } + + public void setFeatureQueried(@Nullable LegacyDeviceFeature featureQueried) { + synchronized (mrequestQueue) { + this.featureQueried = featureQueried; + } + } + + public void setDeviceConfigMap(Map deviceConfigMap) { + this.deviceConfigMap = deviceConfigMap; + } + + public Map getDeviceConfigMap() { + return deviceConfigMap; + } + + public @Nullable LegacyDeviceFeature getFeatureQueried() { + synchronized (mrequestQueue) { + return featureQueried; + } + } + + /** + * Removes feature listener from this device + * + * @param itemName name of the feature listener to remove + * @return true if a feature listener was successfully removed + */ + public boolean removeFeatureListener(String itemName) { + boolean removedListener = false; + synchronized (features) { + for (Iterator> it = features.entrySet().iterator(); it.hasNext();) { + LegacyDeviceFeature feature = it.next().getValue(); + if (feature.removeListener(itemName)) { + removedListener = true; + } + } + } + return removedListener; + } + + /** + * Invoked to process an openHAB command + * + * @param driver The driver to use + * @param config The item configuration + * @param command The actual command to execute + */ + public void processCommand(LegacyDriver driver, InsteonLegacyChannelConfiguration config, Command command) { + logger.debug("processing command {} features: {}", command, features.size()); + synchronized (features) { + for (LegacyDeviceFeature feature : features.values()) { + if (feature.isReferencedByItem(config.getChannelName())) { + feature.handleCommand(config, command); + } + } + } + } + + /** + * Execute poll on this device: create an array of messages, + * add them to the request queue, and schedule the queue + * for processing. + * + * @param delay scheduling delay (in milliseconds) + */ + public void doPoll(long delay) { + long now = System.currentTimeMillis(); + List list = new ArrayList<>(); + synchronized (features) { + int spacing = 0; + for (LegacyDeviceFeature feature : features.values()) { + if (feature.hasListeners()) { + Msg msg = feature.makePollMsg(); + if (msg != null) { + list.add(new QEntry(feature, msg, now + delay + spacing)); + spacing += TIME_BETWEEN_POLL_MESSAGES; + } + } + } + } + if (list.isEmpty()) { + return; + } + synchronized (mrequestQueue) { + for (QEntry qe : list) { + mrequestQueue.add(qe); + } + } + LegacyRequestManager instance = LegacyRequestManager.instance(); + if (instance != null) { + instance.addQueue(this, now + delay); + } else { + logger.warn("request queue manager is null"); + } + + if (!list.isEmpty()) { + lastTimePolled = now; + } + } + + /** + * Handle incoming message for this device by forwarding + * it to all features that this device supports + * + * @param msg the incoming message + */ + public void handleMessage(Msg msg) { + lastMsgReceived = System.currentTimeMillis(); + synchronized (features) { + // first update all features that are + // not status features + for (LegacyDeviceFeature feature : features.values()) { + if (!feature.isStatusFeature()) { + logger.debug("----- applying message to feature: {}", feature.getName()); + if (feature.handleMessage(msg)) { + // handled a reply to a query, + // mark it as processed + logger.trace("handled reply of direct: {}", feature); + setFeatureQueried(null); + break; + } + } + } + // then update all the status features, + // e.g. when the device was last updated + for (LegacyDeviceFeature feature : features.values()) { + if (feature.isStatusFeature()) { + feature.handleMessage(msg); + } + } + } + } + + /** + * Called by the RequestQueueManager when the queue has expired + * + * @param timeNow + * @return time when to schedule the next message (timeNow + quietTime) + */ + public long processRequestQueue(long timeNow) { + synchronized (mrequestQueue) { + if (mrequestQueue.isEmpty()) { + return 0L; + } + LegacyDeviceFeature featureQueried = this.featureQueried; + if (featureQueried != null) { + // A feature has been queried, but + // the response has not been digested yet. + // Must wait for the query to be processed. + long delta = timeNow - (lastQueryTime + featureQueried.getDirectAckTimeout()); + if (delta < 0) { + logger.debug("still waiting for query reply from {} for another {} usec", address, -delta); + return timeNow + 2000L; // retry soon + } else { + logger.debug("gave up waiting for query reply from device {}", address); + } + } + QEntry qe = mrequestQueue.poll(); // take it off the queue! + if (qe == null) { + return 0L; + } + if (!qe.getMsg().isBroadcast()) { + logger.debug("qe taken off direct: {} {}", qe.getFeature(), qe.getMsg()); + lastQueryTime = timeNow; + // mark feature as pending + qe.getFeature().setQueryStatus(LegacyDeviceFeature.QueryStatus.QUERY_PENDING); + // also mark this queue as pending so there is no doubt + this.featureQueried = qe.getFeature(); + } else { + logger.debug("qe taken off bcast: {} {}", qe.getFeature(), qe.getMsg()); + } + long quietTime = qe.getMsg().getQuietTime(); + qe.getMsg().setQuietTime(500L); // rate limiting downstream! + try { + writeMessage(qe.getMsg()); + } catch (IOException e) { + logger.warn("message write failed for msg {}", qe.getMsg(), e); + } + // figure out when the request queue should be checked next + QEntry qnext = mrequestQueue.peek(); + long nextExpTime = (qnext == null ? 0L : qnext.getExpirationTime()); + long nextTime = Math.max(timeNow + quietTime, nextExpTime); + logger.debug("next request queue processed in {} msec, quiettime = {}", nextTime - timeNow, quietTime); + return nextTime; + } + } + + /** + * Enqueues message to be sent at the next possible time + * + * @param msg message to be sent + * @param feature device feature that sent this message (so we can associate the response message with it) + */ + public void enqueueMessage(Msg msg, LegacyDeviceFeature feature) { + enqueueDelayedMessage(msg, feature, 0); + } + + /** + * Enqueues message to be sent after a delay + * + * @param msg message to be sent + * @param feature device feature that sent this message (so we can associate the response message with it) + * @param delay time (in milliseconds) to delay before enqueuing message + */ + public void enqueueDelayedMessage(Msg msg, LegacyDeviceFeature feature, long delay) { + long now = System.currentTimeMillis(); + synchronized (mrequestQueue) { + mrequestQueue.add(new QEntry(feature, msg, now + delay)); + } + if (!msg.isBroadcast()) { + msg.setQuietTime(QUIET_TIME_DIRECT_MESSAGE); + } + logger.trace("enqueing direct message with delay {}", delay); + LegacyRequestManager instance = LegacyRequestManager.instance(); + if (instance != null) { + instance.addQueue(this, now + delay); + } else { + logger.warn("request queue manger instance is null"); + } + } + + private void writeMessage(Msg msg) throws IOException { + LegacyDriver driver = this.driver; + if (driver != null) { + driver.writeMessage(msg); + } + } + + private void instantiateFeatures(LegacyDeviceType deviceType) { + for (Entry entry : deviceType.getFeatures().entrySet()) { + LegacyDeviceFeature feature = LegacyDeviceFeature.makeDeviceFeature(entry.getValue()); + if (feature == null) { + logger.warn("device type {} references unknown feature: {}", deviceType, entry.getValue()); + } else { + addFeature(entry.getKey(), feature); + } + } + for (Entry entry : deviceType.getFeatureGroups().entrySet()) { + FeatureGroup featureGroup = entry.getValue(); + @Nullable + LegacyDeviceFeature feature = LegacyDeviceFeature.makeDeviceFeature(featureGroup.getType()); + if (feature == null) { + logger.warn("device type {} references unknown feature group: {}", deviceType, featureGroup.getType()); + } else { + addFeature(entry.getKey(), feature); + connectFeatures(entry.getKey(), feature, featureGroup.getFeatures()); + } + } + } + + private void connectFeatures(String name, LegacyDeviceFeature groupFeature, ArrayList groupFeatures) { + for (String featureName : groupFeatures) { + @Nullable + LegacyDeviceFeature feature = features.get(featureName); + if (feature == null) { + logger.warn("feature group {} references unknown feature {}", name, featureName); + } else { + logger.debug("{} connected feature: {}", name, feature); + groupFeature.addConnectedFeature(feature); + } + } + } + + private void addFeature(String name, LegacyDeviceFeature feature) { + feature.setDevice(this); + synchronized (features) { + features.put(name, feature); + } + } + + /** + * Get the state of the state machine that suppresses duplicates for group messages. + * The state machine is advance the first time it is called for a message, + * otherwise return the current state. + * + * @param group the insteon group of the broadcast message + * @param groupMessage the type of group message came in (action etc) + * @param cmd1 cmd1 from the message received + * @return true if this is message is NOT a duplicate + */ + public boolean getGroupState(int group, GroupMessage groupMessage, byte cmd1) { + LegacyGroupMessageStateMachine stateMachine = groupState.get(group); + if (stateMachine == null) { + stateMachine = new LegacyGroupMessageStateMachine(); + groupState.put(group, stateMachine); + logger.trace("{} created group {} state", address, group); + } else { + if (lastMsgReceived <= stateMachine.getLastUpdated()) { + logger.trace("{} using previous group {} state for {}", address, group, groupMessage); + return stateMachine.getPublish(); + } + } + + logger.trace("{} updating group {} state to {}", address, group, groupMessage); + return stateMachine.action(groupMessage, address, group, cmd1); + } + + @Override + public String toString() { + String s = address.toString(); + for (Entry entry : features.entrySet()) { + s += "|" + entry.getKey() + "->" + entry.getValue().toString(); + } + return s; + } + + /** + * Factory method + * + * @param deviceType device type after which to model the device + * @return newly created device + */ + public static LegacyDevice makeDevice(LegacyDeviceType deviceType) { + LegacyDevice device = new LegacyDevice(); + device.instantiateFeatures(deviceType); + return device; + } + + /** + * Queue entry helper class + * + * @author Bernd Pfrommer - Initial contribution + */ + public static class QEntry implements Comparable { + private LegacyDeviceFeature feature; + private Msg msg; + private long expirationTime; + + QEntry(LegacyDeviceFeature feature, Msg msg, long expirationTime) { + this.feature = feature; + this.msg = msg; + this.expirationTime = expirationTime; + } + + public LegacyDeviceFeature getFeature() { + return feature; + } + + public Msg getMsg() { + return msg; + } + + public long getExpirationTime() { + return expirationTime; + } + + @Override + public int compareTo(QEntry qe) { + return (int) (expirationTime - qe.expirationTime); + } + } +} 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 new file mode 100644 index 0000000000000..c50650dd910f9 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceFeature.java @@ -0,0 +1,392 @@ +/** + * 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.Iterator; +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.config.InsteonLegacyChannelConfiguration; +import org.openhab.binding.insteon.internal.device.feature.LegacyCommandHandler; +import org.openhab.binding.insteon.internal.device.feature.LegacyFeatureListener; +import org.openhab.binding.insteon.internal.device.feature.LegacyFeatureListener.StateChangeType; +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.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.transport.message.Msg; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +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, DeviceFeatureListeners can register with the DeviceFeature to get notifications when + * the state of a feature has changed. In practice, a DeviceFeatureListener 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 LegacyDeviceFeature { + public enum QueryStatus { + NEVER_QUERIED, + QUERY_PENDING, + QUERY_ANSWERED + } + + private final Logger logger = LoggerFactory.getLogger(LegacyDeviceFeature.class); + + private LegacyDevice device = new LegacyDevice(); + private String name = "INVALID_FEATURE_NAME"; + private boolean isStatus = false; + private int directAckTimeout = 6000; + private QueryStatus queryStatus = QueryStatus.NEVER_QUERIED; + + private LegacyMessageHandler defaultMsgHandler = new LegacyMessageHandler.DefaultMsgHandler(this); + private LegacyCommandHandler defaultCommandHandler = new LegacyCommandHandler.WarnCommandHandler(this); + private @Nullable LegacyPollHandler pollHandler = null; + private @Nullable LegacyMessageDispatcher dispatcher = null; + + private Map msgHandlers = new HashMap<>(); + private Map, LegacyCommandHandler> commandHandlers = new HashMap<>(); + private List listeners = new ArrayList<>(); + private List connectedFeatures = new ArrayList<>(); + + /** + * Constructor + * + * @param device Insteon device to which this feature belongs + * @param name descriptive name for that feature + */ + public LegacyDeviceFeature(LegacyDevice device, String name) { + this.name = name; + setDevice(device); + } + + /** + * Constructor + * + * @param name descriptive name of the feature + */ + public LegacyDeviceFeature(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public synchronized QueryStatus getQueryStatus() { + return queryStatus; + } + + public LegacyDevice getDevice() { + return device; + } + + public boolean isFeatureGroup() { + return !connectedFeatures.isEmpty(); + } + + public boolean isStatusFeature() { + return isStatus; + } + + public int getDirectAckTimeout() { + return directAckTimeout; + } + + public LegacyMessageHandler getDefaultMsgHandler() { + return defaultMsgHandler; + } + + public Map getMsgHandlers() { + return this.msgHandlers; + } + + public List getConnectedFeatures() { + return connectedFeatures; + } + + public void setStatusFeature(boolean isStatus) { + this.isStatus = isStatus; + } + + public void setPollHandler(LegacyPollHandler pollHandler) { + this.pollHandler = pollHandler; + } + + public void setDevice(LegacyDevice device) { + this.device = device; + } + + public void setMessageDispatcher(LegacyMessageDispatcher dispatcher) { + this.dispatcher = dispatcher; + } + + public void setDefaultCommandHandler(LegacyCommandHandler defaultCommandHandler) { + this.defaultCommandHandler = defaultCommandHandler; + } + + public void setDefaultMsgHandler(LegacyMessageHandler defaultMsgHandler) { + this.defaultMsgHandler = defaultMsgHandler; + } + + public synchronized void setQueryStatus(QueryStatus queryStatus) { + logger.trace("{} set query status to: {}", name, queryStatus); + this.queryStatus = queryStatus; + } + + public void setTimeout(@Nullable String timeout) { + if (timeout != null && !timeout.isEmpty()) { + try { + directAckTimeout = Integer.parseInt(timeout); + logger.trace("ack timeout set to {}", directAckTimeout); + } catch (NumberFormatException e) { + logger.warn("invalid number for timeout: {}", timeout); + } + } + } + + /** + * Add a listener (item) to a device feature + * + * @param listener the listener + */ + public void addListener(LegacyFeatureListener listener) { + synchronized (listeners) { + for (LegacyFeatureListener l : listeners) { + if (l.getItemName().equals(listener.getItemName())) { + return; + } + } + listeners.add(listener); + } + } + + /** + * Adds a connected feature such that this DeviceFeature can + * act as a feature group + * + * @param feature the device feature related to this feature + */ + public void addConnectedFeature(LegacyDeviceFeature feature) { + connectedFeatures.add(feature); + } + + public boolean hasListeners() { + if (!listeners.isEmpty()) { + return true; + } + for (LegacyDeviceFeature feature : connectedFeatures) { + if (feature.hasListeners()) { + return true; + } + } + return false; + } + + /** + * removes a DeviceFeatureListener from this feature + * + * @param itemName name of the item to remove as listener + * @return true if a listener was removed + */ + public boolean removeListener(String itemName) { + boolean listenerRemoved = false; + synchronized (listeners) { + for (Iterator it = listeners.iterator(); it.hasNext();) { + LegacyFeatureListener listener = it.next(); + if (listener.getItemName().equals(itemName)) { + it.remove(); + listenerRemoved = true; + } + } + } + return listenerRemoved; + } + + public boolean isReferencedByItem(String itemName) { + synchronized (listeners) { + for (LegacyFeatureListener listener : listeners) { + if (listener.getItemName().equals(itemName)) { + return true; + } + } + } + return false; + } + + /** + * Called when message is incoming. Dispatches message according to message dispatcher + * + * @param msg The message to dispatch + * @return true if dispatch successful + */ + public boolean handleMessage(Msg msg) { + LegacyMessageDispatcher dispatcher = this.dispatcher; + if (dispatcher == null) { + logger.warn("{} no dispatcher for msg {}", name, msg); + return false; + } + return dispatcher.dispatch(msg); + } + + /** + * Called when an openhab command arrives for this device feature + * + * @param config the binding config of the item which sends the command + * @param cmd the command to be exectued + */ + public void handleCommand(InsteonLegacyChannelConfiguration config, Command cmd) { + Class key = cmd.getClass(); + LegacyCommandHandler handler = commandHandlers.containsKey(key) ? commandHandlers.get(key) + : defaultCommandHandler; + if (handler != null) { + logger.trace("{} uses {} to handle command {} for {}", getName(), handler.getClass().getSimpleName(), + key.getSimpleName(), getDevice().getAddress()); + handler.handleCommand(config, cmd, getDevice()); + } + } + + /** + * Make a poll message using the configured poll message handler + * + * @return the poll message + */ + public @Nullable Msg makePollMsg() { + LegacyPollHandler pollHandler = this.pollHandler; + if (pollHandler == null) { + return null; + } + logger.trace("{} making poll msg for {} using handler {}", getName(), getDevice().getAddress(), + pollHandler.getClass().getSimpleName()); + return pollHandler.makeMsg(device); + } + + /** + * Publish new state to all device feature listeners, but give them + * additional dataKey and dataValue information so they can decide + * whether to publish the data to the bus. + * + * @param newState state to be published + * @param changeType what kind of changes to publish + * @param dataKey the key on which to filter + * @param dataValue the value that must be matched + */ + public void publish(State newState, StateChangeType changeType, String dataKey, String dataValue) { + logger.debug("{}:{} publishing: {}", this.getDevice().getAddress(), getName(), newState); + synchronized (listeners) { + for (LegacyFeatureListener listener : listeners) { + listener.stateChanged(newState, changeType, dataKey, dataValue); + } + } + } + + /** + * Publish new state to all device feature listeners + * + * @param newState state to be published + * @param changeType what kind of changes to publish + */ + public void publish(State newState, StateChangeType changeType) { + logger.debug("{}:{} publishing: {}", this.getDevice().getAddress(), getName(), newState); + synchronized (listeners) { + for (LegacyFeatureListener listener : listeners) { + listener.stateChanged(newState, changeType); + } + } + } + + /** + * Poll all device feature listeners for related devices + */ + public void pollRelatedDevices() { + synchronized (listeners) { + for (LegacyFeatureListener listener : listeners) { + listener.pollRelatedDevices(); + } + } + } + + /** + * Adds a message handler to this device feature. + * + * @param cm1 The insteon cmd1 of the incoming message for which the handler should be used + * @param handler the handler to invoke + */ + public void addMessageHandler(int cm1, LegacyMessageHandler handler) { + synchronized (msgHandlers) { + msgHandlers.put(cm1, handler); + } + } + + /** + * Adds a command handler to this device feature + * + * @param command the command for which this handler is invoked + * @param handler the handler to call + */ + public void addCommandHandler(Class command, LegacyCommandHandler handler) { + synchronized (commandHandlers) { + commandHandlers.put(command, handler); + } + } + + /** + * Turn DeviceFeature into String + */ + @Override + public String toString() { + return name + "(" + listeners.size() + ":" + commandHandlers.size() + ":" + msgHandlers.size() + ")"; + } + + /** + * Factory method for creating DeviceFeatures. + * + * @param name The name of the device feature to create. + * @return The newly created DeviceFeature, or null if requested DeviceFeature does not exist. + */ + public static @Nullable LegacyDeviceFeature makeDeviceFeature(String name) { + LegacyFeatureTemplate template = LegacyFeatureTemplateLoader.instance().getTemplate(name); + return template != null ? template.build() : null; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceType.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceType.java new file mode 100644 index 0000000000000..eac472ff5063d --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceType.java @@ -0,0 +1,165 @@ +/** + * 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.Map; +import java.util.Map.Entry; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The DeviceType class holds device type definitions that are read from + * an xml file. + * + * @author Bernd Pfrommer - Initial contribution + * @author Rob Nielsen - Port to openHAB 2 insteon binding + */ +@NonNullByDefault +public class LegacyDeviceType { + private String productKey; + private String model = ""; + private String description = ""; + private Map features = new HashMap<>(); + private Map featureGroups = new HashMap<>(); + + /** + * Constructor + * + * @param productKey the product key for this device type + */ + public LegacyDeviceType(String productKey) { + this.productKey = productKey; + } + + /** + * Get supported features + * + * @return all features that this device type supports + */ + public Map getFeatures() { + return features; + } + + /** + * Get all feature groups + * + * @return all feature groups of this device type + */ + public Map getFeatureGroups() { + return featureGroups; + } + + /** + * Sets the descriptive model string + * + * @param model descriptive model string + */ + public void setModel(String model) { + this.model = model; + } + + /** + * Sets free text description + * + * @param description free text description + */ + public void setDescription(String description) { + this.description = description; + } + + /** + * Adds feature to this device type + * + * @param key the key (e.g. "switch") under which this feature can be referenced in the item binding config + * @param featureName the name (e.g. "GenericSwitch") under which the feature has been defined + * @return false if feature was already there + */ + public boolean addFeature(String key, String featureName) { + if (features.containsKey(key)) { + return false; + } + features.put(key, featureName); + return true; + } + + /** + * Adds feature group to device type + * + * @param key name of the feature group, which acts as key for lookup later + * @param featureGroup feature group to add + * @return true if add succeeded, false if group was already there + */ + public boolean addFeatureGroup(String key, FeatureGroup featureGroup) { + if (features.containsKey(key)) { + return false; + } + featureGroups.put(key, featureGroup); + return true; + } + + @Override + public String toString() { + String s = "pk:" + productKey + "|model:" + model + "|desc:" + description + "|features"; + for (Entry entry : features.entrySet()) { + s += ":" + entry.getKey() + "=" + entry.getValue(); + } + s += "|groups"; + for (Entry entry : featureGroups.entrySet()) { + s += ":" + entry.getKey() + "=" + entry.getValue(); + } + return s; + } + + /** + * Class that reflects feature group association + * + * @author Bernd Pfrommer - Initial contribution + */ + public static class FeatureGroup { + private String name; + private String type; + private ArrayList fgFeatures = new ArrayList<>(); + + FeatureGroup(String name, String type) { + this.name = name; + this.type = type; + } + + public void addFeature(String f) { + fgFeatures.add(f); + } + + public ArrayList getFeatures() { + return fgFeatures; + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + @Override + public String toString() { + String s = ""; + for (String feature : fgFeatures) { + s += feature + ","; + } + return s.replaceAll(",$", ""); + } + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceTypeLoader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceTypeLoader.java new file mode 100644 index 0000000000000..42b8f89d589d0 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceTypeLoader.java @@ -0,0 +1,175 @@ +/** + * 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 org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.InsteonResourceLoader; +import org.openhab.binding.insteon.internal.device.LegacyDeviceType.FeatureGroup; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** + * Reads the device types from an xml file. + * + * @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 LegacyDeviceTypeLoader extends InsteonResourceLoader { + private static final LegacyDeviceTypeLoader DEVICE_TYPE_LOADER = new LegacyDeviceTypeLoader(); + private static final String RESOURCE_NAME = "/legacy-device-types.xml"; + + private Map deviceTypes = new HashMap<>(); + + private LegacyDeviceTypeLoader() { + super(RESOURCE_NAME); + } + + /** + * Finds the device type for a given product key + * + * @param productKey product key to search for + * @return the device type, or null if not found + */ + public @Nullable LegacyDeviceType getDeviceType(String productKey) { + return deviceTypes.get(productKey); + } + + /** + * Returns known device types + * + * @return currently known device types + */ + public Map getDeviceTypes() { + return deviceTypes; + } + + /** + * Parses the device types document + * + * @param element element to parse + * @throws SAXException + */ + @Override + protected void parseDocument(Element element) throws SAXException { + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE && "device".equals(node.getNodeName())) { + processDevice((Element) node); + } + } + } + + /** + * Process device node + * + * @param element name of the element to process + * @throws SAXException + */ + private void processDevice(Element element) throws SAXException { + String productKey = element.getAttribute("productKey"); + if (productKey.isEmpty()) { + throw new SAXException("device in device_types file has no product key!"); + } + if (deviceTypes.containsKey(productKey)) { + logger.warn("overwriting previous definition of device {}", productKey); + deviceTypes.remove(productKey); + } + LegacyDeviceType devType = new LegacyDeviceType(productKey); + + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + Element subElement = (Element) node; + String nodeName = subElement.getNodeName(); + if ("model".equals(nodeName)) { + devType.setModel(subElement.getTextContent()); + } else if ("description".equals(nodeName)) { + devType.setDescription(subElement.getTextContent()); + } else if ("feature".equals(nodeName)) { + processFeature(devType, subElement); + } else if ("feature_group".equals(nodeName)) { + processFeatureGroup(devType, subElement); + } + deviceTypes.put(productKey, devType); + } + } + + private String processFeature(LegacyDeviceType devType, Element element) throws SAXException { + String name = element.getAttribute("name"); + if (name.isEmpty()) { + throw new SAXException("feature " + element.getNodeName() + " has feature without name!"); + } + if (!name.equals(name.toLowerCase())) { + throw new SAXException("feature name '" + name + "' must be lower case"); + } + if (!devType.addFeature(name, element.getTextContent())) { + throw new SAXException("duplicate feature: " + name); + } + return name; + } + + private String processFeatureGroup(LegacyDeviceType devType, Element element) throws SAXException { + String name = element.getAttribute("name"); + if (name.isEmpty()) { + throw new SAXException("feature group " + element.getNodeName() + " has no name attr!"); + } + String type = element.getAttribute("type"); + if (type.isEmpty()) { + throw new SAXException("feature group " + element.getNodeName() + " has no type attr!"); + } + FeatureGroup fg = new FeatureGroup(name, type); + NodeList nodes = element.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node node = nodes.item(i); + if (node.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + Element subElement = (Element) node; + String nodeName = subElement.getNodeName(); + if ("feature".equals(nodeName)) { + fg.addFeature(processFeature(devType, subElement)); + } else if ("feature_group".equals(nodeName)) { + fg.addFeature(processFeatureGroup(devType, subElement)); + } + } + if (!devType.addFeatureGroup(name, fg)) { + throw new SAXException("duplicate feature group " + name); + } + return name; + } + + /** + * Singleton instance function, creates DeviceTypeLoader + * + * @return DeviceTypeLoader singleton reference + */ + public static synchronized LegacyDeviceTypeLoader instance() { + if (DEVICE_TYPE_LOADER.getDeviceTypes().isEmpty()) { + DEVICE_TYPE_LOADER.initialize(); + } + return DEVICE_TYPE_LOADER; + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Poller.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyPollManager.java similarity index 72% rename from bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Poller.java rename to bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyPollManager.java index 4e6b898caece3..5f8d5be27786a 100644 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Poller.java +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyPollManager.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.insteon.internal.driver; +package org.openhab.binding.insteon.internal.device; import java.sql.Date; import java.util.Iterator; @@ -20,7 +20,6 @@ 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.device.InsteonDevice; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,11 +41,11 @@ * @author Rob Nielsen - Port to openHAB 2 insteon binding */ @NonNullByDefault -public class Poller { +public class LegacyPollManager { private static final long MIN_MSEC_BETWEEN_POLLS = 2000L; - private final Logger logger = LoggerFactory.getLogger(Poller.class); - private static Poller poller = new Poller(); // for singleton + private final Logger logger = LoggerFactory.getLogger(LegacyPollManager.class); + private static LegacyPollManager poller = new LegacyPollManager(); // for singleton private @Nullable Thread pollThread = null; private TreeSet pollQueue = new TreeSet<>(); @@ -55,7 +54,7 @@ public class Poller { /** * Constructor */ - private Poller() { + private LegacyPollManager() { } /** @@ -64,23 +63,23 @@ private Poller() { * @return number of devices being polled */ public int getSizeOfQueue() { - return (pollQueue.size()); + return pollQueue.size(); } /** * Register a device for polling. * - * @param d device to register for polling - * @param aNumDev approximate number of total devices + * @param device device to register for polling + * @param numDev approximate number of total devices */ - public void startPolling(InsteonDevice d, int aNumDev) { - logger.debug("start polling device {}", d); + public void startPolling(LegacyDevice device, int numDev) { + logger.debug("start polling device {}", device); synchronized (pollQueue) { // try to spread out the scheduling when // starting up int n = pollQueue.size(); - long pollDelay = n * d.getPollInterval() / (aNumDev > 0 ? aNumDev : 1); - addToPollQueue(d, System.currentTimeMillis() + pollDelay); + long pollDelay = n * device.getPollInterval() / (numDev > 0 ? numDev : 1); + addToPollQueue(device, System.currentTimeMillis() + pollDelay); pollQueue.notify(); } } @@ -88,14 +87,14 @@ public void startPolling(InsteonDevice d, int aNumDev) { /** * Start polling a given device * - * @param d reference to the device to be polled + * @param device reference to the device to be polled */ - public void stopPolling(InsteonDevice d) { + public void stopPolling(LegacyDevice device) { synchronized (pollQueue) { for (Iterator i = pollQueue.iterator(); i.hasNext();) { - if (i.next().getDevice().getAddress().equals(d.getAddress())) { + if (i.next().getDevice().getAddress().equals(device.getAddress())) { i.remove(); - logger.debug("stopped polling device {}", d); + logger.debug("stopped polling device {}", device); } } } @@ -145,14 +144,14 @@ public void stop() { * Adds a device to the poll queue. After this call, the device's doPoll() method * will be called according to the polling frequency set. * - * @param d the device to poll periodically + * @param device the device to poll periodically * @param time the target time for the next poll to happen. Note that this time is merely * a suggestion, and may be adjusted, because there must be at least a minimum gap in polling. */ - private void addToPollQueue(InsteonDevice d, long time) { - long texp = findNextExpirationTime(d, time); - PQEntry ne = new PQEntry(d, texp); + private void addToPollQueue(LegacyDevice device, long time) { + long texp = findNextExpirationTime(device, time); + PQEntry ne = new PQEntry(device, texp); logger.trace("added entry {} originally aimed at time {}", ne, String.format("%tc", new Date(time))); pollQueue.add(ne); } @@ -162,35 +161,36 @@ private void addToPollQueue(InsteonDevice d, long time) { * desired expiration time, but does not collide with any of the already scheduled * polls. * - * @param d device to poll (for logging) - * @param aTime desired time after which the device should be polled + * @param device device to poll (for logging) + * @param time desired time after which the device should be polled * @return the suggested time to poll */ - private long findNextExpirationTime(InsteonDevice d, long aTime) { - long expTime = aTime; - // tailSet finds all those that expire after aTime - buffer - SortedSet ts = pollQueue.tailSet(new PQEntry(d, aTime - MIN_MSEC_BETWEEN_POLLS)); - if (ts.isEmpty()) { + private long findNextExpirationTime(LegacyDevice device, long time) { + long expTime = time; + // tailSet finds all those that expire after time - buffer + SortedSet expired = pollQueue.tailSet(new PQEntry(device, time - MIN_MSEC_BETWEEN_POLLS)); + if (expired.isEmpty()) { // all entries in the poll queue are ahead of the new element, // go ahead and simply add it to the end - expTime = aTime; + expTime = time; } else { - Iterator pqi = ts.iterator(); + Iterator pqi = expired.iterator(); PQEntry prev = pqi.next(); - if (prev.getExpirationTime() > aTime + MIN_MSEC_BETWEEN_POLLS) { + if (prev.getExpirationTime() > time + MIN_MSEC_BETWEEN_POLLS) { // there is a time slot free before the head of the tail set - expTime = aTime; + expTime = time; } else { // look for a gap where we can squeeze in // a new poll while maintaining MIN_MSEC_BETWEEN_POLLS while (pqi.hasNext()) { PQEntry pqe = pqi.next(); - long tcurr = pqe.getExpirationTime(); - long tprev = prev.getExpirationTime(); - if (tcurr - tprev >= 2 * MIN_MSEC_BETWEEN_POLLS) { + long currTime = pqe.getExpirationTime(); + long prevTime = prev.getExpirationTime(); + if (currTime - prevTime >= 2 * MIN_MSEC_BETWEEN_POLLS) { // found gap - logger.trace("dev {} time {} found slot between {} and {}", d, aTime, tprev, tcurr); + logger.trace("device {} time {} found slot between {} and {}", device, time, prevTime, + currTime); break; } prev = pqe; @@ -234,11 +234,11 @@ private void readPollQueue() throws InterruptedException { // something is in the queue long now = System.currentTimeMillis(); PQEntry pqe = pollQueue.first(); - long tfirst = pqe.getExpirationTime(); - long dt = tfirst - now; - if (dt > 0) { // must wait for this item to expire - logger.trace("waiting for {} msec until {} comes due", dt, pqe); - pollQueue.wait(dt); + long expTime = pqe.getExpirationTime(); + long delta = expTime - now; + if (delta > 0) { // must wait for this item to expire + logger.trace("waiting for {} msec until {} comes due", delta, pqe); + pollQueue.wait(delta); } else { // queue entry has expired, process it! logger.trace("entry {} expired at time {}", pqe, now); processQueue(now); @@ -271,30 +271,30 @@ private void processQueue(long now, @Nullable PQEntry pqe) { * */ private static class PQEntry implements Comparable { - private InsteonDevice dev; + private LegacyDevice device; private long expirationTime; - PQEntry(InsteonDevice dev, long time) { - this.dev = dev; - this.expirationTime = time; + PQEntry(LegacyDevice device, long expirationTime) { + this.device = device; + this.expirationTime = expirationTime; } long getExpirationTime() { return expirationTime; } - InsteonDevice getDevice() { - return dev; + LegacyDevice getDevice() { + return device; } @Override - public int compareTo(PQEntry b) { - return (int) (expirationTime - b.expirationTime); + public int compareTo(PQEntry pqe) { + return (int) (expirationTime - pqe.expirationTime); } @Override public String toString() { - return dev.getAddress().toString() + "/" + String.format("%tc", new Date(expirationTime)); + return device.getAddress().toString() + "/" + String.format("%tc", new Date(expirationTime)); } } @@ -303,8 +303,8 @@ public String toString() { * * @return the poller instance */ - public static synchronized Poller instance() { + public static synchronized LegacyPollManager instance() { poller.start(); - return (poller); + return poller; } } diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RequestQueueManager.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyRequestManager.java similarity index 69% rename from bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RequestQueueManager.java rename to bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyRequestManager.java index eb9b62153fd21..34aeceb823c36 100644 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RequestQueueManager.java +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyRequestManager.java @@ -38,15 +38,15 @@ * @author Rob Nielsen - Port to openHAB 2 insteon binding */ @NonNullByDefault -public class RequestQueueManager { - private static @Nullable RequestQueueManager instance = null; - private final Logger logger = LoggerFactory.getLogger(RequestQueueManager.class); +public class LegacyRequestManager { + private static @Nullable LegacyRequestManager instance = null; + private final Logger logger = LoggerFactory.getLogger(LegacyRequestManager.class); private @Nullable Thread queueThread = null; private Queue requestQueues = new PriorityQueue<>(); - private Map requestQueueHash = new HashMap<>(); + private Map requestQueueHash = new HashMap<>(); private boolean keepRunning = true; - private RequestQueueManager() { + private LegacyRequestManager() { queueThread = new Thread(new RequestQueueReader()); setParamsAndStart(queueThread); } @@ -62,32 +62,32 @@ private void setParamsAndStart(@Nullable Thread thread) { /** * Add device to global request queue. * - * @param dev the device to add + * @param device the device to add * @param time the time when the queue should be processed */ - public void addQueue(InsteonDevice dev, long time) { + public void addQueue(LegacyDevice device, long time) { synchronized (requestQueues) { - RequestQueue q = requestQueueHash.get(dev); - if (q == null) { - logger.trace("scheduling request for device {} in {} msec", dev.getAddress(), + RequestQueue queue = requestQueueHash.get(device); + if (queue == null) { + logger.trace("scheduling request for device {} in {} msec", device.getAddress(), time - System.currentTimeMillis()); - q = new RequestQueue(dev, time); + queue = new RequestQueue(device, time); } else { - logger.trace("queue for dev {} is already scheduled in {} msec", dev.getAddress(), - q.getExpirationTime() - System.currentTimeMillis()); - if (!requestQueues.remove(q)) { - logger.warn("queue for {} should be there, report as bug!", dev); + logger.trace("queue for device {} is already scheduled in {} msec", device.getAddress(), + queue.getExpirationTime() - System.currentTimeMillis()); + if (!requestQueues.remove(queue)) { + logger.warn("queue for {} should be there, report as bug!", device); } - requestQueueHash.remove(dev); + requestQueueHash.remove(device); } - long expTime = q.getExpirationTime(); + long expTime = queue.getExpirationTime(); if (expTime > time) { - q.setExpirationTime(time); + queue.setExpirationTime(time); } // add the queue back in after (maybe) having modified // the expiration time - requestQueues.add(q); - requestQueueHash.put(dev, q); + requestQueues.add(queue); + requestQueueHash.put(device, queue); requestQueues.notify(); } } @@ -121,39 +121,33 @@ public void run() { synchronized (requestQueues) { while (keepRunning) { try { - RequestQueue q; - while (keepRunning && (q = requestQueues.peek()) != null) { + RequestQueue queue; + while (keepRunning && (queue = requestQueues.peek()) != null) { long now = System.currentTimeMillis(); - long expTime = q.getExpirationTime(); - InsteonDevice dev = q.getDevice(); + long expTime = queue.getExpirationTime(); + LegacyDevice device = queue.getDevice(); if (expTime > now) { - // // The head of the queue is not up for processing yet, wait(). - // - logger.trace("request queue head: {} must wait for {} msec", dev.getAddress(), + logger.trace("request queue head: {} must wait for {} msec", device.getAddress(), expTime - now); requestQueues.wait(expTime - now); - // // note that the wait() can also return because of changes to // the queue, not just because the time expired! - // continue; } - // // The head of the queue has expired and can be processed! - // - q = requestQueues.poll(); // remove front element - requestQueueHash.remove(dev); // and remove from hash map - long nextExp = dev.processRequestQueue(now); + queue = requestQueues.poll(); // remove front element + requestQueueHash.remove(device); // and remove from hash map + long nextExp = device.processRequestQueue(now); if (nextExp > 0) { - q = new RequestQueue(dev, nextExp); - requestQueues.add(q); - requestQueueHash.put(dev, q); - logger.trace("device queue for {} rescheduled in {} msec", dev.getAddress(), + queue = new RequestQueue(device, nextExp); + requestQueues.add(queue); + requestQueueHash.put(device, queue); + logger.trace("device queue for {} rescheduled in {} msec", device.getAddress(), nextExp - now); } else { // remove from hash since queue is no longer scheduled - logger.debug("device queue for {} is empty!", dev.getAddress()); + logger.debug("device queue for {} is empty!", device.getAddress()); } } logger.trace("waiting for request queues to fill"); @@ -169,15 +163,15 @@ public void run() { } public static class RequestQueue implements Comparable { - private InsteonDevice device; + private LegacyDevice device; private long expirationTime; - RequestQueue(InsteonDevice dev, long expirationTime) { - this.device = dev; + RequestQueue(LegacyDevice device, long expirationTime) { + this.device = device; this.expirationTime = expirationTime; } - public InsteonDevice getDevice() { + public LegacyDevice getDevice() { return device; } @@ -190,23 +184,23 @@ public void setExpirationTime(long t) { } @Override - public int compareTo(RequestQueue a) { - return (int) (expirationTime - a.expirationTime); + public int compareTo(RequestQueue queue) { + return (int) (expirationTime - queue.expirationTime); } } - public static synchronized @Nullable RequestQueueManager instance() { + public static synchronized @Nullable LegacyRequestManager instance() { if (instance == null) { - instance = new RequestQueueManager(); + instance = new LegacyRequestManager(); } return instance; } public static synchronized void destroyInstance() { - RequestQueueManager instance = RequestQueueManager.instance; + LegacyRequestManager instance = LegacyRequestManager.instance; if (instance != null) { instance.stopThread(); - RequestQueueManager.instance = null; + LegacyRequestManager.instance = null; } } } diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LinkManager.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LinkManager.java new file mode 100644 index 0000000000000..78a9aa0004f55 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LinkManager.java @@ -0,0 +1,351 @@ +/** + * 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.LinkedList; +import java.util.Queue; +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.database.LinkMode; +import org.openhab.binding.insteon.internal.device.database.ModemDBEntry; +import org.openhab.binding.insteon.internal.transport.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 LinkManager} manages linking/unlinking a device to/from modem + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public class LinkManager implements PortListener { + private static final int LINKING_TIMEOUT = 30000; // in milliseconds + private static final int DEFAULT_CONTROLLER_GROUP = 0; + private static final int DEFAULT_RESPONDER_GROUP = 1; + + private final Logger logger = LoggerFactory.getLogger(LinkManager.class); + + private InsteonModem modem; + private ScheduledExecutorService scheduler; + private @Nullable ScheduledFuture job; + private @Nullable InsteonAddress address; + private Queue requests = new LinkedList<>(); + private boolean buttonPressed = false; + private boolean complete = false; + private boolean done = true; + private int group = -1; + + public LinkManager(InsteonModem modem, ScheduledExecutorService scheduler) { + this.modem = modem; + this.scheduler = scheduler; + } + + public boolean isRunning() { + return job != null; + } + + private void setAddress(@Nullable InsteonAddress address) { + this.address = address; + } + + private void setGroup(int group) { + this.group = group; + } + + private @Nullable LinkingRequest getNextLinkingRequest() { + synchronized (requests) { + return requests.poll(); + } + } + + private void addLinkingRequest(LinkMode mode, int group) { + synchronized (requests) { + LinkingRequest request = new LinkingRequest(mode, group); + if (!requests.contains(request)) { + requests.add(request); + } + } + } + + private void removeLinkingRequests(LinkMode mode) { + synchronized (requests) { + requests.removeIf(request -> request.getLinkMode() == mode); + } + } + + public void link(@Nullable InsteonAddress address) { + addLinkingRequest(LinkMode.RESPONDER, DEFAULT_RESPONDER_GROUP); + addLinkingRequest(LinkMode.CONTROLLER, DEFAULT_CONTROLLER_GROUP); + start(address); + } + + public void unlink(InsteonAddress address, boolean force) { + ModemDBEntry dbe = modem.getDB().getEntry(address); + if (dbe == null) { + logger.debug("device {} not in modem database", address); + return; + } + + if (force) { + dbe.getRecords().forEach(record -> modem.getDB().markRecordForDelete(record)); + modem.getDB().update(); + } else { + dbe.getRecords().forEach(record -> addLinkingRequest(LinkMode.DELETE, record.getGroup())); + start(address); + } + } + + private void start(@Nullable InsteonAddress address) { + long startTime = System.currentTimeMillis(); + + logger.debug("starting device linker for {}", address); + + modem.getPort().registerListener(this); + modem.getRequestManager().pause(); + + setAddress(address); + setGroup(-1); + buttonPressed = false; + complete = false; + done = false; + + cancelModemLinking(); + if (address != null) { + cancelLinkingMode(address); + } + handleNextLinkingRequest(); + + job = scheduler.scheduleWithFixedDelay(() -> { + if (System.currentTimeMillis() - startTime > LINKING_TIMEOUT) { + logger.debug("device linker timeout for {}, aborting", address); + done(); + } + }, 0, 1, TimeUnit.SECONDS); + } + + public void stop() { + logger.debug("device linker finished for {}", address); + + modem.getRequestManager().resume(); + modem.getPort().unregisterListener(this); + + if (!complete) { + cancelModemLinking(); + InsteonAddress address = this.address; + if (address != null) { + cancelLinkingMode(address); + } + } + + ScheduledFuture job = this.job; + if (job != null) { + job.cancel(true); + this.job = null; + } + } + + private void done() { + done = true; + stop(); + } + + private void startModemLinking(int linkCode, int group) { + try { + Msg msg = Msg.makeMessage("StartALLLinking"); + msg.setByte("LinkCode", (byte) linkCode); + msg.setByte("ALLLinkGroup", (byte) group); + modem.writeMessage(msg); + } catch (FieldException e) { + logger.warn("cannot access field:", e); + } catch (IOException e) { + logger.warn("error sending start modem linking query ", e); + } catch (InvalidMessageTypeException e) { + logger.warn("invalid message ", e); + } + } + + private void cancelModemLinking() { + try { + Msg msg = Msg.makeMessage("CancelALLLinking"); + modem.writeMessage(msg); + } catch (IOException e) { + logger.warn("error sending cancel modem linking query ", e); + } catch (InvalidMessageTypeException e) { + logger.warn("invalid message ", e); + } + } + + private void startLinkingMode(InsteonAddress address, int group) { + try { + Msg msg = Msg.makeExtendedMessage(address, (byte) 0x09, (byte) group, true); + modem.writeMessage(msg); + } catch (FieldException e) { + logger.warn("cannot access field:", e); + } catch (IOException e) { + logger.warn("error sending linking mode query ", e); + } catch (InvalidMessageTypeException e) { + logger.warn("invalid message ", e); + } + } + + private void cancelLinkingMode(InsteonAddress address) { + try { + Msg msg = Msg.makeStandardMessage(address, (byte) 0x08, (byte) 0x00); + modem.writeMessage(msg); + } catch (FieldException e) { + logger.warn("cannot access field:", e); + } catch (IOException e) { + logger.warn("error sending cancel linking 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) { + try { + if (msg.isPureNack()) { + return; + } + if (msg.getCommand() == 0x50 && msg.isBroadcast() + && (msg.getByte("command1") == 0x01 || msg.getByte("command1") == 0x02)) { + // we got a set button pressed message + handleButtonPressed(msg); + } else if (msg.getCommand() == 0x53) { + // we got a linking completed message + handleNextLinkingRequest(); + } else if (msg.getCommand() == 0x5C + && (msg.getByte("command1") == 0x08 || msg.getByte("command1") == 0x09)) { + // we got a linking mode failure report message + handleLinkingModeFailure(msg); + } else if (msg.getCommand() == 0x64) { + // we got a start linking response + handleLinkingStarted(); + } + } catch (FieldException e) { + logger.warn("error parsing link db info reply field ", e); + } + } + + @Override + public void messageSent(Msg msg) { + // ignore outbound message + } + + private void handleButtonPressed(Msg msg) throws FieldException { + InsteonAddress address = this.address; + if (address == null) { + setAddress(msg.getInsteonAddress("fromAddress")); + } else if (!msg.isFromAddress(address)) { + return; + } + if (!buttonPressed && msg.getByte("command1") == 0x02) { + buttonPressed = true; + // remove modem controller linking requests if controller only device + // cmd1 => 0x01: controller + responder; 0x02: controller only (e.g. sensors) + removeLinkingRequests(LinkMode.CONTROLLER); + } + } + + private void handleLinkingModeFailure(Msg msg) throws FieldException { + if (msg.isFromAddress(address)) { + logger.debug("device {} not responding, aborting", address); + setAddress(null); + done(); + } + } + + private void handleLinkingStarted() { + InsteonAddress address = this.address; + if (address != null && group != -1) { + startLinkingMode(address, group); + setGroup(-1); + } + } + + private void handleNextLinkingRequest() { + LinkingRequest request = getNextLinkingRequest(); + if (request == null) { + complete = true; + done(); + } else { + startModemLinking(request.getLinkCode(), request.getGroup()); + setGroup(request.getGroup()); + } + } + + /** + * Linking request class + */ + private static class LinkingRequest { + private LinkMode mode; + private int group; + + public LinkingRequest(LinkMode mode, int group) { + this.mode = mode; + this.group = group; + } + + public LinkMode getLinkMode() { + return mode; + } + + public int getLinkCode() { + return mode.getLinkCode(); + } + + public int getGroup() { + return group; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == this) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + LinkingRequest other = (LinkingRequest) obj; + return mode == other.mode && group == other.group; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + mode.hashCode(); + result = prime * result + group; + return result; + } + } +} 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..351d694ce107c --- /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.FanLincFanSpeed; +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 FanLincFanSpeed.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(FanLincFanSpeed.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 FanLincFanSpeed.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/PollManager.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/PollManager.java new file mode 100644 index 0000000000000..961fea92d2d1c --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/PollManager.java @@ -0,0 +1,284 @@ +/** + * 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.sql.Date; +import java.util.Iterator; +import java.util.SortedSet; +import java.util.TreeSet; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class manages the polling of all devices. + * Between successive polls of any device there is a quiet time of + * at least MIN_MSEC_BETWEEN_POLLS. This avoids bunching up of poll messages + * and keeps the network bandwidth open for other messages. + * + * - An entry in the poll queue corresponds to a single device, i.e. each device should + * have exactly one entry in the poll queue. That entry is created when startPolling() + * is called, and then re-enqueued whenever it expires. + * - When a device comes up for polling, its doPoll() method is called, which in turn + * puts an entry into that devices request queue. So the Poller class actually never + * sends out messages directly. That is done by the device itself via its request + * queue. The poller just reminds the device to poll. + * + * @author Bernd Pfrommer - Initial contribution + * @author Rob Nielsen - Port to openHAB 2 insteon binding + * @author Jeremy Setton - Rewrite insteon binding + */ +@NonNullByDefault +public class PollManager { + private static final long MIN_MSEC_BETWEEN_POLLS = 2000L; + + private final Logger logger = LoggerFactory.getLogger(PollManager.class); + + private ScheduledExecutorService scheduler; + private @Nullable ScheduledFuture job; + private TreeSet pollQueue = new TreeSet<>(); + + /** + * Constructor + */ + public PollManager(ScheduledExecutorService scheduler) { + this.scheduler = scheduler; + } + + /** + * Returns if poller is running + * + * @return true if poll queue reader job is defined + */ + private boolean isRunning() { + return job != null; + } + + /** + * Get size of poll queue + * + * @return number of devices being polled + */ + public int getSizeOfQueue() { + return pollQueue.size(); + } + + /** + * Register a device for polling. + * + * @param device device to register for polling + * @param pollInterval device poll interval + * @param numDev approximate number of total devices + */ + public void startPolling(Device device, long pollInterval, int numDev) { + logger.debug("start polling device {}", device.getAddress()); + + synchronized (pollQueue) { + // try to spread out the scheduling when starting up + long pollDelay = pollQueue.size() * pollInterval / (numDev + 1); + addToPollQueue(device, pollInterval, System.currentTimeMillis() + pollDelay); + pollQueue.notify(); + } + } + + /** + * Stops polling a given device + * + * @param device reference to the device to be polled + */ + public void stopPolling(Device device) { + synchronized (pollQueue) { + for (Iterator it = pollQueue.iterator(); it.hasNext();) { + if (it.next().getDevice().getAddress().equals(device.getAddress())) { + it.remove(); + logger.debug("stopped polling device {}", device.getAddress()); + } + } + } + } + + /** + * Starts the poller thread + */ + public void start() { + if (isRunning()) { + logger.debug("poll manager already running, not started again"); + return; + } + job = scheduler.schedule(new PollQueueReader(), 0, TimeUnit.SECONDS); + } + + /** + * Stops the poller thread + */ + public void stop() { + ScheduledFuture job = this.job; + if (job != null) { + job.cancel(true); + this.job = null; + } + } + + /** + * Adds a device to the poll queue. After this call, the device's doPoll() method + * will be called according to the polling frequency set. + * + * @param device the device to poll periodically + * @param pollInterval the device poll interval + * @param time the target time for the next poll to happen. Note that this time is merely + * a suggestion, and may be adjusted, because there must be at least a minimum gap in polling. + */ + + private void addToPollQueue(Device device, long pollInterval, long time) { + long expTime = findNextExpirationTime(device, pollInterval, time); + PQEntry queue = new PQEntry(device, pollInterval, expTime); + logger.trace("added entry {}", queue); + pollQueue.add(queue); + } + + /** + * Finds the best expiration time for a poll queue, i.e. a time slot that is after the + * desired expiration time, but does not collide with any of the already scheduled + * polls. + * + * @param device device to poll (for logging) + * @param pollInterval device poll interval + * @param time desired time after which the device should be polled + * @return the suggested time to poll + */ + + private long findNextExpirationTime(Device device, long pollInterval, long time) { + long expTime; + // tailSet finds all those that expire after time - buffer + PQEntry queue = new PQEntry(device, pollInterval, time - MIN_MSEC_BETWEEN_POLLS); + SortedSet tailSet = pollQueue.tailSet(queue); + if (tailSet.isEmpty()) { + // all entries in the poll queue are ahead of the new element, + // go ahead and simply add it to the end + expTime = time; + } else { + Iterator it = tailSet.iterator(); + PQEntry prevQueue = it.next(); + if (prevQueue.getExpirationTime() > time + MIN_MSEC_BETWEEN_POLLS) { + // there is a time slot free before the head of the tail set + expTime = time; + } else { + // look for a gap where we can squeeze in + // a new poll while maintaining MIN_MSEC_BETWEEN_POLLS + while (it.hasNext()) { + PQEntry currQueue = it.next(); + long currTime = currQueue.getExpirationTime(); + long prevTime = prevQueue.getExpirationTime(); + if (currTime - prevTime >= 2 * MIN_MSEC_BETWEEN_POLLS) { + // found gap + logger.trace("device {} time {} found slot between {} and {}", device.getAddress(), time, + prevTime, currTime); + break; + } + prevQueue = currQueue; + } + expTime = prevQueue.getExpirationTime() + MIN_MSEC_BETWEEN_POLLS; + } + } + return expTime; + } + + private class PollQueueReader implements Runnable { + @Override + public void run() { + logger.debug("starting poll queue thread"); + try { + while (!Thread.interrupted()) { + synchronized (pollQueue) { + if (pollQueue.isEmpty()) { + logger.trace("waiting for poll queue to fill"); + pollQueue.wait(); + continue; + } + // something is in the queue + long now = System.currentTimeMillis(); + PQEntry queue = pollQueue.first(); + long delay = queue.getExpirationTime() - now; + if (delay > 0) { // must wait for this item to expire + logger.trace("waiting for {} msec until {} comes due", delay, queue); + pollQueue.wait(delay); + } else { // queue entry has expired, process it! + logger.trace("poll queue {} has expired", queue); + processQueueEntry(now); + } + } + } + } catch (InterruptedException e) { + logger.debug("poll queue thread interrupted!"); + } + logger.debug("exiting poll queue thread!"); + } + + /** + * Takes first element off the poll queue, polls the corresponding device, + * and puts the device back into the poll queue to be polled again later. + * + * @param now the current time + */ + private void processQueueEntry(long now) { + PQEntry queue = pollQueue.pollFirst(); + if (queue != null) { + queue.getDevice().doPoll(0L); + addToPollQueue(queue.getDevice(), queue.getPollInterval(), now + queue.getPollInterval()); + } + } + } + + /** + * A poll queue entry corresponds to a single device that needs + * to be polled. + */ + private static class PQEntry implements Comparable { + private Device device; + private long pollInterval; + private long expirationTime; + + PQEntry(Device device, long pollInterval, long expirationTime) { + this.device = device; + this.pollInterval = pollInterval; + this.expirationTime = expirationTime; + } + + long getExpirationTime() { + return expirationTime; + } + + long getPollInterval() { + return pollInterval; + } + + Device getDevice() { + return device; + } + + @Override + public int compareTo(PQEntry other) { + return (int) (expirationTime - other.expirationTime); + } + + @Override + public String toString() { + return String.format("%s/%tc", device.getAddress(), new Date(expirationTime)); + } + } +} 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..6c2a7e1837073 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ProductDataRegistry.java @@ -0,0 +1,200 @@ +/** + * 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 org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.insteon.internal.InsteonResourceLoader; +import org.openhab.binding.insteon.internal.utils.HexUtils; +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 InsteonResourceLoader { + private static final ProductDataRegistry PRODUCT_DATA_REGISTRY = new ProductDataRegistry(); + private static final String RESOURCE_NAME = "/device-products.xml"; + + private Map products = new HashMap<>(); + + private ProductDataRegistry() { + super(RESOURCE_NAME); + } + + /** + * 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(); + + logger.debug("loaded {} products", products.size()); + if (logger.isTraceEnabled()) { + products.values().stream().map(String::valueOf).forEach(logger::trace); + } + } + + /** + * Parses product data document + * + * @param element element to parse + * @throws SAXException + */ + @Override + protected void parseDocument(Element element) throws SAXException { + 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/RequestManager.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RequestManager.java new file mode 100644 index 0000000000000..4241b3c9cb6a8 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RequestManager.java @@ -0,0 +1,242 @@ +/** + * 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.PriorityQueue; +import java.util.Queue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Class that manages all the per-device request queues using a single thread. + * + * - Each device has its own request queue, and the RequestQueueManager keeps a + * queue of queues. + * - Each entry in requestQueues corresponds to a single device's request queue. + * A device should never be more than once in requestQueues. + * - A hash map (requestQueueHash) is kept in sync with requestQueues for + * faster lookup in case a request queue is modified and needs to be + * rescheduled. + * + * @author Bernd Pfrommer - Initial contribution + * @author Rob Nielsen - Port to openHAB 2 insteon binding + * @author Jeremy Setton - Rewrite insteon binding + */ +@NonNullByDefault +public class RequestManager { + private final Logger logger = LoggerFactory.getLogger(RequestManager.class); + + private ScheduledExecutorService scheduler; + private @Nullable ScheduledFuture job; + private Queue requestQueues = new PriorityQueue<>(); + private Map requestQueueHash = new HashMap<>(); + private AtomicBoolean paused = new AtomicBoolean(false); + + /** + * Constructor + */ + public RequestManager(ScheduledExecutorService scheduler) { + this.scheduler = scheduler; + } + + /** + * Returns if request manager is running + * + * @return true if request queue reader job is defined + */ + private boolean isRunning() { + return job != null; + } + + /** + * Adds device to global request queue. + * + * @param device the device to add + * @param time (in milliseconds) to delay queue processing + */ + public void addQueue(Device device, long delay) { + synchronized (requestQueues) { + long now = System.currentTimeMillis(); + long time = now + delay; + RequestQueue queue = requestQueueHash.get(device); + if (queue == null) { + logger.trace("scheduling request for device {} in {} msec", device.getAddress(), delay); + queue = new RequestQueue(device, time); + requestQueues.add(queue); + requestQueueHash.put(device, queue); + requestQueues.notify(); + } else if (queue.getExpirationTime() > time) { + logger.trace("rescheduling request for device {} from {} to {} msec", device.getAddress(), + queue.getExpirationTime() - now, delay); + queue.setExpirationTime(time); + } + } + } + + /** + * Pauses request manager thread + */ + public void pause() { + if (isRunning() && !paused.getAndSet(true)) { + logger.debug("pausing request queue thread"); + + synchronized (requestQueues) { + requestQueues.notify(); + } + } + } + + /** + * Resumes request queue thread + */ + public void resume() { + if (isRunning() && paused.getAndSet(false)) { + logger.debug("resuming request queue thread"); + + synchronized (paused) { + paused.notify(); + } + } + } + + /** + * Starts request queue thread + */ + public void start() { + if (isRunning()) { + logger.debug("request manager already running, not started again"); + return; + } + job = scheduler.schedule(new RequestQueueReader(), 0, TimeUnit.SECONDS); + } + + /** + * Stops request queue thread + */ + public void stop() { + ScheduledFuture job = this.job; + if (job != null) { + job.cancel(true); + this.job = null; + } + } + + /** + * Request queue reader class + */ + private class RequestQueueReader implements Runnable { + @Override + public void run() { + logger.debug("starting request queue thread"); + try { + while (!Thread.interrupted()) { + synchronized (paused) { + if (paused.get()) { + logger.trace("waiting for request queue thread to resume"); + paused.wait(); + continue; + } + } + synchronized (requestQueues) { + if (requestQueues.isEmpty()) { + logger.trace("waiting for request queues to fill"); + requestQueues.wait(); + continue; + } + RequestQueue queue = requestQueues.peek(); + if (queue != null) { + long now = System.currentTimeMillis(); + long expTime = queue.getExpirationTime(); + long delay = expTime - now; + Device device = queue.getDevice(); + if (delay > 0) { + // The head of the queue is not up for processing yet, wait(). + logger.trace("request queue head: {} must wait for {} msec", device.getAddress(), + delay); + requestQueues.wait(delay); + } else { + // The head of the queue has expired and can be processed! + processRequestQueue(now); + } + } + } + } + } catch (InterruptedException e) { + logger.debug("request queue thread interrupted!"); + } + logger.debug("exiting request queue thread!"); + } + + /** + * Processes the head of the queue + * + * @param now the current time + */ + private void processRequestQueue(long now) { + RequestQueue queue = requestQueues.poll(); // remove front element + if (queue != null) { + Device device = queue.getDevice(); + requestQueueHash.remove(device); // and remove from hash map + long nextExp = device.handleNextRequest(); + if (nextExp > 0) { + queue = new RequestQueue(device, nextExp); + requestQueues.add(queue); + requestQueueHash.put(device, queue); + logger.trace("device queue for {} rescheduled in {} msec", device.getAddress(), nextExp - now); + } else { + // remove from hash since queue is no longer scheduled + logger.trace("device queue for {} is empty!", device.getAddress()); + } + } + } + } + + /** + * Class that represents a request queue + */ + private static class RequestQueue implements Comparable { + private Device device; + private long expirationTime; + + RequestQueue(Device device, long expirationTime) { + this.device = device; + this.expirationTime = expirationTime; + } + + public Device getDevice() { + return device; + } + + public long getExpirationTime() { + return expirationTime; + } + + public void setExpirationTime(long expirationTime) { + this.expirationTime = expirationTime; + } + + @Override + public int compareTo(RequestQueue other) { + return (int) (expirationTime - other.expirationTime); + } + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/Scene.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/Scene.java new file mode 100644 index 0000000000000..36b7f3aa377ed --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/Scene.java @@ -0,0 +1,35 @@ +/** + * 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; + +/** + * Interface for classes that represent a scene + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public interface Scene { + /** + * Returns the group for this scene + * + * @return the scene group + */ + public int getGroup(); + + /** + * Refreshes this scene + */ + public void refresh(); +} 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..b2f4663086976 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Address.java @@ -0,0 +1,206 @@ +/** + * 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 { + String[] parts = address.replace(".", "").split(""); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid X10 address format"); + } + this.houseCode = (byte) houseStringToCode(parts[0]); + this.unitCode = (byte) unitStringToCode(parts[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/database/DatabaseCache.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseCache.java new file mode 100644 index 0000000000000..55aef78bd4acb --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseCache.java @@ -0,0 +1,142 @@ +/** + * 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.database; + +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.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/device/database/DatabaseChange.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseChange.java new file mode 100644 index 0000000000000..525b5bc39435b --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.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/device/database/DatabaseManager.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseManager.java new file mode 100644 index 0000000000000..f5110f8e4897d --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseManager.java @@ -0,0 +1,197 @@ +/** + * 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.database; + +import java.util.LinkedList; +import java.util.Queue; +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.Device; +import org.openhab.binding.insteon.internal.device.InsteonDevice; +import org.openhab.binding.insteon.internal.device.InsteonModem; + +/** + * The {@link DatabaseManager} manages database read/write operations + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public class DatabaseManager { + public static final int MESSAGE_TIMEOUT = 6000; // in milliseconds + + private static enum OperationType { + READ, + WRITE + } + + private InsteonModem modem; + private LinkDBReader ldbr; + private LinkDBWriter ldbw; + private ModemDBReader mdbr; + private ModemDBWriter mdbw; + private ScheduledExecutorService scheduler; + private @Nullable ScheduledFuture job; + private Queue operationQueue = new LinkedList<>(); + private boolean terminated = false; + + public DatabaseManager(InsteonModem modem, ScheduledExecutorService scheduler) { + this.modem = modem; + this.scheduler = scheduler; + this.ldbr = new LinkDBReader(modem, scheduler); + this.ldbw = new LinkDBWriter(modem, scheduler); + this.mdbr = new ModemDBReader(modem, scheduler); + this.mdbw = new ModemDBWriter(modem, scheduler); + } + + public void read(Device device, long delay) { + addOperation(device, OperationType.READ, delay); + } + + public void write(Device device, long delay) { + addOperation(device, OperationType.WRITE, delay); + } + + public void stop() { + terminated = true; + + ScheduledFuture job = this.job; + if (job != null) { + job.cancel(true); + this.job = null; + } + + if (ldbr.isRunning()) { + ldbr.stop(); + } + + if (ldbw.isRunning()) { + ldbw.stop(); + } + + if (mdbr.isRunning()) { + mdbr.stop(); + } + + if (mdbw.isRunning()) { + mdbw.stop(); + } + } + + /** + * Adds a database operation + * + * @param device database device + * @param type operation type + * @param delay scheduling delay (in milliseconds) + */ + private synchronized void addOperation(Device device, OperationType type, long delay) { + DatabaseOperation operation = new DatabaseOperation(device, type); + if (!operationQueue.contains(operation)) { + operationQueue.add(operation); + } + + if (job == null && !terminated) { + job = scheduler.schedule(() -> { + modem.getRequestManager().pause(); + + handleNextOperation(); + }, delay, TimeUnit.MILLISECONDS); + } + } + + /** + * Handles the next database operation + */ + private synchronized void handleNextOperation() { + DatabaseOperation operation = operationQueue.poll(); + if (operation == null || terminated) { + modem.getRequestManager().resume(); + job = null; + return; + } + + Device device = operation.getDevice(); + switch (operation.getType()) { + case READ: + if (device instanceof InsteonModem) { + mdbr.read(); + } else if (device instanceof InsteonDevice insteonDevice) { + ldbr.read(insteonDevice); + } + break; + case WRITE: + if (device instanceof InsteonModem) { + mdbw.write(); + } else if (device instanceof InsteonDevice insteonDevice) { + ldbw.write(insteonDevice); + } + break; + } + } + + /** + * Notifies that the last database operation has completed + */ + public void operationCompleted() { + handleNextOperation(); + } + + /** + * Class that reflects a database operation + */ + private static class DatabaseOperation { + private Device device; + private OperationType type; + + public DatabaseOperation(Device device, OperationType type) { + this.device = device; + this.type = type; + } + + public Device getDevice() { + return device; + } + + public OperationType getType() { + return type; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == this) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + DatabaseOperation other = (DatabaseOperation) obj; + return device.equals(other.device) && type == other.type; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + device.hashCode(); + result = prime * result + type.hashCode(); + return result; + } + } +} diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseRecord.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseRecord.java new file mode 100644 index 0000000000000..4d779fac4d358 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.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/device/ModemDBBuilder.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBBuilder.java similarity index 70% rename from bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ModemDBBuilder.java rename to bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBBuilder.java index 35b693fa0d079..bd7c7d57ea068 100644 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ModemDBBuilder.java +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBBuilder.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.insteon.internal.device; +package org.openhab.binding.insteon.internal.device.database; import java.io.IOException; import java.util.List; @@ -22,13 +22,13 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.insteon.internal.driver.ModemDBEntry; -import org.openhab.binding.insteon.internal.driver.Port; -import org.openhab.binding.insteon.internal.message.FieldException; -import org.openhab.binding.insteon.internal.message.InvalidMessageTypeException; -import org.openhab.binding.insteon.internal.message.Msg; -import org.openhab.binding.insteon.internal.message.MsgListener; -import org.openhab.binding.insteon.internal.utils.Utils; +import org.openhab.binding.insteon.internal.device.InsteonAddress; +import org.openhab.binding.insteon.internal.transport.LegacyPort; +import org.openhab.binding.insteon.internal.transport.LegacyPortListener; +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; @@ -37,21 +37,22 @@ * * @author Bernd Pfrommer - Initial contribution * @author Rob Nielsen - Port to openHAB 2 insteon binding + * @author Jeremy Setton - Rewrite insteon binding */ @NonNullByDefault -public class ModemDBBuilder implements MsgListener { +public class LegacyModemDBBuilder implements LegacyPortListener { private static final int MESSAGE_TIMEOUT = 30000; - private final Logger logger = LoggerFactory.getLogger(ModemDBBuilder.class); + private final Logger logger = LoggerFactory.getLogger(LegacyModemDBBuilder.class); private volatile boolean isComplete = false; - private Port port; + private LegacyPort port; private ScheduledExecutorService scheduler; private @Nullable ScheduledFuture job = null; private volatile long lastMessageTimestamp; private volatile int messageCount = 0; - public ModemDBBuilder(Port port, ScheduledExecutorService scheduler) { + public LegacyModemDBBuilder(LegacyPort port, ScheduledExecutorService scheduler) { this.port = port; this.scheduler = scheduler; } @@ -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) { @@ -155,15 +164,15 @@ private synchronized void done() { private void logModemDB() { try { logger.debug("MDB ------- start of modem link records ------------------"); - Map dbes = port.getDriver().lockModemDBEntries(); - for (Entry db : dbes.entrySet()) { - List lrs = db.getValue().getLinkRecords(); - for (Msg m : lrs) { - int recordFlags = m.getByte("RecordFlags") & 0xff; + Map dbes = port.getDriver().lockModemDBEntries(); + for (Entry db : dbes.entrySet()) { + List records = db.getValue().getLinkRecords(); + for (Msg msg : records) { + int recordFlags = msg.getByte("RecordFlags") & 0xff; String ms = ((recordFlags & (0x1 << 6)) != 0) ? "CTRL" : "RESP"; logger.debug("MDB {}: {} group: {} data1: {} data2: {} data3: {}", db.getKey(), ms, - toHex(m.getByte("ALLLinkGroup")), toHex(m.getByte("LinkData1")), - toHex(m.getByte("LinkData2")), toHex(m.getByte("LinkData2"))); + toHex(msg.getByte("ALLLinkGroup")), toHex(msg.getByte("LinkData1")), + toHex(msg.getByte("LinkData2")), toHex(msg.getByte("LinkData2"))); } logger.debug("MDB -----"); } @@ -176,23 +185,23 @@ private void logModemDB() { } public static String toHex(byte b) { - return Utils.getHexString(b); + return HexUtils.getHexString(b); } - public void updateModemDB(InsteonAddress linkAddr, Port port, @Nullable Msg m, boolean isModem) { + public void updateModemDB(InsteonAddress linkAddr, LegacyPort port, @Nullable Msg msg, boolean isModem) { try { - Map dbes = port.getDriver().lockModemDBEntries(); - ModemDBEntry dbe = dbes.get(linkAddr); + Map dbes = port.getDriver().lockModemDBEntries(); + LegacyModemDBEntry dbe = dbes.get(linkAddr); if (dbe == null) { - dbe = new ModemDBEntry(linkAddr, isModem); + dbe = new LegacyModemDBEntry(linkAddr, isModem); dbes.put(linkAddr, dbe); } dbe.setPort(port); - if (m != null) { - dbe.addLinkRecord(m); + if (msg != null) { + dbe.addLinkRecord(msg); try { - byte group = m.getByte("ALLLinkGroup"); - int recordFlags = m.getByte("RecordFlags") & 0xff; + byte group = msg.getByte("ALLLinkGroup"); + int recordFlags = msg.getByte("RecordFlags") & 0xff; if ((recordFlags & (0x1 << 6)) != 0) { dbe.addControls(group); } else { diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/ModemDBEntry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBEntry.java similarity index 78% rename from bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/ModemDBEntry.java rename to bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBEntry.java index 129662f107855..c43e12121c98e 100644 --- a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/ModemDBEntry.java +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBEntry.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.insteon.internal.driver; +package org.openhab.binding.insteon.internal.device.database; import java.util.ArrayList; import java.util.Collections; @@ -18,8 +18,9 @@ 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.message.Msg; -import org.openhab.binding.insteon.internal.utils.Utils; +import org.openhab.binding.insteon.internal.transport.LegacyPort; +import org.openhab.binding.insteon.internal.transport.message.Msg; +import org.openhab.binding.insteon.internal.utils.HexUtils; /** * The ModemDBEntry class holds a modem device type record @@ -29,16 +30,16 @@ * @author Rob Nielsen - Port to openHAB 2 insteon binding */ @NonNullByDefault -public class ModemDBEntry { +public class LegacyModemDBEntry { private @Nullable InsteonAddress address = null; private boolean isModem; - private @Nullable Port port = null; + private @Nullable LegacyPort port = null; private ArrayList linkRecords = new ArrayList<>(); private ArrayList controls = new ArrayList<>(); private ArrayList respondsTo = new ArrayList<>(); - public ModemDBEntry(InsteonAddress aAddr, boolean isModem) { - this.address = aAddr; + public LegacyModemDBEntry(InsteonAddress address, boolean isModem) { + this.address = address; this.isModem = isModem; } @@ -70,11 +71,11 @@ public ArrayList getRespondsTo() { return respondsTo; } - public void setPort(Port p) { + public void setPort(LegacyPort p) { port = p; } - public @Nullable Port getPort() { + public @Nullable LegacyPort getPort() { return port; } @@ -82,8 +83,8 @@ public void setPort(Port p) { public String toString() { String s = "addr:" + address + "|controls:[" + toGroupString(controls) + "]|responds_to:[" + toGroupString(respondsTo) + "]|link_recors"; - for (Msg m : linkRecords) { - s += ":(" + m + ")"; + for (Msg msg : linkRecords) { + s += ":(" + msg + ")"; } return s; } @@ -98,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/device/database/LinkDB.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDB.java new file mode 100644 index 0000000000000..014010d267b1e --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDB.java @@ -0,0 +1,589 @@ +/** + * 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.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.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) { + 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) { + logger.trace("setting link db reload to {} for {}", reload, device.getAddress()); + this.reload = reload; + } + + private synchronized void setUpdate(boolean update) { + logger.trace("setting link db update to {} for {}", update, device.getAddress()); + this.update = update; + } + + private synchronized void setStatus(DatabaseStatus status) { + 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: + return ReadWriteMode.PEEK_POKE; + case I2: + case I2CS: + return ReadWriteMode.STANDARD; + default: + return ReadWriteMode.UNKNOWN; + } + } + + /** + * Clears this link db + */ + public synchronized void clear() { + 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()) { + logger.debug("deferring load link db for {}, device is not awake or responding", device.getAddress()); + setReload(true); + } else if (dbm == null) { + 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()) { + logger.debug("no changes to update link db for {}", device.getAddress()); + setUpdate(false); + } else if (!device.isAwake() || !device.isResponding()) { + logger.debug("deferring update link db for {}, device is not awake or responding", device.getAddress()); + setUpdate(true); + } else if (dbm == null) { + 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) { + 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() { + 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) { + logger.trace("added change: {}", change); + } else { + 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()) { + 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()) { + 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()) { + logger.debug("got unexpected first record location for {}", device.getAddress()); + setStatus(DatabaseStatus.PARTIAL); + } else if (!records.lastEntry().getValue().isLast()) { + logger.debug("got unexpected last record type for {}", device.getAddress()); + setStatus(DatabaseStatus.PARTIAL); + } else if (records.size() != expected) { + logger.debug("got {} records for {} expected {}", records.size(), device.getAddress(), expected); + setStatus(DatabaseStatus.PARTIAL); + } else { + 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/device/database/LinkDBChange.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBChange.java new file mode 100644 index 0000000000000..536833b1f32f1 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.database; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.insteon.internal.device.InsteonAddress; + +/** + * The {@link LinkDBChange} 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/device/database/LinkDBReader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBReader.java new file mode 100644 index 0000000000000..fa3837dbc7e0c --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBReader.java @@ -0,0 +1,251 @@ +/** + * 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.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.transport.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 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 = true; + 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) { + logger.debug("starting link database reader for {}", device.getAddress()); + + this.device = device; + + getAllRecords(); + + job = scheduler.scheduleWithFixedDelay(() -> { + if (System.currentTimeMillis() - lastMsgReceived > DatabaseManager.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; + case UNKNOWN: + logger.debug("unsupported database read/write mode for {}, aborting", device.getAddress()); + done(); + } + } + + public void stop() { + 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; + } + + 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/device/database/LinkDBRecord.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBRecord.java new file mode 100644 index 0000000000000..04dc81ff7cd4c --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.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/device/database/LinkDBWriter.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBWriter.java new file mode 100644 index 0000000000000..acc8bb2ecbc03 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBWriter.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.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.transport.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 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 = true; + 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) { + logger.debug("starting link database writer for {}", device.getAddress()); + + this.device = device; + + applyChanges(); + + job = scheduler.scheduleWithFixedDelay(() -> { + if (System.currentTimeMillis() - lastMsgReceived > DatabaseManager.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; + case UNKNOWN: + logger.debug("unsupported database read/write mode for {}, aborting", device.getAddress()); + done(); + } + } + + public void stop() { + 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) { + 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) { + 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/device/database/LinkMode.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkMode.java new file mode 100644 index 0000000000000..12fef83f4d481 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.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/device/database/ManageRecordAction.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ManageRecordAction.java new file mode 100644 index 0000000000000..544723ad37516 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.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/device/database/ModemDB.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDB.java new file mode 100644 index 0000000000000..420388e79df92 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDB.java @@ -0,0 +1,614 @@ +/** + * 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.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.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()); + } + + 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()); + } + + 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) { + 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)) { + 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()); + } + + 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); + } + logger.trace("added change: {}", change); + } else { + ModemDBChange prevChange; + synchronized (changes) { + prevChange = changes.set(index, change); + } + 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) { + 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()) { + 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) { + 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); + + 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/device/database/ModemDBChange.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBChange.java new file mode 100644 index 0000000000000..91d350794fe00 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.database; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.insteon.internal.device.InsteonAddress; + +/** + * The {@link ModemDBChange} 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/device/database/ModemDBEntry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBEntry.java new file mode 100644 index 0000000000000..c140b4d3db50b --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.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/device/database/ModemDBReader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBReader.java new file mode 100644 index 0000000000000..e748c24cb604d --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBReader.java @@ -0,0 +1,313 @@ +/** + * 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.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.transport.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 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 = true; + 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() { + logger.debug("starting modem database reader"); + + getAllRecords(); + + job = scheduler.scheduleWithFixedDelay(() -> { + if (System.currentTimeMillis() - lastMsgReceived > DatabaseManager.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() { + 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"); + restart(); + } + } + + @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 (done) { + logger.debug("unsolicited link record, ignoring"); + 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/device/database/ModemDBRecord.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBRecord.java new file mode 100644 index 0000000000000..2d42c0a107d7e --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.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/device/database/ModemDBWriter.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBWriter.java new file mode 100644 index 0000000000000..53ff4b3a9d6c5 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBWriter.java @@ -0,0 +1,155 @@ +/** + * 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.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.transport.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 final Logger logger = LoggerFactory.getLogger(ModemDBWriter.class); + + private InsteonModem modem; + private ScheduledExecutorService scheduler; + private @Nullable ScheduledFuture job; + private boolean done = true; + private long lastMsgReceived; + + public ModemDBWriter(InsteonModem modem, ScheduledExecutorService scheduler) { + this.modem = modem; + this.scheduler = scheduler; + } + + public boolean isRunning() { + return job != null; + } + + public void write() { + logger.debug("starting modem database writer"); + + applyChanges(); + + job = scheduler.scheduleWithFixedDelay(() -> { + if (System.currentTimeMillis() - lastMsgReceived > DatabaseManager.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() { + 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/device/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/device/database/RecordFlags.java index c58b015a506e7..f63170ca54c73 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/device/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.device.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/device/database/RecordType.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/RecordType.java new file mode 100644 index 0000000000000..e02faabd4f291 --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/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.device.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/feature/BaseFeatureHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/BaseFeatureHandler.java new file mode 100644 index 0000000000000..3857f406c1d4a --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/BaseFeatureHandler.java @@ -0,0 +1,111 @@ +/** + * 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 java.util.Map; + +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.DeviceFeature; +import org.openhab.binding.insteon.internal.device.InsteonDevice; +import org.openhab.binding.insteon.internal.device.InsteonModem; +import org.openhab.binding.insteon.internal.device.X10Device; +import org.openhab.binding.insteon.internal.utils.ParameterParser; + +/** + * The {@link BaseFeatureHandler} represents a base feature handler + * + * @author Jeremy Setton - Initial contribution + */ +@NonNullByDefault +public abstract class BaseFeatureHandler { + + protected DeviceFeature feature; + protected Map parameters = Map.of(); + + public BaseFeatureHandler(DeviceFeature feature) { + this.feature = feature; + } + + protected Device getDevice() { + return feature.getDevice(); + } + + protected InsteonDevice getInsteonDevice() { + if (feature.getDevice() instanceof InsteonDevice insteonDevice) { + return insteonDevice; + } + throw new UnsupportedOperationException("Not Insteon device"); + } + + protected InsteonModem getInsteonModem() { + if (feature.getDevice() instanceof InsteonModem insteonModem) { + return insteonModem; + } + throw new UnsupportedOperationException("Not Insteon modem"); + } + + protected X10Device getX10Device() { + if (feature.getDevice() instanceof X10Device x10Device) { + return x10Device; + } + throw new UnsupportedOperationException("Not X10 device"); + } + + private @Nullable String getParameter(String key) { + return feature.hasParameter(key) ? feature.getParameter(key) : parameters.get(key); + } + + protected boolean getParameterAsBoolean(String key, boolean defaultValue) { + return ParameterParser.getParameterAsOrDefault(getParameter(key), Boolean.class, defaultValue); + } + + protected double getParameterAsDouble(String key, double defaultValue) { + return ParameterParser.getParameterAsOrDefault(getParameter(key), Double.class, defaultValue); + } + + protected int getParameterAsInteger(String key, int defaultValue) { + return ParameterParser.getParameterAsOrDefault(getParameter(key), Integer.class, defaultValue); + } + + protected long getParameterAsLong(String key, long defaultValue) { + return ParameterParser.getParameterAsOrDefault(getParameter(key), Long.class, defaultValue); + } + + protected String getParameterAsString(String key, String defaultValue) { + return ParameterParser.getParameterAsOrDefault(getParameter(key), String.class, defaultValue); + } + + protected void setParameters(Map parameters) { + this.parameters = parameters; + } + + /** + * Returns shorthand class name for logging purposes + * + * @return name of the class + */ + protected String nm() { + return this.getClass().getSimpleName(); + } + + @Override + public String toString() { + String s = nm(); + if (!parameters.isEmpty()) { + s += parameters; + } + return s; + } +} 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..a67101a79db6b --- /dev/null +++ b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/CommandHandler.java @@ -0,0 +1,2307 @@ +/** + * 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.FanLincFanSpeed; +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.SirenAlertType; +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.ThermostatTemperatureScale; +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 BaseFeatureHandler { + 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 feature 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); + + /** + * 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.updateBit(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