]> git.basschouten.com Git - openhab-addons.git/commitdiff
[insteon] Rewrite with backward compatibility (#17146)
authorJeremy <jsetton@users.noreply.github.com>
Thu, 10 Oct 2024 04:49:49 +0000 (00:49 -0400)
committerGitHub <noreply@github.com>
Thu, 10 Oct 2024 04:49:49 +0000 (06:49 +0200)
* [insteon] Restructure legacy code for rewrite

Signed-off-by: jsetton <jeremy.setton@gmail.com>
193 files changed:
CODEOWNERS
bundles/org.openhab.binding.insteon/README.md
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonBinding.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonBindingConstants.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonHandlerFactory.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBinding.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonResourceLoader.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonStateDescriptionProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/ChannelCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/DebugCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/DeviceCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonCommandExtension.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/InsteonLegacyCommandExtension.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/ModemCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/command/SceneCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonBridgeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonChannelConfiguration.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonDeviceConfiguration.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonHub1Configuration.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonHub2Configuration.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyChannelConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyDeviceConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonLegacyNetworkConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonNetworkConfiguration.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonPLMConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/InsteonSceneConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/config/X10DeviceConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/BaseDevice.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/CommandHandler.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DefaultLink.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/Device.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceAddress.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceCache.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceFeature.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceFeatureListener.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceType.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceTypeLoader.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceTypeRegistry.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/FeatureTemplate.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/FeatureTemplateLoader.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/GroupMessageStateMachine.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/HandlerEntry.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonAddress.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonDevice.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonEngine.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonModem.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/InsteonScene.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDevice.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceFeature.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceType.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyDeviceTypeLoader.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyPollManager.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyRequestManager.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LinkManager.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/MessageDispatcher.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/MessageHandler.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ModemDBBuilder.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/OnLevel.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/PollHandler.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/PollManager.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ProductData.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/ProductDataRegistry.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RampRate.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RequestManager.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RequestQueueManager.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/Scene.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Address.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Command.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Device.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/X10Flag.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseCache.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseChange.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseManager.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/DatabaseRecord.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBBuilder.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBEntry.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDB.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBChange.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBReader.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBRecord.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkDBWriter.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LinkMode.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ManageRecordAction.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDB.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBChange.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBEntry.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBReader.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBRecord.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/ModemDBWriter.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/RecordFlags.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/RecordType.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/BaseFeatureHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/CommandHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureCache.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureEnums.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureListener.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureTemplate.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureTemplateRegistry.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/HandlerEntry.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyCommandHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyFeatureListener.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyFeatureTemplate.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyFeatureTemplateLoader.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyMessageDispatcher.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyMessageHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyPollHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/MessageDispatcher.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/MessageHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/PollHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/discovery/InsteonDeviceDiscoveryService.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/discovery/InsteonDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/discovery/InsteonLegacyDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Driver.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/DriverListener.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/IOStream.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/ModemDBEntry.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Poller.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Port.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/SerialIOStream.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/TcpIOStream.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/hub/HubIOStream.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/hub/ReadByteBuffer.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonBaseThingHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonChannelHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonDeviceHandler.java
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonLegacyDeviceHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonLegacyNetworkHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonNetworkHandler.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonSceneHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonThingHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/X10DeviceHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/DataType.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/Field.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/FieldException.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/InvalidMessageTypeException.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/Msg.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgDefinition.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgFactory.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgListener.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgType.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/XMLMessageReader.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/HubIOStream.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/IOStream.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyDriver.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyDriverListener.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyPort.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyPortListener.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/Port.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/PortListener.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/ReadByteBuffer.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/SerialIOStream.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/TcpIOStream.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/DataType.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/Field.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/FieldException.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/GroupMessageStateMachine.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/InvalidMessageTypeException.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/LegacyGroupMessageStateMachine.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/Msg.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgDefinition.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgDefinitionRegistry.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgType.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/BinaryUtils.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/HexUtils.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/Pair.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/ParameterParser.java [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/Utils.java [deleted file]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/config/config.xml
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/config/legacy-config.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/i18n/insteon.properties
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/channels.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/device.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/hub1.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/hub2.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/legacy-thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/plm.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/scene.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/thing-types.xml [deleted file]
bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/x10.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/device-features.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/device-products.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/device-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/device_features.xml [deleted file]
bundles/org.openhab.binding.insteon/src/main/resources/device_types.xml [deleted file]
bundles/org.openhab.binding.insteon/src/main/resources/legacy-device-features.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/legacy-device-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/msg-definitions.xml [new file with mode: 0644]
bundles/org.openhab.binding.insteon/src/main/resources/msg_definitions.xml [deleted file]

index ae0d5d1a3c9589ed1a6da89d97df37eb226a04bc..f2eb395a8a37642e83788ac46bb8c614ff1ddaae 100755 (executable)
 /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
index e033abe0beb6617e290ea58b32ede333318a5910..4bee1a400ea92707cc4b6327c661ec8fc291333f 100644 (file)
 # 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:**<br>- PLM on  Linux: `/dev/ttyS0` or `/dev/ttyUSB0`<br>- Smartenit ZBPLM on Linux: `/dev/ttyUSB0,baudRate=115200`<br>- PLM on Windows: `COM1`<br>- 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`<br>- Legacy hub (2242-222) at 192.168.1.100 on port 9761:`/hub/192.168.1.100:9761`<br>- 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                            |
+
+<details>
+  <summary>Supported X10 device types</summary>
+
+  | Device Type | Description |
+  | ----------- | ----------- |
+  | X10_Switch  | X10 Switch  |
+  | X10_Dimmer  | X10 Dimmer  |
+  | X10_Sensor  | X10 Sensor  |
+</details>
+
+### `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 |
+<details>
+  <summary>Port configuration examples</summary>
+
+  | 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)<br>`COM1` (Windows)<br>`/tcp/192.168.1.100:9761` (Networked via ser2net) |
+  | Smartenit ZBPLM       | `/dev/ttyUSB0,baudRate=115200` (Linux)                                                                          |
+</details>
+
+### `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.     |
+
+<details>
+  <summary>Supported product keys</summary>
+
+  | 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   |
+
+</details>
 
 ## 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
+
+<details>
+
+  | 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                             |
+
+</details>
 
 ## 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:
+<details>
+  <summary>Legacy</summary>
+
+  ```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]
+    }
+  }
+  ```
+
+</details>
+
+### 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`.
+<details>
+  <summary>Legacy</summary>
 
-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
+  ```
+
+</details>
 
 ## 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]
+}
+```
+
+<details>
+  <summary>Legacy</summary>
+
+  ```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]
+    }
   }
+  ```
+
+</details>
+
+### 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"}
+```
+
+<details>
+  <summary>Legacy</summary>
+
+  ```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"}
+  ```
+
+</details>
+
+##### 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
+```
+
+<details>
+  <summary>Legacy</summary>
+
+##### 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"
+  }
+  ```
+
+</details>
+
+#### 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.
+<details>
+  <summary>Legacy</summary>
+
+  ```java
+  Dimmer keypadDimmer           "main dimmer" { channel="insteon:device:home:AABBCC:dimmer" }
+  Switch keypadDimmerButtonA    "button A"    { channel="insteon:device:home:AABBCC:buttonA" }
+  ```
+
+</details>
+
+##### 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"    <socket> { channel="insteon:device:home:AABBCC:topOutlet" }
-Switch fOutBot "Front Outlet Bottom" <socket> { 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.
+<details>
+  <summary>Legacy</summary>
+
+  ```java
+  Switch outletTop    "Outlet Top"    { channel="insteon:device:home:AABBCC:topOutlet" }
+  Switch outletBottom "Outlet Bottom" { channel="insteon:device:home:AABBCC:bottomOutlet" }
+  ```
+
+</details>
 
 ### 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.
+<details>
+  <summary>Legacy</summary>
 
-### 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:
+</details>
 
-#### 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.
+<details>
+  <summary>Legacy</summary>
 
-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
+</details>
 
-```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.
+
+<details>
+  <summary>Legacy</summary>
 
-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.
+
+</details>
 
 ### 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" }
+```
+
+<details>
+  <summary>Legacy</summary>
+
+  ```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" }
+  ```
+
+</details>
+
+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" }
 ```
 
+<details>
+  <summary>Legacy</summary>
+
+  ```java
+  Switch doorLock "Front Door [MAP(lock.map):%s]"  { channel="insteon:device:home:AABBCC:switch" }
+  ```
+
+</details>
+
 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"                        <garagedoor>  { 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       |
+<details>
+  <summary>Legacy</summary>
 
-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.
+</details>
 
-#### 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".
+<details>
+  <summary>Legacy</summary>
+
+  ```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" }
+  ```
+
+</details>
+
+### 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](<https://github.com/pfrommerd/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
+<details>
+  <summary>Legacy</summary>
+
+  ```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" }
+  ```
+
+</details>
+
+##### 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
+<details>
+  <summary>Legacy</summary>
 
-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
+  ```
+
+</details>
 
-#### 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`.
+<details>
+  <summary>Legacy</summary>
 
-#### 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:
+</details>
+
+## 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"]
 }
-
 ```
 
+<details>
+  <summary>Legacy</summary>
+
+  ```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"]
+  }
+  ```
+
+</details>
+
 ### 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.
+
+<details>
+  <summary>Legacy</summary>
+
+  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"]
   }
-}
-```
+  ```
+
+</details>
+
+## 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:
+<details>
 
-```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
-    <xml>
-     <device productKey="F00.00.XX">
+  Where the `my-own-devices.xml` file defines a new device like this:
+
+  ```xml
+  <xml>
+    <device productKey="F00.00.XX">
       <model>2456-D3</model>
       <description>LampLinc V2</description>
       <feature name="dimmer">GenericDimmer</feature>
       <feature name="lastheardfrom">GenericLastTime</feature>
-     </device>
-    </xml>
-```
-
-Finding the Insteon product key can be tricky since Insteon has not updated the product key table (<https://www.insteon.com/pdf/insteon_devcats_and_product_keys_20081008.pdf>) 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
-    <xml>
-     <feature name="MyFeature">
-     <message-dispatcher>DefaultDispatcher</message-dispatcher>
-     <message-handler cmd="0x03">NoOpMsgHandler</message-handler>
-     <message-handler cmd="0x06">NoOpMsgHandler</message-handler>
-     <message-handler cmd="0x11">NoOpMsgHandler</message-handler>
-     <message-handler cmd="0x13">NoOpMsgHandler</message-handler>
-     <message-handler cmd="0x19">LightStateSwitchHandler</message-handler>
-     <command-handler command="OnOffType">IOLincOnOffCommandHandler</command-handler>
-     <poll-handler>DefaultPollHandler</poll-handler>
-     </feature>
-    </xml>
-```
+    </device>
+  </xml>
+  ```
+
+  Finding the Insteon product key can be tricky since Insteon has not updated the product key table (<https://www.insteon.com/pdf/insteon_devcats_and_product_keys_20081008.pdf>) 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
+  <xml>
+    <feature name="MyFeature">
+      <message-dispatcher>DefaultDispatcher</message-dispatcher>
+      <message-handler cmd="0x03">NoOpMsgHandler</message-handler>
+      <message-handler cmd="0x06">NoOpMsgHandler</message-handler>
+      <message-handler cmd="0x11">NoOpMsgHandler</message-handler>
+      <message-handler cmd="0x13">NoOpMsgHandler</message-handler>
+      <message-handler cmd="0x19">LightStateSwitchHandler</message-handler>
+      <command-handler command="OnOffType">IOLincOnOffCommandHandler</command-handler>
+      <poll-handler>DefaultPollHandler</poll-handler>
+    </feature>
+  </xml>
+  ```
+
+</details>
 
 ## 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/InsteonBinding.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonBinding.java
deleted file mode 100644 (file)
index 59b43d5..0000000
+++ /dev/null
@@ -1,578 +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;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Set;
-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.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.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
- * org.openhab.binding.insteonplm.InsteonPLMActiveBinding. Including the comments below.
- *
- * -----------------------------------------------------------------------------------------------
- *
- * This class represents the actual implementation of the binding, and controls the high level flow
- * of messages to and from the InsteonModem.
- *
- * Writing this binding has been an odyssey through the quirks of the Insteon protocol
- * and Insteon devices. A substantial redesign was necessary at some point along the way.
- * Here are some of the hard learned lessons that should be considered by anyone who wants
- * to re-architect the binding:
- *
- * 1) The entries of the link database of the modem are not reliable. The category/subcategory entries in
- * particular have junk data. Forget about using the modem database to generate a list of devices.
- * The database should only be used to verify that a device has been linked.
- *
- * 2) Querying devices for their product information does not work either. First of all, battery operated devices
- * (and there are a lot of those) have their radio switched off, and may generally not respond to product
- * queries. Even main stream hardwired devices sold presently (like the 2477s switch and the 2477d dimmer)
- * don't even have a product ID. Although supposedly part of the Insteon protocol, we have yet to
- * encounter a device that would cough up a product id when queried, even among very recent devices. They
- * simply return zeros as product id. Lesson: forget about querying devices to generate a device list.
- *
- * 3) Polling is a thorny issue: too much traffic on the network, and messages will be dropped left and right,
- * and not just the poll related ones, but others as well. In particular sending back-to-back messages
- * seemed to result in the second message simply never getting sent, without flow control back pressure
- * (NACK) from the modem. For now the work-around is to space out the messages upon sending, and
- * in general poll as infrequently as acceptable.
- *
- * 4) Instantiating and tracking devices when reported by the modem (either from the database, or when
- * messages are received) leads to complicated state management because there is no guarantee at what
- * point (if at all) the binding configuration will be available. It gets even more difficult when
- * items are created, destroyed, and modified while the binding runs.
- *
- * For the above reasons, devices are only instantiated when they are referenced by binding information.
- * As nice as it would be to discover devices and their properties dynamically, we have abandoned that
- * path because it had led to a complicated and fragile system which due to the technical limitations
- * above was inherently squirrely.
- *
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Daniel Pfrommer - openHAB 1 insteonplm binding
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class InsteonBinding {
-    private static final int DEAD_DEVICE_COUNT = 10;
-
-    private final Logger logger = LoggerFactory.getLogger(InsteonBinding.class);
-
-    private Driver driver;
-    private Map<InsteonAddress, InsteonDevice> devices = new ConcurrentHashMap<>();
-    private Map<String, InsteonChannelConfiguration> bindingConfigs = new ConcurrentHashMap<>();
-    private PortListener portListener = new PortListener();
-    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;
-
-    public InsteonBinding(InsteonNetworkHandler handler, InsteonNetworkConfiguration config,
-            SerialPortManager serialPortManager, ScheduledExecutorService scheduler) {
-        this.handler = handler;
-
-        String port = config.getPort();
-        logger.debug("port = '{}'", Utils.redactPassword(port));
-
-        driver = new Driver(port, portListener, serialPortManager, scheduler);
-        driver.addMsgListener(portListener);
-
-        Integer devicePollIntervalSeconds = config.getDevicePollIntervalSeconds();
-        if (devicePollIntervalSeconds != null) {
-            devicePollIntervalMilliseconds = devicePollIntervalSeconds * 1000;
-        }
-        logger.debug("device poll interval set to {} seconds", devicePollIntervalMilliseconds / 1000);
-
-        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);
-            }
-        }
-
-        String additionalFeatures = config.getAdditionalFeatures();
-        if (additionalFeatures != null) {
-            logger.debug("reading additional feature templates from {}", additionalFeatures);
-            DeviceFeature.readFeatureTemplates(additionalFeatures);
-        }
-
-        deadDeviceTimeout = devicePollIntervalMilliseconds * DEAD_DEVICE_COUNT;
-        logger.debug("dead device timeout set to {} seconds", deadDeviceTimeout / 1000);
-    }
-
-    public Driver getDriver() {
-        return driver;
-    }
-
-    public boolean isDriverInitialized() {
-        return driverInitialized;
-    }
-
-    public boolean startPolling() {
-        logger.debug("starting to poll {}", driver.getPortName());
-        driver.start();
-        return driver.isRunning();
-    }
-
-    public void setIsActive(boolean isActive) {
-        this.isActive = isActive;
-    }
-
-    public void sendCommand(String channelName, Command command) {
-        if (!isActive) {
-            logger.debug("not ready to handle commands yet, returning.");
-            return;
-        }
-
-        InsteonChannelConfiguration 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) {
-            logger.warn("no device found with insteon address {}", bindingConfig.getAddress());
-            return;
-        }
-
-        dev.processCommand(driver, bindingConfig, command);
-
-        logger.debug("found binding config for channel {}", channelName);
-    }
-
-    public void addFeatureListener(InsteonChannelConfiguration bindingConfig) {
-        logger.debug("adding listener for channel {}", bindingConfig.getChannelName());
-
-        InsteonAddress address = bindingConfig.getAddress();
-        InsteonDevice dev = getDevice(address);
-        if (dev == null) {
-            logger.warn("device for address {} is null", address);
-            return;
-        }
-        @Nullable
-        DeviceFeature f = dev.getFeature(bindingConfig.getFeature());
-        if (f == null || f.isFeatureGroup()) {
-            StringBuilder buf = new StringBuilder();
-            ArrayList<String> names = new ArrayList<>(dev.getFeatures().keySet());
-            Collections.sort(names);
-            for (String name : names) {
-                DeviceFeature feature = dev.getFeature(name);
-                if (feature != null && !feature.isFeatureGroup()) {
-                    if (buf.length() > 0) {
-                        buf.append(", ");
-                    }
-                    buf.append(name);
-                }
-            }
-
-            logger.warn("channel {} references unknown feature: {}, it will be ignored. Known features for {} are: {}.",
-                    bindingConfig.getChannelName(), bindingConfig.getFeature(), bindingConfig.getProductKey(),
-                    buf.toString());
-            return;
-        }
-
-        DeviceFeatureListener fl = new DeviceFeatureListener(this, bindingConfig.getChannelUID(),
-                bindingConfig.getChannelName());
-        fl.setParameters(bindingConfig.getParameters());
-        f.addListener(fl);
-
-        bindingConfigs.put(bindingConfig.getChannelName(), bindingConfig);
-    }
-
-    public void removeFeatureListener(ChannelUID channelUID) {
-        String channelName = channelUID.getAsString();
-
-        logger.debug("removing listener for channel {}", channelName);
-
-        for (Iterator<Entry<InsteonAddress, InsteonDevice>> it = devices.entrySet().iterator(); it.hasNext();) {
-            InsteonDevice dev = it.next().getValue();
-            boolean removedListener = dev.removeFeatureListener(channelName);
-            if (removedListener) {
-                logger.trace("removed feature listener {} from dev {}", channelName, dev);
-            }
-        }
-    }
-
-    public void updateFeatureState(ChannelUID channelUID, State state) {
-        handler.updateState(channelUID, state);
-    }
-
-    public @Nullable InsteonDevice makeNewDevice(InsteonAddress addr, String productKey,
-            Map<String, Object> deviceConfigMap) {
-        DeviceTypeLoader instance = DeviceTypeLoader.instance();
-        if (instance == null) {
-            return null;
-        }
-        DeviceType dt = instance.getDeviceType(productKey);
-        if (dt == 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);
-        }
-        if (driver.isModemDBComplete() && dev.getStatus() != DeviceStatus.POLLING) {
-            int ndev = checkIfInModemDatabase(dev);
-            if (dev.hasModemDBEntry()) {
-                dev.setStatus(DeviceStatus.POLLING);
-                Poller.instance().startPolling(dev, ndev);
-            }
-        }
-        devices.put(addr, dev);
-
-        handler.insteonDeviceWasCreated();
-
-        return (dev);
-    }
-
-    public void removeDevice(InsteonAddress addr) {
-        InsteonDevice dev = devices.remove(addr);
-        if (dev == null) {
-            return;
-        }
-
-        if (dev.getStatus() == DeviceStatus.POLLING) {
-            Poller.instance().stopPolling(dev);
-        }
-    }
-
-    /**
-     * 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
-     * @return number of devices in modem database
-     */
-    private int checkIfInModemDatabase(InsteonDevice dev) {
-        try {
-            InsteonAddress addr = dev.getAddress();
-            Map<InsteonAddress, ModemDBEntry> 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);
-                }
-            }
-            return dbes.size();
-        } finally {
-            driver.unlockModemDBEntries();
-        }
-    }
-
-    public Map<String, String> getDatabaseInfo() {
-        try {
-            Map<String, String> databaseInfo = new HashMap<>();
-            Map<InsteonAddress, ModemDBEntry> dbes = driver.lockModemDBEntries();
-            for (InsteonAddress addr : dbes.keySet()) {
-                String a = addr.toString();
-                databaseInfo.put(a, a + ": " + getLinkInfo(dbes, addr, false));
-            }
-
-            return databaseInfo;
-        } finally {
-            driver.unlockModemDBEntries();
-        }
-    }
-
-    public boolean reconnect() {
-        driver.stop();
-        return startPolling();
-    }
-
-    /**
-     * Everything below was copied from Insteon PLM v1
-     */
-
-    /**
-     * Clean up all state.
-     */
-    public void shutdown() {
-        logger.debug("shutting down Insteon bridge");
-        driver.stop();
-        devices.clear();
-        RequestQueueManager.destroyInstance();
-        Poller.instance().stop();
-        isActive = false;
-    }
-
-    /**
-     * Method to find a device by address
-     *
-     * @param aAddr 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);
-    }
-
-    private String getLinkInfo(Map<InsteonAddress, ModemDBEntry> dbes, InsteonAddress a, boolean prefix) {
-        ModemDBEntry dbe = dbes.get(a);
-        if (dbe == null) {
-            return "";
-        }
-        List<Byte> controls = dbe.getControls();
-        List<Byte> responds = dbe.getRespondsTo();
-
-        Port port = dbe.getPort();
-        if (port == null) {
-            return "";
-        }
-        String deviceName = port.getDeviceName();
-        String s = deviceName.startsWith("/hub") ? "hub" : "plm";
-        StringBuilder buf = new StringBuilder();
-        if (port.isModem(a)) {
-            if (prefix) {
-                buf.append("it is the ");
-            }
-            buf.append(s);
-            buf.append(" (");
-            buf.append(Utils.redactPassword(deviceName));
-            buf.append(")");
-        } else {
-            if (prefix) {
-                buf.append("the ");
-            }
-            buf.append(s);
-            buf.append(" controls groups (");
-            buf.append(toGroupString(controls));
-            buf.append(") and responds to groups (");
-            buf.append(toGroupString(responds));
-            buf.append(")");
-        }
-
-        return buf.toString();
-    }
-
-    private String toGroupString(List<Byte> group) {
-        List<Byte> sorted = new ArrayList<>(group);
-        Collections.sort(sorted, new Comparator<>() {
-            @Override
-            public int compare(Byte b1, Byte b2) {
-                int i1 = b1 & 0xFF;
-                int i2 = b2 & 0xFF;
-                return i1 < i2 ? -1 : i1 == i2 ? 0 : 1;
-            }
-        });
-
-        StringBuilder buf = new StringBuilder();
-        for (Byte b : sorted) {
-            if (buf.length() > 0) {
-                buf.append(",");
-            }
-            buf.append(b & 0xFF);
-        }
-
-        return buf.toString();
-    }
-
-    public void logDeviceStatistics() {
-        String msg = String.format("devices: %3d configured, %3d polling, msgs received: %5d", devices.size(),
-                Poller.instance().getSizeOfQueue(), messagesReceived);
-        logger.debug("{}", msg);
-        messagesReceived = 0;
-        for (InsteonDevice dev : devices.values()) {
-            if (dev.isModem()) {
-                continue;
-            }
-            if (deadDeviceTimeout > 0 && dev.getPollOverDueTime() > deadDeviceTimeout) {
-                logger.debug("device {} has not responded to polls for {} sec", dev.toString(),
-                        dev.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);
-            if (msg.isX10()) {
-                handleX10Message(msg);
-            } else {
-                handleInsteonMessage(msg);
-            }
-        }
-
-        @Override
-        public void driverCompletelyInitialized() {
-            List<String> missing = new ArrayList<>();
-            try {
-                Map<InsteonAddress, ModemDBEntry> 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<InsteonAddress> 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);
-                        }
-                    } else {
-                        if (!dev.hasModemDBEntry()) {
-                            addrs.add(a);
-                            logger.debug("device {} found in the modem database and {}.", a,
-                                    getLinkInfo(dbes, a, true));
-                            dev.setHasModemDBEntry(true);
-                        }
-                        if (dev.getStatus() != DeviceStatus.POLLING) {
-                            Poller.instance().startPolling(dev, 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));
-
-                        missing.add(k.toString());
-                    }
-                }
-            } finally {
-                driver.unlockModemDBEntries();
-            }
-
-            if (!missing.isEmpty()) {
-                handler.addMissingDevices(missing);
-            }
-
-            driverInitialized = true;
-        }
-
-        @Override
-        public void disconnected() {
-            handler.bindingDisconnected();
-        }
-
-        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);
-        }
-
-        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 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);
-            }
-        }
-    }
-}
index 10f431d7bce67da6d7c098ffbfc8c5e2d5bdd133..526bb8ddc3f0a072734182951f0fbabbaf0801c0 100644 (file)
  */
 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 @@ import org.openhab.core.thing.ThingTypeUID;
  * 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<ThingTypeUID> DISCOVERABLE_THING_TYPES_UIDS = Set.of(THING_TYPE_DEVICE, THING_TYPE_SCENE);
+    public static final Set<ThingTypeUID> DISCOVERABLE_LEGACY_THING_TYPES_UIDS = Set.of(THING_TYPE_LEGACY_DEVICE);
+
+    public static final Set<ThingTypeUID> 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<String, String[]> 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)));
 }
index d31796854323bf3d224031c8d37323793e4031fb..d130910550893da26595366c2612ba989c254475 100644 (file)
@@ -14,29 +14,34 @@ package org.openhab.binding.insteon.internal;
 
 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 @@ import org.osgi.service.component.annotations.Reference;
  * 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<ThingTypeUID> 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<ThingUID, ServiceRegistration<?>> discoveryServiceRegs = new HashMap<>();
-    private final Map<ThingUID, ServiceRegistration<?>> 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 class InsteonHandlerFactory extends BaseThingHandlerFactory {
     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/InsteonLegacyBinding.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/InsteonLegacyBinding.java
new file mode 100644 (file)
index 0000000..fc1ae27
--- /dev/null
@@ -0,0 +1,550 @@
+/**
+ * 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.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+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.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.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;
+
+/**
+ * A majority of the code in this file is from the openHAB 1 binding
+ * org.openhab.binding.insteonplm.InsteonPLMActiveBinding. Including the comments below.
+ *
+ * -----------------------------------------------------------------------------------------------
+ *
+ * This class represents the actual implementation of the binding, and controls the high level flow
+ * of messages to and from the InsteonModem.
+ *
+ * Writing this binding has been an odyssey through the quirks of the Insteon protocol
+ * and Insteon devices. A substantial redesign was necessary at some point along the way.
+ * Here are some of the hard learned lessons that should be considered by anyone who wants
+ * to re-architect the binding:
+ *
+ * 1) The entries of the link database of the modem are not reliable. The category/subcategory entries in
+ * particular have junk data. Forget about using the modem database to generate a list of devices.
+ * The database should only be used to verify that a device has been linked.
+ *
+ * 2) Querying devices for their product information does not work either. First of all, battery operated devices
+ * (and there are a lot of those) have their radio switched off, and may generally not respond to product
+ * queries. Even main stream hardwired devices sold presently (like the 2477s switch and the 2477d dimmer)
+ * don't even have a product ID. Although supposedly part of the Insteon protocol, we have yet to
+ * encounter a device that would cough up a product id when queried, even among very recent devices. They
+ * simply return zeros as product id. Lesson: forget about querying devices to generate a device list.
+ *
+ * 3) Polling is a thorny issue: too much traffic on the network, and messages will be dropped left and right,
+ * and not just the poll related ones, but others as well. In particular sending back-to-back messages
+ * seemed to result in the second message simply never getting sent, without flow control back pressure
+ * (NACK) from the modem. For now the work-around is to space out the messages upon sending, and
+ * in general poll as infrequently as acceptable.
+ *
+ * 4) Instantiating and tracking devices when reported by the modem (either from the database, or when
+ * messages are received) leads to complicated state management because there is no guarantee at what
+ * point (if at all) the binding configuration will be available. It gets even more difficult when
+ * items are created, destroyed, and modified while the binding runs.
+ *
+ * For the above reasons, devices are only instantiated when they are referenced by binding information.
+ * As nice as it would be to discover devices and their properties dynamically, we have abandoned that
+ * path because it had led to a complicated and fragile system which due to the technical limitations
+ * above was inherently squirrely.
+ *
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Daniel Pfrommer - openHAB 1 insteonplm binding
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class InsteonLegacyBinding implements LegacyDriverListener, LegacyPortListener {
+    private static final int DEAD_DEVICE_COUNT = 10;
+
+    private final Logger logger = LoggerFactory.getLogger(InsteonLegacyBinding.class);
+
+    private LegacyDriver driver;
+    private Map<DeviceAddress, LegacyDevice> devices = new ConcurrentHashMap<>();
+    private Map<String, InsteonLegacyChannelConfiguration> 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 InsteonLegacyNetworkHandler handler;
+
+    public InsteonLegacyBinding(InsteonLegacyNetworkHandler handler, InsteonLegacyNetworkConfiguration config,
+            SerialPortManager serialPortManager, ScheduledExecutorService scheduler) {
+        this.handler = handler;
+
+        String port = config.getRedactedPort();
+        logger.debug("port = '{}'", port);
+
+        driver = new LegacyDriver(config, this, serialPortManager, scheduler);
+        driver.addPortListener(this);
+
+        Integer devicePollIntervalSeconds = config.getDevicePollIntervalSeconds();
+        if (devicePollIntervalSeconds != null) {
+            devicePollIntervalMilliseconds = devicePollIntervalSeconds * 1000;
+        }
+        logger.debug("device poll interval set to {} seconds", devicePollIntervalMilliseconds / 1000);
+
+        String additionalDevices = config.getAdditionalDevices();
+        if (additionalDevices != null) {
+            logger.debug("loading additional device types from {}", additionalDevices);
+            LegacyDeviceTypeLoader.instance().loadDocument(additionalDevices);
+        }
+
+        String additionalFeatures = config.getAdditionalFeatures();
+        if (additionalFeatures != null) {
+            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 LegacyDriver getDriver() {
+        return driver;
+    }
+
+    public boolean isDriverInitialized() {
+        return driverInitialized;
+    }
+
+    public boolean startPolling() {
+        logger.debug("starting to poll {}", driver.getPortName());
+        driver.start();
+        return driver.isRunning();
+    }
+
+    public void setIsActive(boolean isActive) {
+        this.isActive = isActive;
+    }
+
+    public void sendCommand(String channelName, Command command) {
+        if (!isActive) {
+            logger.debug("not ready to handle commands yet, returning.");
+            return;
+        }
+
+        InsteonLegacyChannelConfiguration bindingConfig = bindingConfigs.get(channelName);
+        if (bindingConfig == null) {
+            logger.warn("unable to find binding config for channel {}", channelName);
+            return;
+        }
+
+        LegacyDevice device = getDevice(bindingConfig.getAddress());
+        if (device == null) {
+            logger.warn("no device found with insteon address {}", bindingConfig.getAddress());
+            return;
+        }
+
+        device.processCommand(driver, bindingConfig, command);
+
+        logger.debug("found binding config for channel {}", channelName);
+    }
+
+    public void addFeatureListener(InsteonLegacyChannelConfiguration bindingConfig) {
+        logger.debug("adding listener for channel {}", bindingConfig.getChannelName());
+
+        DeviceAddress address = bindingConfig.getAddress();
+        LegacyDevice device = getDevice(address);
+        if (device == null) {
+            logger.warn("device for address {} is null", address);
+            return;
+        }
+        @Nullable
+        LegacyDeviceFeature feature = device.getFeature(bindingConfig.getFeature());
+        if (feature == null || feature.isFeatureGroup()) {
+            StringBuilder buf = new StringBuilder();
+            ArrayList<String> names = new ArrayList<>(device.getFeatures().keySet());
+            Collections.sort(names);
+            for (String name : names) {
+                LegacyDeviceFeature f = device.getFeature(name);
+                if (f != null && !f.isFeatureGroup()) {
+                    if (buf.length() > 0) {
+                        buf.append(", ");
+                    }
+                    buf.append(name);
+                }
+            }
+
+            logger.warn("channel {} references unknown feature: {}, it will be ignored. Known features for {} are: {}.",
+                    bindingConfig.getChannelName(), bindingConfig.getFeature(), bindingConfig.getProductKey(),
+                    buf.toString());
+            return;
+        }
+
+        LegacyFeatureListener listener = new LegacyFeatureListener(this, bindingConfig.getChannelUID(),
+                bindingConfig.getChannelName());
+        listener.setParameters(bindingConfig.getParameters());
+        feature.addListener(listener);
+
+        bindingConfigs.put(bindingConfig.getChannelName(), bindingConfig);
+    }
+
+    public void removeFeatureListener(ChannelUID channelUID) {
+        String channelName = channelUID.getAsString();
+
+        logger.debug("removing listener for channel {}", channelName);
+
+        for (Iterator<Entry<DeviceAddress, LegacyDevice>> it = devices.entrySet().iterator(); it.hasNext();) {
+            LegacyDevice device = it.next().getValue();
+            boolean removedListener = device.removeFeatureListener(channelName);
+            if (removedListener) {
+                logger.trace("removed feature listener {} from device {}", channelName, device);
+            }
+        }
+    }
+
+    public void updateFeatureState(ChannelUID channelUID, State state) {
+        handler.updateState(channelUID, state);
+    }
+
+    public @Nullable LegacyDevice makeNewDevice(DeviceAddress address, String productKey,
+            Map<String, Object> deviceConfigMap) {
+        LegacyDeviceType deviceType = LegacyDeviceTypeLoader.instance().getDeviceType(productKey);
+        if (deviceType == null) {
+            return null;
+        }
+        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() && device.getStatus() != DeviceStatus.POLLING) {
+            int ndev = checkIfInModemDatabase(device);
+            if (device.hasModemDBEntry()) {
+                device.setStatus(DeviceStatus.POLLING);
+                LegacyPollManager.instance().startPolling(device, ndev);
+            }
+        }
+        devices.put(address, device);
+
+        handler.insteonDeviceWasCreated();
+
+        return device;
+    }
+
+    public void removeDevice(DeviceAddress address) {
+        LegacyDevice device = devices.remove(address);
+        if (device == null) {
+            return;
+        }
+
+        if (device.getStatus() == DeviceStatus.POLLING) {
+            LegacyPollManager.instance().stopPolling(device);
+        }
+    }
+
+    /**
+     * 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 device The device to search for in the modem database
+     * @return number of devices in modem database
+     */
+    private int checkIfInModemDatabase(LegacyDevice device) {
+        try {
+            Map<InsteonAddress, LegacyModemDBEntry> 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();
+        } finally {
+            driver.unlockModemDBEntries();
+        }
+    }
+
+    public Map<String, String> getDatabaseInfo() {
+        try {
+            Map<String, String> databaseInfo = new HashMap<>();
+            Map<InsteonAddress, LegacyModemDBEntry> dbes = driver.lockModemDBEntries();
+            for (InsteonAddress address : dbes.keySet()) {
+                databaseInfo.put(address.toString(), address + ": " + getLinkInfo(dbes, address, false));
+            }
+
+            return databaseInfo;
+        } finally {
+            driver.unlockModemDBEntries();
+        }
+    }
+
+    public boolean reconnect() {
+        driver.stop();
+        return startPolling();
+    }
+
+    /**
+     * Everything below was copied from Insteon PLM v1
+     */
+
+    /**
+     * Clean up all state.
+     */
+    public void shutdown() {
+        logger.debug("shutting down Insteon bridge");
+        driver.stop();
+        devices.clear();
+        LegacyRequestManager.destroyInstance();
+        LegacyPollManager.instance().stop();
+        isActive = false;
+    }
+
+    /**
+     * Method to find a device by address
+     *
+     * @param address the insteon address to search for
+     * @return reference to the device, or null if not found
+     */
+    public @Nullable LegacyDevice getDevice(@Nullable DeviceAddress address) {
+        return address == null ? null : devices.get(address);
+    }
+
+    private String getLinkInfo(Map<InsteonAddress, LegacyModemDBEntry> dbes, InsteonAddress address, boolean prefix) {
+        LegacyModemDBEntry dbe = dbes.get(address);
+        if (dbe == null) {
+            return "";
+        }
+        List<Byte> controls = dbe.getControls();
+        List<Byte> responds = dbe.getRespondsTo();
+
+        LegacyPort port = dbe.getPort();
+        if (port == null) {
+            return "";
+        }
+        String portName = port.getName();
+        String modemType = portName.startsWith("/hub") ? "hub" : "plm";
+        StringBuilder buf = new StringBuilder();
+        if (port.isModem(address)) {
+            if (prefix) {
+                buf.append("it is the ");
+            }
+            buf.append(modemType);
+            buf.append(" (");
+            buf.append(portName);
+            buf.append(")");
+        } else {
+            if (prefix) {
+                buf.append("the ");
+            }
+            buf.append(modemType);
+            buf.append(" controls groups (");
+            buf.append(toGroupString(controls));
+            buf.append(") and responds to groups (");
+            buf.append(toGroupString(responds));
+            buf.append(")");
+        }
+
+        return buf.toString();
+    }
+
+    private String toGroupString(List<Byte> group) {
+        List<Byte> sorted = new ArrayList<>(group);
+        Collections.sort(sorted, new Comparator<>() {
+            @Override
+            public int compare(Byte b1, Byte b2) {
+                int i1 = b1 & 0xFF;
+                int i2 = b2 & 0xFF;
+                return i1 < i2 ? -1 : i1 == i2 ? 0 : 1;
+            }
+        });
+
+        StringBuilder buf = new StringBuilder();
+        for (Byte b : sorted) {
+            if (buf.length() > 0) {
+                buf.append(",");
+            }
+            buf.append(b & 0xFF);
+        }
+
+        return buf.toString();
+    }
+
+    public void logDeviceStatistics() {
+        String msg = String.format("devices: %3d configured, %3d polling, msgs received: %5d", devices.size(),
+                LegacyPollManager.instance().getSizeOfQueue(), messagesReceived);
+        logger.debug("{}", msg);
+        messagesReceived = 0;
+        for (LegacyDevice device : devices.values()) {
+            if (device.isModem()) {
+                continue;
+            }
+            if (deadDeviceTimeout > 0 && device.getPollOverDueTime() > deadDeviceTimeout) {
+                logger.debug("device {} has not responded to polls for {} sec", device.toString(),
+                        device.getPollOverDueTime() / 3600);
+            }
+        }
+    }
+
+    @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 if (msg.isInsteon()) {
+                handleInsteonMessage(msg);
+            }
+        } catch (FieldException e) {
+            logger.warn("got bad message: {}", msg, e);
+        }
+    }
+
+    @Override
+    public void driverCompletelyInitialized() {
+        List<InsteonAddress> missing = new ArrayList<>();
+        try {
+            Map<InsteonAddress, LegacyModemDBEntry> 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<InsteonAddress> 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 (!device.hasModemDBEntry()) {
+                            addrs.add(address);
+                            logger.debug("device {} found in the modem database and {}.", address,
+                                    getLinkInfo(dbes, address, true));
+                            device.setHasModemDBEntry(true);
+                        }
+                        if (device.getStatus() != DeviceStatus.POLLING) {
+                            LegacyPollManager.instance().startPolling(device, dbes.size());
+                        }
+                    }
+                }
+            }
+
+            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(address);
+                }
+            }
+        } finally {
+            driver.unlockModemDBEntries();
+        }
+
+        if (!missing.isEmpty()) {
+            handler.addMissingDevices(missing);
+        }
+
+        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) 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(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 (file)
index 0000000..fc21dc1
--- /dev/null
@@ -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<String> 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 (file)
index 0000000..78a9a8e
--- /dev/null
@@ -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<String, Boolean> getFlags(Element element) {
+        NamedNodeMap attributes = element.getAttributes();
+        Map<String, Boolean> 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<String, String> getParameters(Element element, List<String> excludedAttrs) {
+        NamedNodeMap attributes = element.getAttributes();
+        Map<String, String> 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 (file)
index 0000000..86110bf
--- /dev/null
@@ -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 (file)
index 0000000..cba2e89
--- /dev/null
@@ -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<String> SUBCMDS = List.of(LIST_ALL);
+
+    public ChannelCommand(InsteonCommandExtension commandExtension) {
+        super(NAME, DESCRIPTION, commandExtension);
+    }
+
+    @Override
+    public List<String> 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<String> candidates) {
+        List<String> strings = List.of();
+        if (cursorArgumentIndex == 0) {
+            strings = SUBCMDS;
+        }
+
+        return new StringsCompleter(strings, false).complete(args, cursorArgumentIndex, cursorPosition, candidates);
+    }
+
+    private void listAll(Console console) {
+        Map<String, String> 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 (file)
index 0000000..d35e495
--- /dev/null
@@ -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<String> 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<InsteonAddress> monitoredAddresses = new HashSet<>();
+
+    public DebugCommand(InsteonCommandExtension commandExtension) {
+        super(NAME, DESCRIPTION, commandExtension);
+    }
+
+    @Override
+    public List<String> getUsages() {
+        return List.of(buildCommandUsage(LIST_MONITORED, "list monitored device(s)"),
+                buildCommandUsage(START_MONITORING + " " + ALL_OPTION + "|<address>",
+                        "start logging message events for device(s) in separate file(s)"),
+                buildCommandUsage(STOP_MONITORING + " " + ALL_OPTION + "|<address>",
+                        "stop logging message events for device(s) in separate file(s)"),
+                buildCommandUsage(SEND_BROADCAST_MESSAGE + " <group> <cmd1> <cmd2>",
+                        "send an Insteon broadcast message to a group"),
+                buildCommandUsage(SEND_STANDARD_MESSAGE + " <address> <cmd1> <cmd2>",
+                        "send an Insteon standard message to a device"),
+                buildCommandUsage(SEND_EXTENDED_MESSAGE + " <address> <cmd1> <cmd2> [<data1> ... <data13>]",
+                        "send an Insteon extended message with standard crc to a device"),
+                buildCommandUsage(SEND_EXTENDED_2_MESSAGE + " <address> <cmd1> <cmd2> [<data1> ... <data12>]",
+                        "send an Insteon extended message with a two-byte crc to a device"),
+                buildCommandUsage(SEND_X10_MESSAGE + " <address> <cmd>", "send an X10 message to a device"),
+                buildCommandUsage(SEND_IM_MESSAGE + " <name> [<data1> <data2> ...]",
+                        "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<String> candidates) {
+        List<String> 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 (file)
index 0000000..7538386
--- /dev/null
@@ -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<String> 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<String> getUsages() {
+        return List.of(
+                buildCommandUsage(LIST_ALL, "list configured Insteon/X10 devices with related channels and status"),
+                buildCommandUsage(LIST_DATABASE + " <thingId>",
+                        "list all-link database records and pending changes for a configured Insteon device"),
+                buildCommandUsage(LIST_FEATURES + " <thingId>", "list features for a configured Insteon/X10 device"),
+                buildCommandUsage(LIST_PRODUCT_DATA + " <thingId>",
+                        "list product data for a configured Insteon/X10 device"),
+                buildCommandUsage(LIST_MISSING_LINKS + " " + ALL_OPTION + "|<thingId>",
+                        "list missing links for a specific or all configured Insteon devices"),
+                buildCommandUsage(ADD_MISSING_LINKS + " " + ALL_OPTION + "|<thingId>",
+                        "add missing links for a specific or all configured Insteon devices"),
+                buildCommandUsage(ADD_DATABASE_CONTROLLER + " <thingId> <address> <group> <data1> <data2> <data3>",
+                        "add a controller record to all-link database for a configured Insteon device"),
+                buildCommandUsage(ADD_DATABASE_RESPONDER + " <thingId> <address> <group> <data1> <data2> <data3>",
+                        "add a responder record to all-link database for a configured Insteon device"),
+                buildCommandUsage(DELETE_DATABASE_CONTROLLER + " <thingId> <address> <group> <data3>",
+                        "delete a controller record from all-link database for a configured Insteon device"),
+                buildCommandUsage(DELETE_DATABASE_RESPONDER + " <thingId> <address> <group> <data3>",
+                        "delete a responder record from all-link database for a configured Insteon device"),
+                buildCommandUsage(APPLY_DATABASE_CHANGES + " <thingId> " + CONFIRM_OPTION,
+                        "apply all-link database pending changes for a configured Insteon device"),
+                buildCommandUsage(CLEAR_DATABASE_CHANGES + " <thingId>",
+                        "clear all-link database pending changes for a configured Insteon device"),
+                buildCommandUsage(SET_BUTTON_RADIO_GROUP + " <thingId> <button1> <button2> [<button3> ... <button7>]",
+                        "set a button radio group for a configured Insteon KeypadLinc device"),
+                buildCommandUsage(CLEAR_BUTTON_RADIO_GROUP + " <thingId> <button1> <button2> [<button3> ... <button7>]",
+                        "clear a button radio group for a configured Insteon KeypadLinc device"),
+                buildCommandUsage(REFRESH + " <thingId>", "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<String> candidates) {
+        List<String> 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<String, String> 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<String> 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<String> 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<String> 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<String> deviceLinks = device.getMissingDeviceLinks().entrySet().stream()
+                    .map(entry -> String.format("%s: %s", entry.getKey(), entry.getValue().getRecord())).toList();
+            List<String> 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<Integer> 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<Integer> 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 (file)
index 0000000..7c06806
--- /dev/null
@@ -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<String> 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<String> list) {
+        list.forEach(console::println);
+    }
+
+    protected void print(Console console, Map<String, String> 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<InsteonBridgeHandler> getBridgeHandlers() {
+        return commandExtension.getBridgeHandlers();
+    }
+
+    protected void setBridgeHandler(InsteonBridgeHandler handler) {
+        commandExtension.setBridgeHandler(handler);
+    }
+
+    protected Stream<InsteonThingHandler> getAllDeviceHandlers() {
+        return Stream.concat(getInsteonDeviceHandlers(), getX10DeviceHandlers());
+    }
+
+    protected Stream<InsteonDeviceHandler> getInsteonDeviceHandlers() {
+        return getBridgeHandler().getChildHandlers().filter(InsteonDeviceHandler.class::isInstance)
+                .map(InsteonDeviceHandler.class::cast);
+    }
+
+    protected Stream<X10DeviceHandler> getX10DeviceHandlers() {
+        return getBridgeHandler().getChildHandlers().filter(X10DeviceHandler.class::isInstance)
+                .map(X10DeviceHandler.class::cast);
+    }
+
+    protected Stream<InsteonSceneHandler> 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;
+    }
+}
index 6dc10918cf9a197f7a1bc23b6ff3930c146ad60e..a28af4f1a2fe45b41b57c020c9bee5be0b2e80ab 100644 (file)
  */
 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<Class<? extends InsteonCommand>> 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<InsteonAddress> monitoredAddresses = new HashSet<>();
+    private final ThingRegistry thingRegistry;
+    private final InsteonLegacyCommandExtension legacyCommandExtension;
+    private final Map<String, InsteonCommand> 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<String> 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<String> 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<String> 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<InsteonBridgeHandler> 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<? extends InsteonCommand> 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 (file)
index 0000000..0e00fab
--- /dev/null
@@ -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<InsteonAddress> 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<String> 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 (file)
index 0000000..089338b
--- /dev/null
@@ -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<String> 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<String> 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 + " <address> <group> [<devCat> <subCat> <firmware>]",
+                        "add a controller record to all-link database for the Insteon modem"),
+                buildCommandUsage(ADD_DATABASE_RESPONDER + " <address> <group>",
+                        "add a responder record to all-link database for the Insteon modem"),
+                buildCommandUsage(DELETE_DATABASE_RECORD + " <address> <group>",
+                        "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 + " [<address>]",
+                        "add an Insteon device to the modem, optionally providing its address"),
+                buildCommandUsage(REMOVE_DEVICE + " <address> [" + FORCE_OPTION + "]",
+                        "remove an Insteon device from the modem"),
+                buildCommandUsage(SWITCH + " <thingId>",
+                        "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<String> candidates) {
+        List<String> 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<String, String> 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<String, String> 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<String> 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<String> 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 (file)
index 0000000..c5c6393
--- /dev/null
@@ -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<String> 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<String> getUsages() {
+        return List.of(buildCommandUsage(LIST_ALL, "list configured Insteon scenes with related channels and status"),
+                buildCommandUsage(LIST_DETAILS + " <thingId>", "list details for a configured Insteon scene"),
+                buildCommandUsage(ADD_DEVICE + " " + NEW_OPTION + "|<scene> <device> <feature> <onLevel> [<rampRate>]",
+                        "add an Insteon device feature to a new or configured Insteon scene"),
+                buildCommandUsage(REMOVE_DEVICE + " <scene> <device> <feature>",
+                        "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<String> candidates) {
+        List<String> 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<String, String> 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<InsteonAddress> devices = scene.getDevices();
+        List<String> 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 (file)
index 0000000..ccbb7fc
--- /dev/null
@@ -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;
+    }
+}
index b0c73e97af19722e9f8bc11f5bfee7269f962d23..07397bc564d8e4355c5cf6a9f10c22a75b089b19 100644 (file)
  */
 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<String, String> parameters;
-
-    public InsteonChannelConfiguration(ChannelUID channelUID, String feature, InsteonAddress address, String productKey,
-            Map<String, String> 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<String, String> 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;
     }
 }
index d1528907e4af1cc188fdb8545809a23dd7264845..cafd9efd1d9883d1d4049afc72c0c0c86c1398b7 100644 (file)
 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 (file)
index 0000000..6a4a56d
--- /dev/null
@@ -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 (file)
index 0000000..ceb7107
--- /dev/null
@@ -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 (file)
index 0000000..438c285
--- /dev/null
@@ -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<String, String> parameters;
+
+    public InsteonLegacyChannelConfiguration(ChannelUID channelUID, String feature, DeviceAddress address,
+            String productKey, Map<String, String> 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<String, String> 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 (file)
index 0000000..b8deb23
--- /dev/null
@@ -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 (file)
index 0000000..96f03d5
--- /dev/null
@@ -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)/(?<hostname>[^:]+)(?::(?<port>\\d+))?");
+    private static final Pattern HUB2_PORT_PATTERN = Pattern.compile(
+            "/hub2/(?<username>[^:]+):(?<password>[^@]+)@(?<hostname>[^:,]+)(?::(?<port>\\d+))?(?:,poll_time=(?<pollInterval>\\d+))?");
+    private static final Pattern PLM_PORT_PATTERN = Pattern
+            .compile("(?<serialPort>[^,]+)(?:,baudRate=(?<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 (file)
index 5d24fd7..0000000
+++ /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 (file)
index 0000000..497e3db
--- /dev/null
@@ -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 (file)
index 0000000..d3e6c82
--- /dev/null
@@ -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 (file)
index 0000000..9211179
--- /dev/null
@@ -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 (file)
index 0000000..2f7af2b
--- /dev/null
@@ -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<String, DeviceFeature> features = new LinkedHashMap<>();
+    private Map<String, Boolean> flags = new HashMap<>();
+    private Queue<DeviceRequest> requestQueue = new PriorityQueue<>();
+    private Map<Msg, DeviceRequest> 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<DeviceFeature> 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<String, Boolean> 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<DeviceFeature> 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<String> 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<DeviceRequest> {
+        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/CommandHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/CommandHandler.java
deleted file mode 100644 (file)
index 1c40d8f..0000000
+++ /dev/null
@@ -1,875 +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.lang.reflect.InvocationTargetException;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Timer;
-import java.util.TimerTask;
-
-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.handler.InsteonDeviceHandler;
-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.utils.Utils;
-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.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * A command handler translates an openHAB command into an insteon message
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Bernd Pfrommer - openHAB 1 insteonplm binding
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public abstract class CommandHandler {
-    private static final Logger logger = LoggerFactory.getLogger(CommandHandler.class);
-    DeviceFeature feature; // related DeviceFeature
-    Map<String, String> parameters = new HashMap<>();
-
-    /**
-     * 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.
-     */
-    CommandHandler(DeviceFeature feature) {
-        this.feature = feature;
-    }
-
-    /**
-     * Implements what to do when an openHAB command is received
-     *
-     * @param conf the configuration for the item that generated the command
-     * @param cmd the openhab command issued
-     * @param device the Insteon device to which this command applies
-     */
-    public abstract void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice device);
-
-    /**
-     * Returns parameter as integer
-     *
-     * @param key key of parameter
-     * @param def default
-     * @return value of parameter
-     */
-    protected int getIntParameter(String key, int def) {
-        String val = parameters.get(key);
-        if (val == null) {
-            return (def); // param not found
-        }
-        int ret = def;
-        try {
-            ret = Utils.strToInt(val);
-        } catch (NumberFormatException e) {
-            logger.warn("malformed int parameter in command handler: {}", key);
-        }
-        return ret;
-    }
-
-    /**
-     * Returns parameter as String
-     *
-     * @param key key of parameter
-     * @param def default
-     * @return value of parameter
-     */
-    protected @Nullable String getStringParameter(String key, String def) {
-        return (parameters.get(key) == null ? def : parameters.get(key));
-    }
-
-    /**
-     * Shorthand to return class name for logging purposes
-     *
-     * @return name of the class
-     */
-    protected String nm() {
-        return (this.getClass().getSimpleName());
-    }
-
-    protected int getMaxLightLevel(InsteonChannelConfiguration conf, int defaultLevel) {
-        Map<String, String> params = conf.getParameters();
-        if (conf.getFeature().contains("dimmer")) {
-            String dimmerMax = params.get("dimmermax");
-            if (dimmerMax != null) {
-                String item = conf.getChannelName();
-                try {
-                    int i = Integer.parseInt(dimmerMax);
-                    if (i > 1 && i <= 99) {
-                        int level = (int) Math.ceil((i * 255.0) / 100); // round up
-                        if (level < defaultLevel) {
-                            logger.debug("item {}: using dimmermax value of {}", item, dimmerMax);
-                            return level;
-                        }
-                    } else {
-                        logger.warn("item {}: dimmermax must be between 1-99 inclusive: {}", item, dimmerMax);
-                    }
-                } catch (NumberFormatException e) {
-                    logger.warn("item {}: invalid int value for dimmermax: {}", item, dimmerMax);
-                }
-            }
-        }
-
-        return defaultLevel;
-    }
-
-    void setParameters(Map<String, String> map) {
-        parameters = map;
-    }
-
-    /**
-     * Helper function to extract the group parameter from the binding config,
-     *
-     * @param c the binding configuration to test
-     * @return the value of the "group" parameter, or -1 if none
-     */
-    protected static int getGroup(InsteonChannelConfiguration c) {
-        String v = c.getParameters().get("group");
-        int iv = -1;
-        try {
-            iv = (v == null) ? -1 : Utils.strToInt(v);
-        } catch (NumberFormatException e) {
-            logger.warn("malformed int parameter in for item {}", c.getChannelName());
-        }
-        return iv;
-    }
-
-    public static class WarnCommandHandler extends CommandHandler {
-        WarnCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            logger.warn("{}: command {} is not implemented yet!", nm(), cmd);
-        }
-    }
-
-    public static class NoOpCommandHandler extends CommandHandler {
-        NoOpCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            // do nothing, not even log
-        }
-    }
-
-    public static class LightOnOffCommandHandler extends CommandHandler {
-        LightOnOffCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                int ext = getIntParameter("ext", 0);
-                int direc = 0x00;
-                int level = 0x00;
-                Msg m = null;
-                if (cmd == OnOffType.ON) {
-                    level = getMaxLightLevel(conf, 0xff);
-                    direc = 0x11;
-                    logger.debug("{}: sent msg to switch {} to {}", nm(), dev.getAddress(),
-                            level == 0xff ? "on" : level);
-                } else if (cmd == OnOffType.OFF) {
-                    direc = 0x13;
-                    logger.debug("{}: sent msg to switch {} off", nm(), dev.getAddress());
-                }
-                if (ext == 1 || ext == 2) {
-                    byte[] data = new byte[] { (byte) getIntParameter("d1", 0), (byte) getIntParameter("d2", 0),
-                            (byte) getIntParameter("d3", 0) };
-                    m = dev.makeExtendedMessage((byte) 0x0f, (byte) direc, (byte) level, data);
-                    logger.debug("{}: was an extended message for device {}", nm(), dev.getAddress());
-                    if (ext == 1) {
-                        m.setCRC();
-                    } else if (ext == 2) {
-                        m.setCRC2();
-                    }
-                } else {
-                    m = dev.makeStandardMessage((byte) 0x0f, (byte) direc, (byte) level, getGroup(conf));
-                }
-                logger.debug("Sending message to {}", dev.getAddress());
-                dev.enqueueMessage(m, feature);
-                // expect to get a direct ack after this!
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    public static class FastOnOffCommandHandler extends CommandHandler {
-        FastOnOffCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                if (cmd == OnOffType.ON) {
-                    int level = getMaxLightLevel(conf, 0xff);
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x12, (byte) level, getGroup(conf));
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent fast on to switch {} level {}", nm(), dev.getAddress(),
-                            level == 0xff ? "on" : level);
-                } else if (cmd == OnOffType.OFF) {
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x14, (byte) 0x00, getGroup(conf));
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent fast off to switch {}", nm(), dev.getAddress());
-                }
-                // expect to get a direct ack after this!
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    public static class RampOnOffCommandHandler extends RampCommandHandler {
-        RampOnOffCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                if (cmd == OnOffType.ON) {
-                    double ramptime = getRampTime(conf, 0);
-                    int ramplevel = getRampLevel(conf, 100);
-                    byte cmd2 = encode(ramptime, ramplevel);
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, getOnCmd(), cmd2, getGroup(conf));
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent ramp on to switch {} time {} level {} cmd1 {}", nm(), dev.getAddress(),
-                            ramptime, ramplevel, getOnCmd());
-                } else if (cmd == OnOffType.OFF) {
-                    double ramptime = getRampTime(conf, 0);
-                    int ramplevel = getRampLevel(conf, 0 /* ignored */);
-                    byte cmd2 = encode(ramptime, ramplevel);
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, getOffCmd(), cmd2, getGroup(conf));
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent ramp off to switch {} time {} cmd1 {}", nm(), dev.getAddress(), ramptime,
-                            getOffCmd());
-                }
-                // expect to get a direct ack after this!
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-
-        private int getRampLevel(InsteonChannelConfiguration conf, int defaultValue) {
-            String str = conf.getParameters().get("ramplevel");
-            return str != null ? Integer.parseInt(str) : defaultValue;
-        }
-    }
-
-    public static class ManualChangeCommandHandler extends CommandHandler {
-        ManualChangeCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                if (cmd instanceof DecimalType decimalCommand) {
-                    int v = decimalCommand.intValue();
-                    int cmd1 = (v != 1) ? 0x17 : 0x18; // start or stop
-                    int cmd2 = (v == 2) ? 0x01 : 0; // up or down
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) cmd1, (byte) cmd2, getGroup(conf));
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: cmd {} sent manual change {} {} to {}", nm(), v,
-                            (cmd1 == 0x17) ? "START" : "STOP", (cmd2 == 0x01) ? "UP" : "DOWN", dev.getAddress());
-                } else {
-                    logger.warn("{}: invalid command type: {}", nm(), cmd);
-                }
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    /**
-     * Sends ALLLink broadcast commands to group
-     */
-    public static class GroupBroadcastCommandHandler extends CommandHandler {
-        GroupBroadcastCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                if (cmd == OnOffType.ON || cmd == OnOffType.OFF) {
-                    byte cmd1 = (byte) ((cmd == OnOffType.ON) ? 0x11 : 0x13);
-                    byte value = (byte) ((cmd == OnOffType.ON) ? 0xFF : 0x00);
-                    int group = getGroup(conf);
-                    if (group == -1) {
-                        logger.warn("no group=xx specified in item {}", conf.getChannelName());
-                        return;
-                    }
-                    logger.debug("{}: sending {} broadcast to group {}", nm(), (cmd1 == 0x11) ? "ON" : "OFF",
-                            getGroup(conf));
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, cmd1, value, group);
-                    dev.enqueueMessage(m, feature);
-                    feature.pollRelatedDevices();
-                }
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    public static class LEDOnOffCommandHandler extends CommandHandler {
-        LEDOnOffCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                if (cmd == OnOffType.ON) {
-                    Msg m = dev.makeExtendedMessage((byte) 0x1f, (byte) 0x20, (byte) 0x09,
-                            new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00 });
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to switch {} on", nm(), dev.getAddress());
-                } else if (cmd == OnOffType.OFF) {
-                    Msg m = dev.makeExtendedMessage((byte) 0x1f, (byte) 0x20, (byte) 0x08,
-                            new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00 });
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to switch {} off", nm(), dev.getAddress());
-                }
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    public static class X10OnOffCommandHandler extends CommandHandler {
-        X10OnOffCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                byte houseCode = dev.getX10HouseCode();
-                byte houseUnitCode = (byte) (houseCode << 4 | dev.getX10UnitCode());
-                if (cmd == OnOffType.ON || cmd == OnOffType.OFF) {
-                    byte houseCommandCode = (byte) (houseCode << 4
-                            | (cmd == OnOffType.ON ? X10.Command.ON.code() : X10.Command.OFF.code()));
-                    Msg munit = dev.makeX10Message(houseUnitCode, (byte) 0x00); // send unit code
-                    dev.enqueueMessage(munit, feature);
-                    Msg mcmd = dev.makeX10Message(houseCommandCode, (byte) 0x80); // send command code
-                    dev.enqueueMessage(mcmd, feature);
-                    String onOff = cmd == OnOffType.ON ? "ON" : "OFF";
-                    logger.debug("{}: sent msg to switch {} {}", nm(), dev.getAddress(), onOff);
-                }
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    public static class X10PercentCommandHandler extends CommandHandler {
-        X10PercentCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                //
-                // I did not have hardware that would respond to the PRESET_DIM codes.
-                // This code path needs testing.
-                //
-                byte houseCode = dev.getX10HouseCode();
-                byte houseUnitCode = (byte) (houseCode << 4 | dev.getX10UnitCode());
-                Msg munit = dev.makeX10Message(houseUnitCode, (byte) 0x00); // send unit code
-                dev.enqueueMessage(munit, feature);
-                PercentType pc = (PercentType) cmd;
-                logger.debug("{}: changing level of {} to {}", nm(), dev.getAddress(), pc.intValue());
-                int level = (pc.intValue() * 32) / 100;
-                byte cmdCode = (level >= 16) ? X10.Command.PRESET_DIM_2.code() : X10.Command.PRESET_DIM_1.code();
-                level = level % 16;
-                if (level <= 0) {
-                    level = 0;
-                }
-                houseCode = (byte) x10CodeForLevel[level];
-                cmdCode |= (houseCode << 4);
-                Msg mcmd = dev.makeX10Message(cmdCode, (byte) 0x80); // send command code
-                dev.enqueueMessage(mcmd, feature);
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-
-        private final int[] x10CodeForLevel = { 0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15 };
-    }
-
-    public static class X10IncreaseDecreaseCommandHandler extends CommandHandler {
-        X10IncreaseDecreaseCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                byte houseCode = dev.getX10HouseCode();
-                byte houseUnitCode = (byte) (houseCode << 4 | dev.getX10UnitCode());
-                if (cmd == IncreaseDecreaseType.INCREASE || cmd == IncreaseDecreaseType.DECREASE) {
-                    byte houseCommandCode = (byte) (houseCode << 4
-                            | (cmd == IncreaseDecreaseType.INCREASE ? X10.Command.BRIGHT.code()
-                                    : X10.Command.DIM.code()));
-                    Msg munit = dev.makeX10Message(houseUnitCode, (byte) 0x00); // send unit code
-                    dev.enqueueMessage(munit, feature);
-                    Msg mcmd = dev.makeX10Message(houseCommandCode, (byte) 0x80); // send command code
-                    dev.enqueueMessage(mcmd, feature);
-                    String bd = cmd == IncreaseDecreaseType.INCREASE ? "BRIGHTEN" : "DIM";
-                    logger.debug("{}: sent msg to switch {} {}", nm(), dev.getAddress(), bd);
-                }
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    public static class IOLincOnOffCommandHandler extends CommandHandler {
-        IOLincOnOffCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                if (cmd == OnOffType.ON) {
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x11, (byte) 0xff);
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to switch {} on", nm(), dev.getAddress());
-                } else if (cmd == OnOffType.OFF) {
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x13, (byte) 0x00);
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to switch {} off", nm(), dev.getAddress());
-                }
-                // This used to be configurable, but was made static to make
-                // the architecture of the binding cleaner.
-                int delay = 2000;
-                delay = Math.max(1000, delay);
-                delay = Math.min(10000, delay);
-                Timer timer = new Timer();
-                timer.schedule(new TimerTask() {
-                    @Override
-                    public void run() {
-                        Msg m = feature.makePollMsg();
-                        InsteonDevice dev = feature.getDevice();
-                        if (m != null) {
-                            dev.enqueueMessage(m, feature);
-                        }
-                    }
-                }, delay);
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error: ", nm(), e);
-            }
-        }
-    }
-
-    public static class IncreaseDecreaseCommandHandler extends CommandHandler {
-        IncreaseDecreaseCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                if (cmd == IncreaseDecreaseType.INCREASE) {
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x15, (byte) 0x00);
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to brighten {}", nm(), dev.getAddress());
-                } else if (cmd == IncreaseDecreaseType.DECREASE) {
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x16, (byte) 0x00);
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to dimm {}", nm(), dev.getAddress());
-                }
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    public static class PercentHandler extends CommandHandler {
-        PercentHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                PercentType pc = (PercentType) cmd;
-                logger.debug("changing level of {} to {}", dev.getAddress(), pc.intValue());
-                int level = (int) Math.ceil((pc.intValue() * 255.0) / 100); // round up
-                if (level > 0) { // make light on message with given level
-                    level = getMaxLightLevel(conf, level);
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x11, (byte) level);
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to set {} to {}", nm(), dev.getAddress(), level);
-                } else { // switch off
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x13, (byte) 0x00);
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to set {} to zero by switching off", nm(), dev.getAddress());
-                }
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    private abstract static class RampCommandHandler extends CommandHandler {
-        private static double[] halfRateRampTimes = new double[] { 0.1, 0.3, 2, 6.5, 19, 23.5, 28, 32, 38.5, 47, 90,
-                150, 210, 270, 360, 480 };
-
-        private byte onCmd;
-        private byte offCmd;
-
-        RampCommandHandler(DeviceFeature f) {
-            super(f);
-            // Can't process parameters here because they are set after constructor is invoked.
-            // Unfortunately, this means we can't declare the onCmd, offCmd to be final.
-        }
-
-        @Override
-        void setParameters(Map<String, String> params) {
-            super.setParameters(params);
-            onCmd = (byte) getIntParameter("on", 0x2E);
-            offCmd = (byte) getIntParameter("off", 0x2F);
-        }
-
-        protected final byte getOnCmd() {
-            return onCmd;
-        }
-
-        protected final byte getOffCmd() {
-            return offCmd;
-        }
-
-        protected byte encode(double ramptimeSeconds, int ramplevel) throws FieldException {
-            if (ramplevel < 0 || ramplevel > 100) {
-                throw new FieldException("ramplevel must be in the range 0-100 (inclusive)");
-            }
-
-            if (ramptimeSeconds < 0) {
-                throw new FieldException("ramptime must be greater than 0");
-            }
-
-            int ramptime;
-            int insertionPoint = Arrays.binarySearch(halfRateRampTimes, ramptimeSeconds);
-            if (insertionPoint > 0) {
-                ramptime = 15 - insertionPoint;
-            } else {
-                insertionPoint = -insertionPoint - 1;
-                if (insertionPoint == 0) {
-                    ramptime = 15;
-                } else {
-                    double d1 = Math.abs(halfRateRampTimes[insertionPoint - 1] - ramptimeSeconds);
-                    double d2 = Math.abs(halfRateRampTimes[insertionPoint] - ramptimeSeconds);
-                    ramptime = 15 - (d1 > d2 ? insertionPoint : insertionPoint - 1);
-                    logger.debug("ramp encoding: time {} insert {} d1 {} d2 {} ramp {}", ramptimeSeconds,
-                            insertionPoint, d1, d2, ramptime);
-                }
-            }
-
-            int r = (int) Math.round(ramplevel / (100.0 / 15.0));
-            return (byte) (((r & 0x0f) << 4) | (ramptime & 0xf));
-        }
-
-        protected double getRampTime(InsteonChannelConfiguration conf, double defaultValue) {
-            String str = conf.getParameters().get("ramptime");
-            return str != null ? Double.parseDouble(str) : defaultValue;
-        }
-    }
-
-    public static class RampPercentHandler extends RampCommandHandler {
-
-        RampPercentHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                PercentType pc = (PercentType) cmd;
-                double ramptime = getRampTime(conf, 0);
-                int level = pc.intValue();
-                if (level > 0) { // make light on message with given level
-                    level = getMaxLightLevel(conf, level);
-                    byte cmd2 = encode(ramptime, level);
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, getOnCmd(), cmd2);
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to set {} to {} with {} second ramp time.", nm(), dev.getAddress(),
-                            level, ramptime);
-                } else { // switch off
-                    Msg m = dev.makeStandardMessage((byte) 0x0f, getOffCmd(), (byte) 0x00);
-                    dev.enqueueMessage(m, feature);
-                    logger.debug("{}: sent msg to set {} to zero by switching off with {} ramp time.", nm(),
-                            dev.getAddress(), ramptime);
-                }
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    public static class PowerMeterCommandHandler extends CommandHandler {
-        PowerMeterCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            String cmdParam = conf.getParameters().get(InsteonDeviceHandler.CMD);
-            if (cmdParam == null) {
-                logger.warn("{} ignoring cmd {} because no cmd= is configured!", nm(), cmd);
-                return;
-            }
-            try {
-                if (cmd == OnOffType.ON) {
-                    if (cmdParam.equals(InsteonDeviceHandler.CMD_RESET)) {
-                        Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x80, (byte) 0x00);
-                        dev.enqueueMessage(m, feature);
-                        logger.debug("{}: sent reset msg to power meter {}", nm(), dev.getAddress());
-                        feature.publish(OnOffType.OFF, StateChangeType.ALWAYS, InsteonDeviceHandler.CMD,
-                                InsteonDeviceHandler.CMD_RESET);
-                    } else if (cmdParam.equals(InsteonDeviceHandler.CMD_UPDATE)) {
-                        Msg m = dev.makeStandardMessage((byte) 0x0f, (byte) 0x82, (byte) 0x00);
-                        dev.enqueueMessage(m, feature);
-                        logger.debug("{}: sent update msg to power meter {}", nm(), dev.getAddress());
-                        feature.publish(OnOffType.OFF, StateChangeType.ALWAYS, InsteonDeviceHandler.CMD,
-                                InsteonDeviceHandler.CMD_UPDATE);
-                    } else {
-                        logger.warn("{}: ignoring unknown cmd {} for power meter {}", nm(), cmdParam, dev.getAddress());
-                    }
-                } else if (cmd == OnOffType.OFF) {
-                    logger.debug("{}: ignoring off request for power meter {}", nm(), dev.getAddress());
-                }
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    /**
-     * Command handler that sends a command with a numerical value to a device.
-     * The handler is very parameterizable so it can be reused for different devices.
-     * First used for setting thermostat parameters.
-     */
-
-    public static class NumberCommandHandler extends CommandHandler {
-        NumberCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        public int transform(int cmd) {
-            return (cmd);
-        }
-
-        @Override
-        public void handleCommand(InsteonChannelConfiguration conf, Command cmd, InsteonDevice dev) {
-            try {
-                int dc = transform(((DecimalType) cmd).intValue());
-                int intFactor = getIntParameter("factor", 1);
-                //
-                // determine what level should be, and what field it should be in
-                //
-                int ilevel = dc * intFactor;
-                byte level = (byte) (ilevel > 255 ? 0xFF : ((ilevel < 0) ? 0 : ilevel));
-                String vfield = getStringParameter("value", "");
-                if (vfield == null || vfield.isEmpty()) {
-                    logger.warn("{} has no value field specified", nm());
-                }
-                //
-                // figure out what cmd1, cmd2, d1, d2, d3 are supposed to be
-                // to form a proper message
-                //
-                int cmd1 = getIntParameter("cmd1", -1);
-                if (cmd1 < 0) {
-                    logger.warn("{} has no cmd1 specified!", nm());
-                    return;
-                }
-                int cmd2 = getIntParameter("cmd2", 0);
-                int ext = getIntParameter("ext", 0);
-                Msg m = null;
-                if (ext == 1 || ext == 2) {
-                    byte[] data = new byte[] { (byte) getIntParameter("d1", 0), (byte) getIntParameter("d2", 0),
-                            (byte) getIntParameter("d3", 0) };
-                    m = dev.makeExtendedMessage((byte) 0x0f, (byte) cmd1, (byte) cmd2, data);
-                    m.setByte(vfield, level);
-                    if (ext == 1) {
-                        m.setCRC();
-                    } else if (ext == 2) {
-                        m.setCRC2();
-                    }
-                } else {
-                    m = dev.makeStandardMessage((byte) 0x0f, (byte) cmd1, (byte) cmd2);
-                    m.setByte(vfield, level);
-                }
-                dev.enqueueMessage(m, feature);
-                logger.debug("{}: sent msg to change level to {}", nm(), ((DecimalType) cmd).intValue());
-                m = null;
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("{}: invalid message: ", nm(), e);
-            } catch (FieldException e) {
-                logger.warn("{}: command send message creation error ", nm(), e);
-            }
-        }
-    }
-
-    /**
-     * Handler to set the thermostat system mode
-     */
-    public static class ThermostatSystemModeCommandHandler extends NumberCommandHandler {
-        ThermostatSystemModeCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public int transform(int cmd) {
-            switch (cmd) {
-                case 0:
-                    return (0x09); // off
-                case 1:
-                    return (0x04); // heat
-                case 2:
-                    return (0x05); // cool
-                case 3:
-                    return (0x06); // auto (aka manual auto)
-                case 4:
-                    return (0x0A); // program (aka auto)
-                default:
-                    break;
-            }
-            return (0x0A); // when in doubt go to program
-        }
-    }
-
-    /**
-     * Handler to set the thermostat fan mode
-     */
-    public static class ThermostatFanModeCommandHandler extends NumberCommandHandler {
-        ThermostatFanModeCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public int transform(int cmd) {
-            switch (cmd) {
-                case 0:
-                    return (0x08); // fan mode auto
-                case 1:
-                    return (0x07); // fan always on
-                default:
-                    break;
-            }
-            return (0x08); // when in doubt go auto mode
-        }
-    }
-
-    /**
-     * Handler to set the fanlinc fan mode
-     */
-    public static class FanLincFanCommandHandler extends NumberCommandHandler {
-        FanLincFanCommandHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public int transform(int cmd) {
-            switch (cmd) {
-                case 0:
-                    return (0x00); // fan off
-                case 1:
-                    return (0x55); // fan low
-                case 2:
-                    return (0xAA); // fan medium
-                case 3:
-                    return (0xFF); // fan high
-                default:
-                    break;
-            }
-            return (0x00); // all other modes are "off"
-        }
-    }
-
-    /**
-     * Factory method for creating handlers of a given name using java reflection
-     *
-     * @param name the name of the handler to create
-     * @param params
-     * @param f the feature for which to create the handler
-     * @return the handler which was created
-     */
-    @Nullable
-    public static <T extends CommandHandler> T makeHandler(String name, Map<String, String> params, DeviceFeature f) {
-        String cname = CommandHandler.class.getName() + "$" + name;
-        try {
-            Class<?> c = Class.forName(cname);
-            @SuppressWarnings("unchecked")
-            Class<? extends T> dc = (Class<? extends T>) c;
-            @Nullable
-            T ch = dc.getDeclaredConstructor(DeviceFeature.class).newInstance(f);
-            ch.setParameters(params);
-            return ch;
-        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
-                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
-            logger.warn("error trying to create message handler: {}", name, e);
-        }
-        return null;
-    }
-}
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 (file)
index 0000000..a10413a
--- /dev/null
@@ -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<Msg> commands;
+
+    public DefaultLink(String name, LinkDBRecord linkDBRecord, ModemDBRecord modemDBRecord, List<Msg> 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<Msg> 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 (file)
index 0000000..1a4be06
--- /dev/null
@@ -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<DeviceFeature> 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 (file)
index 0000000..2294b6e
--- /dev/null
@@ -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 (file)
index 0000000..136b401
--- /dev/null
@@ -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<String, FeatureCache> features;
+
+    public @Nullable ProductData getProductData() {
+        return productData;
+    }
+
+    public InsteonEngine getInsteonEngine() {
+        return Objects.requireNonNullElse(engine, InsteonEngine.UNKNOWN);
+    }
+
+    public @Nullable DatabaseCache getDatabaseCache() {
+        return database;
+    }
+
+    public Map<String, FeatureCache> 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<DeviceFeature> 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();
+    }
+}
index c31ce17a118523779c9180867d8443ec8b76bc89..7d36b01e2292476fede4afa4e48705a2fb901fd0 100644 (file)
  */
 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 @@ import org.slf4j.LoggerFactory;
  * 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 @@ import org.slf4j.LoggerFactory;
  * @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<String, FeatureTemplate> 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<Integer, @Nullable MessageHandler> msgHandlers = new HashMap<>();
-    private Map<Class<? extends Command>, @Nullable CommandHandler> commandHandlers = new HashMap<>();
-    private List<DeviceFeatureListener> 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<String, String> parameters = new HashMap<>();
+    private Map<String, MessageHandler> msgHandlers = new HashMap<>();
+    private Map<String, CommandHandler> commandHandlers = new HashMap<>();
     private List<DeviceFeature> connectedFeatures = new ArrayList<>();
+    private Set<FeatureListener> 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<String, String> 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<Integer, @Nullable MessageHandler> 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<DeviceFeature> 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<String, String> 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<String, MessageHandler> 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<DeviceFeatureListener> 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<? extends Command> 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<? extends Command> 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<FeatureTemplate> 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<String, String> 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/DeviceFeatureListener.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/DeviceFeatureListener.java
deleted file mode 100644 (file)
index 8ec6d5d..0000000
+++ /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.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.insteon.internal.InsteonBinding;
-import org.openhab.core.library.types.OnOffType;
-import org.openhab.core.library.types.PercentType;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.types.State;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * A DeviceFeatureListener essentially represents an openHAB item that
- * listens to a particular feature of an Insteon device
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Bernd Pfrommer - openHAB 1 insteonplm binding
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class DeviceFeatureListener {
-    private final Logger logger = LoggerFactory.getLogger(DeviceFeatureListener.class);
-
-    public enum StateChangeType {
-        ALWAYS,
-        CHANGED
-    }
-
-    private String itemName;
-    private ChannelUID channelUID;
-    private Map<String, String> parameters = new HashMap<>();
-    private Map<Class<?>, State> state = new HashMap<>();
-    private List<InsteonAddress> relatedDevices = new ArrayList<>();
-    private InsteonBinding binding;
-    private static final int TIME_DELAY_POLL_RELATED_MSEC = 5000;
-
-    /**
-     * Constructor
-     *
-     * @param binding
-     * @param channelUID channel associated with this item
-     * @param item name of the item that is listening
-     */
-    public DeviceFeatureListener(InsteonBinding binding, ChannelUID channelUID, String item) {
-        this.binding = binding;
-        this.itemName = item;
-        this.channelUID = channelUID;
-    }
-
-    /**
-     * Gets item name
-     *
-     * @return item name
-     */
-    public String getItemName() {
-        return itemName;
-    }
-
-    /**
-     * Test if string parameter is present and has a given value
-     *
-     * @param key key to match
-     * @param value value to match
-     * @return true if key exists and value matches
-     */
-    private boolean parameterHasValue(String key, String value) {
-        String v = parameters.get(key);
-        return (v != null && v.equals(value));
-    }
-
-    /**
-     * Set parameters for this feature listener
-     *
-     * @param p the parameters to set
-     */
-    public void setParameters(Map<String, String> p) {
-        parameters = p;
-        updateRelatedDevices();
-    }
-
-    /**
-     * Publishes a state change on the openhab bus
-     *
-     * @param newState the new state to publish on the openhab bus
-     * @param changeType whether to always publish or not
-     */
-    public void stateChanged(State newState, StateChangeType changeType) {
-        State oldState = state.get(newState.getClass());
-        if (oldState == null) {
-            logger.trace("new state: {}:{}", newState.getClass().getSimpleName(), newState);
-            // state has changed, must publish
-            publishState(newState);
-        } else {
-            logger.trace("old state: {}:{}=?{}", newState.getClass().getSimpleName(), oldState, newState);
-            // only publish if state has changed or it is requested explicitly
-            if (changeType == StateChangeType.ALWAYS || !oldState.equals(newState)) {
-                publishState(newState);
-            }
-        }
-        state.put(newState.getClass(), newState);
-    }
-
-    /**
-     * Call this function to inform about a state change for a given
-     * parameter key and value. If dataKey and dataValue don't match,
-     * the state change will be ignored.
-     *
-     * @param state the new state to which the feature has changed
-     * @param changeType how to process the state change (always, or only when changed)
-     * @param dataKey the data key on which to filter
-     * @param dataValue the value that the data key must match for the state to be published
-     */
-    public void stateChanged(State state, StateChangeType changeType, String dataKey, String dataValue) {
-        if (parameterHasValue(dataKey, dataValue)) {
-            stateChanged(state, changeType);
-        }
-    }
-
-    /**
-     * Publish the state. In the case of PercentType, if the value is
-     * 0, send an OnOffType.OFF and if the value is 100, send an OnOffType.ON.
-     * That way an openHAB Switch will work properly with an Insteon dimmer,
-     * as long it is used like a switch (On/Off). An openHAB DimmerItem will
-     * internally convert the ON back to 100% and OFF back to 0, so there is
-     * no need to send both 0/OFF and 100/ON.
-     *
-     * @param state the new state of the feature
-     */
-    private void publishState(State state) {
-        State publishState = state;
-        if (state instanceof PercentType) {
-            if (state.equals(PercentType.ZERO)) {
-                publishState = OnOffType.OFF;
-            } else if (state.equals(PercentType.HUNDRED)) {
-                publishState = OnOffType.ON;
-            }
-        }
-        pollRelatedDevices();
-        binding.updateFeatureState(channelUID, publishState);
-    }
-
-    /**
-     * Extracts related devices from the parameter list and
-     * stores them for faster access later.
-     */
-
-    private void updateRelatedDevices() {
-        String d = parameters.get("related");
-        if (d == null) {
-            return;
-        }
-        String[] devs = d.split("\\+");
-        for (String dev : devs) {
-            InsteonAddress a = InsteonAddress.parseAddress(dev);
-            relatedDevices.add(a);
-        }
-    }
-
-    /**
-     * polls all devices that are related to this item
-     * by the "related" keyword
-     */
-    public void pollRelatedDevices() {
-        for (InsteonAddress a : relatedDevices) {
-            logger.debug("polling related device {} in {} ms", a, TIME_DELAY_POLL_RELATED_MSEC);
-            InsteonDevice d = binding.getDevice(a);
-            if (d != null) {
-                d.doPoll(TIME_DELAY_POLL_RELATED_MSEC);
-            } else {
-                logger.warn("device {} related to item {} is not configured!", a, itemName);
-            }
-        }
-    }
-}
index 58f069fd9076892181ba8304c8c7ad1fb559ebbf..7e08ba400284c85986265da712dcd9397c5c62f9 100644 (file)
@@ -14,135 +14,123 @@ package org.openhab.binding.insteon.internal.device;
 
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
 
 /**
- * The 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<String, String> features = new HashMap<>();
-    private Map<String, FeatureGroup> featureGroups = new HashMap<>();
+    private String name;
+    private Map<String, Boolean> flags = new HashMap<>();
+    private Map<String, FeatureEntry> features = new LinkedHashMap<>();
+    private Map<String, DefaultLinkEntry> 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<String, Boolean> flags, Map<String, FeatureEntry> features,
+            Map<String, DefaultLinkEntry> 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<String, String> getFeatures() {
-        return features;
-    }
-
-    /**
-     * Get all feature groups
-     *
-     * @return all feature groups of this device type
+     * @return the name for this device type
      */
-    public Map<String, FeatureGroup> 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<String, Boolean> 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<FeatureEntry> 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<FeatureEntry> 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<String, DefaultLinkEntry> getDefaultLinks() {
+        return links;
     }
 
     @Override
     public String toString() {
-        String s = "pk:" + productKey + "|model:" + model + "|desc:" + description + "|features";
-        for (Entry<String, String> 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<String, FeatureGroup> 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<String> fgFeatures = new ArrayList<>();
+        private Map<String, String> parameters;
+        private List<String> connectedFeatures = new ArrayList<>();
 
-        FeatureGroup(String name, String type) {
+        public FeatureEntry(String name, String type, Map<String, String> parameters) {
             this.name = name;
             this.type = type;
-        }
-
-        public void addFeature(String f) {
-            fgFeatures.add(f);
-        }
-
-        public ArrayList<String> getFeatures() {
-            return fgFeatures;
+            this.parameters = parameters;
         }
 
         public String getName() {
@@ -153,13 +141,127 @@ public class DeviceType {
             return type;
         }
 
+        public Map<String, String> getParameters() {
+            return parameters;
+        }
+
+        public List<String> 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<CommandEntry> 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<CommandEntry> 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 (file)
index 52af948..0000000
+++ /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<String, DeviceType> 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<String, DeviceType> 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 (file)
index 0000000..046f878
--- /dev/null
@@ -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<String, DeviceType> deviceTypes = new LinkedHashMap<>();
+    private Map<String, FeatureEntry> 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<String, DeviceType> 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<String, Boolean> flags = getFlags(element);
+        Map<String, FeatureEntry> features = new LinkedHashMap<>();
+        Map<String, DefaultLinkEntry> 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<String, FeatureEntry> 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<String, String> 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<String, FeatureEntry> 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<String, String> 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<String, DefaultLinkEntry> 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/FeatureTemplate.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/FeatureTemplate.java
deleted file mode 100644 (file)
index 00e0615..0000000
+++ /dev/null
@@ -1,183 +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;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.core.types.Command;
-
-/**
- * A simple class which contains the basic info needed to create a device feature.
- * Here, all handlers are represented as strings. The actual device feature
- * is then instantiated from the template by calling the build() function.
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class FeatureTemplate {
-    private String name;
-    private String timeout;
-    private boolean isStatus;
-    private @Nullable HandlerEntry dispatcher = null;
-    private @Nullable HandlerEntry pollHandler = null;
-    private @Nullable HandlerEntry defaultMsgHandler = null;
-    private @Nullable HandlerEntry defaultCmdHandler = null;
-    private Map<Integer, HandlerEntry> messageHandlers = new HashMap<>();
-    private Map<Class<? extends Command>, HandlerEntry> commandHandlers = new HashMap<>();
-
-    public FeatureTemplate(String name, boolean isStatus, String timeout) {
-        this.name = name;
-        this.isStatus = isStatus;
-        this.timeout = timeout;
-    }
-
-    // simple getters
-    public String getName() {
-        return name;
-    }
-
-    public String getTimeout() {
-        return timeout;
-    }
-
-    public boolean isStatusFeature() {
-        return isStatus;
-    }
-
-    public @Nullable HandlerEntry getPollHandler() {
-        return pollHandler;
-    }
-
-    public @Nullable HandlerEntry getDispatcher() {
-        return dispatcher;
-    }
-
-    public @Nullable HandlerEntry getDefaultCommandHandler() {
-        return defaultCmdHandler;
-    }
-
-    public @Nullable HandlerEntry getDefaultMessageHandler() {
-        return defaultMsgHandler;
-    }
-
-    /**
-     * Retrieves a hashmap of message command code to command handler name
-     *
-     * @return a Hashmap from Integer to String representing the command codes and the associated message handlers
-     */
-    public Map<Integer, HandlerEntry> getMessageHandlers() {
-        return messageHandlers;
-    }
-
-    /**
-     * Similar to getMessageHandlers(), but for command handlers
-     * Instead of Integers it uses the class of the Command as a key
-     *
-     * @see #getMessageHandlers()
-     * @return a HashMap from Command Classes to CommandHandler names
-     */
-    public Map<Class<? extends Command>, HandlerEntry> getCommandHandlers() {
-        return commandHandlers;
-    }
-
-    // simple setters
-
-    public void setMessageDispatcher(HandlerEntry he) {
-        dispatcher = he;
-    }
-
-    public void setPollHandler(HandlerEntry he) {
-        pollHandler = he;
-    }
-
-    public void setDefaultCommandHandler(HandlerEntry cmd) {
-        defaultCmdHandler = cmd;
-    }
-
-    public void setDefaultMessageHandler(HandlerEntry he) {
-        defaultMsgHandler = he;
-    }
-
-    /**
-     * Adds a message handler mapped from the command which this handler should be invoked for
-     * to the name of the handler to be created
-     *
-     * @param cmd command to be mapped
-     * @param he handler entry to map to
-     */
-    public void addMessageHandler(int cmd, HandlerEntry he) {
-        messageHandlers.put(cmd, he);
-    }
-
-    /**
-     * Adds a command handler mapped from the command class which this handler should be invoke for
-     * to the name of the handler to be created
-     */
-    public void addCommandHandler(Class<? extends Command> command, HandlerEntry he) {
-        commandHandlers.put(command, he);
-    }
-
-    /**
-     * Builds the actual feature
-     *
-     * @return the feature which this template describes
-     */
-    public DeviceFeature build() {
-        DeviceFeature f = new DeviceFeature(name);
-        f.setStatusFeature(isStatus);
-        f.setTimeout(timeout);
-        HandlerEntry dispatcher = this.dispatcher;
-        if (dispatcher != null) {
-            f.setMessageDispatcher(MessageDispatcher.makeHandler(dispatcher.getName(), dispatcher.getParams(), f));
-        }
-        HandlerEntry pollHandler = this.pollHandler;
-        if (pollHandler != null) {
-            f.setPollHandler(PollHandler.makeHandler(pollHandler, f));
-        }
-        HandlerEntry defaultCmdHandler = this.defaultCmdHandler;
-        if (defaultCmdHandler != null) {
-            CommandHandler h = CommandHandler.makeHandler(defaultCmdHandler.getName(), defaultCmdHandler.getParams(),
-                    f);
-            if (h != null) {
-                f.setDefaultCommandHandler(h);
-            }
-        }
-        HandlerEntry defaultMsgHandler = this.defaultMsgHandler;
-        if (defaultMsgHandler != null) {
-            MessageHandler h = MessageHandler.makeHandler(defaultMsgHandler.getName(), defaultMsgHandler.getParams(),
-                    f);
-            if (h != null) {
-                f.setDefaultMsgHandler(h);
-            }
-        }
-        for (Entry<Integer, HandlerEntry> mH : messageHandlers.entrySet()) {
-            f.addMessageHandler(mH.getKey(),
-                    MessageHandler.makeHandler(mH.getValue().getName(), mH.getValue().getParams(), f));
-        }
-        for (Entry<Class<? extends Command>, HandlerEntry> cH : commandHandlers.entrySet()) {
-            f.addCommandHandler(cH.getKey(),
-                    CommandHandler.makeHandler(cH.getValue().getName(), cH.getValue().getParams(), f));
-        }
-        return f;
-    }
-
-    @Override
-    public String toString() {
-        return getName() + "(" + isStatusFeature() + ")";
-    }
-}
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 (file)
index 7a05a47..0000000
+++ /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<FeatureTemplate> readTemplates(InputStream input) throws IOException, ParsingException {
-        List<FeatureTemplate> 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<String, String> 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<? extends Command> 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<? extends Command> 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/GroupMessageStateMachine.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/GroupMessageStateMachine.java
deleted file mode 100644 (file)
index bbd8f59..0000000
+++ /dev/null
@@ -1,206 +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 org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.insteon.internal.utils.Utils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Ideally, Insteon ALL LINK messages are received in this order, and
- * only a single one of each:
- *
- * BCAST (a broadcast message from the device to all group members)
- * CLEAN (a cleanup point-to-point message to ensure more reliable transmission)
- * SUCCESS (a broadcast report of success or failure of cleanup, with cmd1=0x06)
- *
- * But often, the BCAST, CLEAN and SUCCESS messages are retransmitted multiple times,
- * or (less frequently) messages are lost. The present state machine was developed
- * to remove duplicates, yet make sure that a single lost message does not cause
- * the binding to miss an update.
- *
- * @formatter:off
- *                          "SUCCESS"
- *                         EXPECT_BCAST
- *                    ^ /                ^ \
- *           SUCCESS / /                  \ \ [BCAST]
- *                  / /['CLEAN']  'SUCCESS'\ \
- *                 / /                      \ \
- *                / V         CLEAN          \ V
- * "CLEAN" EXPECT_SUCCESS <-------------- EXPECT_CLEAN "BCAST"
- *                         -------------->
- *                            ['BCAST']
- * @formatter:on
- *
- * How to read this diagram:
- *
- * Regular, expected, non-duplicate messages do not have any quotes around them,
- * and lead to the obvious state transitions.
- *
- * The actions in [square brackets] are transitions that cause a state
- * update to be published when they occur.
- *
- * The presence of double quotes indicates a duplicate that does not lead
- * to any state transitions, i.e. it is simply ignored.
- *
- * Single quotes indicate a message that is the result of a single dropped
- * message, and leads to a state transition, in some cases even to a state
- * update to be published.
- *
- * For instance at the top of the diagram, if a "SUCCESS" message is received
- * when in state EXPECT_BCAST, it is considered a duplicate (it has "").
- *
- * When in state EXPECT_SUCCESS though, receiving a ['BCAST'] is most likely because
- * the SUCCESS message was missed, and therefore it is considered the result
- * of a single lost message (has '' around it). The state changes to EXPECT_CLEAN,
- * and the message should lead to publishing of a state update (it has [] around it).
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class GroupMessageStateMachine {
-    private final Logger logger = LoggerFactory.getLogger(GroupMessageStateMachine.class);
-
-    /**
-     * The different kinds of Insteon ALL Link (Group) messages that can be received.
-     * Here is a typical sequence:
-     * BCAST:
-     * IN:Cmd:0x50|fromAddress:20.AC.99|toAddress:00.00.01|messageFlags:0xCB=ALL_LINK_BROADCAST:3:2|command1:0x13|
-     * command2:0x00|
-     * CLEAN:
-     * IN:Cmd:0x50|fromAddress:20.AC.99|toAddress:23.9B.65|messageFlags:0x41=ALL_LINK_CLEANUP:1:0|command1:0x13|command2
-     * :0x01|
-     * SUCCESS:
-     * IN:Cmd:0x50|fromAddress:20.AC.99|toAddress:13.03.01|messageFlags:0xCB=ALL_LINK_BROADCAST:3:2|command1:0x06|
-     * command2:0x00|
-     */
-    enum GroupMessage {
-        BCAST,
-        CLEAN,
-        SUCCESS
-    }
-
-    /**
-     * The state of the machine (i.e. what message we are expecting next).
-     * The usual state should be EXPECT_BCAST
-     */
-    enum State {
-        EXPECT_BCAST,
-        EXPECT_CLEAN,
-        EXPECT_SUCCESS
-    }
-
-    private State state = State.EXPECT_BCAST;
-    private long lastUpdated = 0;
-    private boolean publish = false;
-    private byte lastCmd1 = 0;
-
-    /**
-     * Advance the state machine and determine if update is genuine (no duplicate)
-     *
-     * @param a the group message (action) that was received
-     * @param address the address of the device that this state machine belongs to
-     * @param group the group that this state machine belongs to
-     * @param cmd1 cmd1 from the message received
-     * @return true if the group message is not a duplicate
-     */
-    public boolean action(GroupMessage a, InsteonAddress address, int group, byte cmd1) {
-        publish = false;
-        long currentTime = System.currentTimeMillis();
-        switch (state) {
-            case EXPECT_BCAST:
-                switch (a) {
-                    case BCAST:
-                        publish = true;
-                        break; // missed() move state machine and pub!
-                    case CLEAN:
-                        publish = true;
-                        break; // missed(BCAST)
-                    case SUCCESS:
-                        publish = false;
-                        break;
-                } // missed(BCAST,CLEAN) or dup SUCCESS
-                break;
-            case EXPECT_CLEAN:
-                switch (a) {
-                    case BCAST:
-                        if (lastCmd1 == cmd1) {
-                            if (currentTime > lastUpdated + 30000) {
-                                if (logger.isDebugEnabled()) {
-                                    logger.debug(
-                                            "{} group {} cmd1 {} is not a dup BCAST, received last message over 30000 ms ago",
-                                            address, group, Utils.getHexByte(cmd1));
-                                }
-                                publish = true;
-                            } else {
-                                publish = false;
-                            }
-                        } else {
-                            if (logger.isDebugEnabled()) {
-                                logger.debug("{} group {} cmd1 {} is not a dup BCAST, last cmd1 {}", address, group,
-                                        Utils.getHexByte(cmd1), Utils.getHexByte(lastCmd1));
-                            }
-                            publish = true;
-                        }
-                        break; // missed(CLEAN, SUCCESS) or dup BCAST
-                    case CLEAN:
-                        publish = false;
-                        break; // missed() move state machine, no pub
-                    case SUCCESS:
-                        publish = false;
-                        break;
-                } // missed(CLEAN)
-                break;
-            case EXPECT_SUCCESS:
-                switch (a) {
-                    case BCAST:
-                        publish = true;
-                        break; // missed(SUCCESS)
-                    case CLEAN:
-                        publish = false;
-                        break; // missed(SUCCESS,BCAST) or dup CLEAN
-                    case SUCCESS:
-                        publish = false;
-                        break;
-                } // missed(), move state machine, no pub
-                break;
-        }
-        State oldState = state;
-        switch (a) {
-            case BCAST:
-                state = State.EXPECT_CLEAN;
-                break;
-            case CLEAN:
-                state = State.EXPECT_SUCCESS;
-                break;
-            case SUCCESS:
-                state = State.EXPECT_BCAST;
-                break;
-        }
-
-        lastCmd1 = cmd1;
-        lastUpdated = currentTime;
-        logger.debug("{} group {} state: {} --{}--> {}, publish: {}", address, group, oldState, a, state, publish);
-        return (publish);
-    }
-
-    public long getLastUpdated() {
-        return lastUpdated;
-    }
-
-    public boolean getPublish() {
-        return publish;
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/HandlerEntry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/HandlerEntry.java
deleted file mode 100644 (file)
index ff36bd7..0000000
+++ /dev/null
@@ -1,42 +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.Map;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * Ugly little helper class to facilitate late instantiation of handlers
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class HandlerEntry {
-    Map<String, String> params;
-    String name;
-
-    HandlerEntry(String name, Map<String, String> params) {
-        this.name = name;
-        this.params = params;
-    }
-
-    Map<String, String> getParams() {
-        return params;
-    }
-
-    String getName() {
-        return name;
-    }
-}
index fed14815b46e15ac89e8b49007d33b4d62487596..da26c5681adb1f29315a1ffb65f9881c73b532cc 100644 (file)
@@ -14,88 +14,53 @@ 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.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 class InsteonAddress {
         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 class InsteonAddress {
             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 class InsteonAddress {
         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);
     }
 }
index db4430f3f8448c2db214c51be0d8d326ac097689..c5e5de007ef4e9ce617dadb8e15696e73ccc2032 100644 (file)
  */
 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<String, DeviceFeature> 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<InsteonAddress, InsteonDeviceHandler> {
+    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<String, DefaultLink> defaultLinks = new LinkedHashMap<>();
+    private List<Msg> storedMessages = new LinkedList<>();
+    private Queue<DeviceRequest> deferredQueue = new PriorityQueue<>();
+    private Map<Msg, DeviceRequest> deferredQueueHash = new HashMap<>();
+    private Map<Byte, Long> lastBroadcastReceived = new HashMap<>();
     private Map<Integer, GroupMessageStateMachine> groupState = new HashMap<>();
-    private Map<String, Object> 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<DefaultLink> getDefaultLinks() {
+        synchronized (defaultLinks) {
+            return defaultLinks.values().stream().toList();
+        }
     }
 
-    public InsteonAddress getAddress() {
-        return (address);
+    public List<Msg> getStoredMessages() {
+        synchronized (storedMessages) {
+            return storedMessages;
+        }
     }
 
-    public @Nullable Driver getDriver() {
-        return driver;
+    public List<DeviceFeature> getControllerFeatures() {
+        return getFeatures().stream().filter(DeviceFeature::isControllerFeature).toList();
     }
 
-    public long getPollInterval() {
-        return pollInterval;
+    public List<DeviceFeature> getResponderFeatures() {
+        return getFeatures().stream().filter(DeviceFeature::isResponderFeature).toList();
     }
 
-    public boolean isModem() {
-        return isModem;
+    public List<DeviceFeature> getControllerOrResponderFeatures() {
+        return getFeatures().stream().filter(DeviceFeature::isControllerOrResponderFeature).toList();
     }
 
-    public @Nullable DeviceFeature getFeature(String f) {
-        return features.get(f);
+    public List<DeviceFeature> getFeatures(String type) {
+        return getFeatures().stream().filter(feature -> feature.getType().equals(type)).toList();
     }
 
-    public Map<String, DeviceFeature> 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<String, Object> 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<String, Object> 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<Entry<String, DeviceFeature>> 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<DeviceFeature> 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<QEntry> 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<InsteonAddress> 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<Msg> 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<Msg> 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<String, LinkDBChange> getMissingDeviceLinks() {
+        Map<String, LinkDBChange> 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<String, ModemDBChange> getMissingModemLinks() {
+        Map<String, ModemDBChange> 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<String> getMissingLinks() {
+        return Stream.of(getMissingDeviceLinks().keySet(), getMissingModemLinks().keySet()).flatMap(Set::stream)
+                .collect(Collectors.toSet());
+    }
+
+    /**
+     * Logs missing links for this device
+     */
+    public void logMissingLinks() {
+        Set<String> 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<LinkDBChange> 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<String, String> 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<String, FeatureGroup> 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<ModemDBChange> 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<String> 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<Integer> 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<Integer> buttons) {
+        List<Integer> 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<Integer> 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<String, DeviceFeature> 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<QEntry> {
-        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 (file)
index 0000000..a733f38
--- /dev/null
@@ -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<Integer, InsteonEngine> 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 (file)
index 0000000..b41212d
--- /dev/null
@@ -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<InsteonAddress, InsteonBridgeHandler> 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<DeviceAddress, Device> devices = new ConcurrentHashMap<>();
+    private Map<Integer, Scene> 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<Device> getDevices() {
+        return devices.values().stream().toList();
+    }
+
+    public @Nullable InsteonDevice getInsteonDevice(InsteonAddress address) {
+        return (InsteonDevice) getDevice(address);
+    }
+
+    public List<InsteonDevice> 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<X10Device> 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<Scene> getScenes() {
+        return scenes.values().stream().toList();
+    }
+
+    public @Nullable InsteonScene getInsteonScene(int group) {
+        return (InsteonScene) getScene(group);
+    }
+
+    public List<InsteonScene> 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 (file)
index 0000000..33c19aa
--- /dev/null
@@ -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<SceneEntry> 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<SceneEntry> getEntries() {
+        synchronized (entries) {
+            return entries.stream().toList();
+        }
+    }
+
+    public List<SceneEntry> getEntries(InsteonAddress address) {
+        return getEntries().stream().filter(entry -> entry.getAddress().equals(address)).toList();
+    }
+
+    public List<InsteonAddress> getDevices() {
+        return getEntries().stream().map(SceneEntry::getAddress).distinct().toList();
+    }
+
+    public List<DeviceFeature> getFeatures() {
+        return getEntries().stream().map(SceneEntry::getFeature).toList();
+    }
+
+    public List<DeviceFeature> 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 (file)
index 0000000..4d7db03
--- /dev/null
@@ -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<String, LegacyDeviceFeature> 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<Integer, LegacyGroupMessageStateMachine> groupState = new HashMap<>();
+    private Map<String, Object> 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<String, LegacyDeviceFeature> 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<String, Object> deviceConfigMap) {
+        this.deviceConfigMap = deviceConfigMap;
+    }
+
+    public Map<String, Object> 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<Entry<String, LegacyDeviceFeature>> 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<QEntry> 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<String, String> 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<String, FeatureGroup> 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<String> 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<String, LegacyDeviceFeature> 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<QEntry> {
+        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 (file)
index 0000000..c50650d
--- /dev/null
@@ -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<Integer, LegacyMessageHandler> msgHandlers = new HashMap<>();
+    private Map<Class<? extends Command>, LegacyCommandHandler> commandHandlers = new HashMap<>();
+    private List<LegacyFeatureListener> listeners = new ArrayList<>();
+    private List<LegacyDeviceFeature> 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<Integer, LegacyMessageHandler> getMsgHandlers() {
+        return this.msgHandlers;
+    }
+
+    public List<LegacyDeviceFeature> 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<LegacyFeatureListener> 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<? extends Command> 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<? extends Command> 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 (file)
index 0000000..eac472f
--- /dev/null
@@ -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<String, String> features = new HashMap<>();
+    private Map<String, FeatureGroup> 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<String, String> getFeatures() {
+        return features;
+    }
+
+    /**
+     * Get all feature groups
+     *
+     * @return all feature groups of this device type
+     */
+    public Map<String, FeatureGroup> 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<String, String> entry : features.entrySet()) {
+            s += ":" + entry.getKey() + "=" + entry.getValue();
+        }
+        s += "|groups";
+        for (Entry<String, FeatureGroup> 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<String> fgFeatures = new ArrayList<>();
+
+        FeatureGroup(String name, String type) {
+            this.name = name;
+            this.type = type;
+        }
+
+        public void addFeature(String f) {
+            fgFeatures.add(f);
+        }
+
+        public ArrayList<String> 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 (file)
index 0000000..42b8f89
--- /dev/null
@@ -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<String, LegacyDeviceType> 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<String, LegacyDeviceType> 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/device/LegacyPollManager.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyPollManager.java
new file mode 100644 (file)
index 0000000..5f8d5be
--- /dev/null
@@ -0,0 +1,310 @@
+/**
+ * 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.InsteonBindingConstants;
+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
+ */
+@NonNullByDefault
+public class LegacyPollManager {
+    private static final long MIN_MSEC_BETWEEN_POLLS = 2000L;
+
+    private final Logger logger = LoggerFactory.getLogger(LegacyPollManager.class);
+    private static LegacyPollManager poller = new LegacyPollManager(); // for singleton
+
+    private @Nullable Thread pollThread = null;
+    private TreeSet<PQEntry> pollQueue = new TreeSet<>();
+    private boolean keepRunning = true;
+
+    /**
+     * Constructor
+     */
+    private LegacyPollManager() {
+    }
+
+    /**
+     * 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 numDev approximate number of total devices
+     */
+    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 * device.getPollInterval() / (numDev > 0 ? numDev : 1);
+            addToPollQueue(device, System.currentTimeMillis() + pollDelay);
+            pollQueue.notify();
+        }
+    }
+
+    /**
+     * Start polling a given device
+     *
+     * @param device reference to the device to be polled
+     */
+    public void stopPolling(LegacyDevice device) {
+        synchronized (pollQueue) {
+            for (Iterator<PQEntry> i = pollQueue.iterator(); i.hasNext();) {
+                if (i.next().getDevice().getAddress().equals(device.getAddress())) {
+                    i.remove();
+                    logger.debug("stopped polling device {}", device);
+                }
+            }
+        }
+    }
+
+    /**
+     * Starts the poller thread
+     */
+    public void start() {
+        if (pollThread == null) {
+            pollThread = new Thread(new PollQueueReader());
+            setParamsAndStart(pollThread);
+        }
+    }
+
+    private void setParamsAndStart(@Nullable Thread thread) {
+        if (thread != null) {
+            thread.setName("OH-binding-" + InsteonBindingConstants.BINDING_ID + "-pollQueueReader");
+            thread.setDaemon(true);
+            thread.start();
+        }
+    }
+
+    /**
+     * Stops the poller thread
+     */
+    public void stop() {
+        logger.debug("stopping poller!");
+        synchronized (pollQueue) {
+            pollQueue.clear();
+            keepRunning = false;
+            pollQueue.notify();
+        }
+        try {
+            Thread pollThread = this.pollThread;
+            if (pollThread != null) {
+                pollThread.join();
+                this.pollThread = null;
+            }
+            keepRunning = true;
+        } catch (InterruptedException e) {
+            logger.debug("got interrupted on exit: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 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 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(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);
+    }
+
+    /**
+     * 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 time desired time after which the device should be polled
+     * @return the suggested time to poll
+     */
+
+    private long findNextExpirationTime(LegacyDevice device, long time) {
+        long expTime = time;
+        // tailSet finds all those that expire after time - buffer
+        SortedSet<PQEntry> 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 = time;
+        } else {
+            Iterator<PQEntry> pqi = expired.iterator();
+            PQEntry prev = pqi.next();
+            if (prev.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 (pqi.hasNext()) {
+                    PQEntry pqe = pqi.next();
+                    long currTime = pqe.getExpirationTime();
+                    long prevTime = prev.getExpirationTime();
+                    if (currTime - prevTime >= 2 * MIN_MSEC_BETWEEN_POLLS) {
+                        // found gap
+                        logger.trace("device {} time {} found slot between {} and {}", device, time, prevTime,
+                                currTime);
+                        break;
+                    }
+                    prev = pqe;
+                }
+                expTime = prev.getExpirationTime() + MIN_MSEC_BETWEEN_POLLS;
+            }
+        }
+        return expTime;
+    }
+
+    private class PollQueueReader implements Runnable {
+        @Override
+        public void run() {
+            logger.debug("starting poll thread.");
+            synchronized (pollQueue) {
+                while (keepRunning) {
+                    try {
+                        readPollQueue();
+                    } catch (InterruptedException e) {
+                        logger.warn("poll queue reader thread interrupted!");
+                        break;
+                    }
+                }
+            }
+            logger.debug("poll thread exiting");
+        }
+
+        /**
+         * Waits for first element of poll queue to become current,
+         * then process it.
+         *
+         * @throws InterruptedException
+         */
+        private void readPollQueue() throws InterruptedException {
+            while (pollQueue.isEmpty() && keepRunning) {
+                pollQueue.wait();
+            }
+            if (!keepRunning) {
+                return;
+            }
+            // something is in the queue
+            long now = System.currentTimeMillis();
+            PQEntry pqe = pollQueue.first();
+            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);
+            }
+        }
+
+        /**
+         * 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 processQueue(long now) {
+            processQueue(now, pollQueue.pollFirst());
+        }
+
+        private void processQueue(long now, @Nullable PQEntry pqe) {
+            if (pqe != null) {
+                pqe.getDevice().doPoll(0);
+                addToPollQueue(pqe.getDevice(), now + pqe.getDevice().getPollInterval());
+            }
+        }
+    }
+
+    /**
+     * A poll queue entry corresponds to a single device that needs
+     * to be polled.
+     *
+     * @author Bernd Pfrommer - Initial contribution
+     *
+     */
+    private static class PQEntry implements Comparable<PQEntry> {
+        private LegacyDevice device;
+        private long expirationTime;
+
+        PQEntry(LegacyDevice device, long expirationTime) {
+            this.device = device;
+            this.expirationTime = expirationTime;
+        }
+
+        long getExpirationTime() {
+            return expirationTime;
+        }
+
+        LegacyDevice getDevice() {
+            return device;
+        }
+
+        @Override
+        public int compareTo(PQEntry pqe) {
+            return (int) (expirationTime - pqe.expirationTime);
+        }
+
+        @Override
+        public String toString() {
+            return device.getAddress().toString() + "/" + String.format("%tc", new Date(expirationTime));
+        }
+    }
+
+    /**
+     * Singleton pattern instance() method
+     *
+     * @return the poller instance
+     */
+    public static synchronized LegacyPollManager instance() {
+        poller.start();
+        return poller;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyRequestManager.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/LegacyRequestManager.java
new file mode 100644 (file)
index 0000000..34aeceb
--- /dev/null
@@ -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.HashMap;
+import java.util.Map;
+import java.util.PriorityQueue;
+import java.util.Queue;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.InsteonBindingConstants;
+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 m_requestQueues corresponds to a single device's request queue.
+ * A device should never be more than once in m_requestQueues.
+ * - A hash map (m_requestQueueHash) is kept in sync with m_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
+ */
+@NonNullByDefault
+public class LegacyRequestManager {
+    private static @Nullable LegacyRequestManager instance = null;
+    private final Logger logger = LoggerFactory.getLogger(LegacyRequestManager.class);
+    private @Nullable Thread queueThread = null;
+    private Queue<RequestQueue> requestQueues = new PriorityQueue<>();
+    private Map<LegacyDevice, RequestQueue> requestQueueHash = new HashMap<>();
+    private boolean keepRunning = true;
+
+    private LegacyRequestManager() {
+        queueThread = new Thread(new RequestQueueReader());
+        setParamsAndStart(queueThread);
+    }
+
+    private void setParamsAndStart(@Nullable Thread thread) {
+        if (thread != null) {
+            thread.setName("OH-binding-" + InsteonBindingConstants.BINDING_ID + "-requestQueueReader");
+            thread.setDaemon(true);
+            thread.start();
+        }
+    }
+
+    /**
+     * Add device to global request queue.
+     *
+     * @param device the device to add
+     * @param time the time when the queue should be processed
+     */
+    public void addQueue(LegacyDevice device, long time) {
+        synchronized (requestQueues) {
+            RequestQueue queue = requestQueueHash.get(device);
+            if (queue == null) {
+                logger.trace("scheduling request for device {} in {} msec", device.getAddress(),
+                        time - System.currentTimeMillis());
+                queue = new RequestQueue(device, time);
+            } else {
+                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(device);
+            }
+            long expTime = queue.getExpirationTime();
+            if (expTime > time) {
+                queue.setExpirationTime(time);
+            }
+            // add the queue back in after (maybe) having modified
+            // the expiration time
+            requestQueues.add(queue);
+            requestQueueHash.put(device, queue);
+            requestQueues.notify();
+        }
+    }
+
+    /**
+     * Stops request queue thread
+     */
+    private void stopThread() {
+        logger.debug("stopping thread");
+        Thread queueThread = this.queueThread;
+        if (queueThread != null) {
+            synchronized (requestQueues) {
+                keepRunning = false;
+                requestQueues.notifyAll();
+            }
+            try {
+                logger.debug("waiting for thread to join");
+                queueThread.join();
+                logger.debug("request queue thread exited!");
+            } catch (InterruptedException e) {
+                logger.warn("got interrupted waiting for thread exit ", e);
+            }
+            this.queueThread = null;
+        }
+    }
+
+    class RequestQueueReader implements Runnable {
+        @Override
+        public void run() {
+            logger.debug("starting request queue thread");
+            synchronized (requestQueues) {
+                while (keepRunning) {
+                    try {
+                        RequestQueue queue;
+                        while (keepRunning && (queue = requestQueues.peek()) != null) {
+                            long now = System.currentTimeMillis();
+                            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", 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!
+                            queue = requestQueues.poll(); // remove front element
+                            requestQueueHash.remove(device); // and remove from hash map
+                            long nextExp = device.processRequestQueue(now);
+                            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.debug("device queue for {} is empty!", device.getAddress());
+                            }
+                        }
+                        logger.trace("waiting for request queues to fill");
+                        requestQueues.wait();
+                    } catch (InterruptedException e) {
+                        logger.warn("request queue thread got interrupted, breaking..", e);
+                        break;
+                    }
+                }
+            }
+            logger.debug("exiting request queue thread!");
+        }
+    }
+
+    public static class RequestQueue implements Comparable<RequestQueue> {
+        private LegacyDevice device;
+        private long expirationTime;
+
+        RequestQueue(LegacyDevice device, long expirationTime) {
+            this.device = device;
+            this.expirationTime = expirationTime;
+        }
+
+        public LegacyDevice getDevice() {
+            return device;
+        }
+
+        public long getExpirationTime() {
+            return expirationTime;
+        }
+
+        public void setExpirationTime(long t) {
+            expirationTime = t;
+        }
+
+        @Override
+        public int compareTo(RequestQueue queue) {
+            return (int) (expirationTime - queue.expirationTime);
+        }
+    }
+
+    public static synchronized @Nullable LegacyRequestManager instance() {
+        if (instance == null) {
+            instance = new LegacyRequestManager();
+        }
+        return instance;
+    }
+
+    public static synchronized void destroyInstance() {
+        LegacyRequestManager instance = LegacyRequestManager.instance;
+        if (instance != null) {
+            instance.stopThread();
+            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 (file)
index 0000000..78a9aa0
--- /dev/null
@@ -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<LinkingRequest> 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/MessageDispatcher.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/MessageDispatcher.java
deleted file mode 100644 (file)
index 3ecb4df..0000000
+++ /dev/null
@@ -1,410 +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.lang.reflect.InvocationTargetException;
-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.message.FieldException;
-import org.openhab.binding.insteon.internal.message.Msg;
-import org.openhab.binding.insteon.internal.utils.Utils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Does preprocessing of messages to decide which handler should be called.
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public abstract class MessageDispatcher {
-    private static final Logger logger = LoggerFactory.getLogger(MessageDispatcher.class);
-
-    DeviceFeature feature;
-    @Nullable
-    Map<String, String> parameters = new HashMap<>();
-
-    /**
-     * Constructor
-     *
-     * @param f DeviceFeature to which this MessageDispatcher belongs
-     */
-    MessageDispatcher(DeviceFeature f) {
-        feature = f;
-    }
-
-    public void setParameters(@Nullable Map<String, String> map) {
-        parameters = map;
-    }
-
-    /**
-     * Generic handling of incoming ALL LINK messages
-     *
-     * @param msg the message received
-     * @return true if the message was handled by this function
-     */
-    protected boolean handleAllLinkMessage(Msg msg) {
-        if (!msg.isAllLink()) {
-            return false;
-        }
-        try {
-            InsteonAddress a = msg.getAddress("toAddress");
-            // ALL_LINK_BROADCAST and ALL_LINK_CLEANUP
-            // have a valid Command1 field
-            // but the CLEANUP_SUCCESS (of type ALL_LINK_BROADCAST!)
-            // message has cmd1 = 0x06 and the cmd as the
-            // high byte of the toAddress.
-            byte cmd1 = msg.getByte("command1");
-            if (!msg.isCleanup() && cmd1 == 0x06) {
-                cmd1 = a.getHighByte();
-            }
-            // For ALL_LINK_BROADCAST messages, the group is
-            // in the low byte of the toAddress. For direct
-            // ALL_LINK_CLEANUP, it is in Command2
-
-            int group = (msg.isCleanup() ? msg.getByte("command2") : a.getLowByte()) & 0xff;
-            MessageHandler h = feature.getMsgHandlers().get(cmd1 & 0xFF);
-            if (h == null) {
-                logger.debug("msg is not for this feature");
-                return true;
-            }
-            if (!h.isDuplicate(msg)) {
-                if (h.matchesGroup(group) && h.matches(msg)) {
-                    logger.debug("{}:{}->{} cmd1:{} group {}/{}", feature.getDevice().getAddress(), feature.getName(),
-                            h.getClass().getSimpleName(), Utils.getHexByte(cmd1), group, h.getGroup());
-                    h.handleMessage(group, cmd1, msg, feature);
-                } else {
-                    logger.debug("message ignored because matches group: {} matches filter: {}", h.matchesGroup(group),
-                            h.matches(msg));
-                }
-            } else {
-                logger.debug("message ignored as duplicate. Matches group: {} matches filter: {}",
-                        h.matchesGroup(group), h.matches(msg));
-            }
-        } catch (FieldException e) {
-            logger.warn("couldn't parse ALL_LINK message: {}", msg, e);
-        }
-        return true;
-    }
-
-    /**
-     * Checks if this message is in response to previous query by this feature
-     *
-     * @param msg
-     * @return true;
-     */
-    @SuppressWarnings("PMD.CompareObjectsWithEquals")
-    boolean isMyDirectAck(Msg msg) {
-        return msg.isAckOfDirect() && (feature.getQueryStatus() == DeviceFeature.QueryStatus.QUERY_PENDING)
-                && feature.getDevice().getFeatureQueried() == feature;
-    }
-
-    /**
-     * Dispatches message
-     *
-     * @param msg Message to dispatch
-     * @return true if this message was found to be a reply to a direct message,
-     *         and was claimed by one of the handlers
-     */
-    public abstract boolean dispatch(Msg msg);
-
-    //
-    //
-    // ------------ implementations of MessageDispatchers start here ------------------
-    //
-    //
-
-    public static class DefaultDispatcher extends MessageDispatcher {
-        DefaultDispatcher(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public boolean dispatch(Msg msg) {
-            byte cmd = 0x00;
-            byte cmd1 = 0x00;
-            boolean isConsumed = false;
-            int key = -1;
-            try {
-                cmd = msg.getByte("Cmd");
-                cmd1 = msg.getByte("command1");
-            } catch (FieldException e) {
-                logger.debug("no command found, dropping msg {}", msg);
-                return false;
-            }
-            if (msg.isAllLinkCleanupAckOrNack()) {
-                // Had cases when a KeypadLinc would send an ALL_LINK_CLEANUP_ACK
-                // in response to a direct status query message
-                return false;
-            }
-            if (handleAllLinkMessage(msg)) {
-                return false;
-            }
-            if (msg.isAckOfDirect()) {
-                // in the case of direct ack, the cmd1 code is useless.
-                // you have to know what message was sent before to
-                // interpret the reply message
-                if (isMyDirectAck(msg)) {
-                    logger.debug("{}:{} DIRECT_ACK: q:{} cmd: {}", feature.getDevice().getAddress(), feature.getName(),
-                            feature.getQueryStatus(), cmd);
-                    isConsumed = true;
-                    if (cmd == 0x50) {
-                        // must be a reply to our message, tweak the cmd1 code!
-                        logger.debug("changing key to 0x19 for msg {}", msg);
-                        key = 0x19; // we have installed a handler under that command number
-                    }
-                }
-            } else {
-                key = (cmd1 & 0xFF);
-            }
-            if (key != -1 || feature.isStatusFeature()) {
-                MessageHandler h = feature.getMsgHandlers().get(key);
-                if (h == null) {
-                    h = feature.getDefaultMsgHandler();
-                }
-                if (h.matches(msg)) {
-                    if (!isConsumed) {
-                        logger.debug("{}:{}->{} DIRECT", feature.getDevice().getAddress(), feature.getName(),
-                                h.getClass().getSimpleName());
-                    }
-                    h.handleMessage(-1, cmd1, msg, feature);
-                }
-            }
-            if (isConsumed) {
-                feature.setQueryStatus(DeviceFeature.QueryStatus.QUERY_ANSWERED);
-                logger.debug("defdisp: {}:{} set status to: {}", feature.getDevice().getAddress(), feature.getName(),
-                        feature.getQueryStatus());
-            }
-            return isConsumed;
-        }
-    }
-
-    public static class DefaultGroupDispatcher extends MessageDispatcher {
-        DefaultGroupDispatcher(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public boolean dispatch(Msg msg) {
-            byte cmd = 0x00;
-            byte cmd1 = 0x00;
-            boolean isConsumed = false;
-            int key = -1;
-            try {
-                cmd = msg.getByte("Cmd");
-                cmd1 = msg.getByte("command1");
-            } catch (FieldException e) {
-                logger.debug("no command found, dropping msg {}", msg);
-                return false;
-            }
-            if (msg.isAllLinkCleanupAckOrNack()) {
-                // Had cases when a KeypadLinc would send an ALL_LINK_CLEANUP_ACK
-                // in response to a direct status query message
-                return false;
-            }
-            if (handleAllLinkMessage(msg)) {
-                return false;
-            }
-            if (msg.isAckOfDirect()) {
-                // in the case of direct ack, the cmd1 code is useless.
-                // you have to know what message was sent before to
-                // interpret the reply message
-                if (isMyDirectAck(msg)) {
-                    logger.debug("{}:{} qs:{} cmd: {}", feature.getDevice().getAddress(), feature.getName(),
-                            feature.getQueryStatus(), cmd);
-                    isConsumed = true;
-                    if (cmd == 0x50) {
-                        // must be a reply to our message, tweak the cmd1 code!
-                        logger.debug("changing key to 0x19 for msg {}", msg);
-                        key = 0x19; // we have installed a handler under that command number
-                    }
-                }
-            } else {
-                key = (cmd1 & 0xFF);
-            }
-            if (key != -1) {
-                for (DeviceFeature f : feature.getConnectedFeatures()) {
-                    MessageHandler h = f.getMsgHandlers().get(key);
-                    if (h == null) {
-                        h = f.getDefaultMsgHandler();
-                    }
-                    if (h.matches(msg)) {
-                        if (!isConsumed) {
-                            logger.debug("{}:{}->{} DIRECT", f.getDevice().getAddress(), f.getName(),
-                                    h.getClass().getSimpleName());
-                        }
-                        h.handleMessage(-1, cmd1, msg, f);
-                    }
-
-                }
-            }
-            if (isConsumed) {
-                feature.setQueryStatus(DeviceFeature.QueryStatus.QUERY_ANSWERED);
-                logger.debug("{}:{} set status to: {}", feature.getDevice().getAddress(), feature.getName(),
-                        feature.getQueryStatus());
-            }
-            return isConsumed;
-        }
-    }
-
-    public static class PollGroupDispatcher extends MessageDispatcher {
-        PollGroupDispatcher(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public boolean dispatch(Msg msg) {
-            if (msg.isAllLinkCleanupAckOrNack()) {
-                // Had cases when a KeypadLinc would send an ALL_LINK_CLEANUP_ACK
-                // in response to a direct status query message
-                return false;
-            }
-            if (handleAllLinkMessage(msg)) {
-                return false;
-            }
-            if (msg.isAckOfDirect()) {
-                boolean isMyAck = isMyDirectAck(msg);
-                if (isMyAck) {
-                    logger.debug("{}:{} got poll ACK", feature.getDevice().getAddress(), feature.getName());
-                }
-                return (isMyAck);
-            }
-            return (false); // not a direct ack, so we didn't consume it either
-        }
-    }
-
-    public static class SimpleDispatcher extends MessageDispatcher {
-        SimpleDispatcher(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public boolean dispatch(Msg msg) {
-            byte cmd1 = 0x00;
-            try {
-                if (handleAllLinkMessage(msg)) {
-                    return false;
-                }
-                if (msg.isAllLinkCleanupAckOrNack()) {
-                    // Had cases when a KeypadLinc would send an ALL_LINK_CLEANUP_ACK
-                    // in response to a direct status query message
-                    return false;
-                }
-                cmd1 = msg.getByte("command1");
-            } catch (FieldException e) {
-                logger.debug("no cmd1 found, dropping msg {}", msg);
-                return false;
-            }
-            boolean isConsumed = isMyDirectAck(msg);
-            int key = (cmd1 & 0xFF);
-            MessageHandler h = feature.getMsgHandlers().get(key);
-            if (h == null) {
-                h = feature.getDefaultMsgHandler();
-            }
-            if (h.matches(msg)) {
-                logger.trace("{}:{}->{} {}", feature.getDevice().getAddress(), feature.getName(),
-                        h.getClass().getSimpleName(), msg);
-                h.handleMessage(-1, cmd1, msg, feature);
-            }
-            return isConsumed;
-        }
-    }
-
-    public static class X10Dispatcher extends MessageDispatcher {
-        X10Dispatcher(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public boolean dispatch(Msg msg) {
-            try {
-                byte rawX10 = msg.getByte("rawX10");
-                int cmd = (rawX10 & 0x0f);
-                MessageHandler h = feature.getMsgHandlers().get(cmd);
-                if (h == null) {
-                    h = feature.getDefaultMsgHandler();
-                }
-                logger.debug("{}:{}->{} {}", feature.getDevice().getAddress(), feature.getName(),
-                        h.getClass().getSimpleName(), msg);
-                if (h.matches(msg)) {
-                    h.handleMessage(-1, (byte) cmd, msg, feature);
-                }
-            } catch (FieldException e) {
-                logger.warn("error parsing {}: ", msg, e);
-            }
-            return false;
-        }
-    }
-
-    public static class PassThroughDispatcher extends MessageDispatcher {
-        PassThroughDispatcher(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public boolean dispatch(Msg msg) {
-            MessageHandler h = feature.getDefaultMsgHandler();
-            if (h.matches(msg)) {
-                logger.trace("{}:{}->{} {}", feature.getDevice().getAddress(), feature.getName(),
-                        h.getClass().getSimpleName(), msg);
-                h.handleMessage(-1, (byte) 0x01, msg, feature);
-            }
-            return false;
-        }
-    }
-
-    /**
-     * Drop all incoming messages silently
-     */
-    public static class NoOpDispatcher extends MessageDispatcher {
-        NoOpDispatcher(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public boolean dispatch(Msg msg) {
-            return false;
-        }
-    }
-
-    /**
-     * Factory method for creating a dispatcher of a given name using java reflection
-     *
-     * @param name the name of the dispatcher to create
-     * @param params
-     * @param f the feature for which to create the dispatcher
-     * @return the handler which was created
-     */
-    @Nullable
-    public static <T extends MessageDispatcher> T makeHandler(String name, @Nullable Map<String, String> params,
-            DeviceFeature f) {
-        String cname = MessageDispatcher.class.getName() + "$" + name;
-        try {
-            Class<?> c = Class.forName(cname);
-            @SuppressWarnings("unchecked")
-            Class<? extends T> dc = (Class<? extends T>) c;
-            @Nullable
-            T ch = dc.getDeclaredConstructor(DeviceFeature.class).newInstance(f);
-            ch.setParameters(params);
-            return ch;
-        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
-                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
-            logger.warn("error trying to create dispatcher: {}", name, e);
-        }
-        return null;
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/MessageHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/MessageHandler.java
deleted file mode 100644 (file)
index 54ea05c..0000000
+++ /dev/null
@@ -1,1328 +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.lang.reflect.InvocationTargetException;
-import java.math.BigDecimal;
-import java.math.RoundingMode;
-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.device.DeviceFeatureListener.StateChangeType;
-import org.openhab.binding.insteon.internal.device.GroupMessageStateMachine.GroupMessage;
-import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler;
-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.MsgType;
-import org.openhab.binding.insteon.internal.utils.Utils;
-import org.openhab.core.library.types.DateTimeType;
-import org.openhab.core.library.types.DecimalType;
-import org.openhab.core.library.types.OnOffType;
-import org.openhab.core.library.types.OpenClosedType;
-import org.openhab.core.library.types.PercentType;
-import org.openhab.core.library.types.QuantityType;
-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.State;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * A message handler processes incoming Insteon messages and reacts by publishing
- * corresponding messages on the openhab bus, updating device state etc.
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Bernd Pfrommer - openHAB 1 insteonplm binding
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public abstract class MessageHandler {
-    private static final Logger logger = LoggerFactory.getLogger(MessageHandler.class);
-
-    protected DeviceFeature feature;
-    protected Map<String, String> parameters = new HashMap<>();
-
-    /**
-     * Constructor
-     *
-     * @param p state publishing object for dissemination of state changes
-     */
-    MessageHandler(DeviceFeature p) {
-        feature = p;
-    }
-
-    /**
-     * Method that processes incoming message. The cmd1 parameter
-     * has been extracted earlier already (to make a decision which message handler to call),
-     * and is passed in as an argument so cmd1 does not have to be extracted from the message again.
-     *
-     * @param group all-link group or -1 if not specified
-     * @param cmd1 the insteon cmd1 field
-     * @param msg the received insteon message
-     * @param feature the DeviceFeature to which this message handler is attached
-     */
-    public abstract void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature feature);
-
-    /**
-     * Method to send an extended insteon message for querying a device
-     *
-     * @param f DeviceFeature that is being currently handled
-     * @param aCmd1 cmd1 for message to be sent
-     * @param aCmd2 cmd2 for message to be sent
-     */
-    public void sendExtendedQuery(DeviceFeature f, byte aCmd1, byte aCmd2) {
-        InsteonDevice d = f.getDevice();
-        try {
-            Msg m = d.makeExtendedMessage((byte) 0x1f, aCmd1, aCmd2);
-            m.setQuietTime(500L);
-            d.enqueueMessage(m, f);
-        } catch (InvalidMessageTypeException e) {
-            logger.warn("msg exception sending query message to device {}", d.getAddress());
-        } catch (FieldException e) {
-            logger.warn("field exception sending query message to device {}", d.getAddress());
-        }
-    }
-
-    /**
-     * Check if group matches
-     *
-     * @param group group to test for
-     * @return true if group matches or no group is specified
-     */
-    public boolean matchesGroup(int group) {
-        int g = getIntParameter("group", -1);
-        return (g == -1 || g == group);
-    }
-
-    /**
-     * Retrieve group parameter or -1 if no group is specified
-     *
-     * @return group parameter
-     */
-    public int getGroup() {
-        return (getIntParameter("group", -1));
-    }
-
-    /**
-     * Helper function to get an integer parameter for the handler
-     *
-     * @param key name of the int parameter (as specified in device features!)
-     * @param def default to return if parameter not found
-     * @return value of int parameter (or default if not found)
-     */
-    protected int getIntParameter(String key, int def) {
-        String val = parameters.get(key);
-        if (val == null) {
-            return (def); // param not found
-        }
-        int ret = def;
-        try {
-            ret = Utils.strToInt(val);
-        } catch (NumberFormatException e) {
-            logger.warn("malformed int parameter in message handler: {}", key);
-        }
-        return ret;
-    }
-
-    /**
-     * Helper function to get a String parameter for the handler
-     *
-     * @param key name of the String parameter (as specified in device features!)
-     * @param def default to return if parameter not found
-     * @return value of parameter (or default if not found)
-     */
-    protected @Nullable String getStringParameter(String key, @Nullable String def) {
-        String str = parameters.get(key);
-        return str != null ? str : def;
-    }
-
-    /**
-     * Helper function to get a double parameter for the handler
-     *
-     * @param key name of the parameter (as specified in device features!)
-     * @param def default to return if parameter not found
-     * @return value of parameter (or default if not found)
-     */
-    protected double getDoubleParameter(String key, double def) {
-        try {
-            String str = parameters.get(key);
-            return str != null ? Double.parseDouble(str) : def;
-        } catch (NumberFormatException e) {
-            logger.warn("malformed int parameter in message handler: {}", key);
-        }
-        return def;
-    }
-
-    protected boolean getBooleanDeviceConfig(String key, boolean def) {
-        Object o = feature.getDevice().getDeviceConfigMap().get(key);
-        if (o != null) {
-            if (o instanceof Boolean booleanValue) {
-                return booleanValue;
-            } else {
-                logger.warn("{} {}: The value for the '{}' key is not boolean in the device configuration parameter.",
-                        nm(), feature.getDevice().getAddress(), key);
-            }
-        }
-
-        return def;
-    }
-
-    /**
-     * Test if message refers to the button configured for given feature
-     *
-     * @param msg received message
-     * @param f device feature to test
-     * @return true if we have no button configured or the message is for this button
-     */
-    protected boolean isMybutton(Msg msg, DeviceFeature f) {
-        int myButton = getIntParameter("button", -1);
-        // if there is no button configured for this handler
-        // the message is assumed to refer to this feature
-        // no matter what button is addressed in the message
-        if (myButton == -1) {
-            return true;
-        }
-
-        int button = getButtonInfo(msg, f);
-        return button != -1 && myButton == button;
-    }
-
-    /**
-     * Test if parameter matches value
-     *
-     * @param param name of parameter to match
-     * @param msg message to search
-     * @param field field name to match
-     * @return true if parameter matches
-     * @throws FieldException if field not there
-     */
-    protected boolean testMatch(String param, Msg msg, String field) throws FieldException {
-        int mp = getIntParameter(param, -1);
-        // parameter not filtered for, declare this a match!
-        if (mp == -1) {
-            return (true);
-        }
-        byte value = msg.getByte(field);
-        return (value == mp);
-    }
-
-    /**
-     * Test if message matches the filter parameters
-     *
-     * @param msg message to be tested against
-     * @return true if message matches
-     */
-    public boolean matches(Msg msg) {
-        try {
-            int ext = getIntParameter("ext", -1);
-            if (ext != -1) {
-                if ((msg.isExtended() && ext != 1) || (!msg.isExtended() && ext != 0)) {
-                    return (false);
-                }
-                if (!testMatch("match_cmd1", msg, "command1")) {
-                    return (false);
-                }
-            }
-            if (!testMatch("match_cmd2", msg, "command2")) {
-                return (false);
-            }
-            if (!testMatch("match_d1", msg, "userData1")) {
-                return (false);
-            }
-            if (!testMatch("match_d2", msg, "userData2")) {
-                return (false);
-            }
-            if (!testMatch("match_d3", msg, "userData3")) {
-                return (false);
-            }
-        } catch (FieldException e) {
-            logger.warn("error matching message: {}", msg, e);
-            return (false);
-        }
-        return (true);
-    }
-
-    /**
-     * Determines is an incoming ALL LINK message is a duplicate
-     *
-     * @param msg the received ALL LINK message
-     * @return true if this message is a duplicate
-     */
-    protected boolean isDuplicate(Msg msg) {
-        boolean isDuplicate = false;
-        try {
-            MsgType t = MsgType.fromValue(msg.getByte("messageFlags"));
-            if (t == MsgType.ALL_LINK_BROADCAST) {
-                int group = msg.getAddress("toAddress").getLowByte() & 0xff;
-                byte cmd1 = msg.getByte("command1");
-                // if the command is 0x06, then it's success message
-                // from the original broadcaster, with which the device
-                // confirms that it got all cleanup replies successfully.
-                GroupMessage gm = (cmd1 == 0x06) ? GroupMessage.SUCCESS : GroupMessage.BCAST;
-                isDuplicate = !feature.getDevice().getGroupState(group, gm, cmd1);
-            } else if (t == MsgType.ALL_LINK_CLEANUP) {
-                // the cleanup messages are direct messages, so the
-                // group # is not in the toAddress, but in cmd2
-                int group = msg.getByte("command2") & 0xff;
-                isDuplicate = !feature.getDevice().getGroupState(group, GroupMessage.CLEAN, (byte) 0);
-            }
-        } catch (IllegalArgumentException e) {
-            logger.warn("cannot parse msg: {}", msg, e);
-        } catch (FieldException e) {
-            logger.warn("cannot parse msg: {}", msg, e);
-        }
-        return (isDuplicate);
-    }
-
-    /**
-     * Extract button information from message
-     *
-     * @param msg the message to extract from
-     * @param f the device feature (needed for debug printing)
-     * @return the button number or -1 if no button found
-     */
-    protected static int getButtonInfo(Msg msg, DeviceFeature f) {
-        // the cleanup messages have the button number in the command2 field
-        // the broadcast messages have it as the lsb of the toAddress
-        try {
-            int bclean = msg.getByte("command2") & 0xff;
-            int bbcast = msg.getAddress("toAddress").getLowByte() & 0xff;
-            int button = msg.isCleanup() ? bclean : bbcast;
-            logger.trace("{} button: {} bclean: {} bbcast: {}", f.getDevice().getAddress(), button, bclean, bbcast);
-            return button;
-        } catch (FieldException e) {
-            logger.warn("field exception while parsing msg {}: ", msg, e);
-        }
-        return -1;
-    }
-
-    /**
-     * Shorthand to return class name for logging purposes
-     *
-     * @return name of the class
-     */
-    protected String nm() {
-        return (this.getClass().getSimpleName());
-    }
-
-    /**
-     * Set parameter map
-     *
-     * @param map the parameter map for this message handler
-     */
-    public void setParameters(Map<String, String> map) {
-        parameters = map;
-    }
-
-    //
-    //
-    // ---------------- the various command handler start here -------------------
-    //
-    //
-
-    public static class DefaultMsgHandler extends MessageHandler {
-        DefaultMsgHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            logger.debug("{} ignoring unimpl message with cmd1:{}", nm(), Utils.getHexByte(cmd1));
-        }
-    }
-
-    public static class NoOpMsgHandler extends MessageHandler {
-        NoOpMsgHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            logger.trace("{} ignore msg {}: {}", nm(), Utils.getHexByte(cmd1), msg);
-        }
-    }
-
-    public static class LightOnDimmerHandler extends MessageHandler {
-        LightOnDimmerHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            if (!isMybutton(msg, f)) {
-                return;
-            }
-            InsteonAddress a = f.getDevice().getAddress();
-            if (msg.isAckOfDirect()) {
-                logger.warn("{}: device {}: ignoring ack of direct.", nm(), a);
-            } else {
-                String mode = getStringParameter("mode", "REGULAR");
-                logger.debug("{}: device {} was turned on {}. " + "Sending poll request to get actual level", nm(), a,
-                        mode);
-                feature.publish(PercentType.HUNDRED, StateChangeType.ALWAYS);
-                // need to poll to find out what level the dimmer is at now.
-                // it may not be at 100% because dimmers can be configured
-                // to switch to e.g. 75% when turned on.
-                Msg m = f.makePollMsg();
-                if (m != null) {
-                    f.getDevice().enqueueDelayedMessage(m, f, 1000);
-                }
-            }
-        }
-    }
-
-    public static class LightOffDimmerHandler extends MessageHandler {
-        LightOffDimmerHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            if (isMybutton(msg, f)) {
-                String mode = getStringParameter("mode", "REGULAR");
-                logger.debug("{}: device {} was turned off {}.", nm(), f.getDevice().getAddress(), mode);
-                f.publish(PercentType.ZERO, StateChangeType.ALWAYS);
-            }
-        }
-    }
-
-    public static class LightOnSwitchHandler extends MessageHandler {
-        LightOnSwitchHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            if (isMybutton(msg, f)) {
-                String mode = getStringParameter("mode", "REGULAR");
-                logger.debug("{}: device {} was switched on {}.", nm(), f.getDevice().getAddress(), mode);
-                f.publish(OnOffType.ON, StateChangeType.ALWAYS);
-            } else {
-                logger.debug("ignored message: {}", isMybutton(msg, f));
-            }
-        }
-    }
-
-    public static class LightOffSwitchHandler extends MessageHandler {
-        LightOffSwitchHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            if (isMybutton(msg, f)) {
-                String mode = getStringParameter("mode", "REGULAR");
-                logger.debug("{}: device {} was switched off {}.", nm(), f.getDevice().getAddress(), mode);
-                f.publish(OnOffType.OFF, StateChangeType.ALWAYS);
-            }
-        }
-    }
-
-    /**
-     * This message handler processes replies to Ramp ON/OFF commands.
-     * Currently, it's been tested for the 2672-222 LED Bulb. Other
-     * devices may use a different pair of commands (0x2E, 0x2F). This
-     * handler and the command handler will need to be extended to support
-     * those devices.
-     */
-    public static class RampDimmerHandler extends MessageHandler {
-        private int onCmd;
-        private int offCmd;
-
-        RampDimmerHandler(DeviceFeature p) {
-            super(p);
-            // Can't process parameters here because they are set after constructor is invoked.
-            // Unfortunately, this means we can't declare the onCmd, offCmd to be final.
-        }
-
-        @Override
-        public void setParameters(Map<String, String> params) {
-            super.setParameters(params);
-            onCmd = getIntParameter("on", 0x2E);
-            offCmd = getIntParameter("off", 0x2F);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            if (cmd1 == onCmd) {
-                int level = getLevel(msg);
-                logger.debug("{}: device {} was switched on using ramp to level {}.", nm(), f.getDevice().getAddress(),
-                        level);
-                if (level == 100) {
-                    f.publish(OnOffType.ON, StateChangeType.ALWAYS);
-                } else {
-                    // The publisher will convert an ON at level==0 to an OFF.
-                    // However, this is not completely accurate since a ramp
-                    // off at level == 0 may not turn off the dimmer completely
-                    // (if I understand the Insteon docs correctly). In any
-                    // case,
-                    // it would be an odd scenario to turn ON a light at level
-                    // == 0
-                    // rather than turn if OFF.
-                    f.publish(new PercentType(level), StateChangeType.ALWAYS);
-                }
-            } else if (cmd1 == offCmd) {
-                logger.debug("{}: device {} was switched off using ramp.", nm(), f.getDevice().getAddress());
-                f.publish(new PercentType(0), StateChangeType.ALWAYS);
-            }
-        }
-
-        private int getLevel(Msg msg) {
-            try {
-                byte cmd2 = msg.getByte("command2");
-                return (int) Math.round(((cmd2 >> 4) & 0x0f) * (100 / 15d));
-            } catch (FieldException e) {
-                logger.warn("Can't access command2 byte", e);
-                return 0;
-            }
-        }
-    }
-
-    /**
-     * A message handler that processes replies to queries.
-     * If command2 == 0xFF then the light has been turned on
-     * else if command2 == 0x00 then the light has been turned off
-     */
-
-    public static class SwitchRequestReplyHandler extends MessageHandler {
-        SwitchRequestReplyHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            try {
-                InsteonAddress a = f.getDevice().getAddress();
-                int cmd2 = msg.getByte("command2") & 0xff;
-                int button = this.getIntParameter("button", -1);
-                if (button < 0) {
-                    handleNoButtons(cmd2, a, msg);
-                } else {
-                    boolean isOn = isLEDLit(cmd2, button);
-                    logger.debug("{}: dev {} button {} switched to {}", nm(), a, button, isOn ? "ON" : "OFF");
-                    feature.publish(OnOffType.from(isOn), StateChangeType.CHANGED);
-                }
-            } catch (FieldException e) {
-                logger.warn("{} error parsing {}: ", nm(), msg, e);
-            }
-        }
-
-        /**
-         * Handle the case where no buttons have been configured.
-         * In this situation, the only return values should be 0 (light off)
-         * or 0xff (light on)
-         *
-         * @param cmd2
-         */
-        void handleNoButtons(int cmd2, InsteonAddress a, Msg msg) {
-            if (cmd2 == 0) {
-                logger.debug("{}: set device {} to OFF", nm(), a);
-                feature.publish(OnOffType.OFF, StateChangeType.CHANGED);
-            } else if (cmd2 == 0xff) {
-                logger.debug("{}: set device {} to ON", nm(), a);
-                feature.publish(OnOffType.ON, StateChangeType.CHANGED);
-            } else {
-                logger.warn("{}: {} ignoring unexpected cmd2 in msg: {}", nm(), a, msg);
-            }
-        }
-
-        /**
-         * Test if cmd byte indicates that button is lit.
-         * The cmd byte has the LED status bitwise from the left:
-         * 87654321
-         * Note that the 2487S has buttons assigned like this:
-         * 22|6543|11
-         * They used the basis of the 8-button remote, and assigned
-         * the ON button to 1+2, the OFF button to 7+8
-         *
-         * @param cmd cmd byte as received in message
-         * @param button button to test (number in range 1..8)
-         * @return true if button is lit, false otherwise
-         */
-        private boolean isLEDLit(int cmd, int button) {
-            boolean isSet = (cmd & (0x1 << (button - 1))) != 0;
-            logger.trace("cmd: {} button {}", Integer.toBinaryString(cmd), button);
-            logger.trace("msk: {} isSet: {}", Integer.toBinaryString(0x1 << (button - 1)), isSet);
-            return (isSet);
-        }
-    }
-
-    /**
-     * Handles Dimmer replies to status requests.
-     * In the dimmers case the command2 byte represents the light level from 0-255
-     */
-    public static class DimmerRequestReplyHandler extends MessageHandler {
-        DimmerRequestReplyHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonDevice dev = f.getDevice();
-            try {
-                int cmd2 = msg.getByte("command2") & 0xff;
-                if (cmd2 == 0xfe) {
-                    // sometimes dimmer devices are returning 0xfe when on instead of 0xff
-                    cmd2 = 0xff;
-                }
-
-                if (cmd2 == 0) {
-                    logger.debug("{}: set device {} to level 0", nm(), dev.getAddress());
-                    feature.publish(PercentType.ZERO, StateChangeType.CHANGED);
-                } else if (cmd2 == 0xff) {
-                    logger.debug("{}: set device {} to level 100", nm(), dev.getAddress());
-                    feature.publish(PercentType.HUNDRED, StateChangeType.CHANGED);
-                } else {
-                    int level = cmd2 * 100 / 255;
-                    if (level == 0) {
-                        level = 1;
-                    }
-                    logger.debug("{}: set device {} to level {}", nm(), dev.getAddress(), level);
-                    feature.publish(new PercentType(level), StateChangeType.CHANGED);
-                }
-            } catch (FieldException e) {
-                logger.warn("{}: error parsing {}: ", nm(), msg, e);
-            }
-        }
-    }
-
-    public static class DimmerStopManualChangeHandler extends MessageHandler {
-        DimmerStopManualChangeHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public boolean isDuplicate(Msg msg) {
-            // Disable duplicate elimination because
-            // there are no cleanup or success messages for start/stop.
-            return (false);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            Msg m = f.makePollMsg();
-            if (m != null) {
-                f.getDevice().enqueueMessage(m, f);
-            }
-        }
-    }
-
-    public static class StartManualChangeHandler extends MessageHandler {
-        StartManualChangeHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public boolean isDuplicate(Msg msg) {
-            // Disable duplicate elimination because
-            // there are no cleanup or success messages for start/stop.
-            return (false);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            try {
-                int cmd2 = msg.getByte("command2") & 0xff;
-                int upDown = (cmd2 == 0) ? 0 : 2;
-                logger.debug("{}: dev {} manual state change: {}", nm(), f.getDevice().getAddress(),
-                        (upDown == 0) ? "DOWN" : "UP");
-                feature.publish(new DecimalType(upDown), StateChangeType.ALWAYS);
-            } catch (FieldException e) {
-                logger.warn("{} error parsing {}: ", nm(), msg, e);
-            }
-        }
-    }
-
-    public static class StopManualChangeHandler extends MessageHandler {
-        StopManualChangeHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public boolean isDuplicate(Msg msg) {
-            // Disable duplicate elimination because
-            // there are no cleanup or success messages for start/stop.
-            return (false);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            logger.debug("{}: dev {} manual state change: {}", nm(), f.getDevice().getAddress(), 0);
-            feature.publish(new DecimalType(1), StateChangeType.ALWAYS);
-        }
-    }
-
-    public static class InfoRequestReplyHandler extends MessageHandler {
-        InfoRequestReplyHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonDevice dev = f.getDevice();
-            if (!msg.isExtended()) {
-                logger.warn("{} device {} expected extended msg as info reply, got {}", nm(), dev.getAddress(), msg);
-                return;
-            }
-            try {
-                int cmd2 = msg.getByte("command2") & 0xff;
-                switch (cmd2) {
-                    case 0x00: // this is a product data response message
-                        int prodKey = msg.getInt24("userData2", "userData3", "userData4");
-                        int devCat = msg.getByte("userData5");
-                        int subCat = msg.getByte("userData6");
-                        logger.debug("{} {} got product data: cat: {} subcat: {} key: {} ", nm(), dev.getAddress(),
-                                devCat, subCat, Utils.getHexString(prodKey));
-                        break;
-                    case 0x02: // this is a device text string response message
-                        logger.debug("{} {} got text str {} ", nm(), dev.getAddress(), msg);
-                        break;
-                    default:
-                        logger.warn("{} unknown cmd2 = {} in info reply message {}", nm(), cmd2, msg);
-                        break;
-                }
-            } catch (FieldException e) {
-                logger.warn("error parsing {}: ", msg, e);
-            }
-        }
-    }
-
-    public static class MotionSensorDataReplyHandler extends MessageHandler {
-        MotionSensorDataReplyHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonDevice dev = f.getDevice();
-            if (!msg.isExtended()) {
-                logger.trace("{} device {} ignoring non-extended msg {}", nm(), dev.getAddress(), msg);
-                return;
-            }
-            try {
-                int cmd2 = msg.getByte("command2") & 0xff;
-                int batteryLevel;
-                int lightLevel;
-                int temperatureLevel;
-                switch (cmd2) {
-                    case 0x00: // this is a product data response message
-                        batteryLevel = msg.getByte("userData12") & 0xff;
-                        lightLevel = msg.getByte("userData11") & 0xff;
-                        logger.debug("{}: {} got light level: {}, battery level: {}", nm(), dev.getAddress(),
-                                lightLevel, batteryLevel);
-                        feature.publish(new DecimalType(lightLevel), StateChangeType.CHANGED,
-                                InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_LIGHT_LEVEL);
-                        feature.publish(new DecimalType(batteryLevel), StateChangeType.CHANGED,
-                                InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_BATTERY_LEVEL);
-                        break;
-                    case 0x03: // this is the 2844-222 data response message
-                        batteryLevel = msg.getByte("userData6") & 0xff;
-                        lightLevel = msg.getByte("userData7") & 0xff;
-                        temperatureLevel = msg.getByte("userData8") & 0xff;
-                        logger.debug("{}: {} got light level: {}, battery level: {}, temperature level: {}", nm(),
-                                dev.getAddress(), lightLevel, batteryLevel, temperatureLevel);
-                        feature.publish(new DecimalType(lightLevel), StateChangeType.CHANGED,
-                                InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_LIGHT_LEVEL);
-                        feature.publish(new DecimalType(batteryLevel), StateChangeType.CHANGED,
-                                InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_BATTERY_LEVEL);
-                        feature.publish(new DecimalType(temperatureLevel), StateChangeType.CHANGED,
-                                InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_TEMPERATURE_LEVEL);
-
-                        // per 2844-222 dev doc: working battery level range is 0xd2 - 0x70
-                        int batteryPercentage;
-                        if (batteryLevel >= 0xd2) {
-                            batteryPercentage = 100;
-                        } else if (batteryLevel <= 0x70) {
-                            batteryPercentage = 0;
-                        } else {
-                            batteryPercentage = (batteryLevel - 0x70) * 100 / (0xd2 - 0x70);
-                        }
-                        logger.debug("{}: {} battery percentage: {}", nm(), dev.getAddress(), batteryPercentage);
-                        feature.publish(new QuantityType<>(batteryPercentage, Units.PERCENT), StateChangeType.CHANGED,
-                                InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_BATTERY_PERCENTAGE);
-                        break;
-                    default:
-                        logger.warn("unknown cmd2 = {} in info reply message {}", cmd2, msg);
-                        break;
-                }
-            } catch (FieldException e) {
-                logger.warn("error parsing {}: ", msg, e);
-            }
-        }
-    }
-
-    public static class MotionSensor2AlternateHeartbeatHandler extends MessageHandler {
-        MotionSensor2AlternateHeartbeatHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonDevice dev = f.getDevice();
-            try {
-                // group 0x0B (11) - alternate heartbeat group
-                InsteonAddress toAddr = msg.getAddr("toAddress");
-                if (toAddr == null) {
-                    logger.warn("toAddr is null");
-                    return;
-                }
-                int batteryLevel = toAddr.getHighByte() & 0xff;
-                int lightLevel = toAddr.getMiddleByte() & 0xff;
-                int temperatureLevel = msg.getByte("command2") & 0xff;
-
-                logger.debug("{}: {} got light level: {}, battery level: {}, temperature level: {}", nm(),
-                        dev.getAddress(), lightLevel, batteryLevel, temperatureLevel);
-                feature.publish(new DecimalType(lightLevel), StateChangeType.CHANGED, InsteonDeviceHandler.FIELD,
-                        InsteonDeviceHandler.FIELD_LIGHT_LEVEL);
-                feature.publish(new DecimalType(batteryLevel), StateChangeType.CHANGED, InsteonDeviceHandler.FIELD,
-                        InsteonDeviceHandler.FIELD_BATTERY_LEVEL);
-                feature.publish(new DecimalType(temperatureLevel), StateChangeType.CHANGED, InsteonDeviceHandler.FIELD,
-                        InsteonDeviceHandler.FIELD_TEMPERATURE_LEVEL);
-
-                // per 2844-222 dev doc: working battery level range is 0xd2 - 0x70
-                int batteryPercentage;
-                if (batteryLevel >= 0xd2) {
-                    batteryPercentage = 100;
-                } else if (batteryLevel <= 0x70) {
-                    batteryPercentage = 0;
-                } else {
-                    batteryPercentage = (batteryLevel - 0x70) * 100 / (0xd2 - 0x70);
-                }
-                logger.debug("{}: {} battery percentage: {}", nm(), dev.getAddress(), batteryPercentage);
-                feature.publish(new QuantityType<>(batteryPercentage, Units.PERCENT), StateChangeType.CHANGED,
-                        InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_BATTERY_PERCENTAGE);
-            } catch (FieldException e) {
-                logger.warn("error parsing {}: ", msg, e);
-            }
-        }
-    }
-
-    public static class HiddenDoorSensorDataReplyHandler extends MessageHandler {
-        HiddenDoorSensorDataReplyHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonDevice dev = f.getDevice();
-            if (!msg.isExtended()) {
-                logger.trace("{} device {} ignoring non-extended msg {}", nm(), dev.getAddress(), msg);
-                return;
-            }
-            try {
-                int cmd2 = msg.getByte("command2") & 0xff;
-                switch (cmd2) {
-                    case 0x00: // this is a product data response message
-                        int batteryLevel = msg.getByte("userData4") & 0xff;
-                        int batteryWatermark = msg.getByte("userData7") & 0xff;
-                        logger.debug("{}: {} got light level: {}, battery level: {}", nm(), dev.getAddress(),
-                                batteryWatermark, batteryLevel);
-                        feature.publish(new DecimalType(batteryWatermark), StateChangeType.CHANGED,
-                                InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_BATTERY_WATERMARK_LEVEL);
-                        feature.publish(new DecimalType(batteryLevel), StateChangeType.CHANGED,
-                                InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_BATTERY_LEVEL);
-                        break;
-                    default:
-                        logger.warn("unknown cmd2 = {} in info reply message {}", cmd2, msg);
-                        break;
-                }
-            } catch (FieldException e) {
-                logger.warn("error parsing {}: ", msg, e);
-            }
-        }
-    }
-
-    public static class PowerMeterUpdateHandler extends MessageHandler {
-        PowerMeterUpdateHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            if (msg.isExtended()) {
-                try {
-                    // see iMeter developer notes 2423A1dev-072013-en.pdf
-                    int b7 = msg.getByte("userData7") & 0xff;
-                    int b8 = msg.getByte("userData8") & 0xff;
-                    int watts = (b7 << 8) | b8;
-                    if (watts > 32767) {
-                        watts -= 65535;
-                    }
-
-                    int b9 = msg.getByte("userData9") & 0xff;
-                    int b10 = msg.getByte("userData10") & 0xff;
-                    int b11 = msg.getByte("userData11") & 0xff;
-                    int b12 = msg.getByte("userData12") & 0xff;
-                    BigDecimal kwh = BigDecimal.ZERO;
-                    if (b9 < 254) {
-                        int e = (b9 << 24) | (b10 << 16) | (b11 << 8) | b12;
-                        kwh = new BigDecimal(e * 65535.0 / (1000 * 60 * 60 * 60)).setScale(4, RoundingMode.HALF_UP);
-                    }
-
-                    logger.debug("{}:{} watts: {} kwh: {} ", nm(), f.getDevice().getAddress(), watts, kwh);
-                    feature.publish(new QuantityType<>(kwh, Units.KILOWATT_HOUR), StateChangeType.CHANGED,
-                            InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_KWH);
-                    feature.publish(new QuantityType<>(watts, Units.WATT), StateChangeType.CHANGED,
-                            InsteonDeviceHandler.FIELD, InsteonDeviceHandler.FIELD_WATTS);
-                } catch (FieldException e) {
-                    logger.warn("error parsing {}: ", msg, e);
-                }
-            }
-        }
-    }
-
-    public static class PowerMeterResetHandler extends MessageHandler {
-        PowerMeterResetHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonDevice dev = f.getDevice();
-            logger.debug("{}: power meter {} was reset", nm(), dev.getAddress());
-
-            // poll device to get updated kilowatt hours and watts
-            Msg m = f.makePollMsg();
-            if (m != null) {
-                f.getDevice().enqueueMessage(m, f);
-            }
-        }
-    }
-
-    public static class LastTimeHandler extends MessageHandler {
-        LastTimeHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1a, Msg msg, DeviceFeature f) {
-            feature.publish(new DateTimeType(), StateChangeType.ALWAYS);
-        }
-    }
-
-    public static class ContactRequestReplyHandler extends MessageHandler {
-        ContactRequestReplyHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1a, Msg msg, DeviceFeature f) {
-            byte cmd = 0x00;
-            byte cmd2 = 0x00;
-            try {
-                cmd = msg.getByte("Cmd");
-                cmd2 = msg.getByte("command2");
-            } catch (FieldException e) {
-                logger.debug("{} no cmd found, dropping msg {}", nm(), msg);
-                return;
-            }
-            if (msg.isAckOfDirect() && (f.getQueryStatus() == DeviceFeature.QueryStatus.QUERY_PENDING) && cmd == 0x50) {
-                OpenClosedType oc = (cmd2 == 0) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
-                logger.debug("{}: set contact {} to: {}", nm(), f.getDevice().getAddress(), oc);
-                feature.publish(oc, StateChangeType.CHANGED);
-            }
-        }
-    }
-
-    public static class ClosedContactHandler extends MessageHandler {
-        ClosedContactHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            feature.publish(OpenClosedType.CLOSED, StateChangeType.ALWAYS);
-        }
-    }
-
-    public static class OpenedContactHandler extends MessageHandler {
-        OpenedContactHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            feature.publish(OpenClosedType.OPEN, StateChangeType.ALWAYS);
-        }
-    }
-
-    public static class OpenedOrClosedContactHandler extends MessageHandler {
-        OpenedOrClosedContactHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            try {
-                byte cmd2 = msg.getByte("command2");
-                switch (cmd1) {
-                    case 0x11:
-                        switch (cmd2) {
-                            case 0x02:
-                                feature.publish(OpenClosedType.CLOSED, StateChangeType.CHANGED);
-                                break;
-                            case 0x01:
-                            case 0x04:
-                                feature.publish(OpenClosedType.OPEN, StateChangeType.CHANGED);
-                                break;
-                            default: // do nothing
-                                break;
-                        }
-                        break;
-                    case 0x13:
-                        switch (cmd2) {
-                            case 0x04:
-                                feature.publish(OpenClosedType.CLOSED, StateChangeType.CHANGED);
-                                break;
-                            default: // do nothing
-                                break;
-                        }
-                        break;
-                }
-            } catch (FieldException e) {
-                logger.debug("{} no cmd2 found, dropping msg {}", nm(), msg);
-                return;
-            }
-        }
-    }
-
-    public static class ClosedSleepingContactHandler extends MessageHandler {
-        ClosedSleepingContactHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            feature.publish(OpenClosedType.CLOSED, StateChangeType.ALWAYS);
-            if (f.getDevice().hasProductKey(InsteonDeviceHandler.MOTION_SENSOR_II_PRODUCT_KEY)) {
-                if (!getBooleanDeviceConfig("heartbeatOnly", false)) {
-                    sendExtendedQuery(f, (byte) 0x2e, (byte) 03);
-                }
-            } else {
-                sendExtendedQuery(f, (byte) 0x2e, (byte) 00);
-            }
-        }
-    }
-
-    public static class OpenedSleepingContactHandler extends MessageHandler {
-        OpenedSleepingContactHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            feature.publish(OpenClosedType.OPEN, StateChangeType.ALWAYS);
-            if (f.getDevice().hasProductKey(InsteonDeviceHandler.MOTION_SENSOR_II_PRODUCT_KEY)) {
-                if (!getBooleanDeviceConfig("heartbeatOnly", false)) {
-                    sendExtendedQuery(f, (byte) 0x2e, (byte) 03);
-                }
-            } else {
-                sendExtendedQuery(f, (byte) 0x2e, (byte) 00);
-            }
-        }
-    }
-
-    /**
-     * Triggers a poll when a message comes in. Use this handler to react
-     * to messages that notify of a status update, but don't carry the information
-     * that you are interested in. Example: you send a command to change a setting,
-     * get a DIRECT ack back, but the ack does not have the value of the updated setting.
-     * Then connect this handler to the ACK, such that the device will be polled, and
-     * the settings updated.
-     */
-    public static class TriggerPollMsgHandler extends MessageHandler {
-        TriggerPollMsgHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            feature.getDevice().doPoll(2000); // 2000 ms delay
-        }
-    }
-
-    /**
-     * Flexible handler to extract numerical data from messages.
-     */
-    public static class NumberMsgHandler extends MessageHandler {
-        NumberMsgHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            try {
-                // first do the bit manipulations to focus on the right area
-                int mask = getIntParameter("mask", 0xFFFF);
-                int rawValue = extractValue(msg, group);
-                int cooked = (rawValue & mask) >> getIntParameter("rshift", 0);
-                // now do an arbitrary transform on the data
-                double value = transform(cooked);
-                // last, multiply with factor and add an offset
-                double dvalue = getDoubleParameter("offset", 0) + value * getDoubleParameter("factor", 1.0);
-
-                @Nullable
-                State state;
-                String scale = getStringParameter("scale", null);
-                if ("celsius".equals(scale)) {
-                    state = new QuantityType<>(dvalue, SIUnits.CELSIUS);
-                } else if ("fahrenheit".equals(scale)) {
-                    state = new QuantityType<>(dvalue, ImperialUnits.FAHRENHEIT);
-                } else {
-                    state = new DecimalType(dvalue);
-                }
-                feature.publish(state, StateChangeType.CHANGED);
-            } catch (FieldException e) {
-                logger.warn("error parsing {}: ", msg, e);
-            }
-        }
-
-        public int transform(int raw) {
-            return (raw);
-        }
-
-        private int extractValue(Msg msg, int group) throws FieldException {
-            String lowByte = getStringParameter("low_byte", null);
-            if (lowByte == null) {
-                logger.warn("{} handler misconfigured, missing low_byte!", nm());
-                return 0;
-            }
-            int value = 0;
-            if ("group".equals(lowByte)) {
-                value = group;
-            } else {
-                value = msg.getByte(lowByte) & 0xFF;
-            }
-            String highByte = getStringParameter("high_byte", null);
-            if (highByte != null) {
-                value |= (msg.getByte(highByte) & 0xFF) << 8;
-            }
-            return (value);
-        }
-    }
-
-    /**
-     * Convert system mode field to number 0...4. Insteon has two different
-     * conventions for numbering, we use the one of the status update messages
-     */
-    public static class ThermostatSystemModeMsgHandler extends NumberMsgHandler {
-        ThermostatSystemModeMsgHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public int transform(int raw) {
-            switch (raw) {
-                case 0:
-                    return (0); // off
-                case 1:
-                    return (3); // auto
-                case 2:
-                    return (1); // heat
-                case 3:
-                    return (2); // cool
-                case 4:
-                    return (4); // program
-                default:
-                    break;
-            }
-            return (4); // when in doubt assume to be in "program" mode
-        }
-    }
-
-    /**
-     * Handle reply to system mode change command
-     */
-    public static class ThermostatSystemModeReplyHandler extends NumberMsgHandler {
-        ThermostatSystemModeReplyHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public int transform(int raw) {
-            switch (raw) {
-                case 0x09:
-                    return (0); // off
-                case 0x04:
-                    return (1); // heat
-                case 0x05:
-                    return (2); // cool
-                case 0x06:
-                    return (3); // auto
-                case 0x0A:
-                    return (4); // program
-                default:
-                    break;
-            }
-            return (4); // when in doubt assume to be in "program" mode
-        }
-    }
-
-    /**
-     * Handle reply to fan mode change command
-     */
-    public static class ThermostatFanModeReplyHandler extends NumberMsgHandler {
-        ThermostatFanModeReplyHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public int transform(int raw) {
-            switch (raw) {
-                case 0x08:
-                    return (0); // auto
-                case 0x07:
-                    return (1); // always on
-                default:
-                    break;
-            }
-            return (0); // when in doubt assume to be auto mode
-        }
-    }
-
-    /**
-     * Handle reply to fanlinc fan speed change command
-     */
-    public static class FanLincFanReplyHandler extends NumberMsgHandler {
-        FanLincFanReplyHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public int transform(int raw) {
-            switch (raw) {
-                case 0x00:
-                    return (0); // off
-                case 0x55:
-                    return (1); // low
-                case 0xAA:
-                    return (2); // medium
-                case 0xFF:
-                    return (3); // high
-                default:
-                    logger.warn("fanlinc got unexpected level: {}", raw);
-            }
-            return (0); // when in doubt assume to be off
-        }
-    }
-
-    /**
-     * Process X10 messages that are generated when another controller
-     * changes the state of an X10 device.
-     */
-    public static class X10OnHandler extends MessageHandler {
-        X10OnHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonAddress a = f.getDevice().getAddress();
-            logger.debug("{}: set X10 device {} to ON", nm(), a);
-            feature.publish(OnOffType.ON, StateChangeType.ALWAYS);
-        }
-    }
-
-    public static class X10OffHandler extends MessageHandler {
-        X10OffHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonAddress a = f.getDevice().getAddress();
-            logger.debug("{}: set X10 device {} to OFF", nm(), a);
-            feature.publish(OnOffType.OFF, StateChangeType.ALWAYS);
-        }
-    }
-
-    public static class X10BrightHandler extends MessageHandler {
-        X10BrightHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonAddress a = f.getDevice().getAddress();
-            logger.debug("{}: ignoring brighten message for device {}", nm(), a);
-        }
-    }
-
-    public static class X10DimHandler extends MessageHandler {
-        X10DimHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonAddress a = f.getDevice().getAddress();
-            logger.debug("{}: ignoring dim message for device {}", nm(), a);
-        }
-    }
-
-    public static class X10OpenHandler extends MessageHandler {
-        X10OpenHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonAddress a = f.getDevice().getAddress();
-            logger.debug("{}: set X10 device {} to OPEN", nm(), a);
-            feature.publish(OpenClosedType.OPEN, StateChangeType.ALWAYS);
-        }
-    }
-
-    public static class X10ClosedHandler extends MessageHandler {
-        X10ClosedHandler(DeviceFeature p) {
-            super(p);
-        }
-
-        @Override
-        public void handleMessage(int group, byte cmd1, Msg msg, DeviceFeature f) {
-            InsteonAddress a = f.getDevice().getAddress();
-            logger.debug("{}: set X10 device {} to CLOSED", nm(), a);
-            feature.publish(OpenClosedType.CLOSED, StateChangeType.ALWAYS);
-        }
-    }
-
-    /**
-     * Factory method for creating handlers of a given name using java reflection
-     *
-     * @param name the name of the handler to create
-     * @param params
-     * @param f the feature for which to create the handler
-     * @return the handler which was created
-     */
-    public static @Nullable <T extends MessageHandler> T makeHandler(String name, Map<String, String> params,
-            DeviceFeature f) {
-        String cname = MessageHandler.class.getName() + "$" + name;
-        try {
-            Class<?> c = Class.forName(cname);
-            @SuppressWarnings("unchecked")
-            Class<? extends T> dc = (Class<? extends T>) c;
-            @Nullable
-            T mh = dc.getDeclaredConstructor(DeviceFeature.class).newInstance(f);
-            mh.setParameters(params);
-            return mh;
-        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
-                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
-            logger.warn("error trying to create message handler: {}", name, e);
-        }
-        return null;
-    }
-}
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/ModemDBBuilder.java
deleted file mode 100644 (file)
index 35b693f..0000000
+++ /dev/null
@@ -1,209 +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.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-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.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.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Builds the modem database from incoming link record messages
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class ModemDBBuilder implements MsgListener {
-    private static final int MESSAGE_TIMEOUT = 30000;
-
-    private final Logger logger = LoggerFactory.getLogger(ModemDBBuilder.class);
-
-    private volatile boolean isComplete = false;
-    private Port port;
-    private ScheduledExecutorService scheduler;
-    private @Nullable ScheduledFuture<?> job = null;
-    private volatile long lastMessageTimestamp;
-    private volatile int messageCount = 0;
-
-    public ModemDBBuilder(Port port, ScheduledExecutorService scheduler) {
-        this.port = port;
-        this.scheduler = scheduler;
-    }
-
-    public void start() {
-        port.addListener(this);
-
-        logger.trace("starting modem db builder");
-        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;
-            } else {
-                if (System.currentTimeMillis() - lastMessageTimestamp > 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("Modem database download was unsuccessful, restarting!{}", s);
-                    startDownload();
-                }
-            }
-        }, 0, 1, TimeUnit.SECONDS);
-    }
-
-    private void startDownload() {
-        logger.trace("starting modem database download");
-        port.clearModemDB();
-        lastMessageTimestamp = System.currentTimeMillis();
-        messageCount = 0;
-        getFirstLinkRecord();
-    }
-
-    public boolean isComplete() {
-        return isComplete;
-    }
-
-    private void getFirstLinkRecord() {
-        try {
-            port.writeMessage(Msg.makeMessage("GetFirstALLLinkRecord"));
-        } catch (IOException e) {
-            logger.warn("error sending link record query ", e);
-        } catch (InvalidMessageTypeException e) {
-            logger.warn("invalid message ", e);
-        }
-    }
-
-    /**
-     * processes link record messages from the modem to build database
-     * and request more link records if not finished.
-     * {@inheritDoc}
-     */
-    @Override
-    public void msg(Msg msg) {
-        lastMessageTimestamp = System.currentTimeMillis();
-        messageCount++;
-
-        if (msg.isPureNack()) {
-            return;
-        }
-        try {
-            if (msg.getByte("Cmd") == 0x69 || msg.getByte("Cmd") == 0x6a) {
-                // If the flag is "ACK/NACK", a record response
-                // will follow, so we do nothing here.
-                // If its "NACK", there are none
-                if (msg.getByte("ACK/NACK") == 0x15) {
-                    logger.debug("got all link records.");
-                    done();
-                }
-            } else if (msg.getByte("Cmd") == 0x57) {
-                // we got the link record response
-                updateModemDB(msg.getAddress("LinkAddr"), port, msg, false);
-                port.writeMessage(Msg.makeMessage("GetNextALLLinkRecord"));
-            }
-        } catch (FieldException e) {
-            logger.debug("bad field handling link records {}", e.getMessage());
-        } catch (IOException e) {
-            logger.debug("got IO exception handling link records {}", e.getMessage());
-        } catch (IllegalStateException e) {
-            logger.debug("got exception requesting link records {}", e.getMessage());
-        } catch (InvalidMessageTypeException e) {
-            logger.warn("invalid message ", e);
-        }
-    }
-
-    private synchronized void done() {
-        isComplete = true;
-        logModemDB();
-        port.removeListener(this);
-        port.modemDBComplete();
-    }
-
-    private void logModemDB() {
-        try {
-            logger.debug("MDB ------- start of modem link records ------------------");
-            Map<InsteonAddress, ModemDBEntry> dbes = port.getDriver().lockModemDBEntries();
-            for (Entry<InsteonAddress, ModemDBEntry> db : dbes.entrySet()) {
-                List<Msg> lrs = db.getValue().getLinkRecords();
-                for (Msg m : lrs) {
-                    int recordFlags = m.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")));
-                }
-                logger.debug("MDB -----");
-            }
-            logger.debug("MDB ---------------- end of modem link records -----------");
-        } catch (FieldException e) {
-            logger.warn("cannot access field:", e);
-        } finally {
-            port.getDriver().unlockModemDBEntries();
-        }
-    }
-
-    public static String toHex(byte b) {
-        return Utils.getHexString(b);
-    }
-
-    public void updateModemDB(InsteonAddress linkAddr, Port port, @Nullable Msg m, boolean isModem) {
-        try {
-            Map<InsteonAddress, ModemDBEntry> dbes = port.getDriver().lockModemDBEntries();
-            ModemDBEntry dbe = dbes.get(linkAddr);
-            if (dbe == null) {
-                dbe = new ModemDBEntry(linkAddr, isModem);
-                dbes.put(linkAddr, dbe);
-            }
-            dbe.setPort(port);
-            if (m != null) {
-                dbe.addLinkRecord(m);
-                try {
-                    byte group = m.getByte("ALLLinkGroup");
-                    int recordFlags = m.getByte("RecordFlags") & 0xff;
-                    if ((recordFlags & (0x1 << 6)) != 0) {
-                        dbe.addControls(group);
-                    } else {
-                        dbe.addRespondsTo(group);
-                    }
-                } catch (FieldException e) {
-                    logger.warn("cannot access field:", e);
-                }
-            }
-        } finally {
-            port.getDriver().unlockModemDBEntries();
-        }
-    }
-}
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 (file)
index 0000000..351d694
--- /dev/null
@@ -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<Temperature>(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<String> 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/PollHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/PollHandler.java
deleted file mode 100644 (file)
index 939aacb..0000000
+++ /dev/null
@@ -1,160 +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.lang.reflect.InvocationTargetException;
-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.message.FieldException;
-import org.openhab.binding.insteon.internal.message.InvalidMessageTypeException;
-import org.openhab.binding.insteon.internal.message.Msg;
-import org.openhab.binding.insteon.internal.utils.Utils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * A PollHandler creates an Insteon message to query a particular
- * DeviceFeature of an Insteon device.
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public abstract class PollHandler {
-    private static final Logger logger = LoggerFactory.getLogger(PollHandler.class);
-    DeviceFeature feature;
-    Map<String, String> parameters = new HashMap<>();
-
-    /**
-     * Constructor
-     *
-     * @param feature The device feature being polled
-     */
-    PollHandler(DeviceFeature feature) {
-        this.feature = feature;
-    }
-
-    /**
-     * Creates Insteon message that can be used to poll a feature
-     * via the Insteon network.
-     *
-     * @param device reference to the insteon device to be polled
-     * @return Insteon query message or null if creation failed
-     */
-    public abstract @Nullable Msg makeMsg(InsteonDevice device);
-
-    public void setParameters(Map<String, String> hm) {
-        parameters = hm;
-    }
-
-    /**
-     * Returns parameter as integer
-     *
-     * @param key key of parameter
-     * @param def default
-     * @return value of parameter
-     */
-    protected int getIntParameter(String key, int def) {
-        String val = parameters.get(key);
-        if (val == null) {
-            return (def); // param not found
-        }
-        int ret = def;
-        try {
-            ret = Utils.strToInt(val);
-        } catch (NumberFormatException e) {
-            logger.warn("malformed int parameter in command handler: {}", key);
-        }
-        return ret;
-    }
-
-    /**
-     * A flexible, parameterized poll handler that can generate
-     * most query messages. Provide the suitable parameters in
-     * the device features file.
-     */
-    public static class FlexPollHandler extends PollHandler {
-        FlexPollHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public @Nullable Msg makeMsg(InsteonDevice d) {
-            Msg m = null;
-            int cmd1 = getIntParameter("cmd1", 0);
-            int cmd2 = getIntParameter("cmd2", 0);
-            int ext = getIntParameter("ext", -1);
-            try {
-                if (ext == 1 || ext == 2) {
-                    int d1 = getIntParameter("d1", 0);
-                    int d2 = getIntParameter("d2", 0);
-                    int d3 = getIntParameter("d3", 0);
-                    m = d.makeExtendedMessage((byte) 0x0f, (byte) cmd1, (byte) cmd2,
-                            new byte[] { (byte) d1, (byte) d2, (byte) d3 });
-                    if (ext == 1) {
-                        m.setCRC();
-                    } else if (ext == 2) {
-                        m.setCRC2();
-                    }
-                } else {
-                    m = d.makeStandardMessage((byte) 0x0f, (byte) cmd1, (byte) cmd2);
-                }
-                m.setQuietTime(500L);
-            } catch (FieldException e) {
-                logger.warn("error setting field in msg: ", e);
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("invalid message ", e);
-            }
-            return m;
-        }
-    }
-
-    public static class NoPollHandler extends PollHandler {
-        NoPollHandler(DeviceFeature f) {
-            super(f);
-        }
-
-        @Override
-        public @Nullable Msg makeMsg(InsteonDevice d) {
-            return null;
-        }
-    }
-
-    /**
-     * Factory method for creating handlers of a given name using java reflection
-     *
-     * @param ph the name of the handler to create
-     * @param f the feature for which to create the handler
-     * @return the handler which was created
-     */
-    @Nullable
-    public static <T extends PollHandler> T makeHandler(HandlerEntry ph, DeviceFeature f) {
-        String cname = PollHandler.class.getName() + "$" + ph.getName();
-        try {
-            Class<?> c = Class.forName(cname);
-            @SuppressWarnings("unchecked")
-            Class<? extends T> dc = (Class<? extends T>) c;
-            @Nullable
-            T phc = dc.getDeclaredConstructor(DeviceFeature.class).newInstance(f);
-            phc.setParameters(ph.getParams());
-            return phc;
-        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
-                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
-            logger.warn("error trying to create message handler: {}", ph.getName(), e);
-        }
-        return null;
-    }
-}
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 (file)
index 0000000..961fea9
--- /dev/null
@@ -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<PQEntry> 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<PQEntry> 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<PQEntry> 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<PQEntry> 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<PQEntry> {
+        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 (file)
index 0000000..358c083
--- /dev/null
@@ -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<String> 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<String> 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 (file)
index 0000000..6c2a7e1
--- /dev/null
@@ -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<Integer, ProductData> 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<Integer, ProductData> 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 (file)
index 0000000..7a9079a
--- /dev/null
@@ -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<Integer, RampRate> 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 (file)
index 0000000..4241b3c
--- /dev/null
@@ -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<RequestQueue> requestQueues = new PriorityQueue<>();
+    private Map<Device, RequestQueue> 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<RequestQueue> {
+        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/RequestQueueManager.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/RequestQueueManager.java
deleted file mode 100644 (file)
index eb9b621..0000000
+++ /dev/null
@@ -1,212 +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.PriorityQueue;
-import java.util.Queue;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.insteon.internal.InsteonBindingConstants;
-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 m_requestQueues corresponds to a single device's request queue.
- * A device should never be more than once in m_requestQueues.
- * - A hash map (m_requestQueueHash) is kept in sync with m_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
- */
-@NonNullByDefault
-public class RequestQueueManager {
-    private static @Nullable RequestQueueManager instance = null;
-    private final Logger logger = LoggerFactory.getLogger(RequestQueueManager.class);
-    private @Nullable Thread queueThread = null;
-    private Queue<RequestQueue> requestQueues = new PriorityQueue<>();
-    private Map<InsteonDevice, RequestQueue> requestQueueHash = new HashMap<>();
-    private boolean keepRunning = true;
-
-    private RequestQueueManager() {
-        queueThread = new Thread(new RequestQueueReader());
-        setParamsAndStart(queueThread);
-    }
-
-    private void setParamsAndStart(@Nullable Thread thread) {
-        if (thread != null) {
-            thread.setName("OH-binding-" + InsteonBindingConstants.BINDING_ID + "-requestQueueReader");
-            thread.setDaemon(true);
-            thread.start();
-        }
-    }
-
-    /**
-     * Add device to global request queue.
-     *
-     * @param dev the device to add
-     * @param time the time when the queue should be processed
-     */
-    public void addQueue(InsteonDevice dev, long time) {
-        synchronized (requestQueues) {
-            RequestQueue q = requestQueueHash.get(dev);
-            if (q == null) {
-                logger.trace("scheduling request for device {} in {} msec", dev.getAddress(),
-                        time - System.currentTimeMillis());
-                q = new RequestQueue(dev, 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);
-                }
-                requestQueueHash.remove(dev);
-            }
-            long expTime = q.getExpirationTime();
-            if (expTime > time) {
-                q.setExpirationTime(time);
-            }
-            // add the queue back in after (maybe) having modified
-            // the expiration time
-            requestQueues.add(q);
-            requestQueueHash.put(dev, q);
-            requestQueues.notify();
-        }
-    }
-
-    /**
-     * Stops request queue thread
-     */
-    private void stopThread() {
-        logger.debug("stopping thread");
-        Thread queueThread = this.queueThread;
-        if (queueThread != null) {
-            synchronized (requestQueues) {
-                keepRunning = false;
-                requestQueues.notifyAll();
-            }
-            try {
-                logger.debug("waiting for thread to join");
-                queueThread.join();
-                logger.debug("request queue thread exited!");
-            } catch (InterruptedException e) {
-                logger.warn("got interrupted waiting for thread exit ", e);
-            }
-            this.queueThread = null;
-        }
-    }
-
-    class RequestQueueReader implements Runnable {
-        @Override
-        public void run() {
-            logger.debug("starting request queue thread");
-            synchronized (requestQueues) {
-                while (keepRunning) {
-                    try {
-                        RequestQueue q;
-                        while (keepRunning && (q = requestQueues.peek()) != null) {
-                            long now = System.currentTimeMillis();
-                            long expTime = q.getExpirationTime();
-                            InsteonDevice dev = q.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(),
-                                        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);
-                            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(),
-                                        nextExp - now);
-                            } else {
-                                // remove from hash since queue is no longer scheduled
-                                logger.debug("device queue for {} is empty!", dev.getAddress());
-                            }
-                        }
-                        logger.trace("waiting for request queues to fill");
-                        requestQueues.wait();
-                    } catch (InterruptedException e) {
-                        logger.warn("request queue thread got interrupted, breaking..", e);
-                        break;
-                    }
-                }
-            }
-            logger.debug("exiting request queue thread!");
-        }
-    }
-
-    public static class RequestQueue implements Comparable<RequestQueue> {
-        private InsteonDevice device;
-        private long expirationTime;
-
-        RequestQueue(InsteonDevice dev, long expirationTime) {
-            this.device = dev;
-            this.expirationTime = expirationTime;
-        }
-
-        public InsteonDevice getDevice() {
-            return device;
-        }
-
-        public long getExpirationTime() {
-            return expirationTime;
-        }
-
-        public void setExpirationTime(long t) {
-            expirationTime = t;
-        }
-
-        @Override
-        public int compareTo(RequestQueue a) {
-            return (int) (expirationTime - a.expirationTime);
-        }
-    }
-
-    public static synchronized @Nullable RequestQueueManager instance() {
-        if (instance == null) {
-            instance = new RequestQueueManager();
-        }
-        return instance;
-    }
-
-    public static synchronized void destroyInstance() {
-        RequestQueueManager instance = RequestQueueManager.instance;
-        if (instance != null) {
-            instance.stopThread();
-            RequestQueueManager.instance = null;
-        }
-    }
-}
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 (file)
index 0000000..36b7f3a
--- /dev/null
@@ -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 (file)
index e887045..0000000
+++ /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<Integer, String> 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<Integer, Integer> 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<Integer, String> houseCodeToString = new HashMap<>();
-    /**
-     * Map between 4-bit X10 code and the unit code.
-     */
-    private static Map<Integer, Integer> 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 (file)
index 0000000..b2f4663
--- /dev/null
@@ -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<String, Integer> 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<Integer, Integer> 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 (file)
index 0000000..787240d
--- /dev/null
@@ -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 (file)
index 0000000..f25bb1b
--- /dev/null
@@ -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<X10Address, X10DeviceHandler> {
+    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 (file)
index 0000000..c978a00
--- /dev/null
@@ -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 (file)
index 0000000..55aef78
--- /dev/null
@@ -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<DatabaseRecord> records;
+    private @Nullable Map<String, ProductData> products;
+
+    public int getDelta() {
+        return Objects.requireNonNullElse(delta, -1);
+    }
+
+    public boolean getReload() {
+        return Objects.requireNonNullElse(reload, false);
+    }
+
+    public List<DatabaseRecord> getRecords() {
+        return Objects.requireNonNullElse(records, Collections.emptyList());
+    }
+
+    public Map<String, ProductData> 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<LinkDBRecord> 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<InsteonAddress, ProductData> 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<ModemDBRecord> records = getRecords().stream().map(ModemDBRecord::new).toList();
+        if (!records.isEmpty()) {
+            modemDB.loadRecords(records);
+        }
+    }
+
+    /**
+     * Class that represents a database cache builder
+     */
+    public static class Builder {
+        private final DatabaseCache cache = new DatabaseCache();
+
+        private Builder() {
+        }
+
+        public Builder withDatabaseDelta(int delta) {
+            cache.delta = delta;
+            return this;
+        }
+
+        public Builder withReload(boolean reload) {
+            cache.reload = reload;
+            return this;
+        }
+
+        public Builder withRecords(List<? extends DatabaseRecord> records) {
+            cache.records = records.stream().map(DatabaseRecord.class::cast).toList();
+            return this;
+        }
+
+        public Builder withProducts(Map<InsteonAddress, ProductData> 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 (file)
index 0000000..525b5bc
--- /dev/null
@@ -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 (file)
index 0000000..f5110f8
--- /dev/null
@@ -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<DatabaseOperation> 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 (file)
index 0000000..4d779fa
--- /dev/null
@@ -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/database/LegacyModemDBBuilder.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBBuilder.java
new file mode 100644 (file)
index 0000000..bd7c7d5
--- /dev/null
@@ -0,0 +1,218 @@
+/**
+ * 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.List;
+import java.util.Map;
+import java.util.Map.Entry;
+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.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;
+
+/**
+ * Builds the modem database from incoming link record messages
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class LegacyModemDBBuilder implements LegacyPortListener {
+    private static final int MESSAGE_TIMEOUT = 30000;
+
+    private final Logger logger = LoggerFactory.getLogger(LegacyModemDBBuilder.class);
+
+    private volatile boolean isComplete = false;
+    private LegacyPort port;
+    private ScheduledExecutorService scheduler;
+    private @Nullable ScheduledFuture<?> job = null;
+    private volatile long lastMessageTimestamp;
+    private volatile int messageCount = 0;
+
+    public LegacyModemDBBuilder(LegacyPort port, ScheduledExecutorService scheduler) {
+        this.port = port;
+        this.scheduler = scheduler;
+    }
+
+    public void start() {
+        port.addListener(this);
+
+        logger.trace("starting modem db builder");
+        startDownload();
+        job = scheduler.scheduleWithFixedDelay(() -> {
+            if (isComplete()) {
+                stop();
+            } else {
+                if (System.currentTimeMillis() - lastMessageTimestamp > 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("Modem database download was unsuccessful, restarting!{}", s);
+                    startDownload();
+                }
+            }
+        }, 0, 1, TimeUnit.SECONDS);
+    }
+
+    private void startDownload() {
+        logger.trace("starting modem database download");
+        port.clearModemDB();
+        lastMessageTimestamp = System.currentTimeMillis();
+        messageCount = 0;
+        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"));
+        } catch (IOException e) {
+            logger.warn("error sending link record query ", e);
+        } catch (InvalidMessageTypeException e) {
+            logger.warn("invalid message ", e);
+        }
+    }
+
+    /**
+     * processes link record messages from the modem to build database
+     * and request more link records if not finished.
+     * {@inheritDoc}
+     */
+    @Override
+    public void msg(Msg msg) {
+        lastMessageTimestamp = System.currentTimeMillis();
+        messageCount++;
+
+        if (msg.isPureNack()) {
+            return;
+        }
+        try {
+            if (msg.getByte("Cmd") == 0x69 || msg.getByte("Cmd") == 0x6a) {
+                // If the flag is "ACK/NACK", a record response
+                // will follow, so we do nothing here.
+                // If its "NACK", there are none
+                if (msg.getByte("ACK/NACK") == 0x15) {
+                    logger.debug("got all link records.");
+                    done();
+                }
+            } else if (msg.getByte("Cmd") == 0x57) {
+                // we got the link record response
+                updateModemDB(msg.getInsteonAddress("LinkAddr"), port, msg, false);
+                port.writeMessage(Msg.makeMessage("GetNextALLLinkRecord"));
+            }
+        } catch (FieldException e) {
+            logger.debug("bad field handling link records {}", e.getMessage());
+        } catch (IOException e) {
+            logger.debug("got IO exception handling link records {}", e.getMessage());
+        } catch (IllegalStateException e) {
+            logger.debug("got exception requesting link records {}", e.getMessage());
+        } catch (InvalidMessageTypeException e) {
+            logger.warn("invalid message ", e);
+        }
+    }
+
+    private synchronized void done() {
+        isComplete = true;
+        logModemDB();
+        port.removeListener(this);
+        port.modemDBComplete();
+    }
+
+    private void logModemDB() {
+        try {
+            logger.debug("MDB ------- start of modem link records ------------------");
+            Map<InsteonAddress, LegacyModemDBEntry> dbes = port.getDriver().lockModemDBEntries();
+            for (Entry<InsteonAddress, LegacyModemDBEntry> db : dbes.entrySet()) {
+                List<Msg> 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(msg.getByte("ALLLinkGroup")), toHex(msg.getByte("LinkData1")),
+                            toHex(msg.getByte("LinkData2")), toHex(msg.getByte("LinkData2")));
+                }
+                logger.debug("MDB -----");
+            }
+            logger.debug("MDB ---------------- end of modem link records -----------");
+        } catch (FieldException e) {
+            logger.warn("cannot access field:", e);
+        } finally {
+            port.getDriver().unlockModemDBEntries();
+        }
+    }
+
+    public static String toHex(byte b) {
+        return HexUtils.getHexString(b);
+    }
+
+    public void updateModemDB(InsteonAddress linkAddr, LegacyPort port, @Nullable Msg msg, boolean isModem) {
+        try {
+            Map<InsteonAddress, LegacyModemDBEntry> dbes = port.getDriver().lockModemDBEntries();
+            LegacyModemDBEntry dbe = dbes.get(linkAddr);
+            if (dbe == null) {
+                dbe = new LegacyModemDBEntry(linkAddr, isModem);
+                dbes.put(linkAddr, dbe);
+            }
+            dbe.setPort(port);
+            if (msg != null) {
+                dbe.addLinkRecord(msg);
+                try {
+                    byte group = msg.getByte("ALLLinkGroup");
+                    int recordFlags = msg.getByte("RecordFlags") & 0xff;
+                    if ((recordFlags & (0x1 << 6)) != 0) {
+                        dbe.addControls(group);
+                    } else {
+                        dbe.addRespondsTo(group);
+                    }
+                } catch (FieldException e) {
+                    logger.warn("cannot access field:", e);
+                }
+            }
+        } finally {
+            port.getDriver().unlockModemDBEntries();
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBEntry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/LegacyModemDBEntry.java
new file mode 100644 (file)
index 0000000..c43e121
--- /dev/null
@@ -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.device.database;
+
+import java.util.ArrayList;
+import java.util.Collections;
+
+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.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
+ * an xml file.
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ */
+@NonNullByDefault
+public class LegacyModemDBEntry {
+    private @Nullable InsteonAddress address = null;
+    private boolean isModem;
+    private @Nullable LegacyPort port = null;
+    private ArrayList<Msg> linkRecords = new ArrayList<>();
+    private ArrayList<Byte> controls = new ArrayList<>();
+    private ArrayList<Byte> respondsTo = new ArrayList<>();
+
+    public LegacyModemDBEntry(InsteonAddress address, boolean isModem) {
+        this.address = address;
+        this.isModem = isModem;
+    }
+
+    public boolean isModem() {
+        return isModem;
+    }
+
+    public ArrayList<Msg> getLinkRecords() {
+        return linkRecords;
+    }
+
+    public void addLinkRecord(Msg m) {
+        linkRecords.add(m);
+    }
+
+    public void addControls(byte c) {
+        controls.add(c);
+    }
+
+    public ArrayList<Byte> getControls() {
+        return controls;
+    }
+
+    public void addRespondsTo(byte r) {
+        respondsTo.add(r);
+    }
+
+    public ArrayList<Byte> getRespondsTo() {
+        return respondsTo;
+    }
+
+    public void setPort(LegacyPort p) {
+        port = p;
+    }
+
+    public @Nullable LegacyPort getPort() {
+        return port;
+    }
+
+    @Override
+    public String toString() {
+        String s = "addr:" + address + "|controls:[" + toGroupString(controls) + "]|responds_to:["
+                + toGroupString(respondsTo) + "]|link_recors";
+        for (Msg msg : linkRecords) {
+            s += ":(" + msg + ")";
+        }
+        return s;
+    }
+
+    private String toGroupString(ArrayList<Byte> group) {
+        ArrayList<Byte> sorted = new ArrayList<>(group);
+        Collections.sort(sorted);
+
+        StringBuilder buf = new StringBuilder();
+        for (Byte b : sorted) {
+            if (buf.length() > 0) {
+                buf.append(",");
+            }
+            buf.append("0x");
+            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 (file)
index 0000000..014010d
--- /dev/null
@@ -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<Integer, LinkDBRecord> records = new TreeMap<>(Collections.reverseOrder());
+    private TreeMap<Integer, LinkDBChange> 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<LinkDBRecord> getRecords() {
+        synchronized (records) {
+            return records.values().stream().toList();
+        }
+    }
+
+    private Stream<LinkDBRecord> 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<LinkDBRecord> getControllerRecords() {
+        return getRecords(null, null, true, true, null).toList();
+    }
+
+    public List<LinkDBRecord> getControllerRecords(InsteonAddress address) {
+        return getRecords(address, null, true, true, null).toList();
+    }
+
+    public List<LinkDBRecord> getControllerRecords(InsteonAddress address, int group) {
+        return getRecords(address, group, true, true, null).toList();
+    }
+
+    public List<LinkDBRecord> getResponderRecords() {
+        return getRecords(null, null, false, true, null).toList();
+    }
+
+    public List<LinkDBRecord> getResponderRecords(InsteonAddress address) {
+        return getRecords(address, null, false, true, null).toList();
+    }
+
+    public List<LinkDBRecord> 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<LinkDBChange> getChanges() {
+        synchronized (changes) {
+            return changes.values().stream().toList();
+        }
+    }
+
+    private Stream<LinkDBChange> 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<LinkDBRecord> 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<Integer> getBroadcastGroups(int componentId) {
+        List<Integer> 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<InsteonAddress> getRelatedDevices(int group) {
+        List<InsteonAddress> 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 (file)
index 0000000..536833b
--- /dev/null
@@ -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<LinkDBRecord> {
+
+    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 (file)
index 0000000..fa3837d
--- /dev/null
@@ -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 (file)
index 0000000..04dc81f
--- /dev/null
@@ -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 (file)
index 0000000..acc8bb2
--- /dev/null
@@ -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 (file)
index 0000000..12fef83
--- /dev/null
@@ -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<Integer, LinkMode> 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 (file)
index 0000000..544723a
--- /dev/null
@@ -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<Integer, ManageRecordAction> 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 (file)
index 0000000..420388e
--- /dev/null
@@ -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<InsteonAddress, ModemDBEntry> dbes = new HashMap<>();
+    private List<ModemDBRecord> records = new ArrayList<>();
+    private List<ModemDBChange> changes = new ArrayList<>();
+    private volatile boolean complete = false;
+
+    public ModemDB(InsteonModem modem) {
+        this.modem = modem;
+    }
+
+    public DatabaseManager getDatabaseManager() {
+        return modem.getDBM();
+    }
+
+    public List<InsteonAddress> getDevices() {
+        synchronized (dbes) {
+            return dbes.keySet().stream().toList();
+        }
+    }
+
+    public List<ModemDBEntry> 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<ModemDBRecord> getRecords() {
+        synchronized (records) {
+            return records.stream().toList();
+        }
+    }
+
+    private Stream<ModemDBRecord> 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<ModemDBRecord> 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<ModemDBChange> getChanges() {
+        synchronized (changes) {
+            return changes.stream().toList();
+        }
+    }
+
+    private Stream<ModemDBChange> 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<InsteonAddress, ProductData> 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<ModemDBRecord> 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<InsteonAddress, ProductData> 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<InsteonAddress> 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<Integer> 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 (file)
index 0000000..91d3507
--- /dev/null
@@ -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<ModemDBRecord> {
+
+    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 (file)
index 0000000..c140b4d
--- /dev/null
@@ -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<Integer> controllers = new TreeSet<>();
+    private Set<Integer> 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<ModemDBRecord> getRecords() {
+        return modemDB.getRecords(address);
+    }
+
+    public boolean hasRecords() {
+        return !getRecords().isEmpty();
+    }
+
+    public synchronized List<Integer> getControllerGroups() {
+        return controllers.stream().toList();
+    }
+
+    public synchronized List<Integer> 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 (file)
index 0000000..e748c24
--- /dev/null
@@ -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<InsteonAddress> 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 (file)
index 0000000..2d42c0a
--- /dev/null
@@ -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 (file)
index 0000000..53ff4b3
--- /dev/null
@@ -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/device/database/RecordFlags.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/database/RecordFlags.java
new file mode 100644 (file)
index 0000000..f63170c
--- /dev/null
@@ -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.device.database;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link RecordFlags} represents Insteon all-link record flags
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public enum RecordFlags {
+    CONTROLLER(0xE2),
+    RESPONDER(0xA2),
+    INACTIVE(0x22),
+    HIGH_WATER_MARK(0x00);
+
+    private final int value;
+
+    private RecordFlags(int value) {
+        this.value = value;
+    }
+
+    public int 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 (file)
index 0000000..e02faab
--- /dev/null
@@ -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 (file)
index 0000000..3857f40
--- /dev/null
@@ -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<String, String> 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<String, String> 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 (file)
index 0000000..a67101a
--- /dev/null
@@ -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<String> 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<Dimensionless>) 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> temperature = (QuantityType<Temperature>) cmd;
+            Unit<Temperature> 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<Temperature> 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<Time> time = (QuantityType<Time>) cmd;
+            Unit<Time> unit = getTimeUnit();
+            return Objects.requireNonNullElse(time.toInvertibleUnit(unit), time).doubleValue();
+        }
+
+        private Unit<Time> getTimeUnit() {
+            String scale = getParameterAsString("scale", "");
+            switch (scale) {
+                case "hour":
+                    return Units.HOUR;
+                case "minute":
+                    return Units.MINUTE;
+                case "second":
+                    return Units.SECOND;
+                default:
+                    logger.debug("{}: no valid time scale parameter found, defaulting to: SECONDS", nm());
+                    return Units.SECOND;
+            }
+        }
+    }
+
+    /**
+     * Generic on/off abstract command handler
+     */
+    public abstract static class OnOffCommandHandler extends CommandHandler {
+        OnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof OnOffType;
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                int cmd1 = getCommandCode(config, cmd);
+                int level = getLevel(config, cmd);
+                int group = getGroup(config);
+                // ignore request if cmd1/level not defined, send broadcast msg if group defined, otherwise direct msg
+                if (cmd1 == -1 || level == -1) {
+                    logger.debug("{}: unable to determine cmd1 or level value, ignoring request", nm());
+                } else if (group != -1) {
+                    Msg msg = Msg.makeBroadcastMessage(group, (byte) cmd1, (byte) level);
+                    feature.sendRequest(msg);
+                    logger.debug("{}: sent broadcast {} request to group {}", nm(), cmd, group);
+                    // poll related devices to broadcast group,
+                    // allowing each responder feature to determine its own poll delay
+                    feature.pollRelatedDevices(group, -1);
+                } else {
+                    InsteonAddress address = getInsteonDevice().getAddress();
+                    int componentId = feature.getGroup();
+                    Msg msg;
+                    if (componentId > 1) {
+                        byte[] data = { (byte) componentId };
+                        boolean setCRC = getInsteonDevice().getInsteonEngine().supportsChecksum();
+                        msg = Msg.makeExtendedMessage(address, (byte) cmd1, (byte) level, data, setCRC);
+                    } else {
+                        msg = Msg.makeStandardMessage(address, (byte) cmd1, (byte) level);
+                    }
+                    feature.sendRequest(msg);
+                    logger.debug("{}: sent {} request to {}", nm(), cmd, address);
+                    // adjust related devices if original channel config (initial request) and device sync enabled
+                    if (config.isOriginal() && getInsteonDevice().isDeviceSyncEnabled()) {
+                        feature.adjustRelatedDevices(config, cmd);
+                    }
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? getParameterAsInteger("off", 0x13) : getParameterAsInteger("on", 0x11);
+        }
+
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? 0x00 : getOnLevel(config);
+        }
+
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return -1;
+        }
+
+        private int getOnLevel(InsteonChannelConfiguration config) {
+            int level = config.getOnLevel();
+            if (level == -1) {
+                State state = getInsteonDevice().getFeatureState(FEATURE_ON_LEVEL);
+                level = (state instanceof PercentType percent ? percent : PercentType.HUNDRED).intValue();
+
+            }
+            logger.trace("{}: using on level {}%", nm(), level);
+            return (int) Math.ceil(level * 255.0 / 100); // round up
+        }
+    }
+
+    /**
+     * Dimmer on/off command handler
+     */
+    public static class DimmerOnOffCommandHandler extends OnOffCommandHandler {
+        DimmerOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            RampRate rampRate = config.getRampRate();
+            if (rampRate == null) {
+                // standard command if ramp rate parameter not configured
+                super.handleCommand(config, cmd);
+            } else if (rampRate == RampRate.INSTANT) {
+                // instant dimmer command if ramp rate parameter is instant (0.1 sec)
+                setInstantDimmer(config, cmd);
+            } else {
+                // ramp dimmer command otherwise
+                setRampDimmer(config, cmd);
+            }
+            // update state since dimmer related channels not automatically updated by the framework
+            PercentType state = getState(config, cmd);
+            feature.updateState(state);
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? 0x13 : 0x11;
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return config.getOnLevel() == -1 && getInsteonDevice().isDeviceSyncEnabled()
+                    ? feature.getBroadcastGroup(config)
+                    : -1;
+        }
+
+        protected PercentType getState(InsteonChannelConfiguration config, Command cmd) {
+            if (OnOffType.OFF.equals(cmd)) {
+                return PercentType.ZERO;
+            }
+            int level = config.getOnLevel();
+            if (level != -1) {
+                return new PercentType(level);
+            }
+            State state = getInsteonDevice().getFeatureState(FEATURE_ON_LEVEL);
+            if (state instanceof PercentType percent) {
+                return percent;
+            }
+            return PercentType.HUNDRED;
+        }
+
+        private void setInstantDimmer(InsteonChannelConfiguration config, Command cmd) {
+            InstantDimmerCommandHandler handler = new InstantDimmerCommandHandler(feature);
+            handler.setParameters(parameters);
+            handler.handleCommand(config, cmd);
+        }
+
+        private void setRampDimmer(InsteonChannelConfiguration config, Command cmd) {
+            RampDimmerCommandHandler handler = new RampDimmerCommandHandler(feature);
+            handler.setParameters(parameters);
+            handler.handleCommand(config, cmd);
+        }
+    }
+
+    /**
+     * Dimmer percent command handler
+     */
+    public static class DimmerPercentCommandHandler extends DimmerOnOffCommandHandler {
+        DimmerPercentCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof PercentType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return PercentType.ZERO.equals(cmd) ? 0x13 : 0x11;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            int level = ((PercentType) cmd).intValue();
+            return (int) Math.ceil(level * 255.0 / 100); // round up
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return -1;
+        }
+
+        @Override
+        protected PercentType getState(InsteonChannelConfiguration config, Command cmd) {
+            return (PercentType) cmd;
+        }
+    }
+
+    /**
+     * Dimmer increase/decrease command handler
+     */
+    public static class DimmerIncreaseDecreaseCommandHandler extends OnOffCommandHandler {
+        DimmerIncreaseDecreaseCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof IncreaseDecreaseType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return IncreaseDecreaseType.INCREASE.equals(cmd) ? 0x15 : 0x16;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return 0x00; // not parsed
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return getInsteonDevice().isDeviceSyncEnabled() ? feature.getBroadcastGroup(config) : -1;
+        }
+    }
+
+    /**
+     * Rollershutter up/down command handler
+     */
+    public static class RollershutterUpDownCommandHandler extends OnOffCommandHandler {
+        RollershutterUpDownCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof UpDownType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return 0x17; // manual change start
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return UpDownType.UP.equals(cmd) ? 0x01 : 0x00; // up or down
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return getInsteonDevice().isDeviceSyncEnabled() ? feature.getBroadcastGroup(config) : -1;
+        }
+    }
+
+    /**
+     * Rollershutter stop command handler
+     */
+    public static class RollershutterStopCommandHandler extends OnOffCommandHandler {
+        RollershutterStopCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return StopMoveType.STOP.equals(cmd);
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return 0x18; // manual change stop
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return 0x00; // not parsed
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return getInsteonDevice().isDeviceSyncEnabled() ? feature.getBroadcastGroup(config) : -1;
+        }
+    }
+
+    /**
+     * Instant dimmer command handler
+     */
+    public static class InstantDimmerCommandHandler extends OnOffCommandHandler {
+        InstantDimmerCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof OnOffType || cmd instanceof PercentType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return 0x21;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            if (cmd instanceof PercentType percent) {
+                return (int) Math.ceil(percent.intValue() * 255.0 / 100); // round up
+            } else {
+                return super.getLevel(config, cmd);
+            }
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return -1;
+        }
+    }
+
+    /**
+     * Ramp dimmer command handler
+     */
+    public static class RampDimmerCommandHandler extends InstantDimmerCommandHandler {
+        RampDimmerCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                InsteonAddress address = getInsteonDevice().getAddress();
+                int level = getLevel(config, cmd);
+                RampRate rampRate = getRampRate(config);
+                int cmd1 = getCommandCode(level);
+                int cmd2 = getEncodedValue(level, rampRate.getValue());
+                Msg msg = Msg.makeStandardMessage(address, (byte) cmd1, (byte) cmd2);
+                feature.sendRequest(msg);
+                logger.debug("{}: sent level {} with ramp time {} to {}", nm(), cmd, rampRate, address);
+                if (config.isOriginal() && getInsteonDevice().isDeviceSyncEnabled()) {
+                    feature.adjustRelatedDevices(config, cmd);
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        private RampRate getRampRate(InsteonChannelConfiguration config) {
+            return Objects.requireNonNullElse(config.getRampRate(), RampRate.DEFAULT);
+        }
+
+        private int getCommandCode(int level) {
+            ProductData productData = getInsteonDevice().getProductData();
+            // newer device with firmware >= 0x44 supports commands 0x34/0x35, while older supports 0x2E/0x2F
+            if (productData != null && productData.getFirmwareVersion() >= 0x44) {
+                return level > 0 ? 0x34 : 0x35;
+            } else {
+                return level > 0 ? 0x2E : 0x2F;
+            }
+        }
+
+        private int getEncodedValue(int level, int rampRate) {
+            int highByte = (int) Math.round(Math.max(0, level - 0x0F) / 16.0);
+            int lowByte = (int) Math.round(Math.max(0, rampRate - 0x01) / 2.0);
+            return highByte << 4 | lowByte;
+        }
+    }
+
+    /**
+     * Switch on/off command handler
+     */
+    public static class SwitchOnOffCommandHandler extends OnOffCommandHandler {
+        SwitchOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? 0x13 : 0x11;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? 0x00 : 0xFF;
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return getInsteonDevice().isDeviceSyncEnabled() ? feature.getBroadcastGroup(config) : -1;
+        }
+    }
+
+    /**
+     * Switch percent command handler
+     */
+    public static class SwitchPercentCommandHandler extends OnOffCommandHandler {
+        SwitchPercentCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof PercentType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return PercentType.ZERO.equals(cmd) ? 0x13 : 0x11;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return PercentType.ZERO.equals(cmd) ? 0x00 : 0xFF;
+        }
+    }
+
+    /**
+     * Switch increment command handler
+     */
+    public static class SwitchIncrementCommandHandler extends OnOffCommandHandler {
+        SwitchIncrementCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return IncreaseDecreaseType.INCREASE.equals(cmd) || UpDownType.UP.equals(cmd);
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return 0x11;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return 0xFF;
+        }
+    }
+
+    /**
+     * Broadcast on/off command handler
+     */
+    public static class BroadcastOnOffCommandHandler extends OnOffCommandHandler {
+        BroadcastOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            if (getGroup(config) != -1) {
+                super.handleCommand(config, cmd);
+            }
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? 0x13 : 0x11;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return 0x00; // not parsed
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return feature.getBroadcastGroup(config);
+        }
+    }
+
+    /**
+     * Broadcast fast on/off command handler
+     */
+    public static class BroadcastFastOnOffCommandHandler extends BroadcastOnOffCommandHandler {
+        BroadcastFastOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? 0x14 : 0x12;
+        }
+    }
+
+    /**
+     * Broadcast manual change up/down command handler
+     */
+    public static class BroadcastManualChangeUpDownCommandHandler extends BroadcastOnOffCommandHandler {
+        BroadcastManualChangeUpDownCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof UpDownType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return 0x17; // manual change start
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return UpDownType.UP.equals(cmd) ? 0x01 : 0x00; // up or down
+        }
+    }
+
+    /**
+     * Broadcast manual change stop command handler
+     */
+    public static class BroadcastManualChangeStopCommandHandler extends BroadcastOnOffCommandHandler {
+        BroadcastManualChangeStopCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return StopMoveType.STOP.equals(cmd);
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return 0x18; // manual change stop
+        }
+    }
+
+    /**
+     * Broadcast refresh command handler
+     */
+    public static class BroadcastRefreshCommandHandler extends RefreshCommandHandler {
+        BroadcastRefreshCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            int group = feature.getBroadcastGroup(config);
+            if (group != -1) {
+                feature.pollRelatedDevices(group, 0L);
+            }
+        }
+    }
+
+    /**
+     * Keypad button on/off command handler
+     */
+    public static class KeypadButtonOnOffCommandHandler extends CustomBitmaskCommandHandler {
+        KeypadButtonOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            OnOffType onOffCmd = getOnOffCommand(cmd);
+            int group = getGroup(config);
+            KeypadButtonToggleMode toggleMode = getToggleMode();
+            if (KeypadButtonToggleMode.ALWAYS_ON.equals(toggleMode) && OnOffType.OFF.equals(onOffCmd)
+                    || KeypadButtonToggleMode.ALWAYS_OFF.equals(toggleMode) && OnOffType.ON.equals(onOffCmd)) {
+                // ignore command when keypad button toggle mode is always on or off
+                logger.debug("{}: {} toggle mode is {}, ignoring {} command", nm(), feature.getName(), toggleMode,
+                        onOffCmd);
+            } else if (group != -1) {
+                // send broadcast message if group defined
+                logger.debug("{}: sending broadcast message", nm());
+                sendBroadcastOnOff(config, onOffCmd);
+                // update state since button channels not automatically updated by the framework
+                feature.updateState(onOffCmd);
+            } else {
+                // set button led bitmask otherwise
+                logger.debug("{}: setting button led bitmask", nm());
+                super.handleCommand(config, onOffCmd);
+                // update state since button channels not automatically updated by the framework
+                feature.updateState(onOffCmd);
+                // adjust related devices if original channel config and device sync enabled
+                if (config.isOriginal() && getInsteonDevice().isDeviceSyncEnabled()) {
+                    feature.adjustRelatedDevices(config, cmd);
+                }
+            }
+        }
+
+        @Override
+        protected int getBitNumber() {
+            return feature.getGroup() - 1;
+        }
+
+        @Override
+        protected int getBitmask(Command cmd) {
+            int bitmask = super.getBitmask(cmd);
+            if (bitmask != -1) {
+                int onMask = getInsteonDevice().getLastMsgValueAsInteger(FEATURE_TYPE_KEYPAD_BUTTON_ON_MASK,
+                        feature.getGroup(), -1);
+                int offMask = getInsteonDevice().getLastMsgValueAsInteger(FEATURE_TYPE_KEYPAD_BUTTON_OFF_MASK,
+                        feature.getGroup(), -1);
+                if (onMask == -1 || offMask == -1) {
+                    logger.debug("{}: undefined button on/off mask last values for {}", nm(), feature.getName());
+                    bitmask = -1;
+                } else {
+                    if (logger.isTraceEnabled()) {
+                        logger.trace("{}: bitmask:{} onMask:{} offMask:{}", nm(), BinaryUtils.getBinaryString(bitmask),
+                                BinaryUtils.getBinaryString(onMask), BinaryUtils.getBinaryString(offMask));
+                    }
+                    // apply button on/off mask
+                    bitmask = bitmask & ~offMask | onMask;
+                    // update last bitmask value
+                    updateLastBitmaskValue(bitmask);
+                }
+            }
+            return bitmask;
+        }
+
+        protected OnOffType getOnOffCommand(Command cmd) {
+            return (OnOffType) cmd;
+        }
+
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return getInsteonDevice().isDeviceSyncEnabled() ? feature.getBroadcastGroup(config) : -1;
+        }
+
+        private KeypadButtonToggleMode getToggleMode() {
+            try {
+                State state = getInsteonDevice().getFeatureState(FEATURE_TYPE_KEYPAD_BUTTON_TOGGLE_MODE,
+                        feature.getGroup());
+                if (state != null) {
+                    return KeypadButtonToggleMode.valueOf(state.toString());
+                }
+            } catch (IllegalArgumentException e) {
+            }
+            return KeypadButtonToggleMode.TOGGLE;
+        }
+
+        private void sendBroadcastOnOff(InsteonChannelConfiguration config, Command cmd) {
+            BroadcastOnOffCommandHandler handler = new BroadcastOnOffCommandHandler(feature);
+            handler.setParameters(parameters);
+            handler.handleCommand(config, cmd);
+        }
+
+        private void updateLastBitmaskValue(int value) {
+            DeviceFeature groupFeature = feature.getGroupFeature();
+            if (groupFeature != null) {
+                // set button group feature last msg value
+                groupFeature.setLastMsgValue(value);
+                // set button related features last msg value
+                groupFeature.getConnectedFeatures().forEach(feature -> feature.setLastMsgValue(value));
+            }
+        }
+    }
+
+    /**
+     * Keypad button percent command handler
+     */
+    public static class KeypadButtonPercentCommandHandler extends KeypadButtonOnOffCommandHandler {
+        KeypadButtonPercentCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof PercentType;
+        }
+
+        @Override
+        protected OnOffType getOnOffCommand(Command cmd) {
+            return OnOffType.from(!PercentType.ZERO.equals(cmd));
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return -1;
+        }
+    }
+
+    /**
+     * Keypad button increment command handler
+     */
+    public static class KeypadButtonIncrementCommandHandler extends KeypadButtonOnOffCommandHandler {
+        KeypadButtonIncrementCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return IncreaseDecreaseType.INCREASE.equals(cmd) || UpDownType.UP.equals(cmd);
+        }
+
+        @Override
+        protected OnOffType getOnOffCommand(Command cmd) {
+            return OnOffType.ON;
+        }
+
+        @Override
+        protected int getGroup(InsteonChannelConfiguration config) {
+            return -1;
+        }
+    }
+
+    /**
+     * Keypad button config command handler
+     */
+    public static class KeypadButtonConfigCommandHandler extends OpFlagsCommandHandler {
+        KeypadButtonConfigCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof StringType;
+        }
+
+        @Override
+        protected int getOpFlagCommand(Command cmd) {
+            try {
+                String config = ((StringType) cmd).toString();
+                return KeypadButtonConfig.valueOf(config).getValue();
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected button config command: {}, ignoring request", nm(), cmd);
+                return -1;
+            }
+        }
+
+        @Override
+        protected boolean isStateRetrievable() {
+            return true;
+        }
+    }
+
+    /**
+     * Keypad button toggle mode command handler
+     */
+    public static class KeypadButtonToggleModeCommandHandler extends CommandHandler {
+        KeypadButtonToggleModeCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof DecimalType || cmd instanceof StringType;
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                if (cmd instanceof DecimalType decimalCmd) {
+                    setToggleMode(decimalCmd.intValue() >> 8, decimalCmd.intValue() & 0xFF);
+                } else if (cmd instanceof StringType stringCmd) {
+                    int bit = feature.getGroup() - 1;
+                    if (bit < 0 || bit > 7) {
+                        logger.debug("{}: invalid bit number {} for {}", nm(), bit, feature.getName());
+                        return;
+                    }
+                    int lastValue = feature.getLastMsgValueAsInteger(-1);
+                    if (lastValue == -1) {
+                        logger.debug("{}: undefined toggle mode last value for {}", nm(), feature.getName());
+                        return;
+                    }
+                    KeypadButtonToggleMode mode = KeypadButtonToggleMode.valueOf(stringCmd.toString());
+                    int nonToggleMask = BinaryUtils.updateBit(lastValue >> 8, bit,
+                            mode != KeypadButtonToggleMode.TOGGLE);
+                    int alwaysOnOffMask = BinaryUtils.updateBit(lastValue & 0xFF, bit,
+                            mode == KeypadButtonToggleMode.ALWAYS_ON);
+                    setToggleMode(nonToggleMask, alwaysOnOffMask);
+                }
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected toggle mode command: {}, ignoring request", nm(), cmd);
+            }
+        }
+
+        private void setToggleMode(int nonToggleMask, int alwaysOnOffMask) {
+            try {
+                InsteonAddress address = getInsteonDevice().getAddress();
+                boolean setCRC = getInsteonDevice().getInsteonEngine().supportsChecksum();
+                // define ext command message to set keypad button non toggle mask
+                Msg nonToggleMaskMsg = Msg.makeExtendedMessage(address, (byte) 0x2E, (byte) 0x00,
+                        new byte[] { (byte) 0x01, (byte) 0x08, (byte) nonToggleMask }, setCRC);
+                // define ext command message to set keypad button always on/off mask
+                Msg alwaysOnOffMaskMsg = Msg.makeExtendedMessage(address, (byte) 0x2E, (byte) 0x00,
+                        new byte[] { (byte) 0x01, (byte) 0x0B, (byte) alwaysOnOffMask }, setCRC);
+                // send requests
+                if (logger.isDebugEnabled()) {
+                    logger.debug("{}: sent keypad button non toggle mask {} request to {}", nm(),
+                            HexUtils.getHexString(nonToggleMask), address);
+                }
+                feature.sendRequest(nonToggleMaskMsg);
+                if (logger.isDebugEnabled()) {
+                    logger.debug("{}: sent keypad button always on/off mask {} request to {}", nm(),
+                            HexUtils.getHexString(alwaysOnOffMask), address);
+                }
+                feature.sendRequest(alwaysOnOffMaskMsg);
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    /**
+     * Heartbeat interval command handler
+     */
+    public static class HeartbeatIntervalCommandHandler extends CustomCommandHandler {
+        HeartbeatIntervalCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof DecimalType || cmd instanceof QuantityType;
+        }
+
+        @Override
+        protected double getValue(Command cmd) {
+            int interval = getInterval(cmd);
+            int increment = getParameterAsInteger("increment", -1);
+            int preset = getParameterAsInteger("preset", 0);
+            if (increment == -1) {
+                logger.warn("{}: no increment parameter specified in command handler", nm());
+            } else if (interval == -1) {
+                logger.warn("{}: got unexpected heartbeat interval command: {}, ignoring request", nm(), cmd);
+            } else {
+                int value = (int) Math.floor(interval / increment); // round down
+                return interval == preset ? 0x00 : Math.max(0x00, Math.min(value, 0xFF));
+            }
+            return -1;
+        }
+
+        private int getInterval(Command cmd) {
+            if (cmd instanceof DecimalType time) {
+                return time.intValue();
+            } else if (cmd instanceof QuantityType<?> time) {
+                return Objects.requireNonNullElse(time.toInvertibleUnit(Units.MINUTE), time).intValue();
+            }
+            return -1;
+        }
+    }
+
+    /**
+     * Motion sensor 2 heartbeat interval command handler
+     */
+    public static class MotionSensor2HeartbeatIntervalCommandHandler extends HeartbeatIntervalCommandHandler {
+        MotionSensor2HeartbeatIntervalCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                int heartbeatInterval = (int) getValue(cmd);
+                int lowBatteryThreshold = getInsteonDevice().getLastMsgValueAsInteger(FEATURE_LOW_BATTERY_THRESHOLD,
+                        -1);
+                if (heartbeatInterval != -1 && lowBatteryThreshold != -1) {
+                    InsteonAddress address = getInsteonDevice().getAddress();
+                    byte[] data = { (byte) 0x00, (byte) 0x09, (byte) lowBatteryThreshold, (byte) heartbeatInterval };
+                    Msg msg = Msg.makeExtendedMessage(address, (byte) 0x2E, (byte) 0x00, data, true);
+                    feature.sendRequest(msg);
+                    if (logger.isDebugEnabled()) {
+                        logger.debug("{}: sent heartbeat interval {} request to {}", nm(),
+                                HexUtils.getHexString(heartbeatInterval), address);
+                    }
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    /**
+     * Siren on/off command handler
+     */
+    public static class SirenOnOffCommandHandler extends SwitchOnOffCommandHandler {
+        SirenOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? 0x00 : 0x7F; // no delay + max duration (127 seconds)
+        }
+    }
+
+    /**
+     * Siren armed command handler
+     */
+    public static class SirenArmedCommandHandler extends OpFlagsCommandHandler {
+        SirenArmedCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected byte[] getOpFlagData(Command cmd) {
+            return OnOffType.ON.equals(cmd) ? new byte[] { (byte) 0x01 } : new byte[0];
+        }
+
+        @Override
+        protected boolean isStateRetrievable() {
+            return true;
+        }
+    }
+
+    /**
+     * Siren alert duration command handler
+     */
+    public static class SirenAlertDurationCommandHandler extends CustomCommandHandler {
+        SirenAlertDurationCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof DecimalType || cmd instanceof QuantityType;
+        }
+
+        @Override
+        protected double getValue(Command cmd) {
+            int duration = getDuration(cmd);
+            int value = feature.getLastMsgValueAsInteger(-1);
+            if (value == -1) {
+                logger.debug("{}: unable to determine last value for {}", nm(), feature.getName());
+            } else if (duration == -1) {
+                logger.warn("{}: got unexpected siren alert duration cmd {}, ignoring request", nm(), cmd);
+            } else {
+                return value & 0x80 | duration;
+            }
+            return -1;
+        }
+
+        private int getDuration(Command cmd) {
+            int duration = -1;
+            if (cmd instanceof DecimalType time) {
+                duration = time.intValue();
+            } else if (cmd instanceof QuantityType<?> time) {
+                duration = Objects.requireNonNullElse(time.toInvertibleUnit(Units.SECOND), time).intValue();
+            }
+            return duration != -1 ? Math.max(0, Math.min(duration, 127)) : -1; // allowed range 0-127 seconds
+        }
+    }
+
+    /**
+     * Siren alert type command handler
+     */
+    public static class SirenAlertTypeCommandHandler extends CustomCommandHandler {
+        SirenAlertTypeCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof StringType;
+        }
+
+        @Override
+        protected double getValue(Command cmd) {
+            try {
+                String type = ((StringType) cmd).toString();
+                return SirenAlertType.valueOf(type).getValue();
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected alert type command: {}, ignoring request", nm(), cmd);
+                return -1;
+
+            }
+        }
+    }
+
+    /**
+     * LED brightness command handler
+     */
+    public static class LEDBrightnessCommandHandler extends CommandHandler {
+        LEDBrightnessCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof OnOffType || cmd instanceof PercentType;
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                int level = getLevel(cmd);
+                int userData2 = getParameterAsInteger("d2", -1);
+                if (userData2 != -1) {
+                    // set led on/off
+                    setLEDOnOff(config, OnOffType.from(level > 0));
+                    // set led brightness level
+                    InsteonAddress address = getInsteonDevice().getAddress();
+                    byte[] data = { (byte) 0x01, (byte) userData2, (byte) level };
+                    boolean setCRC = getInsteonDevice().getInsteonEngine().supportsChecksum();
+                    Msg msg = Msg.makeExtendedMessage(address, (byte) 0x2E, (byte) 0x00, data, setCRC);
+                    feature.sendRequest(msg);
+                    if (logger.isDebugEnabled()) {
+                        logger.debug("{}: sent led brightness level {} request to {}", nm(),
+                                HexUtils.getHexString(level), address);
+                    }
+                } else {
+                    logger.warn("{}: no d2 parameter specified in command handler", nm());
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        private int getLevel(Command cmd) {
+            int level;
+            if (cmd instanceof PercentType percent) {
+                level = percent.intValue();
+            } else {
+                level = OnOffType.OFF.equals(cmd) ? 0 : 100;
+            }
+            return (int) Math.round(level * 127.0 / 100);
+        }
+
+        private void setLEDOnOff(InsteonChannelConfiguration config, Command cmd) {
+            State state = getInsteonDevice().getFeatureState(FEATURE_LED_ON_OFF);
+            if (!((State) cmd).equals(state)) {
+                feature.handleCommand(config, cmd);
+            }
+        }
+    }
+
+    /**
+     * Momentary on command handler
+     */
+    public static class MomentaryOnCommandHandler extends CommandHandler {
+        MomentaryOnCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return OnOffType.ON.equals(cmd);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                int cmd1 = getParameterAsInteger("cmd1", -1);
+                if (cmd1 != -1) {
+                    InsteonAddress address = getInsteonDevice().getAddress();
+                    Msg msg = Msg.makeStandardMessage(address, (byte) cmd1, (byte) 0x00);
+                    feature.sendRequest(msg);
+                    logger.debug("{}: sent {} request to {}", nm(), feature.getName(), address);
+                } else {
+                    logger.warn("{}: no cmd1 field specified", nm());
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    /**
+     * Operating flags command handler
+     */
+    public static class OpFlagsCommandHandler extends CommandHandler {
+        OpFlagsCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof OnOffType;
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                int cmd2 = getOpFlagCommand(cmd);
+                if (cmd2 != -1) {
+                    byte[] data = getOpFlagData(cmd);
+                    Msg msg = getOpFlagMessage(cmd2, data);
+                    feature.sendRequest(msg);
+                    logger.debug("{}: sent op flag {} {} request to {}", nm(), feature.getName(), cmd,
+                            getInsteonDevice().getAddress());
+                    // update state if not retrievable (e.g. stayAwake)
+                    if (!isStateRetrievable()) {
+                        feature.updateState((State) cmd);
+                    }
+                } else {
+                    logger.warn("{}: unable to determine op flags command, ignoring request", nm());
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        protected int getOpFlagCommand(Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? getParameterAsInteger("off", -1) : getParameterAsInteger("on", -1);
+        }
+
+        protected byte[] getOpFlagData(Command cmd) {
+            return new byte[0];
+        }
+
+        protected Msg getOpFlagMessage(int cmd2, byte[] data) throws FieldException, InvalidMessageTypeException {
+            InsteonAddress address = getInsteonDevice().getAddress();
+            if (getInsteonDevice().getInsteonEngine().supportsChecksum()) {
+                return Msg.makeExtendedMessage(address, (byte) 0x20, (byte) cmd2, data, true);
+            } else {
+                return Msg.makeStandardMessage(address, (byte) 0x20, (byte) cmd2);
+            }
+        }
+
+        protected boolean isStateRetrievable() {
+            // op flag state is retrieved if a valid bit (0-7) parameter is defined
+            int bit = getParameterAsInteger("bit", -1);
+            return bit >= 0 && bit <= 7;
+        }
+    }
+
+    /**
+     * Multi-operating flags abstract command handler
+     */
+    public abstract static class MultiOpFlagsCommandHandler extends OpFlagsCommandHandler {
+        MultiOpFlagsCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof StringType;
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                for (Map.Entry<Integer, String> entry : getOpFlagCommands(cmd).entrySet()) {
+                    Msg msg = getOpFlagMessage(entry.getKey(), new byte[0]);
+                    feature.sendRequest(msg);
+                    logger.debug("{}: sent op flag {} request to {}", nm(), entry.getValue(),
+                            getInsteonDevice().getAddress());
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        protected abstract Map<Integer, String> getOpFlagCommands(Command cmd);
+    }
+
+    /**
+     * Ramp rate command handler
+     */
+    public static class RampRateCommandHandler extends CommandHandler {
+        RampRateCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof DecimalType || cmd instanceof QuantityType;
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                RampRate rampRate = getRampRate(cmd);
+                if (rampRate != null) {
+                    InsteonAddress address = getInsteonDevice().getAddress();
+                    byte[] data = { (byte) feature.getGroup(), (byte) 0x05, (byte) rampRate.getValue() };
+                    boolean setCRC = getInsteonDevice().getInsteonEngine().supportsChecksum();
+                    Msg msg = Msg.makeExtendedMessage(address, (byte) 0x2E, (byte) 0x00, data, setCRC);
+                    feature.sendRequest(msg);
+                    logger.debug("{}: sent ramp time {} to {}", nm(), rampRate, address);
+                } else {
+                    logger.warn("{}: got unexpected ramp rate command {}, ignoreing request", nm(), cmd);
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        private @Nullable RampRate getRampRate(Command cmd) {
+            double rampTime = -1;
+            if (cmd instanceof DecimalType time) {
+                rampTime = time.doubleValue();
+            } else if (cmd instanceof QuantityType<?> time) {
+                rampTime = Objects.requireNonNullElse(time.toInvertibleUnit(Units.SECOND), time).doubleValue();
+            }
+            return rampTime != -1 ? RampRate.fromTime(rampTime) : null;
+        }
+    }
+
+    /**
+     * FanLinc fan speed command handler
+     */
+    public static class FanLincFanSpeedCommandHandler extends OnOffCommandHandler {
+        FanLincFanSpeedCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof StringType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                String speed = ((StringType) cmd).toString();
+                return FanLincFanSpeed.valueOf(speed) == FanLincFanSpeed.OFF ? 0x13 : 0x11;
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected fan speed command: {}, ignoring request", nm(), cmd);
+                return -1;
+            }
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                String speed = ((StringType) cmd).toString();
+                return FanLincFanSpeed.valueOf(speed).getValue();
+            } catch (IllegalArgumentException e) {
+                return -1;
+            }
+        }
+    }
+
+    /**
+     * FanLinc fan on/off command handler
+     */
+    public static class FanLincFanOnOffCommandHandler extends OnOffCommandHandler {
+        FanLincFanOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.OFF.equals(cmd) ? 0x13 : 0x11;
+        }
+    }
+
+    /**
+     * FanLinc fan percent command handler
+     */
+    public static class FanLincFanPercentCommandHandler extends OnOffCommandHandler {
+        FanLincFanPercentCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof PercentType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return PercentType.ZERO.equals(cmd) ? 0x13 : 0x11;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            int level = ((PercentType) cmd).intValue();
+            return (int) Math.ceil(level * 255.0 / 100); // round up
+        }
+    }
+
+    /**
+     * I/O linc momentary duration command handler
+     */
+    public static class IOLincMomentaryDurationCommandHandler extends CommandHandler {
+        IOLincMomentaryDurationCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof DecimalType || cmd instanceof QuantityType;
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                double duration = getDuration(cmd);
+                if (duration != -1) {
+                    InsteonAddress address = getInsteonDevice().getAddress();
+                    int prescaler = 1;
+                    int delay = (int) Math.round(duration * 10);
+                    if (delay > 255) {
+                        prescaler = (int) Math.ceil(delay / 255.0);
+                        delay = (int) Math.round(delay / (double) prescaler);
+                    }
+                    boolean setCRC = getInsteonDevice().getInsteonEngine().supportsChecksum();
+                    // define ext command message to set momentary duration delay
+                    Msg delayMsg = Msg.makeExtendedMessage(address, (byte) 0x2E, (byte) 0x00,
+                            new byte[] { (byte) 0x01, (byte) 0x06, (byte) delay }, setCRC);
+                    // define ext command message to set momentary duration prescaler
+                    Msg prescalerMsg = Msg.makeExtendedMessage(address, (byte) 0x2E, (byte) 0x00,
+                            new byte[] { (byte) 0x01, (byte) 0x07, (byte) prescaler }, setCRC);
+                    // send requests
+                    feature.sendRequest(delayMsg);
+                    if (logger.isDebugEnabled()) {
+                        logger.debug("{}: sent momentary duration delay {} request to {}", nm(),
+                                HexUtils.getHexString(delay), address);
+                    }
+                    feature.sendRequest(prescalerMsg);
+                    if (logger.isDebugEnabled()) {
+                        logger.debug("{}: sent momentary duration prescaler {} request to {}", nm(),
+                                HexUtils.getHexString(prescaler), address);
+                    }
+                } else {
+                    logger.warn("{}: got unexpected momentary duration command {}, ignoring request", nm(), cmd);
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        private double getDuration(Command cmd) {
+            if (cmd instanceof DecimalType time) {
+                return time.doubleValue();
+            } else if (cmd instanceof QuantityType<?> time) {
+                return Objects.requireNonNullElse(time.toInvertibleUnit(Units.SECOND), time).doubleValue();
+            }
+            return -1;
+        }
+    }
+
+    /**
+     * I/O linc relay mode command handler
+     */
+    public static class IOLincRelayModeCommandHandler extends MultiOpFlagsCommandHandler {
+        IOLincRelayModeCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected Map<Integer, String> getOpFlagCommands(Command cmd) {
+            Map<Integer, String> commands = new HashMap<>();
+            try {
+                String mode = ((StringType) cmd).toString();
+                switch (IOLincRelayMode.valueOf(mode)) {
+                    case LATCHING:
+                        commands.put(0x07, "momentary mode OFF");
+                        break;
+                    case MOMENTARY_A:
+                        commands.put(0x06, "momentary mode ON");
+                        commands.put(0x13, "momentary trigger on/off OFF");
+                        commands.put(0x15, "momentary sensor follow OFF");
+                        break;
+                    case MOMENTARY_B:
+                        commands.put(0x06, "momentary mode ON");
+                        commands.put(0x12, "momentary trigger on/off ON");
+                        commands.put(0x15, "momentary sensor follow OFF");
+                        break;
+                    case MOMENTARY_C:
+                        commands.put(0x06, "momentary mode ON");
+                        commands.put(0x13, "momentary trigger on/off OFF");
+                        commands.put(0x14, "momentary sensor follow ON");
+                        break;
+                }
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected relay mode command: {}, ignoring request", nm(), cmd);
+            }
+            return commands;
+        }
+    }
+
+    /**
+     * Micro module operation mode command handler
+     */
+    public static class MicroModuleOpModeCommandHandler extends MultiOpFlagsCommandHandler {
+        MicroModuleOpModeCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected Map<Integer, String> getOpFlagCommands(Command cmd) {
+            Map<Integer, String> commands = new HashMap<>();
+            try {
+                String mode = ((StringType) cmd).toString();
+                switch (MicroModuleOpMode.valueOf(mode)) {
+                    case LATCHING:
+                        commands.put(0x20, "momentary line OFF");
+                        break;
+                    case SINGLE_MOMENTARY:
+                        commands.put(0x21, "momentary line ON");
+                        commands.put(0x1E, "dual line OFF");
+                        break;
+                    case DUAL_MOMENTARY:
+                        commands.put(0x21, "momentary line ON");
+                        commands.put(0x1E, "dual line ON");
+                        break;
+                }
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected operation mode command: {}, ignoring request", nm(), cmd);
+            }
+            return commands;
+        }
+    }
+
+    /**
+     * Sprinkler valve on/off command handler
+     */
+    public static class SprinklerValveOnOffCommandHandler extends OnOffCommandHandler {
+        SprinklerValveOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return OnOffType.ON.equals(cmd) ? 0x40 : 0x41;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return getParameterAsInteger("valve", -1);
+        }
+    }
+
+    /**
+     * Sprinkler program on/off command handler
+     */
+    public static class SprinklerProgramOnOffCommandHandler extends OnOffCommandHandler {
+        SprinklerProgramOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof PlayPauseType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return PlayPauseType.PLAY.equals(cmd) ? 0x42 : 0x43;
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return getParameterAsInteger("program", -1);
+        }
+    }
+
+    /**
+     * Sprinkler program next/previous command handler
+     */
+    public static class SprinklerProgramNextPreviousCommandHandler extends OnOffCommandHandler {
+        SprinklerProgramNextPreviousCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof NextPreviousType;
+        }
+
+        @Override
+        protected int getCommandCode(InsteonChannelConfiguration config, Command cmd) {
+            return 0x44; // sprinkler control
+        }
+
+        @Override
+        protected int getLevel(InsteonChannelConfiguration config, Command cmd) {
+            return NextPreviousType.NEXT.equals(cmd) ? 0x05 : 0x06; // skip forward or back
+        }
+    }
+
+    /**
+     * Thermostat fan mode command handler
+     */
+    public static class ThermostatFanModeCommandHandler extends CustomCommandHandler {
+        ThermostatFanModeCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof StringType;
+        }
+
+        @Override
+        protected double getValue(Command cmd) {
+            try {
+                String mode = ((StringType) cmd).toString();
+                return ThermostatFanMode.valueOf(mode).getValue();
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected fan mode command: {}, ignoring request", nm(), cmd);
+                return -1;
+            }
+        }
+    }
+
+    /**
+     * Thermostat system mode command handler
+     */
+    public static class ThermostatSystemModeCommandHandler extends CustomCommandHandler {
+        ThermostatSystemModeCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof StringType;
+        }
+
+        @Override
+        protected double getValue(Command cmd) {
+            try {
+                String mode = ((StringType) cmd).toString();
+                return ThermostatSystemMode.valueOf(mode).getValue();
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected system mode command: {}, ignoring request", nm(), cmd);
+                return -1;
+            }
+        }
+    }
+
+    /**
+     * Venstar thermostat system mode handler
+     */
+    public static class VenstarSystemModeCommandHandler extends CustomCommandHandler {
+        VenstarSystemModeCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof StringType;
+        }
+
+        @Override
+        protected double getValue(Command cmd) {
+            try {
+                String mode = ((StringType) cmd).toString();
+                return VenstarSystemMode.valueOf(mode).getValue();
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected system mode command: {}, ignoring request", nm(), cmd);
+                return -1;
+            }
+        }
+    }
+
+    /**
+     * Thermostat temperature scale command handler
+     */
+    public static class ThermostatTemperatureScaleCommandHandler extends CustomBitmaskCommandHandler {
+        ThermostatTemperatureScaleCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof StringType;
+        }
+
+        @Override
+        protected @Nullable Boolean shouldSetBit(Command cmd) {
+            try {
+                String scale = ((StringType) cmd).toString();
+                return ThermostatTemperatureScale.valueOf(scale) == ThermostatTemperatureScale.CELSIUS;
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected temperature scale command: {}, ignoring request", nm(), cmd);
+                return null;
+            }
+        }
+    }
+
+    /**
+     * Thermostat time format command handler
+     */
+    public static class ThermostatTimeFormatCommandHandler extends CustomBitmaskCommandHandler {
+        ThermostatTimeFormatCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof StringType;
+        }
+
+        @Override
+        protected @Nullable Boolean shouldSetBit(Command cmd) {
+            try {
+                String format = ((StringType) cmd).toString();
+                return ThermostatTimeFormat.from(format) == ThermostatTimeFormat.HR_24;
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected temperature format command: {}, ignoring request", nm(), cmd);
+                return null;
+            }
+        }
+    }
+
+    /**
+     * Thermostat sync time command handler
+     */
+    public static class ThermostatSyncTimeCommandHandler extends MomentaryOnCommandHandler {
+        ThermostatSyncTimeCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                InsteonAddress address = getInsteonDevice().getAddress();
+                ZonedDateTime time = ZonedDateTime.now();
+                byte[] data = { (byte) 0x02, (byte) (time.getDayOfWeek().getValue() % 7), (byte) time.getHour(),
+                        (byte) time.getMinute(), (byte) time.getSecond() };
+                Msg msg = Msg.makeExtendedMessageCRC2(address, (byte) 0x2E, (byte) 0x02, data);
+                feature.sendRequest(msg);
+                logger.debug("{}: sent set time data request to {}", nm(), address);
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    /**
+     * IM generic abstract command handler
+     */
+    public abstract static class IMCommandHandler extends CommandHandler {
+        IMCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                Msg msg = getIMMessage(cmd);
+                feature.sendRequest(msg);
+                logger.debug("{}: sent {} request to {}", nm(), cmd, getInsteonModem().getAddress());
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        protected abstract Msg getIMMessage(Command cmd) throws InvalidMessageTypeException, FieldException;
+    }
+
+    /**
+     * IM led on/off command handler
+     */
+    public static class IMLEDOnOffCommandHandler extends IMCommandHandler {
+        IMLEDOnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof OnOffType;
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            // set led control
+            setLEDControl(config);
+            // set led on/off
+            super.handleCommand(config, cmd);
+            // update state since not retrievable
+            feature.updateState((State) cmd);
+        }
+
+        @Override
+        protected Msg getIMMessage(Command cmd) throws InvalidMessageTypeException, FieldException {
+            return Msg.makeMessage(OnOffType.OFF.equals(cmd) ? "LEDOff" : "LEDOn");
+        }
+
+        private void setLEDControl(InsteonChannelConfiguration config) {
+            State state = getInsteonModem().getFeatureState(FEATURE_LED_CONTROL);
+            if (!OnOffType.ON.equals(state)) {
+                feature.handleCommand(config, OnOffType.ON);
+            }
+        }
+    }
+
+    /**
+     * IM beep command handler
+     */
+    public static class IMBeepCommandHandler extends IMCommandHandler {
+        IMBeepCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return OnOffType.ON.equals(cmd);
+        }
+
+        @Override
+        protected Msg getIMMessage(Command cmd) throws InvalidMessageTypeException, FieldException {
+            return Msg.makeMessage("Beep");
+        }
+    }
+
+    /**
+     * IM config command handler
+     */
+    public static class IMConfigCommandHandler extends CustomBitmaskCommandHandler {
+        IMConfigCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                int bitmask = getBitmask(cmd);
+                if (bitmask != -1) {
+                    Msg msg = Msg.makeMessage("SetIMConfig");
+                    msg.setByte("IMConfigurationFlags", (byte) bitmask);
+                    feature.sendRequest(msg);
+                    logger.debug("{}: sent {} request to {}", nm(), cmd, getInsteonModem().getAddress());
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    /**
+     * X10 generic abstract command handler
+     */
+    public abstract static class X10CommandHandler extends CommandHandler {
+        X10CommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonChannelConfiguration config, Command cmd) {
+            try {
+                X10Address address = getX10Device().getAddress();
+                int cmdCode = getCommandCode(cmd, address.getHouseCode());
+                Msg addrMsg = Msg.makeX10AddressMessage(address);
+                feature.sendRequest(addrMsg);
+                Msg cmdMsg = Msg.makeX10CommandMessage((byte) cmdCode);
+                feature.sendRequest(cmdMsg);
+                logger.debug("{}: sent {} request to {}", nm(), cmd, address);
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        protected abstract int getCommandCode(Command cmd, byte houseCode);
+    }
+
+    /**
+     * X10 on/off command handler
+     */
+    public static class X10OnOffCommandHandler extends X10CommandHandler {
+        X10OnOffCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof OnOffType;
+        }
+
+        @Override
+        protected int getCommandCode(Command cmd, byte houseCode) {
+            int cmdCode = OnOffType.OFF.equals(cmd) ? X10Command.OFF.code() : X10Command.ON.code();
+            return houseCode << 4 | cmdCode;
+        }
+    }
+
+    /**
+     * X10 percent command handler
+     */
+    public static class X10PercentCommandHandler extends X10CommandHandler {
+
+        private static final int[] X10_LEVEL_CODES = { 0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15 };
+
+        X10PercentCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof PercentType;
+        }
+
+        @Override
+        protected int getCommandCode(Command cmd, byte houseCode) {
+            int level = ((PercentType) cmd).intValue() * 32 / 100;
+            int levelCode = X10_LEVEL_CODES[level % 16];
+            int cmdCode = level >= 16 ? X10Command.PRESET_DIM_2.code() : X10Command.PRESET_DIM_1.code();
+            return levelCode << 4 | cmdCode;
+        }
+    }
+
+    /**
+     * X10 increase/decrease command handler
+     */
+    public static class X10IncreaseDecreaseCommandHandler extends X10CommandHandler {
+        X10IncreaseDecreaseCommandHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean canHandle(Command cmd) {
+            return cmd instanceof IncreaseDecreaseType;
+        }
+
+        @Override
+        protected int getCommandCode(Command cmd, byte houseCode) {
+            int cmdCode = IncreaseDecreaseType.INCREASE.equals(cmd) ? X10Command.BRIGHT.code() : X10Command.DIM.code();
+            return houseCode << 4 | cmdCode;
+        }
+    }
+
+    /**
+     * Factory method to dermine if a command handler supports a given command type
+     *
+     * @param type the handler command type
+     * @return true if handler supports command type, otherwise false
+     */
+    public static boolean supportsCommandType(String type) {
+        return SUPPORTED_COMMAND_TYPES.contains(type);
+    }
+
+    /**
+     * Factory method for creating default command handler
+     *
+     * @param feature the feature for which to create the handler
+     * @return the default command handler which was created
+     */
+    public static DefaultCommandHandler makeDefaultHandler(DeviceFeature feature) {
+        return new DefaultCommandHandler(feature);
+    }
+
+    /**
+     * Factory method for creating handlers of a given name using java reflection
+     *
+     * @param name the name of the handler to create
+     * @param parameters the parameters of the handler to create
+     * @param feature the feature for which to create the handler
+     * @return the handler which was created
+     */
+    public static @Nullable <T extends CommandHandler> T makeHandler(String name, Map<String, String> parameters,
+            DeviceFeature feature) {
+        try {
+            String className = CommandHandler.class.getName() + "$" + name;
+            @SuppressWarnings("unchecked")
+            Class<? extends T> classRef = (Class<? extends T>) Class.forName(className);
+            @Nullable
+            T handler = classRef.getDeclaredConstructor(DeviceFeature.class).newInstance(feature);
+            handler.setParameters(parameters);
+            return handler;
+        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
+                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
+            return null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureCache.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureCache.java
new file mode 100644 (file)
index 0000000..e713214
--- /dev/null
@@ -0,0 +1,110 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.insteon.internal.device.feature;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+import org.openhab.core.types.State;
+import org.openhab.core.types.TypeParser;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * The {@link FeatureCache} represents a device feature cache
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class FeatureCache {
+    private static final String TYPE_SEPARATOR = "@@@";
+
+    private @Nullable String state;
+    private @Nullable Double lastMsgValue;
+
+    public @Nullable State getState() {
+        String state = this.state;
+        if (state == null) {
+            return null;
+        }
+        String[] parts = state.split(TYPE_SEPARATOR, 2);
+        if (parts.length != 2) {
+            return null;
+        }
+        try {
+            @SuppressWarnings("unchecked")
+            Class<? extends State> type = (Class<? extends State>) Class.forName(parts[0]);
+            return TypeParser.parseState(List.of(type), parts[1]);
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    public @Nullable Double getLastMsgValue() {
+        return lastMsgValue;
+    }
+
+    /**
+     * Loads this feature cache into a device feature
+     *
+     * @param feature the device feature to use
+     */
+    public void load(DeviceFeature feature) {
+        // set feature state if defined
+        State state = getState();
+        if (state != null) {
+            feature.setState(state);
+        }
+
+        // set feature last message value if defined
+        Double lastMsgValue = getLastMsgValue();
+        if (lastMsgValue != null) {
+            feature.setLastMsgValue(lastMsgValue.doubleValue());
+        }
+    }
+
+    /**
+     * Class that represents a feature cache builder
+     */
+    public static class Builder {
+        private final FeatureCache cache = new FeatureCache();
+
+        private Builder() {
+        }
+
+        public Builder withState(State state) {
+            cache.state = state instanceof UnDefType ? null
+                    : state.getClass().getName() + TYPE_SEPARATOR + state.toFullString();
+            return this;
+        }
+
+        public Builder withLastMsgValue(@Nullable Double lastMsgValue) {
+            cache.lastMsgValue = lastMsgValue;
+            return this;
+        }
+
+        public FeatureCache build() {
+            return cache;
+        }
+    }
+
+    /**
+     * Factory method for creating a feature cache builder
+     *
+     * @return the newly created feature cache builder
+     */
+    public static Builder builder() {
+        return new Builder();
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureEnums.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureEnums.java
new file mode 100644 (file)
index 0000000..93a68e7
--- /dev/null
@@ -0,0 +1,404 @@
+/**
+ * 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.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.utils.BinaryUtils;
+
+/**
+ * The {@link FeatureEnums} represents feature enums
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class FeatureEnums {
+    public static enum ButtonEvent {
+        PRESSED_ON,
+        PRESSED_OFF,
+        DOUBLE_PRESSED_ON,
+        DOUBLE_PRESSED_OFF,
+        HELD_UP,
+        HELD_DOWN,
+        RELEASED;
+
+        public static ButtonEvent valueOf(int cmd1, int cmd2) throws IllegalArgumentException {
+            switch (cmd1) {
+                case 0x11:
+                    return ButtonEvent.PRESSED_ON;
+                case 0x12:
+                    return ButtonEvent.DOUBLE_PRESSED_ON;
+                case 0x13:
+                    return ButtonEvent.PRESSED_OFF;
+                case 0x14:
+                    return ButtonEvent.DOUBLE_PRESSED_OFF;
+                case 0x17:
+                    return cmd2 == 0x01 ? ButtonEvent.HELD_UP : ButtonEvent.HELD_DOWN;
+                case 0x18:
+                    return ButtonEvent.RELEASED;
+                default:
+                    throw new IllegalArgumentException("unexpected button event");
+            }
+        }
+    }
+
+    public static enum IMButtonEvent {
+        PRESSED,
+        HELD,
+        RELEASED;
+
+        public static IMButtonEvent valueOf(int cmd) throws IllegalArgumentException {
+            switch (cmd) {
+                case 0x02:
+                    return IMButtonEvent.PRESSED;
+                case 0x03:
+                    return IMButtonEvent.HELD;
+                case 0x04:
+                    return IMButtonEvent.RELEASED;
+                default:
+                    throw new IllegalArgumentException("unexpected im button event");
+            }
+        }
+    }
+
+    public static enum FanLincFanSpeed {
+        OFF(0x00),
+        LOW(0x55),
+        MEDIUM(0xAA),
+        HIGH(0xFF);
+
+        private int value;
+
+        private FanLincFanSpeed(int value) {
+            this.value = value;
+        }
+
+        public int getValue() {
+            return value;
+        }
+
+        public static FanLincFanSpeed valueOf(int value) throws IllegalArgumentException {
+            if (value == 0x00) {
+                return FanLincFanSpeed.OFF;
+            } else if (value >= 0x01 && value <= 0x7F) {
+                return FanLincFanSpeed.LOW;
+            } else if (value >= 0x80 && value <= 0xFE) {
+                return FanLincFanSpeed.MEDIUM;
+            } else if (value == 0xFF) {
+                return FanLincFanSpeed.HIGH;
+            } else {
+                throw new IllegalArgumentException("unexpected fanlinc fan speed");
+            }
+        }
+
+        public static List<String> names() {
+            return Arrays.stream(values()).map(String::valueOf).toList();
+        }
+    }
+
+    public static enum KeypadButtonConfig {
+        BUTTON_6(0x07, 6),
+        BUTTON_8(0x06, 8);
+
+        private int value;
+        private int count;
+
+        private KeypadButtonConfig(int value, int count) {
+            this.value = value;
+            this.count = count;
+        }
+
+        public int getValue() {
+            return value;
+        }
+
+        public int getCount() {
+            return count;
+        }
+
+        public static KeypadButtonConfig from(boolean is8Button) {
+            return is8Button ? KeypadButtonConfig.BUTTON_8 : KeypadButtonConfig.BUTTON_6;
+        }
+    }
+
+    public static enum KeypadButtonToggleMode {
+        TOGGLE,
+        ALWAYS_ON,
+        ALWAYS_OFF;
+
+        public static KeypadButtonToggleMode valueOf(int value, int bit) {
+            if (!BinaryUtils.isBitSet(value >> 8, bit)) {
+                return KeypadButtonToggleMode.TOGGLE;
+            } else if (BinaryUtils.isBitSet(value & 0xFF, bit)) {
+                return KeypadButtonToggleMode.ALWAYS_ON;
+            } else {
+                return KeypadButtonToggleMode.ALWAYS_OFF;
+            }
+        }
+    }
+
+    public static enum IOLincRelayMode {
+        LATCHING,
+        MOMENTARY_A,
+        MOMENTARY_B,
+        MOMENTARY_C;
+
+        public static IOLincRelayMode valueOf(int value) {
+            if (!BinaryUtils.isBitSet(value, 3)) {
+                // return latching, when momentary mode op flag (3) is off
+                return IOLincRelayMode.LATCHING;
+            } else if (BinaryUtils.isBitSet(value, 7)) {
+                // return momentary c, when momentary sensor follow op flag (7) is on
+                return IOLincRelayMode.MOMENTARY_C;
+            } else if (BinaryUtils.isBitSet(value, 4)) {
+                // return momentary b, when momentary trigger on/off op flag (4) is on
+                return IOLincRelayMode.MOMENTARY_B;
+            } else {
+                // return momentary a, otherwise
+                return IOLincRelayMode.MOMENTARY_A;
+            }
+        }
+    }
+
+    public static enum MicroModuleOpMode {
+        LATCHING,
+        SINGLE_MOMENTARY,
+        DUAL_MOMENTARY;
+
+        public static MicroModuleOpMode valueOf(int value) {
+            if (!BinaryUtils.isBitSet(value, 1)) {
+                // return latching, when momentary line op flag (1) is off
+                return MicroModuleOpMode.LATCHING;
+            } else if (!BinaryUtils.isBitSet(value, 0)) {
+                // return single momentary, when dual line op flag (0) is off
+                return MicroModuleOpMode.SINGLE_MOMENTARY;
+            } else {
+                // return dual momentary, otherwise
+                return MicroModuleOpMode.DUAL_MOMENTARY;
+            }
+        }
+    }
+
+    public static enum SirenAlertType {
+        CHIME(0x00),
+        LOUD_SIREN(0x01);
+
+        private static final Map<Integer, SirenAlertType> VALUE_MAP = Arrays.stream(values())
+                .collect(Collectors.toUnmodifiableMap(type -> type.value, Function.identity()));
+
+        private int value;
+
+        private SirenAlertType(int value) {
+            this.value = value;
+        }
+
+        public int getValue() {
+            return value;
+        }
+
+        public static SirenAlertType valueOf(int value) throws IllegalArgumentException {
+            SirenAlertType type = VALUE_MAP.get(value);
+            if (type == null) {
+                throw new IllegalArgumentException("unexpected siren alert type");
+            }
+            return type;
+        }
+    }
+
+    public static enum ThermostatFanMode {
+        AUTO(0x08, 0x00),
+        ALWAYS_ON(0x07, 0x01);
+
+        private static final Map<Integer, ThermostatFanMode> VALUE_MAP = Arrays.stream(values())
+                .collect(Collectors.toUnmodifiableMap(mode -> mode.value, Function.identity()));
+        private static final Map<Integer, ThermostatFanMode> STATUS_MAP = Arrays.stream(values())
+                .collect(Collectors.toUnmodifiableMap(mode -> mode.status, Function.identity()));
+
+        private int value;
+        private int status;
+
+        private ThermostatFanMode(int value, int status) {
+            this.value = value;
+            this.status = status;
+        }
+
+        public int getValue() {
+            return value;
+        }
+
+        public static ThermostatFanMode valueOf(int value) throws IllegalArgumentException {
+            ThermostatFanMode mode = VALUE_MAP.get(value);
+            if (mode == null) {
+                throw new IllegalArgumentException("unexpected thermostat fan mode");
+            }
+            return mode;
+        }
+
+        public static ThermostatFanMode fromStatus(int status) throws IllegalArgumentException {
+            ThermostatFanMode mode = STATUS_MAP.get(status);
+            if (mode == null) {
+                throw new IllegalArgumentException("unexpected thermostat fan status");
+            }
+            return mode;
+        }
+
+        public static List<String> names() {
+            return VALUE_MAP.values().stream().map(String::valueOf).toList();
+        }
+    }
+
+    public static enum ThermostatSystemMode {
+        OFF(0x09, 0x00),
+        AUTO(0x06, 0x01),
+        HEAT(0x04, 0x02),
+        COOL(0x05, 0x03),
+        PROGRAM(0x0A, 0x04);
+
+        private static final Map<Integer, ThermostatSystemMode> VALUE_MAP = Arrays.stream(values())
+                .collect(Collectors.toUnmodifiableMap(mode -> mode.value, Function.identity()));
+        private static final Map<Integer, ThermostatSystemMode> STATUS_MAP = Arrays.stream(values())
+                .collect(Collectors.toUnmodifiableMap(mode -> mode.status, Function.identity()));
+
+        private int value;
+        private int status;
+
+        private ThermostatSystemMode(int value, int status) {
+            this.value = value;
+            this.status = status;
+        }
+
+        public int getValue() {
+            return value;
+        }
+
+        public static ThermostatSystemMode valueOf(int value) throws IllegalArgumentException {
+            ThermostatSystemMode mode = VALUE_MAP.get(value);
+            if (mode == null) {
+                throw new IllegalArgumentException("unexpected thermostat system mode");
+            }
+            return mode;
+        }
+
+        public static ThermostatSystemMode fromStatus(int status) throws IllegalArgumentException {
+            ThermostatSystemMode mode = STATUS_MAP.get(status);
+            if (mode == null) {
+                throw new IllegalArgumentException("unexpected thermostat system status");
+            }
+            return mode;
+        }
+
+        public static List<String> names() {
+            return VALUE_MAP.values().stream().map(String::valueOf).toList();
+        }
+    }
+
+    public static enum VenstarSystemMode {
+        OFF(0x09, 0x00),
+        AUTO(0x06, 0x01),
+        HEAT(0x04, 0x02),
+        COOL(0x05, 0x03),
+        PROGRAM_HEAT(0x0A, 0x04),
+        PROGRAM_COOL(0x0B, 0x05),
+        PROGRAM_AUTO(0x0C, 0x06);
+
+        private static final Map<Integer, VenstarSystemMode> VALUE_MAP = Arrays.stream(values())
+                .collect(Collectors.toUnmodifiableMap(mode -> mode.value, Function.identity()));
+        private static final Map<Integer, VenstarSystemMode> STATUS_MAP = Arrays.stream(values())
+                .collect(Collectors.toUnmodifiableMap(mode -> mode.status, Function.identity()));
+
+        private int value;
+        private int status;
+
+        private VenstarSystemMode(int value, int status) {
+            this.value = value;
+            this.status = status;
+        }
+
+        public int getValue() {
+            return value;
+        }
+
+        public static VenstarSystemMode valueOf(int value) throws IllegalArgumentException {
+            VenstarSystemMode mode = VALUE_MAP.get(value);
+            if (mode == null) {
+                throw new IllegalArgumentException("unexpected venstar system mode");
+            }
+            return mode;
+        }
+
+        public static VenstarSystemMode fromStatus(int status) throws IllegalArgumentException {
+            VenstarSystemMode mode = STATUS_MAP.get(status);
+            if (mode == null) {
+                throw new IllegalArgumentException("unexpected venstar system status");
+            }
+            return mode;
+        }
+
+        public static List<String> names() {
+            return VALUE_MAP.values().stream().map(String::valueOf).toList();
+        }
+    }
+
+    public static enum ThermostatSystemState {
+        OFF,
+        HEATING,
+        COOLING,
+        HUMIDIFYING,
+        DEHUMIDIFYING;
+    }
+
+    public static enum ThermostatTemperatureScale {
+        CELSIUS,
+        FAHRENHEIT;
+
+        public static ThermostatTemperatureScale from(boolean isCelsius) {
+            return isCelsius ? ThermostatTemperatureScale.CELSIUS : ThermostatTemperatureScale.FAHRENHEIT;
+        }
+    }
+
+    public static enum ThermostatTimeFormat {
+        HR_12("12H"),
+        HR_24("24H");
+
+        private static final Map<String, ThermostatTimeFormat> LABEL_MAP = Arrays.stream(values())
+                .collect(Collectors.toUnmodifiableMap(format -> format.label, Function.identity()));
+
+        private String label;
+
+        private ThermostatTimeFormat(String label) {
+            this.label = label;
+        }
+
+        @Override
+        public String toString() {
+            return label;
+        }
+
+        public static ThermostatTimeFormat from(boolean is24Hr) {
+            return is24Hr ? ThermostatTimeFormat.HR_24 : ThermostatTimeFormat.HR_12;
+        }
+
+        public static ThermostatTimeFormat from(String label) throws IllegalArgumentException {
+            ThermostatTimeFormat format = LABEL_MAP.get(label);
+            if (format == null) {
+                throw new IllegalArgumentException("unexpected thermostat time format");
+            }
+            return format;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureListener.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureListener.java
new file mode 100644 (file)
index 0000000..b0d334b
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.types.State;
+
+/**
+ * Interface for classes that want to listen to notifications from an Insteon device feature
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public interface FeatureListener {
+    /**
+     * Notifies that the device feature state has been updated
+     *
+     * @param state the updated state
+     */
+    public void stateUpdated(State state);
+
+    /**
+     * Notifies that the device feature has triggered an event
+     *
+     * @param event the triggered event name
+     */
+    public void eventTriggered(String event);
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureTemplate.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureTemplate.java
new file mode 100644 (file)
index 0000000..c1af91c
--- /dev/null
@@ -0,0 +1,180 @@
+/**
+ * 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.ArrayList;
+import java.util.HashMap;
+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.device.Device;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+
+/**
+ * A simple class which contains the basic info needed to create a device feature.
+ * Here, all handlers are represented as strings. The actual device feature
+ * is then instantiated from the template by calling the build() function.
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class FeatureTemplate {
+    private String type;
+    private @Nullable HandlerEntry dispatcher;
+    private @Nullable HandlerEntry pollHandler;
+    private @Nullable HandlerEntry defaultMsgHandler;
+    private @Nullable HandlerEntry defaultCmdHandler;
+    private List<HandlerEntry> messageHandlers = new ArrayList<>();
+    private List<HandlerEntry> commandHandlers = new ArrayList<>();
+    private Map<String, String> parameters = new HashMap<>();
+
+    public FeatureTemplate(String type, Map<String, String> parameters) {
+        this.type = type;
+        this.parameters = parameters;
+    }
+
+    public void setMessageDispatcher(HandlerEntry dispatcher) {
+        this.dispatcher = dispatcher;
+    }
+
+    public void setPollHandler(HandlerEntry pollHandler) {
+        this.pollHandler = pollHandler;
+    }
+
+    public void setDefaultCommandHandler(HandlerEntry defaultCmdHandler) {
+        this.defaultCmdHandler = defaultCmdHandler;
+    }
+
+    public void setDefaultMessageHandler(HandlerEntry defaultMsgHandler) {
+        this.defaultMsgHandler = defaultMsgHandler;
+    }
+
+    /**
+     * Adds a message handler to this feature template
+     *
+     * @param msgHandler the message handler entry to add
+     */
+    public void addMessageHandler(HandlerEntry msgHandler) {
+        messageHandlers.add(msgHandler);
+    }
+
+    /**
+     * Adds a command handler to this feature template
+     *
+     * @param cmdHandler the command handler entry to add
+     */
+    public void addCommandHandler(HandlerEntry cmdHandler) {
+        commandHandlers.add(cmdHandler);
+    }
+
+    /**
+     * Returns a newly created DeviceFeature instance
+     *
+     * @param name the feature name
+     * @param device the feature device
+     * @return the feature which this template describes
+     */
+    public DeviceFeature build(String name, Device device) {
+        DeviceFeature feature = new DeviceFeature(name, type, device);
+        // add feature template parameters
+        feature.addParameters(parameters);
+
+        HandlerEntry dispatcher = this.dispatcher;
+        if (dispatcher != null) {
+            MessageDispatcher handler = MessageDispatcher.makeHandler(dispatcher.getName(), dispatcher.getParameters(),
+                    feature);
+            if (handler != null) {
+                feature.setMessageDispatcher(handler);
+            }
+        }
+
+        HandlerEntry pollHandler = this.pollHandler;
+        if (pollHandler != null) {
+            PollHandler handler = PollHandler.makeHandler(pollHandler.getName(), pollHandler.getParameters(), feature);
+            if (handler != null) {
+                feature.setPollHandler(handler);
+            }
+        }
+
+        HandlerEntry defaultCmdHandler = this.defaultCmdHandler;
+        if (defaultCmdHandler != null) {
+            CommandHandler handler = CommandHandler.makeHandler(defaultCmdHandler.getName(),
+                    defaultCmdHandler.getParameters(), feature);
+            if (handler != null) {
+                feature.setDefaultCommandHandler(handler);
+            }
+        }
+
+        HandlerEntry defaultMsgHandler = this.defaultMsgHandler;
+        if (defaultMsgHandler != null) {
+            MessageHandler handler = MessageHandler.makeHandler(defaultMsgHandler.getName(),
+                    defaultMsgHandler.getParameters(), feature);
+            if (handler != null) {
+                feature.setDefaultMsgHandler(handler);
+            }
+        }
+
+        for (HandlerEntry msgHandler : messageHandlers) {
+            MessageHandler handler = MessageHandler.makeHandler(msgHandler.getName(), msgHandler.getParameters(),
+                    feature);
+            if (handler != null) {
+                feature.addMessageHandler(handler.getId(), handler);
+            }
+        }
+
+        for (HandlerEntry cmdHandler : commandHandlers) {
+            CommandHandler handler = CommandHandler.makeHandler(cmdHandler.getName(), cmdHandler.getParameters(),
+                    feature);
+            if (handler != null) {
+                feature.addCommandHandler(handler.getId(), handler);
+            }
+        }
+
+        return feature;
+    }
+
+    @Override
+    public String toString() {
+        String s = "type:" + type;
+        if (!parameters.isEmpty()) {
+            s += "|parameters:" + parameters.entrySet().stream().map(Entry::toString).collect(Collectors.joining(","));
+        }
+        if (dispatcher != null) {
+            s += "|dispatcher:" + dispatcher;
+        }
+        if (dispatcher != null) {
+            s += "|pollHandler:" + pollHandler;
+        }
+        if (defaultMsgHandler != null) {
+            s += "|defaultMsgHandler:" + defaultMsgHandler;
+        }
+        if (defaultCmdHandler != null) {
+            s += "|defaultCmdHandler:" + defaultCmdHandler;
+        }
+        if (!messageHandlers.isEmpty()) {
+            s += "|msgHandlers:"
+                    + messageHandlers.stream().map(HandlerEntry::toString).collect(Collectors.joining(","));
+        }
+        if (!commandHandlers.isEmpty()) {
+            s += "|cmdHandlers:"
+                    + commandHandlers.stream().map(HandlerEntry::toString).collect(Collectors.joining(","));
+        }
+        return s;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureTemplateRegistry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/FeatureTemplateRegistry.java
new file mode 100644 (file)
index 0000000..8db5d48
--- /dev/null
@@ -0,0 +1,234 @@
+/**
+ * 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.HashMap;
+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.utils.HexUtils;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
+
+/**
+ * The {@link FeatureTemplateRegistry} represents the feature template registry
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class FeatureTemplateRegistry extends InsteonResourceLoader {
+    private static final FeatureTemplateRegistry FEATURE_TEMPLATE_REGISTRY = new FeatureTemplateRegistry();
+    private static final String RESOURCE_NAME = "/device-features.xml";
+
+    private Map<String, FeatureTemplate> templates = new HashMap<>();
+
+    private FeatureTemplateRegistry() {
+        super(RESOURCE_NAME);
+    }
+
+    /**
+     * Returns feature template for a given type
+     *
+     * @param type feature type to match
+     * @return feature template if found, otherwise null
+     */
+    public @Nullable FeatureTemplate getTemplate(String type) {
+        return templates.get(type);
+    }
+
+    /**
+     * Returns known feature templates
+     *
+     * @return currently known feature templates
+     */
+    public Map<String, FeatureTemplate> getTemplates() {
+        return templates;
+    }
+
+    /**
+     * Initializes feature template registry
+     */
+    @Override
+    protected void initialize() {
+        super.initialize();
+
+        logger.debug("loaded {} feature templates", templates.size());
+        if (logger.isTraceEnabled()) {
+            templates.values().stream().map(String::valueOf).forEach(logger::trace);
+        }
+    }
+
+    /**
+     * Parses feature template 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 ("feature-type".equals(nodeName)) {
+                    parseFeatureType(child);
+                }
+            }
+        }
+    }
+
+    /**
+     * Parses feature type node
+     *
+     * @param element element to parse
+     * @throws SAXException
+     */
+    private void parseFeatureType(Element element) throws SAXException {
+        String name = element.getAttribute("name");
+        if (name.isEmpty()) {
+            throw new SAXException("feature template in device_features file has no name!");
+        }
+        if (templates.containsKey(name)) {
+            logger.warn("overwriting previous definition of feature template {}", name);
+            templates.remove(name);
+        }
+        Map<String, String> params = getParameters(element, List.of("name"));
+        FeatureTemplate template = new FeatureTemplate(name, params);
+
+        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 ("message-handler".equals(nodeName)) {
+                    parseMessageHandler(child, template);
+                } else if ("command-handler".equals(nodeName)) {
+                    parseCommandHandler(child, template);
+                } else if ("message-dispatcher".equals(nodeName)) {
+                    parseMessageDispatcher(child, template);
+                } else if ("poll-handler".equals(nodeName)) {
+                    parsePollHandler(child, template);
+                }
+            }
+        }
+        templates.put(name, template);
+    }
+
+    /**
+     * Parses message handler node
+     *
+     * @param element element to parse
+     * @param template feature template to update
+     * @throws SAXException
+     */
+    private void parseMessageHandler(Element element, FeatureTemplate template) throws SAXException {
+        HandlerEntry handler = makeHandlerEntry(element);
+        if ("true".equals(element.getAttribute("default"))) {
+            template.setDefaultMessageHandler(handler);
+        } else {
+            String command = element.getAttribute("command");
+            if (command.isEmpty()) {
+                throw new SAXException("undefined command hex for " + element.getNodeName());
+            }
+            if (!HexUtils.isValidHexString(command)) {
+                throw new SAXException("invalid command hex: " + command);
+            }
+            template.addMessageHandler(handler);
+        }
+    }
+
+    /**
+     * Parses command handler node
+     *
+     * @param element element to parse
+     * @param template feature template to update
+     * @throws SAXException
+     */
+    private void parseCommandHandler(Element element, FeatureTemplate template) throws SAXException {
+        HandlerEntry handler = makeHandlerEntry(element);
+        if ("true".equals(element.getAttribute("default"))) {
+            template.setDefaultCommandHandler(handler);
+        } else {
+            String command = element.getAttribute("command");
+            if (command.isEmpty()) {
+                throw new SAXException("undefined command type for " + element.getNodeName());
+            }
+            if (!CommandHandler.supportsCommandType(command)) {
+                throw new SAXException("unsupported command type: " + command);
+            }
+            template.addCommandHandler(handler);
+        }
+    }
+
+    /**
+     * Parses message dispatcher node
+     *
+     * @param element element to parse
+     * @param template feature template to update
+     * @throws SAXException
+     */
+    private void parseMessageDispatcher(Element element, FeatureTemplate template) throws SAXException {
+        HandlerEntry handler = makeHandlerEntry(element);
+        template.setMessageDispatcher(handler);
+    }
+
+    /**
+     * Parses poll handler node
+     *
+     * @param element element to parse
+     * @param template feature template to update
+     * @throws SAXException
+     */
+    private void parsePollHandler(Element element, FeatureTemplate template) throws SAXException {
+        HandlerEntry handler = makeHandlerEntry(element);
+        template.setPollHandler(handler);
+    }
+
+    /**
+     * Creates a new HandlerEntry
+     *
+     * @param element element to parse
+     * @return new HandlerEntry
+     * @throws SAXException
+     */
+    private HandlerEntry makeHandlerEntry(Element element) throws SAXException {
+        String name = element.getTextContent();
+        if (name == null) {
+            throw new SAXException("undefined handler name for " + element.getNodeName());
+        }
+        Map<String, String> params = getParameters(element, List.of("default"));
+        return new HandlerEntry(name, params);
+    }
+
+    /**
+     * Singleton instance function
+     *
+     * @return FeatureTemplateRegistry singleton reference
+     */
+    public static synchronized FeatureTemplateRegistry getInstance() {
+        if (FEATURE_TEMPLATE_REGISTRY.getTemplates().isEmpty()) {
+            FEATURE_TEMPLATE_REGISTRY.initialize();
+        }
+        return FEATURE_TEMPLATE_REGISTRY;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/HandlerEntry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/HandlerEntry.java
new file mode 100644 (file)
index 0000000..dce555f
--- /dev/null
@@ -0,0 +1,52 @@
+/**
+ * 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;
+
+/**
+ * The {@link HandlerEntry} represents a feature handler entry
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class HandlerEntry {
+    private String name;
+    private Map<String, String> parameters;
+
+    public HandlerEntry(String name, Map<String, String> parameters) {
+        this.name = name;
+        this.parameters = parameters;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public Map<String, String> getParameters() {
+        return parameters;
+    }
+
+    @Override
+    public String toString() {
+        String s = name;
+        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/LegacyCommandHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyCommandHandler.java
new file mode 100644 (file)
index 0000000..12491fc
--- /dev/null
@@ -0,0 +1,861 @@
+/**
+ * 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.InsteonLegacyBindingConstants.*;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Timer;
+import java.util.TimerTask;
+
+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.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.LegacyDevice;
+import org.openhab.binding.insteon.internal.device.LegacyDeviceFeature;
+import org.openhab.binding.insteon.internal.device.X10Address;
+import org.openhab.binding.insteon.internal.device.X10Command;
+import org.openhab.binding.insteon.internal.device.feature.LegacyFeatureListener.StateChangeType;
+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.ParameterParser;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A command handler translates an openHAB command into an 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 LegacyCommandHandler {
+    protected final Logger logger = LoggerFactory.getLogger(LegacyCommandHandler.class);
+
+    LegacyDeviceFeature feature; // related DeviceFeature
+    Map<String, String> parameters = new HashMap<>();
+
+    /**
+     * 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.
+     */
+    LegacyCommandHandler(LegacyDeviceFeature feature) {
+        this.feature = feature;
+    }
+
+    /**
+     * Implements what to do when an openHAB command is received
+     *
+     * @param conf the configuration for the item that generated the command
+     * @param cmd the openhab command issued
+     * @param device the Insteon device to which this command applies
+     */
+    public abstract void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device);
+
+    /**
+     * Returns parameter as integer
+     *
+     * @param key key of parameter
+     * @param def default
+     * @return value of parameter
+     */
+    protected int getIntParameter(String key, int def) {
+        return ParameterParser.getParameterAsOrDefault(parameters.get(key), Integer.class, def);
+    }
+
+    /**
+     * Returns parameter as String
+     *
+     * @param key key of parameter
+     * @param def default
+     * @return value of parameter
+     */
+    protected @Nullable String getStringParameter(String key, String def) {
+        return parameters.get(key) == null ? def : parameters.get(key);
+    }
+
+    /**
+     * Shorthand to return class name for logging purposes
+     *
+     * @return name of the class
+     */
+    protected String nm() {
+        return this.getClass().getSimpleName();
+    }
+
+    protected int getMaxLightLevel(InsteonLegacyChannelConfiguration conf, int defaultLevel) {
+        if (conf.getFeature().contains("dimmer")) {
+            String dimmerMax = conf.getParameter("dimmermax");
+            if (dimmerMax != null) {
+                String item = conf.getChannelName();
+                try {
+                    int i = Integer.parseInt(dimmerMax);
+                    if (i > 1 && i <= 99) {
+                        int level = (int) Math.ceil((i * 255.0) / 100); // round up
+                        if (level < defaultLevel) {
+                            logger.debug("item {}: using dimmermax value of {}", item, dimmerMax);
+                            return level;
+                        }
+                    } else {
+                        logger.warn("item {}: dimmermax must be between 1-99 inclusive: {}", item, dimmerMax);
+                    }
+                } catch (NumberFormatException e) {
+                    logger.warn("item {}: invalid int value for dimmermax: {}", item, dimmerMax);
+                }
+            }
+        }
+
+        return defaultLevel;
+    }
+
+    void setParameters(Map<String, String> map) {
+        parameters = map;
+    }
+
+    /**
+     * Helper function to extract the group parameter from the binding config,
+     *
+     * @param config the binding configuration to test
+     * @return the value of the "group" parameter, or -1 if none
+     */
+    protected static int getGroup(InsteonLegacyChannelConfiguration config) {
+        return ParameterParser.getParameterAsOrDefault(config.getParameter("group"), Integer.class, -1);
+    }
+
+    public static class WarnCommandHandler extends LegacyCommandHandler {
+        public WarnCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            logger.warn("{}: command {} is not implemented yet!", nm(), cmd);
+        }
+    }
+
+    public static class NoOpCommandHandler extends LegacyCommandHandler {
+        NoOpCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            // do nothing, not even log
+        }
+    }
+
+    public static class LightOnOffCommandHandler extends LegacyCommandHandler {
+        LightOnOffCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                int ext = getIntParameter("ext", 0);
+                int direc = 0x00;
+                int level = 0x00;
+                Msg msg = null;
+                if (cmd == OnOffType.ON) {
+                    level = getMaxLightLevel(conf, 0xff);
+                    direc = 0x11;
+                    logger.debug("{}: sent msg to switch {} to {}", nm(), device.getAddress(),
+                            level == 0xff ? "on" : level);
+                } else if (cmd == OnOffType.OFF) {
+                    direc = 0x13;
+                    logger.debug("{}: sent msg to switch {} off", nm(), device.getAddress());
+                }
+                int group = getGroup(conf);
+                if (group != -1) {
+                    msg = Msg.makeBroadcastMessage(group, (byte) direc, (byte) level);
+                } else if (ext == 0) {
+                    msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) direc, (byte) level);
+                } else {
+                    byte[] data = new byte[] { (byte) getIntParameter("d1", 0), (byte) getIntParameter("d2", 0),
+                            (byte) getIntParameter("d3", 0) };
+                    msg = Msg.makeExtendedMessage((InsteonAddress) device.getAddress(), (byte) direc, (byte) level,
+                            data, false);
+                    logger.debug("{}: was an extended message for device {}", nm(), device.getAddress());
+                    if (ext == 1) {
+                        msg.setCRC();
+                    } else if (ext == 2) {
+                        msg.setCRC2();
+                    }
+                }
+                logger.debug("Sending message to {}", device.getAddress());
+                device.enqueueMessage(msg, feature);
+                // expect to get a direct ack after this!
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    public static class FastOnOffCommandHandler extends LegacyCommandHandler {
+        FastOnOffCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                int cmd1 = cmd == OnOffType.ON ? 0x12 : 0x14;
+                int level = cmd == OnOffType.ON ? getMaxLightLevel(conf, 0xff) : 0x00;
+                int group = getGroup(conf);
+                Msg msg;
+                if (group != -1) {
+                    msg = Msg.makeBroadcastMessage(group, (byte) cmd1, (byte) level);
+                } else {
+                    msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) cmd1, (byte) level);
+                }
+                device.enqueueMessage(msg, feature);
+                logger.debug("{}: sent fast {} to switch {} level {}", nm(), cmd, device.getAddress(), level);
+                // expect to get a direct ack after this!
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    public static class RampOnOffCommandHandler extends RampCommandHandler {
+        RampOnOffCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                byte cmd1 = cmd == OnOffType.ON ? getOnCmd() : getOffCmd();
+                double ramptime = getRampTime(conf, 0);
+                int ramplevel = getRampLevel(conf, 100);
+                byte cmd2 = encode(ramptime, ramplevel);
+                int group = getGroup(conf);
+                Msg msg;
+                if (group != -1) {
+                    msg = Msg.makeBroadcastMessage(group, cmd1, cmd2);
+                } else {
+                    msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), cmd1, cmd2);
+                }
+                device.enqueueMessage(msg, feature);
+                logger.debug("{}: sent ramp {} to switch {} time {} level {} cmd1 {}", nm(), cmd, device.getAddress(),
+                        ramptime, ramplevel, cmd1);
+                // expect to get a direct ack after this!
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        private int getRampLevel(InsteonLegacyChannelConfiguration conf, int defaultValue) {
+            String str = conf.getParameter("ramplevel");
+            return str != null ? Integer.parseInt(str) : defaultValue;
+        }
+    }
+
+    public static class ManualChangeCommandHandler extends LegacyCommandHandler {
+        ManualChangeCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                if (cmd instanceof DecimalType decimalCommand) {
+                    int value = decimalCommand.intValue();
+                    int cmd1 = (value != 1) ? 0x17 : 0x18; // start or stop
+                    int cmd2 = (value == 2) ? 0x01 : 0; // up or down
+                    int group = getGroup(conf);
+                    Msg msg;
+                    if (group != -1) {
+                        msg = Msg.makeBroadcastMessage(group, (byte) cmd1, (byte) cmd2);
+                    } else {
+                        msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) cmd1, (byte) cmd2);
+                    }
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: cmd {} sent manual change {} {} to {}", nm(), value,
+                            (cmd1 == 0x17) ? "START" : "STOP", (cmd2 == 0x01) ? "UP" : "DOWN", device.getAddress());
+                } else {
+                    logger.warn("{}: invalid command type: {}", nm(), cmd);
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    /**
+     * Sends ALLLink broadcast commands to group
+     */
+    public static class GroupBroadcastCommandHandler extends LegacyCommandHandler {
+        GroupBroadcastCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                if (cmd == OnOffType.ON || cmd == OnOffType.OFF) {
+                    int cmd1 = cmd == OnOffType.ON ? 0x11 : 0x13;
+                    int cmd2 = cmd == OnOffType.ON ? 0xFF : 0x00;
+                    int group = getGroup(conf);
+                    if (group == -1) {
+                        logger.warn("no group=xx specified in item {}", conf.getChannelName());
+                        return;
+                    }
+                    Msg msg = Msg.makeBroadcastMessage(group, (byte) cmd1, (byte) cmd2);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent {} broadcast to group {}", nm(), cmd, group);
+                    feature.pollRelatedDevices();
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    public static class LEDOnOffCommandHandler extends LegacyCommandHandler {
+        LEDOnOffCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                if (cmd == OnOffType.ON) {
+                    Msg msg = Msg.makeExtendedMessage((InsteonAddress) device.getAddress(), (byte) 0x20, (byte) 0x09,
+                            new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00 }, true);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to switch {} on", nm(), device.getAddress());
+                } else if (cmd == OnOffType.OFF) {
+                    Msg msg = Msg.makeExtendedMessage((InsteonAddress) device.getAddress(), (byte) 0x20, (byte) 0x08,
+                            new byte[] { (byte) 0x00, (byte) 0x00, (byte) 0x00 }, true);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to switch {} off", nm(), device.getAddress());
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    public static class X10OnOffCommandHandler extends LegacyCommandHandler {
+        X10OnOffCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                X10Address address = (X10Address) device.getAddress();
+                if (cmd == OnOffType.ON || cmd == OnOffType.OFF) {
+                    byte cmdCode = (byte) (address.getHouseCode() << 4
+                            | (cmd == OnOffType.ON ? X10Command.ON.code() : X10Command.OFF.code()));
+                    Msg munit = Msg.makeX10AddressMessage(address); // send unit code
+                    device.enqueueMessage(munit, feature);
+                    Msg mcmd = Msg.makeX10CommandMessage(cmdCode); // send command code
+                    device.enqueueMessage(mcmd, feature);
+                    String onOff = cmd == OnOffType.ON ? "ON" : "OFF";
+                    logger.debug("{}: sent msg to switch {} {}", nm(), address, onOff);
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    public static class X10PercentCommandHandler extends LegacyCommandHandler {
+        X10PercentCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                X10Address address = (X10Address) device.getAddress();
+                Msg munit = Msg.makeX10AddressMessage(address); // send unit code
+                device.enqueueMessage(munit, feature);
+                PercentType pc = (PercentType) cmd;
+                logger.debug("{}: changing level of {} to {}", nm(), address, pc.intValue());
+                int level = (pc.intValue() * 32) / 100;
+                byte cmdCode = (level >= 16) ? X10Command.PRESET_DIM_2.code() : X10Command.PRESET_DIM_1.code();
+                level = level % 16;
+                if (level <= 0) {
+                    level = 0;
+                }
+                byte levelCode = (byte) x10CodeForLevel[level];
+                cmdCode |= (levelCode << 4);
+                Msg mcmd = Msg.makeX10CommandMessage(cmdCode); // send command code
+                device.enqueueMessage(mcmd, feature);
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+
+        private final int[] x10CodeForLevel = { 0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15 };
+    }
+
+    public static class X10IncreaseDecreaseCommandHandler extends LegacyCommandHandler {
+        X10IncreaseDecreaseCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                X10Address address = (X10Address) device.getAddress();
+                if (cmd == IncreaseDecreaseType.INCREASE || cmd == IncreaseDecreaseType.DECREASE) {
+                    byte cmdCode = (byte) (address.getHouseCode() << 4
+                            | (cmd == IncreaseDecreaseType.INCREASE ? X10Command.BRIGHT.code()
+                                    : X10Command.DIM.code()));
+                    Msg munit = Msg.makeX10AddressMessage(address); // send unit code
+                    device.enqueueMessage(munit, feature);
+                    Msg mcmd = Msg.makeX10CommandMessage(cmdCode); // send command code
+                    device.enqueueMessage(mcmd, feature);
+                    String bd = cmd == IncreaseDecreaseType.INCREASE ? "BRIGHTEN" : "DIM";
+                    logger.debug("{}: sent msg to switch {} {}", nm(), address, bd);
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    public static class IOLincOnOffCommandHandler extends LegacyCommandHandler {
+        IOLincOnOffCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                if (cmd == OnOffType.ON) {
+                    Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) 0x11, (byte) 0xff);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to switch {} on", nm(), device.getAddress());
+                } else if (cmd == OnOffType.OFF) {
+                    Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) 0x13, (byte) 0x00);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to switch {} off", nm(), device.getAddress());
+                }
+                // This used to be configurable, but was made static to make
+                // the architecture of the binding cleaner.
+                int delay = 2000;
+                delay = Math.max(1000, delay);
+                delay = Math.min(10000, delay);
+                Timer timer = new Timer();
+                timer.schedule(new TimerTask() {
+                    @Override
+                    public void run() {
+                        Msg msg = feature.makePollMsg();
+                        LegacyDevice device = feature.getDevice();
+                        if (msg != null) {
+                            device.enqueueMessage(msg, feature);
+                        }
+                    }
+                }, delay);
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error: ", nm(), e);
+            }
+        }
+    }
+
+    public static class IncreaseDecreaseCommandHandler extends LegacyCommandHandler {
+        IncreaseDecreaseCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                if (cmd == IncreaseDecreaseType.INCREASE) {
+                    Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) 0x15, (byte) 0x00);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to brighten {}", nm(), device.getAddress());
+                } else if (cmd == IncreaseDecreaseType.DECREASE) {
+                    Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) 0x16, (byte) 0x00);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to dimm {}", nm(), device.getAddress());
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    public static class PercentHandler extends LegacyCommandHandler {
+        PercentHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                PercentType percent = (PercentType) cmd;
+                logger.debug("changing level of {} to {}", device.getAddress(), percent.intValue());
+                int level = (int) Math.ceil((percent.intValue() * 255.0) / 100); // round up
+                if (level > 0) { // make light on message with given level
+                    level = getMaxLightLevel(conf, level);
+                    Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) 0x11, (byte) level);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to set {} to {}", nm(), device.getAddress(), level);
+                } else { // switch off
+                    Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) 0x13, (byte) 0x00);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to set {} to zero by switching off", nm(), device.getAddress());
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    private abstract static class RampCommandHandler extends LegacyCommandHandler {
+        private static double[] halfRateRampTimes = new double[] { 0.1, 0.3, 2, 6.5, 19, 23.5, 28, 32, 38.5, 47, 90,
+                150, 210, 270, 360, 480 };
+
+        private byte onCmd;
+        private byte offCmd;
+
+        RampCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+            // Can't process parameters here because they are set after constructor is invoked.
+            // Unfortunately, this means we can't declare the onCmd, offCmd to be final.
+        }
+
+        @Override
+        void setParameters(Map<String, String> params) {
+            super.setParameters(params);
+            onCmd = (byte) getIntParameter("on", 0x2E);
+            offCmd = (byte) getIntParameter("off", 0x2F);
+        }
+
+        protected final byte getOnCmd() {
+            return onCmd;
+        }
+
+        protected final byte getOffCmd() {
+            return offCmd;
+        }
+
+        protected byte encode(double ramptimeSeconds, int ramplevel) throws FieldException {
+            if (ramplevel < 0 || ramplevel > 100) {
+                throw new FieldException("ramplevel must be in the range 0-100 (inclusive)");
+            }
+
+            if (ramptimeSeconds < 0) {
+                throw new FieldException("ramptime must be greater than 0");
+            }
+
+            int ramptime;
+            int insertionPoint = Arrays.binarySearch(halfRateRampTimes, ramptimeSeconds);
+            if (insertionPoint > 0) {
+                ramptime = 15 - insertionPoint;
+            } else {
+                insertionPoint = -insertionPoint - 1;
+                if (insertionPoint == 0) {
+                    ramptime = 15;
+                } else {
+                    double d1 = Math.abs(halfRateRampTimes[insertionPoint - 1] - ramptimeSeconds);
+                    double d2 = Math.abs(halfRateRampTimes[insertionPoint] - ramptimeSeconds);
+                    ramptime = 15 - (d1 > d2 ? insertionPoint : insertionPoint - 1);
+                    logger.debug("ramp encoding: time {} insert {} d1 {} d2 {} ramp {}", ramptimeSeconds,
+                            insertionPoint, d1, d2, ramptime);
+                }
+            }
+
+            int r = (int) Math.round(ramplevel / (100.0 / 15.0));
+            return (byte) (((r & 0x0f) << 4) | (ramptime & 0xf));
+        }
+
+        protected double getRampTime(InsteonLegacyChannelConfiguration conf, double defaultValue) {
+            String str = conf.getParameter("ramptime");
+            return str != null ? Double.parseDouble(str) : defaultValue;
+        }
+    }
+
+    public static class RampPercentHandler extends RampCommandHandler {
+
+        RampPercentHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                PercentType percent = (PercentType) cmd;
+                double ramptime = getRampTime(conf, 0);
+                int level = percent.intValue();
+                if (level > 0) { // make light on message with given level
+                    level = getMaxLightLevel(conf, level);
+                    byte cmd2 = encode(ramptime, level);
+                    Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), getOnCmd(), cmd2);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to set {} to {} with {} second ramp time.", nm(), device.getAddress(),
+                            level, ramptime);
+                } else { // switch off
+                    Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), getOffCmd(), (byte) 0x00);
+                    device.enqueueMessage(msg, feature);
+                    logger.debug("{}: sent msg to set {} to zero by switching off with {} ramp time.", nm(),
+                            device.getAddress(), ramptime);
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    public static class PowerMeterCommandHandler extends LegacyCommandHandler {
+        PowerMeterCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            String cmdParam = conf.getParameter(CMD);
+            if (cmdParam == null) {
+                logger.warn("{} ignoring cmd {} because no cmd= is configured!", nm(), cmd);
+                return;
+            }
+            try {
+                if (cmd == OnOffType.ON) {
+                    if (cmdParam.equals(CMD_RESET)) {
+                        Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) 0x80,
+                                (byte) 0x00);
+                        device.enqueueMessage(msg, feature);
+                        logger.debug("{}: sent reset msg to power meter {}", nm(), device.getAddress());
+                        feature.publish(OnOffType.OFF, StateChangeType.ALWAYS, CMD, CMD_RESET);
+                    } else if (cmdParam.equals(CMD_UPDATE)) {
+                        Msg msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) 0x82,
+                                (byte) 0x00);
+                        device.enqueueMessage(msg, feature);
+                        logger.debug("{}: sent update msg to power meter {}", nm(), device.getAddress());
+                        feature.publish(OnOffType.OFF, StateChangeType.ALWAYS, CMD, CMD_UPDATE);
+                    } else {
+                        logger.warn("{}: ignoring unknown cmd {} for power meter {}", nm(), cmdParam,
+                                device.getAddress());
+                    }
+                } else if (cmd == OnOffType.OFF) {
+                    logger.debug("{}: ignoring off request for power meter {}", nm(), device.getAddress());
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    /**
+     * Command handler that sends a command with a numerical value to a device.
+     * The handler is very parameterizable so it can be reused for different devices.
+     * First used for setting thermostat parameters.
+     */
+
+    public static class NumberCommandHandler extends LegacyCommandHandler {
+        NumberCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        public int transform(int cmd) {
+            return cmd;
+        }
+
+        @Override
+        public void handleCommand(InsteonLegacyChannelConfiguration conf, Command cmd, LegacyDevice device) {
+            try {
+                int value = transform(((DecimalType) cmd).intValue());
+                int intFactor = getIntParameter("factor", 1);
+                // determine what level should be, and what field it should be in
+                int ilevel = value * intFactor;
+                byte level = (byte) (ilevel > 255 ? 0xFF : ((ilevel < 0) ? 0 : ilevel));
+                String vfield = getStringParameter("value", "");
+                if (vfield == null || vfield.isEmpty()) {
+                    logger.warn("{} has no value field specified", nm());
+                    return;
+                }
+                // figure out what cmd1, cmd2, d1, d2, d3 are supposed to be
+                // to form a proper message
+                int cmd1 = getIntParameter("cmd1", -1);
+                if (cmd1 < 0) {
+                    logger.warn("{} has no cmd1 specified!", nm());
+                    return;
+                }
+                int cmd2 = getIntParameter("cmd2", 0);
+                int ext = getIntParameter("ext", 0);
+                Msg msg = null;
+                if (ext == 1 || ext == 2) {
+                    byte[] data = new byte[] { (byte) getIntParameter("d1", 0), (byte) getIntParameter("d2", 0),
+                            (byte) getIntParameter("d3", 0) };
+                    msg = Msg.makeExtendedMessage((InsteonAddress) device.getAddress(), (byte) cmd1, (byte) cmd2, data,
+                            false);
+                    msg.setByte(vfield, level);
+                    if (ext == 1) {
+                        msg.setCRC();
+                    } else if (ext == 2) {
+                        msg.setCRC2();
+                    }
+                } else {
+                    msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) cmd1, (byte) cmd2);
+                    msg.setByte(vfield, level);
+                }
+                device.enqueueMessage(msg, feature);
+                logger.debug("{}: sent msg to change level to {}", nm(), ((DecimalType) cmd).intValue());
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("{}: invalid message: ", nm(), e);
+            } catch (FieldException e) {
+                logger.warn("{}: command send message creation error ", nm(), e);
+            }
+        }
+    }
+
+    /**
+     * Handler to set the thermostat system mode
+     */
+    public static class ThermostatSystemModeCommandHandler extends NumberCommandHandler {
+        ThermostatSystemModeCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public int transform(int cmd) {
+            switch (cmd) {
+                case 0:
+                    return 0x09; // off
+                case 1:
+                    return 0x04; // heat
+                case 2:
+                    return 0x05; // cool
+                case 3:
+                    return 0x06; // auto (aka manual auto)
+                case 4:
+                    return 0x0A; // program (aka auto)
+                default:
+                    break;
+            }
+            return 0x0A; // when in doubt go to program
+        }
+    }
+
+    /**
+     * Handler to set the thermostat fan mode
+     */
+    public static class ThermostatFanModeCommandHandler extends NumberCommandHandler {
+        ThermostatFanModeCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public int transform(int cmd) {
+            switch (cmd) {
+                case 0:
+                    return 0x08; // fan mode auto
+                case 1:
+                    return 0x07; // fan always on
+                default:
+                    break;
+            }
+            return 0x08; // when in doubt go auto mode
+        }
+    }
+
+    /**
+     * Handler to set the fanlinc fan mode
+     */
+    public static class FanLincFanCommandHandler extends NumberCommandHandler {
+        FanLincFanCommandHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public int transform(int cmd) {
+            switch (cmd) {
+                case 0:
+                    return 0x00; // fan off
+                case 1:
+                    return 0x55; // fan low
+                case 2:
+                    return 0xAA; // fan medium
+                case 3:
+                    return 0xFF; // fan high
+                default:
+                    break;
+            }
+            return 0x00; // all other modes are "off"
+        }
+    }
+
+    /**
+     * Factory method for creating handlers of a given name using java reflection
+     *
+     * @param name the name of the handler to create
+     * @param params
+     * @param feature the feature for which to create the handler
+     * @return the handler which was created
+     */
+    @Nullable
+    public static <T extends LegacyCommandHandler> T makeHandler(String name, Map<String, String> params,
+            LegacyDeviceFeature feature) {
+        try {
+            String className = LegacyCommandHandler.class.getName() + "$" + name;
+            @SuppressWarnings("unchecked")
+            Class<? extends T> classRef = (Class<? extends T>) Class.forName(className);
+            @Nullable
+            T handler = classRef.getDeclaredConstructor(LegacyDeviceFeature.class).newInstance(feature);
+            handler.setParameters(params);
+            return handler;
+        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
+                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
+            return null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyFeatureListener.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyFeatureListener.java
new file mode 100644 (file)
index 0000000..ded8fbf
--- /dev/null
@@ -0,0 +1,192 @@
+/**
+ * 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.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+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.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A DeviceFeatureListener essentially represents an openHAB item that
+ * listens to a particular feature of an Insteon device
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Bernd Pfrommer - openHAB 1 insteonplm binding
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ */
+@NonNullByDefault
+public class LegacyFeatureListener {
+    private final Logger logger = LoggerFactory.getLogger(LegacyFeatureListener.class);
+
+    public enum StateChangeType {
+        ALWAYS,
+        CHANGED
+    }
+
+    private String itemName;
+    private ChannelUID channelUID;
+    private Map<String, String> parameters = new HashMap<>();
+    private Map<Class<?>, State> state = new HashMap<>();
+    private List<InsteonAddress> relatedDevices = new ArrayList<>();
+    private InsteonLegacyBinding binding;
+    private static final int TIME_DELAY_POLL_RELATED_MSEC = 5000;
+
+    /**
+     * Constructor
+     *
+     * @param binding
+     * @param channelUID channel associated with this item
+     * @param item name of the item that is listening
+     */
+    public LegacyFeatureListener(InsteonLegacyBinding binding, ChannelUID channelUID, String item) {
+        this.binding = binding;
+        this.itemName = item;
+        this.channelUID = channelUID;
+    }
+
+    /**
+     * Gets item name
+     *
+     * @return item name
+     */
+    public String getItemName() {
+        return itemName;
+    }
+
+    /**
+     * Test if string parameter is present and has a given value
+     *
+     * @param key key to match
+     * @param value value to match
+     * @return true if key exists and value matches
+     */
+    private boolean parameterHasValue(String key, String value) {
+        String parameter = parameters.get(key);
+        return parameter != null && parameter.equals(value);
+    }
+
+    /**
+     * Set parameters for this feature listener
+     *
+     * @param p the parameters to set
+     */
+    public void setParameters(Map<String, String> parameters) {
+        this.parameters = parameters;
+        updateRelatedDevices();
+    }
+
+    /**
+     * Publishes a state change on the openhab bus
+     *
+     * @param newState the new state to publish on the openhab bus
+     * @param changeType whether to always publish or not
+     */
+    public void stateChanged(State newState, StateChangeType changeType) {
+        State oldState = state.get(newState.getClass());
+        if (oldState == null) {
+            logger.trace("new state: {}:{}", newState.getClass().getSimpleName(), newState);
+            // state has changed, must publish
+            publishState(newState);
+        } else {
+            logger.trace("old state: {}:{}=?{}", newState.getClass().getSimpleName(), oldState, newState);
+            // only publish if state has changed or it is requested explicitly
+            if (changeType == StateChangeType.ALWAYS || !oldState.equals(newState)) {
+                publishState(newState);
+            }
+        }
+        state.put(newState.getClass(), newState);
+    }
+
+    /**
+     * Call this function to inform about a state change for a given
+     * parameter key and value. If dataKey and dataValue don't match,
+     * the state change will be ignored.
+     *
+     * @param state the new state to which the feature has changed
+     * @param changeType how to process the state change (always, or only when changed)
+     * @param dataKey the data key on which to filter
+     * @param dataValue the value that the data key must match for the state to be published
+     */
+    public void stateChanged(State state, StateChangeType changeType, String dataKey, String dataValue) {
+        if (parameterHasValue(dataKey, dataValue)) {
+            stateChanged(state, changeType);
+        }
+    }
+
+    /**
+     * Publish the state. In the case of PercentType, if the value is
+     * 0, send an OnOffType.OFF and if the value is 100, send an OnOffType.ON.
+     * That way an openHAB Switch will work properly with an Insteon dimmer,
+     * as long it is used like a switch (On/Off). An openHAB DimmerItem will
+     * internally convert the ON back to 100% and OFF back to 0, so there is
+     * no need to send both 0/OFF and 100/ON.
+     *
+     * @param state the new state of the feature
+     */
+    private void publishState(State state) {
+        State publishState = state;
+        if (state instanceof PercentType) {
+            if (state.equals(PercentType.ZERO)) {
+                publishState = OnOffType.OFF;
+            } else if (state.equals(PercentType.HUNDRED)) {
+                publishState = OnOffType.ON;
+            }
+        }
+        pollRelatedDevices();
+        binding.updateFeatureState(channelUID, publishState);
+    }
+
+    /**
+     * Extracts related devices from the parameter list and
+     * stores them for faster access later.
+     */
+
+    private void updateRelatedDevices() {
+        String value = parameters.get("related");
+        if (value == null) {
+            return;
+        }
+        for (String device : value.split("\\+")) {
+            InsteonAddress address = new InsteonAddress(device);
+            relatedDevices.add(address);
+        }
+    }
+
+    /**
+     * polls all devices that are related to this item
+     * by the "related" keyword
+     */
+    public void pollRelatedDevices() {
+        for (InsteonAddress address : relatedDevices) {
+            logger.debug("polling related device {} in {} ms", address, TIME_DELAY_POLL_RELATED_MSEC);
+            LegacyDevice device = binding.getDevice(address);
+            if (device != null) {
+                device.doPoll(TIME_DELAY_POLL_RELATED_MSEC);
+            } else {
+                logger.warn("device {} related to item {} is not configured!", address, itemName);
+            }
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyFeatureTemplate.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyFeatureTemplate.java
new file mode 100644 (file)
index 0000000..370aa19
--- /dev/null
@@ -0,0 +1,196 @@
+/**
+ * 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.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.LegacyDeviceFeature;
+import org.openhab.core.types.Command;
+
+/**
+ * A simple class which contains the basic info needed to create a device feature.
+ * Here, all handlers are represented as strings. The actual device feature
+ * is then instantiated from the template by calling the build() function.
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class LegacyFeatureTemplate {
+    private String name;
+    private String timeout;
+    private boolean isStatus;
+    private @Nullable HandlerEntry dispatcher = null;
+    private @Nullable HandlerEntry pollHandler = null;
+    private @Nullable HandlerEntry defaultMsgHandler = null;
+    private @Nullable HandlerEntry defaultCmdHandler = null;
+    private Map<Integer, HandlerEntry> messageHandlers = new HashMap<>();
+    private Map<Class<? extends Command>, HandlerEntry> commandHandlers = new HashMap<>();
+
+    public LegacyFeatureTemplate(String name, boolean isStatus, String timeout) {
+        this.name = name;
+        this.isStatus = isStatus;
+        this.timeout = timeout;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getTimeout() {
+        return timeout;
+    }
+
+    public boolean isStatusFeature() {
+        return isStatus;
+    }
+
+    public @Nullable HandlerEntry getPollHandler() {
+        return pollHandler;
+    }
+
+    public @Nullable HandlerEntry getDispatcher() {
+        return dispatcher;
+    }
+
+    public @Nullable HandlerEntry getDefaultCommandHandler() {
+        return defaultCmdHandler;
+    }
+
+    public @Nullable HandlerEntry getDefaultMessageHandler() {
+        return defaultMsgHandler;
+    }
+
+    /**
+     * Retrieves a hashmap of message command code to command handler name
+     *
+     * @return a Hashmap from Integer to String representing the command codes and the associated message handlers
+     */
+    public Map<Integer, HandlerEntry> getMessageHandlers() {
+        return messageHandlers;
+    }
+
+    /**
+     * Similar to getMessageHandlers(), but for command handlers
+     * Instead of Integers it uses the class of the Command as a key
+     *
+     * @see #getMessageHandlers()
+     * @return a HashMap from Command Classes to CommandHandler names
+     */
+    public Map<Class<? extends Command>, HandlerEntry> getCommandHandlers() {
+        return commandHandlers;
+    }
+
+    public void setMessageDispatcher(HandlerEntry he) {
+        dispatcher = he;
+    }
+
+    public void setPollHandler(HandlerEntry he) {
+        pollHandler = he;
+    }
+
+    public void setDefaultCommandHandler(HandlerEntry cmd) {
+        defaultCmdHandler = cmd;
+    }
+
+    public void setDefaultMessageHandler(HandlerEntry he) {
+        defaultMsgHandler = he;
+    }
+
+    /**
+     * Adds a message handler mapped from the command which this handler should be invoked for
+     * to the name of the handler to be created
+     *
+     * @param cmd command to be mapped
+     * @param he handler entry to map to
+     */
+    public void addMessageHandler(int cmd, HandlerEntry he) {
+        messageHandlers.put(cmd, he);
+    }
+
+    /**
+     * Adds a command handler mapped from the command class which this handler should be invoke for
+     * to the name of the handler to be created
+     */
+    public void addCommandHandler(Class<? extends Command> command, HandlerEntry he) {
+        commandHandlers.put(command, he);
+    }
+
+    /**
+     * Builds the actual feature
+     *
+     * @return the feature which this template describes
+     */
+    public LegacyDeviceFeature build() {
+        LegacyDeviceFeature feature = new LegacyDeviceFeature(name);
+        feature.setStatusFeature(isStatus);
+        feature.setTimeout(timeout);
+        HandlerEntry dispatcher = this.dispatcher;
+        if (dispatcher != null) {
+            LegacyMessageDispatcher handler = LegacyMessageDispatcher.makeHandler(dispatcher.getName(),
+                    dispatcher.getParameters(), feature);
+            if (handler != null) {
+                feature.setMessageDispatcher(handler);
+            }
+        }
+        HandlerEntry pollHandler = this.pollHandler;
+        if (pollHandler != null) {
+            LegacyPollHandler handler = LegacyPollHandler.makeHandler(pollHandler.getName(),
+                    pollHandler.getParameters(), feature);
+            if (handler != null) {
+                feature.setPollHandler(handler);
+            }
+        }
+        HandlerEntry defaultCmdHandler = this.defaultCmdHandler;
+        if (defaultCmdHandler != null) {
+            LegacyCommandHandler handler = LegacyCommandHandler.makeHandler(defaultCmdHandler.getName(),
+                    defaultCmdHandler.getParameters(), feature);
+            if (handler != null) {
+                feature.setDefaultCommandHandler(handler);
+            }
+        }
+        HandlerEntry defaultMsgHandler = this.defaultMsgHandler;
+        if (defaultMsgHandler != null) {
+            LegacyMessageHandler handler = LegacyMessageHandler.makeHandler(defaultMsgHandler.getName(),
+                    defaultMsgHandler.getParameters(), feature);
+            if (handler != null) {
+                feature.setDefaultMsgHandler(handler);
+            }
+        }
+        for (Entry<Integer, HandlerEntry> entry : messageHandlers.entrySet()) {
+            LegacyMessageHandler handler = LegacyMessageHandler.makeHandler(entry.getValue().getName(),
+                    entry.getValue().getParameters(), feature);
+            if (handler != null) {
+                feature.addMessageHandler(entry.getKey(), handler);
+            }
+        }
+        for (Entry<Class<? extends Command>, HandlerEntry> entry : commandHandlers.entrySet()) {
+            LegacyCommandHandler handler = LegacyCommandHandler.makeHandler(entry.getValue().getName(),
+                    entry.getValue().getParameters(), feature);
+            if (handler != null) {
+                feature.addCommandHandler(entry.getKey(), handler);
+            }
+        }
+        return feature;
+    }
+
+    @Override
+    public String toString() {
+        return getName() + "(" + isStatusFeature() + ")";
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyFeatureTemplateLoader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyFeatureTemplateLoader.java
new file mode 100644 (file)
index 0000000..7ba64a3
--- /dev/null
@@ -0,0 +1,164 @@
+/**
+ * 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.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.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.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
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class LegacyFeatureTemplateLoader extends InsteonResourceLoader {
+    private static final LegacyFeatureTemplateLoader FEATURE_TEMPLATE_LOADER = new LegacyFeatureTemplateLoader();
+    private static final String RESOURCE_NAME = "/legacy-device-features.xml";
+
+    private static Map<String, LegacyFeatureTemplate> features = new HashMap<>();
+
+    private LegacyFeatureTemplateLoader() {
+        super(RESOURCE_NAME);
+    }
+
+    public @Nullable LegacyFeatureTemplate getTemplate(String name) {
+        return features.get(name);
+    }
+
+    public Map<String, LegacyFeatureTemplate> getTemplates() {
+        return features;
+    }
+
+    @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;
+                if ("feature".equals(child.getTagName())) {
+                    parseFeature(child);
+                }
+            }
+        }
+    }
+
+    private void parseFeature(Element element) throws SAXException {
+        String name = element.getAttribute("name");
+        boolean statusFeature = "true".equals(element.getAttribute("statusFeature"));
+        LegacyFeatureTemplate feature = new LegacyFeatureTemplate(name, statusFeature, element.getAttribute("timeout"));
+
+        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;
+                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);
+                }
+            }
+        }
+
+        features.put(name, feature);
+    }
+
+    private HandlerEntry makeHandlerEntry(Element element) throws SAXException {
+        String handler = element.getTextContent();
+        if (handler == null) {
+            throw new SAXException("Could not find Handler for: " + element.getTextContent());
+        }
+
+        NamedNodeMap attributes = element.getAttributes();
+        Map<String, String> params = new HashMap<>();
+        for (int i = 0; i < attributes.getLength(); i++) {
+            Node node = attributes.item(i);
+            params.put(node.getNodeName(), node.getNodeValue());
+        }
+        return new HandlerEntry(handler, params);
+    }
+
+    private void parseMessageHandler(Element element, LegacyFeatureTemplate template) throws SAXException {
+        HandlerEntry he = makeHandlerEntry(element);
+        if ("true".equals(element.getAttribute("default"))) {
+            template.setDefaultMessageHandler(he);
+        } else {
+            String attr = element.getAttribute("cmd");
+            int command = (attr == null) ? 0 : HexUtils.toInteger(attr);
+            template.addMessageHandler(command, he);
+        }
+    }
+
+    private void parseCommandHandler(Element element, LegacyFeatureTemplate template) throws SAXException {
+        HandlerEntry he = makeHandlerEntry(element);
+        if ("true".equals(element.getAttribute("default"))) {
+            template.setDefaultCommandHandler(he);
+        } else {
+            Class<? extends Command> command = parseCommandClass(element.getAttribute("command"));
+            template.addCommandHandler(command, he);
+        }
+    }
+
+    private void parseMessageDispatcher(Element element, LegacyFeatureTemplate template) throws SAXException {
+        HandlerEntry he = makeHandlerEntry(element);
+        template.setMessageDispatcher(he);
+    }
+
+    private void parsePollHandler(Element element, LegacyFeatureTemplate template) throws SAXException {
+        HandlerEntry he = makeHandlerEntry(element);
+        template.setPollHandler(he);
+    }
+
+    private Class<? extends Command> parseCommandClass(String command) throws SAXException {
+        if ("OnOffType".equals(command)) {
+            return OnOffType.class;
+        } else if ("PercentType".equals(command)) {
+            return PercentType.class;
+        } else if ("DecimalType".equals(command)) {
+            return DecimalType.class;
+        } else if ("IncreaseDecreaseType".equals(command)) {
+            return IncreaseDecreaseType.class;
+        } else {
+            throw new SAXException("Unknown Command Type");
+        }
+    }
+
+    public static synchronized LegacyFeatureTemplateLoader instance() {
+        if (FEATURE_TEMPLATE_LOADER.getTemplates().isEmpty()) {
+            FEATURE_TEMPLATE_LOADER.initialize();
+        }
+        return FEATURE_TEMPLATE_LOADER;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyMessageDispatcher.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyMessageDispatcher.java
new file mode 100644 (file)
index 0000000..2ef257a
--- /dev/null
@@ -0,0 +1,403 @@
+/**
+ * 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.lang.reflect.InvocationTargetException;
+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.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.LegacyDeviceFeature;
+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.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Does preprocessing of messages to decide which handler should be called.
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public abstract class LegacyMessageDispatcher {
+    protected final Logger logger = LoggerFactory.getLogger(LegacyMessageDispatcher.class);
+
+    LegacyDeviceFeature feature;
+    Map<String, String> parameters = new HashMap<>();
+
+    /**
+     * Constructor
+     *
+     * @param feature DeviceFeature to which this MessageDispatcher belongs
+     */
+    LegacyMessageDispatcher(LegacyDeviceFeature feature) {
+        this.feature = feature;
+    }
+
+    public void setParameters(Map<String, String> parameters) {
+        this.parameters = parameters;
+    }
+
+    /**
+     * Generic handling of incoming ALL LINK messages
+     *
+     * @param msg the message received
+     * @return true if the message was handled by this function
+     */
+    protected boolean handleAllLinkMessage(Msg msg) {
+        if (!msg.isAllLinkBroadcastOrCleanup()) {
+            return false;
+        }
+        try {
+            InsteonAddress address = msg.getInsteonAddress("toAddress");
+            // ALL_LINK_BROADCAST and ALL_LINK_CLEANUP
+            // have a valid Command1 field
+            // but the CLEANUP_SUCCESS (of type ALL_LINK_BROADCAST!)
+            // message has cmd1 = 0x06 and the cmd as the
+            // high byte of the toAddress.
+            byte cmd1 = msg.getByte("command1");
+            if (!msg.isAllLinkCleanup() && cmd1 == 0x06) {
+                cmd1 = address.getHighByte();
+            }
+            // For ALL_LINK_BROADCAST messages, the group is
+            // in the low byte of the toAddress. For direct
+            // ALL_LINK_CLEANUP, it is in Command2
+
+            int group = (msg.isAllLinkCleanup() ? msg.getByte("command2") : address.getLowByte()) & 0xff;
+            LegacyMessageHandler handler = feature.getMsgHandlers().get(cmd1 & 0xFF);
+            if (handler == null) {
+                logger.debug("msg is not for this feature");
+                return true;
+            }
+            if (!handler.isDuplicate(msg)) {
+                if (handler.matchesGroup(group) && handler.matches(msg)) {
+                    logger.debug("{}:{}->{} cmd1:{} group {}/{}", feature.getDevice().getAddress(), feature.getName(),
+                            handler.getClass().getSimpleName(), HexUtils.getHexString(cmd1), group, handler.getGroup());
+                    handler.handleMessage(group, cmd1, msg, feature);
+                } else {
+                    logger.debug("message ignored because matches group: {} matches filter: {}",
+                            handler.matchesGroup(group), handler.matches(msg));
+                }
+            } else {
+                logger.debug("message ignored as duplicate. Matches group: {} matches filter: {}",
+                        handler.matchesGroup(group), handler.matches(msg));
+            }
+        } catch (FieldException e) {
+            logger.warn("couldn't parse ALL_LINK message: {}", msg, e);
+        }
+        return true;
+    }
+
+    /**
+     * Checks if this message is in response to previous query by this feature
+     *
+     * @param msg
+     * @return true;
+     */
+    boolean isMyDirectAck(Msg msg) {
+        return msg.isAckOfDirect() && (feature.getQueryStatus() == LegacyDeviceFeature.QueryStatus.QUERY_PENDING)
+                && feature.equals(feature.getDevice().getFeatureQueried());
+    }
+
+    /**
+     * Dispatches message
+     *
+     * @param msg Message to dispatch
+     * @return true if this message was found to be a reply to a direct message,
+     *         and was claimed by one of the handlers
+     */
+    public abstract boolean dispatch(Msg msg);
+
+    public static class DefaultDispatcher extends LegacyMessageDispatcher {
+        DefaultDispatcher(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            byte cmd = 0x00;
+            byte cmd1 = 0x00;
+            boolean isConsumed = false;
+            int key = -1;
+            try {
+                cmd = msg.getByte("Cmd");
+                cmd1 = msg.getByte("command1");
+            } catch (FieldException e) {
+                logger.debug("no command found, dropping msg {}", msg);
+                return false;
+            }
+            if (msg.isAllLinkCleanupAckOrNack()) {
+                // Had cases when a KeypadLinc would send an ALL_LINK_CLEANUP_ACK
+                // in response to a direct status query message
+                return false;
+            }
+            if (handleAllLinkMessage(msg)) {
+                return false;
+            }
+            if (msg.isAckOfDirect()) {
+                // in the case of direct ack, the cmd1 code is useless.
+                // you have to know what message was sent before to
+                // interpret the reply message
+                if (isMyDirectAck(msg)) {
+                    logger.debug("{}:{} DIRECT_ACK: q:{} cmd: {}", feature.getDevice().getAddress(), feature.getName(),
+                            feature.getQueryStatus(), cmd);
+                    isConsumed = true;
+                    if (cmd == 0x50) {
+                        // must be a reply to our message, tweak the cmd1 code!
+                        logger.debug("changing key to 0x19 for msg {}", msg);
+                        key = 0x19; // we have installed a handler under that command number
+                    }
+                }
+            } else {
+                key = (cmd1 & 0xFF);
+            }
+            if (key != -1 || feature.isStatusFeature()) {
+                LegacyMessageHandler handler = feature.getMsgHandlers().get(key);
+                if (handler == null) {
+                    handler = feature.getDefaultMsgHandler();
+                }
+                if (handler.matches(msg)) {
+                    if (!isConsumed) {
+                        logger.debug("{}:{}->{} DIRECT", feature.getDevice().getAddress(), feature.getName(),
+                                handler.getClass().getSimpleName());
+                    }
+                    handler.handleMessage(-1, cmd1, msg, feature);
+                }
+            }
+            if (isConsumed) {
+                feature.setQueryStatus(LegacyDeviceFeature.QueryStatus.QUERY_ANSWERED);
+                logger.debug("defdisp: {}:{} set status to: {}", feature.getDevice().getAddress(), feature.getName(),
+                        feature.getQueryStatus());
+            }
+            return isConsumed;
+        }
+    }
+
+    public static class DefaultGroupDispatcher extends LegacyMessageDispatcher {
+        DefaultGroupDispatcher(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            byte cmd = 0x00;
+            byte cmd1 = 0x00;
+            boolean isConsumed = false;
+            int key = -1;
+            try {
+                cmd = msg.getByte("Cmd");
+                cmd1 = msg.getByte("command1");
+            } catch (FieldException e) {
+                logger.debug("no command found, dropping msg {}", msg);
+                return false;
+            }
+            if (msg.isAllLinkCleanupAckOrNack()) {
+                // Had cases when a KeypadLinc would send an ALL_LINK_CLEANUP_ACK
+                // in response to a direct status query message
+                return false;
+            }
+            if (handleAllLinkMessage(msg)) {
+                return false;
+            }
+            if (msg.isAckOfDirect()) {
+                // in the case of direct ack, the cmd1 code is useless.
+                // you have to know what message was sent before to
+                // interpret the reply message
+                if (isMyDirectAck(msg)) {
+                    logger.debug("{}:{} qs:{} cmd: {}", feature.getDevice().getAddress(), feature.getName(),
+                            feature.getQueryStatus(), cmd);
+                    isConsumed = true;
+                    if (cmd == 0x50) {
+                        // must be a reply to our message, tweak the cmd1 code!
+                        logger.debug("changing key to 0x19 for msg {}", msg);
+                        key = 0x19; // we have installed a handler under that command number
+                    }
+                }
+            } else {
+                key = (cmd1 & 0xFF);
+            }
+            if (key != -1) {
+                for (LegacyDeviceFeature connectedFeature : feature.getConnectedFeatures()) {
+                    LegacyMessageHandler handler = connectedFeature.getMsgHandlers().get(key);
+                    if (handler == null) {
+                        handler = connectedFeature.getDefaultMsgHandler();
+                    }
+                    if (handler.matches(msg)) {
+                        if (!isConsumed) {
+                            logger.debug("{}:{}->{} DIRECT", connectedFeature.getDevice().getAddress(),
+                                    connectedFeature.getName(), handler.getClass().getSimpleName());
+                        }
+                        handler.handleMessage(-1, cmd1, msg, connectedFeature);
+                    }
+
+                }
+            }
+            if (isConsumed) {
+                feature.setQueryStatus(LegacyDeviceFeature.QueryStatus.QUERY_ANSWERED);
+                logger.debug("{}:{} set status to: {}", feature.getDevice().getAddress(), feature.getName(),
+                        feature.getQueryStatus());
+            }
+            return isConsumed;
+        }
+    }
+
+    public static class PollGroupDispatcher extends LegacyMessageDispatcher {
+        PollGroupDispatcher(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            if (msg.isAllLinkCleanupAckOrNack()) {
+                // Had cases when a KeypadLinc would send an ALL_LINK_CLEANUP_ACK
+                // in response to a direct status query message
+                return false;
+            }
+            if (handleAllLinkMessage(msg)) {
+                return false;
+            }
+            if (msg.isAckOfDirect()) {
+                boolean isMyAck = isMyDirectAck(msg);
+                if (isMyAck) {
+                    logger.debug("{}:{} got poll ACK", feature.getDevice().getAddress(), feature.getName());
+                }
+                return isMyAck;
+            }
+            return false; // not a direct ack, so we didn't consume it either
+        }
+    }
+
+    public static class SimpleDispatcher extends LegacyMessageDispatcher {
+        SimpleDispatcher(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            byte cmd1 = 0x00;
+            try {
+                if (handleAllLinkMessage(msg)) {
+                    return false;
+                }
+                if (msg.isAllLinkCleanupAckOrNack()) {
+                    // Had cases when a KeypadLinc would send an ALL_LINK_CLEANUP_ACK
+                    // in response to a direct status query message
+                    return false;
+                }
+                cmd1 = msg.getByte("command1");
+            } catch (FieldException e) {
+                logger.debug("no cmd1 found, dropping msg {}", msg);
+                return false;
+            }
+            boolean isConsumed = isMyDirectAck(msg);
+            int key = (cmd1 & 0xFF);
+            LegacyMessageHandler handler = feature.getMsgHandlers().get(key);
+            if (handler == null) {
+                handler = feature.getDefaultMsgHandler();
+            }
+            if (handler.matches(msg)) {
+                logger.trace("{}:{}->{} {}", feature.getDevice().getAddress(), feature.getName(),
+                        handler.getClass().getSimpleName(), msg);
+                handler.handleMessage(-1, cmd1, msg, feature);
+            }
+            return isConsumed;
+        }
+    }
+
+    public static class X10Dispatcher extends LegacyMessageDispatcher {
+        X10Dispatcher(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            try {
+                byte rawX10 = msg.getByte("rawX10");
+                int cmd = (rawX10 & 0x0f);
+                LegacyMessageHandler handler = feature.getMsgHandlers().get(cmd);
+                if (handler == null) {
+                    handler = feature.getDefaultMsgHandler();
+                }
+                logger.debug("{}:{}->{} {}", feature.getDevice().getAddress(), feature.getName(),
+                        handler.getClass().getSimpleName(), msg);
+                if (handler.matches(msg)) {
+                    handler.handleMessage(-1, (byte) cmd, msg, feature);
+                }
+            } catch (FieldException e) {
+                logger.warn("error parsing {}: ", msg, e);
+            }
+            return false;
+        }
+    }
+
+    public static class PassThroughDispatcher extends LegacyMessageDispatcher {
+        PassThroughDispatcher(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            LegacyMessageHandler handler = feature.getDefaultMsgHandler();
+            if (handler.matches(msg)) {
+                logger.trace("{}:{}->{} {}", feature.getDevice().getAddress(), feature.getName(),
+                        handler.getClass().getSimpleName(), msg);
+                handler.handleMessage(-1, (byte) 0x01, msg, feature);
+            }
+            return false;
+        }
+    }
+
+    /**
+     * Drop all incoming messages silently
+     */
+    public static class NoOpDispatcher extends LegacyMessageDispatcher {
+        NoOpDispatcher(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            return false;
+        }
+    }
+
+    /**
+     * Factory method for creating a dispatcher of a given name using java reflection
+     *
+     * @param name the name of the dispatcher to create
+     * @param params
+     * @param feature the feature for which to create the dispatcher
+     * @return the handler which was created
+     */
+    @Nullable
+    public static <T extends LegacyMessageDispatcher> T makeHandler(String name, Map<String, String> params,
+            LegacyDeviceFeature feature) {
+        try {
+            String className = LegacyMessageDispatcher.class.getName() + "$" + name;
+            @SuppressWarnings("unchecked")
+            Class<? extends T> classRef = (Class<? extends T>) Class.forName(className);
+            @Nullable
+            T handler = classRef.getDeclaredConstructor(LegacyDeviceFeature.class).newInstance(feature);
+            handler.setParameters(params);
+            return handler;
+        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
+                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
+            return null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyMessageHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyMessageHandler.java
new file mode 100644 (file)
index 0000000..3cd2f95
--- /dev/null
@@ -0,0 +1,1309 @@
+/**
+ * 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.InsteonLegacyBindingConstants.*;
+
+import java.lang.reflect.InvocationTargetException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+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.device.DeviceAddress;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.LegacyDevice;
+import org.openhab.binding.insteon.internal.device.LegacyDeviceFeature;
+import org.openhab.binding.insteon.internal.device.feature.LegacyFeatureListener.StateChangeType;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.InvalidMessageTypeException;
+import org.openhab.binding.insteon.internal.transport.message.LegacyGroupMessageStateMachine.GroupMessage;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.openhab.binding.insteon.internal.transport.message.MsgType;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.openhab.binding.insteon.internal.utils.ParameterParser;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.QuantityType;
+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.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A message handler processes incoming Insteon messages and reacts by publishing
+ * corresponding messages on the openhab bus, updating device state etc.
+ *
+ * @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 LegacyMessageHandler {
+    protected final Logger logger = LoggerFactory.getLogger(LegacyMessageHandler.class);
+
+    protected LegacyDeviceFeature feature;
+    protected Map<String, String> parameters = new HashMap<>();
+
+    LegacyMessageHandler(LegacyDeviceFeature feature) {
+        this.feature = feature;
+    }
+
+    /**
+     * Method that processes incoming message. The cmd1 parameter
+     * has been extracted earlier already (to make a decision which message handler to call),
+     * and is passed in as an argument so cmd1 does not have to be extracted from the message again.
+     *
+     * @param group all-link group or -1 if not specified
+     * @param cmd1 the insteon cmd1 field
+     * @param msg the received insteon message
+     * @param feature the DeviceFeature to which this message handler is attached
+     */
+    public abstract void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature feature);
+
+    /**
+     * Method to send an extended insteon message for querying a device
+     *
+     * @param feature DeviceFeature that is being currently handled
+     * @param cmd1 cmd1 for message to be sent
+     * @param cmd2 cmd2 for message to be sent
+     */
+    public void sendExtendedQuery(LegacyDeviceFeature feature, byte cmd1, byte cmd2) {
+        LegacyDevice device = feature.getDevice();
+        try {
+            Msg msg = Msg.makeExtendedMessage((InsteonAddress) device.getAddress(), cmd1, cmd2, true);
+            msg.setQuietTime(500L);
+            device.enqueueMessage(msg, feature);
+        } catch (InvalidMessageTypeException e) {
+            logger.warn("msg exception sending query message to device {}", device.getAddress());
+        } catch (FieldException e) {
+            logger.warn("field exception sending query message to device {}", device.getAddress());
+        }
+    }
+
+    /**
+     * Check if group matches
+     *
+     * @param group group to test for
+     * @return true if group matches or no group is specified
+     */
+    public boolean matchesGroup(int group) {
+        int parameter = getIntParameter("group", -1);
+        return parameter == -1 || parameter == group;
+    }
+
+    /**
+     * Retrieve group parameter or -1 if no group is specified
+     *
+     * @return group parameter
+     */
+    public int getGroup() {
+        return getIntParameter("group", -1);
+    }
+
+    /**
+     * Helper function to get an integer parameter for the handler
+     *
+     * @param key name of the int parameter (as specified in device features!)
+     * @param def default to return if parameter not found
+     * @return value of int parameter (or default if not found)
+     */
+    protected int getIntParameter(String key, int def) {
+        return ParameterParser.getParameterAsOrDefault(parameters.get(key), Integer.class, def);
+    }
+
+    /**
+     * Helper function to get a String parameter for the handler
+     *
+     * @param key name of the String parameter (as specified in device features!)
+     * @param def default to return if parameter not found
+     * @return value of parameter (or default if not found)
+     */
+    protected @Nullable String getStringParameter(String key, @Nullable String def) {
+        String str = parameters.get(key);
+        return str != null ? str : def;
+    }
+
+    /**
+     * Helper function to get a double parameter for the handler
+     *
+     * @param key name of the parameter (as specified in device features!)
+     * @param def default to return if parameter not found
+     * @return value of parameter (or default if not found)
+     */
+    protected double getDoubleParameter(String key, double def) {
+        try {
+            String str = parameters.get(key);
+            return str != null ? Double.parseDouble(str) : def;
+        } catch (NumberFormatException e) {
+            logger.warn("malformed int parameter in message handler: {}", key);
+        }
+        return def;
+    }
+
+    protected boolean getBooleanDeviceConfig(String key, boolean def) {
+        Object value = feature.getDevice().getDeviceConfigMap().get(key);
+        if (value != null) {
+            if (value instanceof Boolean booleanValue) {
+                return booleanValue;
+            } else {
+                logger.warn("{} {}: The value for the '{}' key is not boolean in the device configuration parameter.",
+                        nm(), feature.getDevice().getAddress(), key);
+            }
+        }
+
+        return def;
+    }
+
+    /**
+     * Test if message refers to the button configured for given feature
+     *
+     * @param msg received message
+     * @param feature device feature to test
+     * @return true if we have no button configured or the message is for this button
+     */
+    protected boolean isMybutton(Msg msg, LegacyDeviceFeature feature) {
+        int myButton = getIntParameter("button", -1);
+        // if there is no button configured for this handler
+        // the message is assumed to refer to this feature
+        // no matter what button is addressed in the message
+        if (myButton == -1) {
+            return true;
+        }
+
+        int button = getButtonInfo(msg, feature);
+        return button != -1 && myButton == button;
+    }
+
+    /**
+     * Test if parameter matches value
+     *
+     * @param param name of parameter to match
+     * @param msg message to search
+     * @param field field name to match
+     * @return true if parameter matches
+     * @throws FieldException if field not there
+     */
+    protected boolean testMatch(String param, Msg msg, String field) throws FieldException {
+        int mp = getIntParameter(param, -1);
+        // parameter not filtered for, declare this a match!
+        if (mp == -1) {
+            return true;
+        }
+        byte value = msg.getByte(field);
+        return value == mp;
+    }
+
+    /**
+     * Test if message matches the filter parameters
+     *
+     * @param msg message to be tested against
+     * @return true if message matches
+     */
+    public boolean matches(Msg msg) {
+        try {
+            int ext = getIntParameter("ext", -1);
+            if (ext != -1) {
+                if ((msg.isExtended() && ext != 1) || (!msg.isExtended() && ext != 0)) {
+                    return false;
+                }
+                if (!testMatch("match_cmd1", msg, "command1")) {
+                    return false;
+                }
+            }
+            if (!testMatch("match_cmd2", msg, "command2")) {
+                return false;
+            }
+            if (!testMatch("match_d1", msg, "userData1")) {
+                return false;
+            }
+            if (!testMatch("match_d2", msg, "userData2")) {
+                return false;
+            }
+            if (!testMatch("match_d3", msg, "userData3")) {
+                return false;
+            }
+        } catch (FieldException e) {
+            logger.warn("error matching message: {}", msg, e);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Determines is an incoming ALL LINK message is a duplicate
+     *
+     * @param msg the received ALL LINK message
+     * @return true if this message is a duplicate
+     */
+    protected boolean isDuplicate(Msg msg) {
+        boolean isDuplicate = false;
+        try {
+            MsgType msgType = MsgType.valueOf(msg.getByte("messageFlags"));
+            if (msgType == MsgType.ALL_LINK_BROADCAST) {
+                int group = msg.getInsteonAddress("toAddress").getLowByte() & 0xff;
+                byte cmd1 = msg.getByte("command1");
+                // if the command is 0x06, then it's success message
+                // from the original broadcaster, with which the device
+                // confirms that it got all cleanup replies successfully.
+                GroupMessage gm = (cmd1 == 0x06) ? GroupMessage.SUCCESS : GroupMessage.BCAST;
+                isDuplicate = !feature.getDevice().getGroupState(group, gm, cmd1);
+            } else if (msgType == MsgType.ALL_LINK_CLEANUP) {
+                // the cleanup messages are direct messages, so the
+                // group # is not in the toAddress, but in cmd2
+                int group = msg.getByte("command2") & 0xff;
+                isDuplicate = !feature.getDevice().getGroupState(group, GroupMessage.CLEAN, (byte) 0);
+            }
+        } catch (IllegalArgumentException e) {
+            logger.warn("cannot parse msg: {}", msg, e);
+        } catch (FieldException e) {
+            logger.warn("cannot parse msg: {}", msg, e);
+        }
+        return isDuplicate;
+    }
+
+    /**
+     * Extract button information from message
+     *
+     * @param msg the message to extract from
+     * @param feature the device feature (needed for debug printing)
+     * @return the button number or -1 if no button found
+     */
+    protected int getButtonInfo(Msg msg, LegacyDeviceFeature feature) {
+        // the cleanup messages have the button number in the command2 field
+        // the broadcast messages have it as the lsb of the toAddress
+        try {
+            int bclean = msg.getByte("command2") & 0xff;
+            int bbcast = msg.getInsteonAddress("toAddress").getLowByte() & 0xff;
+            int button = msg.isAllLinkCleanup() ? bclean : bbcast;
+            logger.trace("{} button: {} bclean: {} bbcast: {}", feature.getDevice().getAddress(), button, bclean,
+                    bbcast);
+            return button;
+        } catch (FieldException e) {
+            logger.warn("field exception while parsing msg {}: ", msg, e);
+        }
+        return -1;
+    }
+
+    /**
+     * Shorthand to return class name for logging purposes
+     *
+     * @return name of the class
+     */
+    protected String nm() {
+        return this.getClass().getSimpleName();
+    }
+
+    /**
+     * Set parameter map
+     *
+     * @param map the parameter map for this message handler
+     */
+    public void setParameters(Map<String, String> map) {
+        parameters = map;
+    }
+
+    public static class DefaultMsgHandler extends LegacyMessageHandler {
+        public DefaultMsgHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            if (logger.isDebugEnabled()) {
+                logger.debug("{} ignoring unimpl message with cmd1:{}", nm(), HexUtils.getHexString(cmd1));
+            }
+        }
+    }
+
+    public static class NoOpMsgHandler extends LegacyMessageHandler {
+        NoOpMsgHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            if (logger.isTraceEnabled()) {
+                logger.trace("{} ignore msg {}: {}", nm(), HexUtils.getHexString(cmd1), msg);
+            }
+        }
+    }
+
+    public static class LightOnDimmerHandler extends LegacyMessageHandler {
+        LightOnDimmerHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            if (!isMybutton(msg, f)) {
+                return;
+            }
+            DeviceAddress address = f.getDevice().getAddress();
+            if (msg.isAckOfDirect()) {
+                logger.warn("{}: device {}: ignoring ack of direct.", nm(), address);
+            } else {
+                String mode = getStringParameter("mode", "REGULAR");
+                logger.debug("{}: device {} was turned on {}. " + "Sending poll request to get actual level", nm(),
+                        address, mode);
+                feature.publish(PercentType.HUNDRED, StateChangeType.ALWAYS);
+                // need to poll to find out what level the dimmer is at now.
+                // it may not be at 100% because dimmers can be configured
+                // to switch to e.g. 75% when turned on.
+                Msg m = f.makePollMsg();
+                if (m != null) {
+                    f.getDevice().enqueueDelayedMessage(m, f, 1000);
+                }
+            }
+        }
+    }
+
+    public static class LightOffDimmerHandler extends LegacyMessageHandler {
+        LightOffDimmerHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            if (isMybutton(msg, f)) {
+                String mode = getStringParameter("mode", "REGULAR");
+                logger.debug("{}: device {} was turned off {}.", nm(), f.getDevice().getAddress(), mode);
+                f.publish(PercentType.ZERO, StateChangeType.ALWAYS);
+            }
+        }
+    }
+
+    public static class LightOnSwitchHandler extends LegacyMessageHandler {
+        LightOnSwitchHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            if (isMybutton(msg, f)) {
+                String mode = getStringParameter("mode", "REGULAR");
+                logger.debug("{}: device {} was switched on {}.", nm(), f.getDevice().getAddress(), mode);
+                f.publish(OnOffType.ON, StateChangeType.ALWAYS);
+            } else {
+                logger.debug("ignored message: {}", isMybutton(msg, f));
+            }
+        }
+    }
+
+    public static class LightOffSwitchHandler extends LegacyMessageHandler {
+        LightOffSwitchHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            if (isMybutton(msg, f)) {
+                String mode = getStringParameter("mode", "REGULAR");
+                logger.debug("{}: device {} was switched off {}.", nm(), f.getDevice().getAddress(), mode);
+                f.publish(OnOffType.OFF, StateChangeType.ALWAYS);
+            }
+        }
+    }
+
+    /**
+     * This message handler processes replies to Ramp ON/OFF commands.
+     * Currently, it's been tested for the 2672-222 LED Bulb. Other
+     * devices may use a different pair of commands (0x2E, 0x2F). This
+     * handler and the command handler will need to be extended to support
+     * those devices.
+     */
+    public static class RampDimmerHandler extends LegacyMessageHandler {
+        private int onCmd;
+        private int offCmd;
+
+        RampDimmerHandler(LegacyDeviceFeature feature) {
+            super(feature);
+            // Can't process parameters here because they are set after constructor is invoked.
+            // Unfortunately, this means we can't declare the onCmd, offCmd to be final.
+        }
+
+        @Override
+        public void setParameters(Map<String, String> params) {
+            super.setParameters(params);
+            onCmd = getIntParameter("on", 0x2E);
+            offCmd = getIntParameter("off", 0x2F);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            if (cmd1 == onCmd) {
+                int level = getLevel(msg);
+                logger.debug("{}: device {} was switched on using ramp to level {}.", nm(), f.getDevice().getAddress(),
+                        level);
+                if (level == 100) {
+                    f.publish(OnOffType.ON, StateChangeType.ALWAYS);
+                } else {
+                    // The publisher will convert an ON at level==0 to an OFF.
+                    // However, this is not completely accurate since a ramp
+                    // off at level == 0 may not turn off the dimmer completely
+                    // (if I understand the Insteon docs correctly). In any
+                    // case,
+                    // it would be an odd scenario to turn ON a light at level
+                    // == 0
+                    // rather than turn if OFF.
+                    f.publish(new PercentType(level), StateChangeType.ALWAYS);
+                }
+            } else if (cmd1 == offCmd) {
+                logger.debug("{}: device {} was switched off using ramp.", nm(), f.getDevice().getAddress());
+                f.publish(new PercentType(0), StateChangeType.ALWAYS);
+            }
+        }
+
+        private int getLevel(Msg msg) {
+            try {
+                byte cmd2 = msg.getByte("command2");
+                return (int) Math.round(((cmd2 >> 4) & 0x0f) * (100 / 15d));
+            } catch (FieldException e) {
+                logger.warn("Can't access command2 byte", e);
+                return 0;
+            }
+        }
+    }
+
+    /**
+     * A message handler that processes replies to queries.
+     * If command2 == 0xFF then the light has been turned on
+     * else if command2 == 0x00 then the light has been turned off
+     */
+
+    public static class SwitchRequestReplyHandler extends LegacyMessageHandler {
+        SwitchRequestReplyHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            try {
+                DeviceAddress address = f.getDevice().getAddress();
+                int cmd2 = msg.getByte("command2") & 0xff;
+                int button = this.getIntParameter("button", -1);
+                if (button < 0) {
+                    handleNoButtons(cmd2, address, msg);
+                } else {
+                    boolean isOn = isLEDLit(cmd2, button);
+                    logger.debug("{}: dev {} button {} switched to {}", nm(), address, button, isOn ? "ON" : "OFF");
+                    feature.publish(OnOffType.from(isOn), StateChangeType.CHANGED);
+                }
+            } catch (FieldException e) {
+                logger.warn("{} error parsing {}: ", nm(), msg, e);
+            }
+        }
+
+        /**
+         * Handle the case where no buttons have been configured.
+         * In this situation, the only return values should be 0 (light off)
+         * or 0xff (light on)
+         *
+         * @param cmd2
+         */
+        void handleNoButtons(int cmd2, DeviceAddress a, Msg msg) {
+            if (cmd2 == 0) {
+                logger.debug("{}: set device {} to OFF", nm(), a);
+                feature.publish(OnOffType.OFF, StateChangeType.CHANGED);
+            } else if (cmd2 == 0xff) {
+                logger.debug("{}: set device {} to ON", nm(), a);
+                feature.publish(OnOffType.ON, StateChangeType.CHANGED);
+            } else {
+                logger.warn("{}: {} ignoring unexpected cmd2 in msg: {}", nm(), a, msg);
+            }
+        }
+
+        /**
+         * Test if cmd byte indicates that button is lit.
+         * The cmd byte has the LED status bitwise from the left:
+         * 87654321
+         * Note that the 2487S has buttons assigned like this:
+         * 22|6543|11
+         * They used the basis of the 8-button remote, and assigned
+         * the ON button to 1+2, the OFF button to 7+8
+         *
+         * @param cmd cmd byte as received in message
+         * @param button button to test (number in range 1..8)
+         * @return true if button is lit, false otherwise
+         */
+        private boolean isLEDLit(int cmd, int button) {
+            boolean isSet = (cmd & (0x1 << (button - 1))) != 0;
+            logger.trace("cmd: {} button {}", Integer.toBinaryString(cmd), button);
+            logger.trace("msk: {} isSet: {}", Integer.toBinaryString(0x1 << (button - 1)), isSet);
+            return isSet;
+        }
+    }
+
+    /**
+     * Handles Dimmer replies to status requests.
+     * In the dimmers case the command2 byte represents the light level from 0-255
+     */
+    public static class DimmerRequestReplyHandler extends LegacyMessageHandler {
+        DimmerRequestReplyHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            LegacyDevice dev = f.getDevice();
+            try {
+                int cmd2 = msg.getByte("command2") & 0xff;
+                if (cmd2 == 0xfe) {
+                    // sometimes dimmer devices are returning 0xfe when on instead of 0xff
+                    cmd2 = 0xff;
+                }
+
+                if (cmd2 == 0) {
+                    logger.debug("{}: set device {} to level 0", nm(), dev.getAddress());
+                    feature.publish(PercentType.ZERO, StateChangeType.CHANGED);
+                } else if (cmd2 == 0xff) {
+                    logger.debug("{}: set device {} to level 100", nm(), dev.getAddress());
+                    feature.publish(PercentType.HUNDRED, StateChangeType.CHANGED);
+                } else {
+                    int level = cmd2 * 100 / 255;
+                    if (level == 0) {
+                        level = 1;
+                    }
+                    logger.debug("{}: set device {} to level {}", nm(), dev.getAddress(), level);
+                    feature.publish(new PercentType(level), StateChangeType.CHANGED);
+                }
+            } catch (FieldException e) {
+                logger.warn("{}: error parsing {}: ", nm(), msg, e);
+            }
+        }
+    }
+
+    public static class DimmerStopManualChangeHandler extends LegacyMessageHandler {
+        DimmerStopManualChangeHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean isDuplicate(Msg msg) {
+            // Disable duplicate elimination because
+            // there are no cleanup or success messages for start/stop.
+            return false;
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            Msg m = f.makePollMsg();
+            if (m != null) {
+                f.getDevice().enqueueMessage(m, f);
+            }
+        }
+    }
+
+    public static class StartManualChangeHandler extends LegacyMessageHandler {
+        StartManualChangeHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean isDuplicate(Msg msg) {
+            // Disable duplicate elimination because
+            // there are no cleanup or success messages for start/stop.
+            return false;
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            try {
+                int cmd2 = msg.getByte("command2") & 0xff;
+                int upDown = (cmd2 == 0) ? 0 : 2;
+                logger.debug("{}: dev {} manual state change: {}", nm(), f.getDevice().getAddress(),
+                        (upDown == 0) ? "DOWN" : "UP");
+                feature.publish(new DecimalType(upDown), StateChangeType.ALWAYS);
+            } catch (FieldException e) {
+                logger.warn("{} error parsing {}: ", nm(), msg, e);
+            }
+        }
+    }
+
+    public static class StopManualChangeHandler extends LegacyMessageHandler {
+        StopManualChangeHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean isDuplicate(Msg msg) {
+            // Disable duplicate elimination because
+            // there are no cleanup or success messages for start/stop.
+            return false;
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            logger.debug("{}: dev {} manual state change: {}", nm(), f.getDevice().getAddress(), 0);
+            feature.publish(new DecimalType(1), StateChangeType.ALWAYS);
+        }
+    }
+
+    public static class InfoRequestReplyHandler extends LegacyMessageHandler {
+        InfoRequestReplyHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            LegacyDevice dev = f.getDevice();
+            if (!msg.isExtended()) {
+                logger.warn("{} device {} expected extended msg as info reply, got {}", nm(), dev.getAddress(), msg);
+                return;
+            }
+            try {
+                int cmd2 = msg.getByte("command2") & 0xff;
+                switch (cmd2) {
+                    case 0x00: // this is a product data response message
+                        int prodKey = msg.getInt24("userData2");
+                        int devCat = msg.getByte("userData5");
+                        int subCat = msg.getByte("userData6");
+                        logger.debug("{} {} got product data: cat: {} subcat: {} key: {} ", nm(), dev.getAddress(),
+                                devCat, subCat, HexUtils.getHexString(prodKey));
+                        break;
+                    case 0x02: // this is a device text string response message
+                        logger.debug("{} {} got text str {} ", nm(), dev.getAddress(), msg);
+                        break;
+                    default:
+                        logger.warn("{} unknown cmd2 = {} in info reply message {}", nm(), cmd2, msg);
+                        break;
+                }
+            } catch (FieldException e) {
+                logger.warn("error parsing {}: ", msg, e);
+            }
+        }
+    }
+
+    public static class MotionSensorDataReplyHandler extends LegacyMessageHandler {
+        MotionSensorDataReplyHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            LegacyDevice dev = f.getDevice();
+            if (!msg.isExtended()) {
+                logger.trace("{} device {} ignoring non-extended msg {}", nm(), dev.getAddress(), msg);
+                return;
+            }
+            try {
+                int cmd2 = msg.getByte("command2") & 0xff;
+                int batteryLevel;
+                int lightLevel;
+                int temperatureLevel;
+                switch (cmd2) {
+                    case 0x00: // this is a product data response message
+                        batteryLevel = msg.getByte("userData12") & 0xff;
+                        lightLevel = msg.getByte("userData11") & 0xff;
+                        logger.debug("{}: {} got light level: {}, battery level: {}", nm(), dev.getAddress(),
+                                lightLevel, batteryLevel);
+                        feature.publish(new DecimalType(lightLevel), StateChangeType.CHANGED, FIELD, FIELD_LIGHT_LEVEL);
+                        feature.publish(new DecimalType(batteryLevel), StateChangeType.CHANGED, FIELD,
+                                FIELD_BATTERY_LEVEL);
+                        break;
+                    case 0x03: // this is the 2844-222 data response message
+                        batteryLevel = msg.getByte("userData6") & 0xff;
+                        lightLevel = msg.getByte("userData7") & 0xff;
+                        temperatureLevel = msg.getByte("userData8") & 0xff;
+                        logger.debug("{}: {} got light level: {}, battery level: {}, temperature level: {}", nm(),
+                                dev.getAddress(), lightLevel, batteryLevel, temperatureLevel);
+                        feature.publish(new DecimalType(lightLevel), StateChangeType.CHANGED, FIELD, FIELD_LIGHT_LEVEL);
+                        feature.publish(new DecimalType(batteryLevel), StateChangeType.CHANGED, FIELD,
+                                FIELD_BATTERY_LEVEL);
+                        feature.publish(new DecimalType(temperatureLevel), StateChangeType.CHANGED, FIELD,
+                                FIELD_TEMPERATURE_LEVEL);
+
+                        // per 2844-222 dev doc: working battery level range is 0xd2 - 0x70
+                        int batteryPercentage;
+                        if (batteryLevel >= 0xd2) {
+                            batteryPercentage = 100;
+                        } else if (batteryLevel <= 0x70) {
+                            batteryPercentage = 0;
+                        } else {
+                            batteryPercentage = (batteryLevel - 0x70) * 100 / (0xd2 - 0x70);
+                        }
+                        logger.debug("{}: {} battery percentage: {}", nm(), dev.getAddress(), batteryPercentage);
+                        feature.publish(new QuantityType<>(batteryPercentage, Units.PERCENT), StateChangeType.CHANGED,
+                                FIELD, FIELD_BATTERY_PERCENTAGE);
+                        break;
+                    default:
+                        logger.warn("unknown cmd2 = {} in info reply message {}", cmd2, msg);
+                        break;
+                }
+            } catch (FieldException e) {
+                logger.warn("error parsing {}: ", msg, e);
+            }
+        }
+    }
+
+    public static class MotionSensor2AlternateHeartbeatHandler extends LegacyMessageHandler {
+        MotionSensor2AlternateHeartbeatHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            LegacyDevice dev = f.getDevice();
+            try {
+                // group 0x0B (11) - alternate heartbeat group
+                InsteonAddress toAddr = msg.getInsteonAddress("toAddress");
+                int batteryLevel = toAddr.getHighByte() & 0xff;
+                int lightLevel = toAddr.getMiddleByte() & 0xff;
+                int temperatureLevel = msg.getByte("command2") & 0xff;
+
+                logger.debug("{}: {} got light level: {}, battery level: {}, temperature level: {}", nm(),
+                        dev.getAddress(), lightLevel, batteryLevel, temperatureLevel);
+                feature.publish(new DecimalType(lightLevel), StateChangeType.CHANGED, FIELD, FIELD_LIGHT_LEVEL);
+                feature.publish(new DecimalType(batteryLevel), StateChangeType.CHANGED, FIELD, FIELD_BATTERY_LEVEL);
+                feature.publish(new DecimalType(temperatureLevel), StateChangeType.CHANGED, FIELD,
+                        FIELD_TEMPERATURE_LEVEL);
+
+                // per 2844-222 dev doc: working battery level range is 0xd2 - 0x70
+                int batteryPercentage;
+                if (batteryLevel >= 0xd2) {
+                    batteryPercentage = 100;
+                } else if (batteryLevel <= 0x70) {
+                    batteryPercentage = 0;
+                } else {
+                    batteryPercentage = (batteryLevel - 0x70) * 100 / (0xd2 - 0x70);
+                }
+                logger.debug("{}: {} battery percentage: {}", nm(), dev.getAddress(), batteryPercentage);
+                feature.publish(new QuantityType<>(batteryPercentage, Units.PERCENT), StateChangeType.CHANGED, FIELD,
+                        FIELD_BATTERY_PERCENTAGE);
+            } catch (FieldException e) {
+                logger.warn("error parsing {}: ", msg, e);
+            }
+        }
+    }
+
+    public static class HiddenDoorSensorDataReplyHandler extends LegacyMessageHandler {
+        HiddenDoorSensorDataReplyHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            LegacyDevice dev = f.getDevice();
+            if (!msg.isExtended()) {
+                logger.trace("{} device {} ignoring non-extended msg {}", nm(), dev.getAddress(), msg);
+                return;
+            }
+            try {
+                int cmd2 = msg.getByte("command2") & 0xff;
+                switch (cmd2) {
+                    case 0x00: // this is a product data response message
+                        int batteryLevel = msg.getByte("userData4") & 0xff;
+                        int batteryWatermark = msg.getByte("userData7") & 0xff;
+                        logger.debug("{}: {} got light level: {}, battery level: {}", nm(), dev.getAddress(),
+                                batteryWatermark, batteryLevel);
+                        feature.publish(new DecimalType(batteryWatermark), StateChangeType.CHANGED, FIELD,
+                                FIELD_BATTERY_WATERMARK_LEVEL);
+                        feature.publish(new DecimalType(batteryLevel), StateChangeType.CHANGED, FIELD,
+                                FIELD_BATTERY_LEVEL);
+                        break;
+                    default:
+                        logger.warn("unknown cmd2 = {} in info reply message {}", cmd2, msg);
+                        break;
+                }
+            } catch (FieldException e) {
+                logger.warn("error parsing {}: ", msg, e);
+            }
+        }
+    }
+
+    public static class PowerMeterUpdateHandler extends LegacyMessageHandler {
+        PowerMeterUpdateHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            if (msg.isExtended()) {
+                try {
+                    // see iMeter developer notes 2423A1dev-072013-en.pdf
+                    int b7 = msg.getByte("userData7") & 0xff;
+                    int b8 = msg.getByte("userData8") & 0xff;
+                    int watts = (b7 << 8) | b8;
+                    if (watts > 32767) {
+                        watts -= 65535;
+                    }
+
+                    int b9 = msg.getByte("userData9") & 0xff;
+                    int b10 = msg.getByte("userData10") & 0xff;
+                    int b11 = msg.getByte("userData11") & 0xff;
+                    int b12 = msg.getByte("userData12") & 0xff;
+                    BigDecimal kwh = BigDecimal.ZERO;
+                    if (b9 < 254) {
+                        int e = (b9 << 24) | (b10 << 16) | (b11 << 8) | b12;
+                        kwh = new BigDecimal(e * 65535.0 / (1000 * 60 * 60 * 60)).setScale(4, RoundingMode.HALF_UP);
+                    }
+
+                    logger.debug("{}:{} watts: {} kwh: {} ", nm(), f.getDevice().getAddress(), watts, kwh);
+                    feature.publish(new QuantityType<>(kwh, Units.KILOWATT_HOUR), StateChangeType.CHANGED, FIELD,
+                            FIELD_KWH);
+                    feature.publish(new QuantityType<>(watts, Units.WATT), StateChangeType.CHANGED, FIELD, FIELD_WATTS);
+                } catch (FieldException e) {
+                    logger.warn("error parsing {}: ", msg, e);
+                }
+            }
+        }
+    }
+
+    public static class PowerMeterResetHandler extends LegacyMessageHandler {
+        PowerMeterResetHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            LegacyDevice dev = f.getDevice();
+            logger.debug("{}: power meter {} was reset", nm(), dev.getAddress());
+
+            // poll device to get updated kilowatt hours and watts
+            Msg m = f.makePollMsg();
+            if (m != null) {
+                f.getDevice().enqueueMessage(m, f);
+            }
+        }
+    }
+
+    public static class LastTimeHandler extends LegacyMessageHandler {
+        LastTimeHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1a, Msg msg, LegacyDeviceFeature f) {
+            feature.publish(new DateTimeType(), StateChangeType.ALWAYS);
+        }
+    }
+
+    public static class ContactRequestReplyHandler extends LegacyMessageHandler {
+        ContactRequestReplyHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1a, Msg msg, LegacyDeviceFeature f) {
+            byte cmd = 0x00;
+            byte cmd2 = 0x00;
+            try {
+                cmd = msg.getByte("Cmd");
+                cmd2 = msg.getByte("command2");
+            } catch (FieldException e) {
+                logger.debug("{} no cmd found, dropping msg {}", nm(), msg);
+                return;
+            }
+            if (msg.isAckOfDirect() && (f.getQueryStatus() == LegacyDeviceFeature.QueryStatus.QUERY_PENDING)
+                    && cmd == 0x50) {
+                OpenClosedType oc = (cmd2 == 0) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
+                logger.debug("{}: set contact {} to: {}", nm(), f.getDevice().getAddress(), oc);
+                feature.publish(oc, StateChangeType.CHANGED);
+            }
+        }
+    }
+
+    public static class ClosedContactHandler extends LegacyMessageHandler {
+        ClosedContactHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            feature.publish(OpenClosedType.CLOSED, StateChangeType.ALWAYS);
+        }
+    }
+
+    public static class OpenedContactHandler extends LegacyMessageHandler {
+        OpenedContactHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            feature.publish(OpenClosedType.OPEN, StateChangeType.ALWAYS);
+        }
+    }
+
+    public static class OpenedOrClosedContactHandler extends LegacyMessageHandler {
+        OpenedOrClosedContactHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            try {
+                byte cmd2 = msg.getByte("command2");
+                switch (cmd1) {
+                    case 0x11:
+                        switch (cmd2) {
+                            case 0x02:
+                                feature.publish(OpenClosedType.CLOSED, StateChangeType.CHANGED);
+                                break;
+                            case 0x01:
+                            case 0x04:
+                                feature.publish(OpenClosedType.OPEN, StateChangeType.CHANGED);
+                                break;
+                            default: // do nothing
+                                break;
+                        }
+                        break;
+                    case 0x13:
+                        switch (cmd2) {
+                            case 0x04:
+                                feature.publish(OpenClosedType.CLOSED, StateChangeType.CHANGED);
+                                break;
+                            default: // do nothing
+                                break;
+                        }
+                        break;
+                }
+            } catch (FieldException e) {
+                logger.debug("{} no cmd2 found, dropping msg {}", nm(), msg);
+                return;
+            }
+        }
+    }
+
+    public static class ClosedSleepingContactHandler extends LegacyMessageHandler {
+        ClosedSleepingContactHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            feature.publish(OpenClosedType.CLOSED, StateChangeType.ALWAYS);
+            if (f.getDevice().hasProductKey(MOTION_SENSOR_II_PRODUCT_KEY)) {
+                if (!getBooleanDeviceConfig("heartbeatOnly", false)) {
+                    sendExtendedQuery(f, (byte) 0x2e, (byte) 03);
+                }
+            } else {
+                sendExtendedQuery(f, (byte) 0x2e, (byte) 00);
+            }
+        }
+    }
+
+    public static class OpenedSleepingContactHandler extends LegacyMessageHandler {
+        OpenedSleepingContactHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            feature.publish(OpenClosedType.OPEN, StateChangeType.ALWAYS);
+            if (f.getDevice().hasProductKey(MOTION_SENSOR_II_PRODUCT_KEY)) {
+                if (!getBooleanDeviceConfig("heartbeatOnly", false)) {
+                    sendExtendedQuery(f, (byte) 0x2e, (byte) 03);
+                }
+            } else {
+                sendExtendedQuery(f, (byte) 0x2e, (byte) 00);
+            }
+        }
+    }
+
+    /**
+     * Triggers a poll when a message comes in. Use this handler to react
+     * to messages that notify of a status update, but don't carry the information
+     * that you are interested in. Example: you send a command to change a setting,
+     * get a DIRECT ack back, but the ack does not have the value of the updated setting.
+     * Then connect this handler to the ACK, such that the device will be polled, and
+     * the settings updated.
+     */
+    public static class TriggerPollMsgHandler extends LegacyMessageHandler {
+        TriggerPollMsgHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            feature.getDevice().doPoll(2000); // 2000 ms delay
+        }
+    }
+
+    /**
+     * Flexible handler to extract numerical data from messages.
+     */
+    public static class NumberMsgHandler extends LegacyMessageHandler {
+        NumberMsgHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            try {
+                // first do the bit manipulations to focus on the right area
+                int mask = getIntParameter("mask", 0xFFFF);
+                int rawValue = extractValue(msg, group);
+                int cooked = (rawValue & mask) >> getIntParameter("rshift", 0);
+                // now do an arbitrary transform on the data
+                double value = transform(cooked);
+                // last, multiply with factor and add an offset
+                double dvalue = getDoubleParameter("offset", 0) + value * getDoubleParameter("factor", 1.0);
+
+                @Nullable
+                State state;
+                String scale = getStringParameter("scale", null);
+                if ("celsius".equals(scale)) {
+                    state = new QuantityType<>(dvalue, SIUnits.CELSIUS);
+                } else if ("fahrenheit".equals(scale)) {
+                    state = new QuantityType<>(dvalue, ImperialUnits.FAHRENHEIT);
+                } else {
+                    state = new DecimalType(dvalue);
+                }
+                feature.publish(state, StateChangeType.CHANGED);
+            } catch (FieldException e) {
+                logger.warn("error parsing {}: ", msg, e);
+            }
+        }
+
+        public int transform(int raw) {
+            return raw;
+        }
+
+        private int extractValue(Msg msg, int group) throws FieldException {
+            String lowByte = getStringParameter("low_byte", null);
+            if (lowByte == null) {
+                logger.warn("{} handler misconfigured, missing low_byte!", nm());
+                return 0;
+            }
+            int value = 0;
+            if ("group".equals(lowByte)) {
+                value = group;
+            } else {
+                value = msg.getByte(lowByte) & 0xFF;
+            }
+            String highByte = getStringParameter("high_byte", null);
+            if (highByte != null) {
+                value |= (msg.getByte(highByte) & 0xFF) << 8;
+            }
+            return value;
+        }
+    }
+
+    /**
+     * Convert system mode field to number 0...4. Insteon has two different
+     * conventions for numbering, we use the one of the status update messages
+     */
+    public static class ThermostatSystemModeMsgHandler extends NumberMsgHandler {
+        ThermostatSystemModeMsgHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public int transform(int raw) {
+            switch (raw) {
+                case 0:
+                    return 0; // off
+                case 1:
+                    return 3; // auto
+                case 2:
+                    return 1; // heat
+                case 3:
+                    return 2; // cool
+                case 4:
+                    return 4; // program
+                default:
+                    break;
+            }
+            return 4; // when in doubt assume to be in "program" mode
+        }
+    }
+
+    /**
+     * Handle reply to system mode change command
+     */
+    public static class ThermostatSystemModeReplyHandler extends NumberMsgHandler {
+        ThermostatSystemModeReplyHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public int transform(int raw) {
+            switch (raw) {
+                case 0x09:
+                    return 0; // off
+                case 0x04:
+                    return 1; // heat
+                case 0x05:
+                    return 2; // cool
+                case 0x06:
+                    return 3; // auto
+                case 0x0A:
+                    return 4; // program
+                default:
+                    break;
+            }
+            return 4; // when in doubt assume to be in "program" mode
+        }
+    }
+
+    /**
+     * Handle reply to fan mode change command
+     */
+    public static class ThermostatFanModeReplyHandler extends NumberMsgHandler {
+        ThermostatFanModeReplyHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public int transform(int raw) {
+            switch (raw) {
+                case 0x08:
+                    return 0; // auto
+                case 0x07:
+                    return 1; // always on
+                default:
+                    break;
+            }
+            return 0; // when in doubt assume to be auto mode
+        }
+    }
+
+    /**
+     * Handle reply to fanlinc fan speed change command
+     */
+    public static class FanLincFanReplyHandler extends NumberMsgHandler {
+        FanLincFanReplyHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public int transform(int raw) {
+            switch (raw) {
+                case 0x00:
+                    return 0; // off
+                case 0x55:
+                    return 1; // low
+                case 0xAA:
+                    return 2; // medium
+                case 0xFF:
+                    return 3; // high
+                default:
+                    logger.warn("fanlinc got unexpected level: {}", raw);
+            }
+            return 0; // when in doubt assume to be off
+        }
+    }
+
+    /**
+     * Process X10 messages that are generated when another controller
+     * changes the state of an X10 device.
+     */
+    public static class X10OnHandler extends LegacyMessageHandler {
+        X10OnHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            DeviceAddress address = f.getDevice().getAddress();
+            logger.debug("{}: set X10 device {} to ON", nm(), address);
+            feature.publish(OnOffType.ON, StateChangeType.ALWAYS);
+        }
+    }
+
+    public static class X10OffHandler extends LegacyMessageHandler {
+        X10OffHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            DeviceAddress address = f.getDevice().getAddress();
+            logger.debug("{}: set X10 device {} to OFF", nm(), address);
+            feature.publish(OnOffType.OFF, StateChangeType.ALWAYS);
+        }
+    }
+
+    public static class X10BrightHandler extends LegacyMessageHandler {
+        X10BrightHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            DeviceAddress address = f.getDevice().getAddress();
+            logger.debug("{}: ignoring brighten message for device {}", nm(), address);
+        }
+    }
+
+    public static class X10DimHandler extends LegacyMessageHandler {
+        X10DimHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            DeviceAddress address = f.getDevice().getAddress();
+            logger.debug("{}: ignoring dim message for device {}", nm(), address);
+        }
+    }
+
+    public static class X10OpenHandler extends LegacyMessageHandler {
+        X10OpenHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature f) {
+            DeviceAddress address = f.getDevice().getAddress();
+            logger.debug("{}: set X10 device {} to OPEN", nm(), address);
+            feature.publish(OpenClosedType.OPEN, StateChangeType.ALWAYS);
+        }
+    }
+
+    public static class X10ClosedHandler extends LegacyMessageHandler {
+        X10ClosedHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(int group, byte cmd1, Msg msg, LegacyDeviceFeature feature) {
+            DeviceAddress address = feature.getDevice().getAddress();
+            logger.debug("{}: set X10 device {} to CLOSED", nm(), address);
+            feature.publish(OpenClosedType.CLOSED, StateChangeType.ALWAYS);
+        }
+    }
+
+    /**
+     * Factory method for creating handlers of a given name using java reflection
+     *
+     * @param name the name of the handler to create
+     * @param params
+     * @param feature the feature for which to create the handler
+     * @return the handler which was created
+     */
+    public static @Nullable <T extends LegacyMessageHandler> T makeHandler(String name, Map<String, String> params,
+            LegacyDeviceFeature feature) {
+        try {
+            String className = LegacyMessageHandler.class.getName() + "$" + name;
+            @SuppressWarnings("unchecked")
+            Class<? extends T> classRef = (Class<? extends T>) Class.forName(className);
+            @Nullable
+            T handler = classRef.getDeclaredConstructor(LegacyDeviceFeature.class).newInstance(feature);
+            handler.setParameters(params);
+            return handler;
+        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
+                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
+            return null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyPollHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/LegacyPollHandler.java
new file mode 100644 (file)
index 0000000..44ca1ea
--- /dev/null
@@ -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.feature;
+
+import java.lang.reflect.InvocationTargetException;
+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.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.LegacyDevice;
+import org.openhab.binding.insteon.internal.device.LegacyDeviceFeature;
+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.ParameterParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A PollHandler creates an Insteon message to query a particular
+ * DeviceFeature of 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 abstract class LegacyPollHandler {
+    protected final Logger logger = LoggerFactory.getLogger(LegacyPollHandler.class);
+
+    LegacyDeviceFeature feature;
+    Map<String, String> parameters = new HashMap<>();
+
+    /**
+     * Constructor
+     *
+     * @param feature The device feature being polled
+     */
+    LegacyPollHandler(LegacyDeviceFeature feature) {
+        this.feature = feature;
+    }
+
+    /**
+     * Creates Insteon message that can be used to poll a feature
+     * via the Insteon network.
+     *
+     * @param device reference to the insteon device to be polled
+     * @return Insteon query message or null if creation failed
+     */
+    public abstract @Nullable Msg makeMsg(LegacyDevice device);
+
+    public void setParameters(Map<String, String> parameters) {
+        this.parameters = parameters;
+    }
+
+    /**
+     * Returns parameter as integer
+     *
+     * @param key key of parameter
+     * @param def default
+     * @return value of parameter
+     */
+    protected int getIntParameter(String key, int def) {
+        return ParameterParser.getParameterAsOrDefault(parameters.get(key), Integer.class, def);
+    }
+
+    /**
+     * A flexible, parameterized poll handler that can generate
+     * most query messages. Provide the suitable parameters in
+     * the device features file.
+     */
+    public static class FlexPollHandler extends LegacyPollHandler {
+        FlexPollHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public @Nullable Msg makeMsg(LegacyDevice device) {
+            Msg msg = null;
+            int cmd1 = getIntParameter("cmd1", 0);
+            int cmd2 = getIntParameter("cmd2", 0);
+            int ext = getIntParameter("ext", -1);
+            try {
+                if (ext == 1 || ext == 2) {
+                    int d1 = getIntParameter("d1", 0);
+                    int d2 = getIntParameter("d2", 0);
+                    int d3 = getIntParameter("d3", 0);
+                    msg = Msg.makeExtendedMessage((InsteonAddress) device.getAddress(), (byte) cmd1, (byte) cmd2,
+                            new byte[] { (byte) d1, (byte) d2, (byte) d3 }, false);
+                    if (ext == 1) {
+                        msg.setCRC();
+                    } else if (ext == 2) {
+                        msg.setCRC2();
+                    }
+                } else {
+                    msg = Msg.makeStandardMessage((InsteonAddress) device.getAddress(), (byte) cmd1, (byte) cmd2);
+                }
+                msg.setQuietTime(500L);
+            } catch (FieldException e) {
+                logger.warn("error setting field in msg: ", e);
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("invalid message ", e);
+            }
+            return msg;
+        }
+    }
+
+    public static class NoPollHandler extends LegacyPollHandler {
+        NoPollHandler(LegacyDeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public @Nullable Msg makeMsg(LegacyDevice device) {
+            return null;
+        }
+    }
+
+    /**
+     * Factory method for creating handlers of a given name using java reflection
+     *
+     * @param name the name of the handler to create
+     * @param params
+     * @param feature the feature for which to create the handler
+     * @return the handler which was created
+     */
+    @Nullable
+    public static <T extends LegacyPollHandler> T makeHandler(String name, Map<String, String> params,
+            LegacyDeviceFeature feature) {
+        try {
+            String className = LegacyPollHandler.class.getName() + "$" + name;
+            @SuppressWarnings("unchecked")
+            Class<? extends T> classRef = (Class<? extends T>) Class.forName(className);
+            @Nullable
+            T handler = classRef.getDeclaredConstructor(LegacyDeviceFeature.class).newInstance(feature);
+            handler.setParameters(params);
+            return handler;
+        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
+                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
+            return null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/MessageDispatcher.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/MessageDispatcher.java
new file mode 100644 (file)
index 0000000..38fe5e4
--- /dev/null
@@ -0,0 +1,294 @@
+/**
+ * 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.lang.reflect.InvocationTargetException;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+import org.openhab.binding.insteon.internal.transport.message.FieldException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Does preprocessing of messages to decide which handler should be called.
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public abstract class MessageDispatcher extends BaseFeatureHandler {
+    protected final Logger logger = LoggerFactory.getLogger(MessageDispatcher.class);
+
+    public MessageDispatcher(DeviceFeature feature) {
+        super(feature);
+    }
+
+    /**
+     * Handles an incoming broadcast message
+     *
+     * @param msg the message received
+     * @param feature the device feature
+     */
+    protected void handleBroadcastMessage(Msg msg, DeviceFeature feature) throws FieldException {
+        byte cmd1 = msg.isAllLinkSuccessReport() ? msg.getInsteonAddress("toAddress").getHighByte()
+                : msg.getByte("command1");
+        int group = msg.getGroup();
+        MessageHandler handler = feature.getMsgHandler(cmd1, group);
+        if (handler == null) {
+            logger.trace("{}:{} ignoring msg as not for this feature", getDevice().getAddress(), feature.getName());
+        } else if (handler.canHandle(msg)) {
+            logger.debug("{}:{}->{} {} group:{}", getDevice().getAddress(), feature.getName(),
+                    handler.getClass().getSimpleName(), msg.getType(), group != -1 ? group : "N/A");
+            handler.handleMessage(cmd1, msg);
+        }
+    }
+
+    /**
+     * Handles an incoming direct message
+     *
+     * @param msg the message received
+     * @param feature the device feature
+     */
+    protected void handleDirectMessage(Msg msg, DeviceFeature feature) throws FieldException {
+        byte cmd1 = msg.getByte("command1");
+        int group = msg.getGroup();
+        // determine msg handler using cmd 0x19 on DIRECT ACK/NACK reply messages
+        MessageHandler handler = feature.getOrDefaultMsgHandler(msg.isAckOrNackOfDirect() ? 0x19 : cmd1, group);
+        if (handler.canHandle(msg)) {
+            logger.debug("{}:{}->{} {} group:{}", getDevice().getAddress(), feature.getName(),
+                    handler.getClass().getSimpleName(), msg.getType(), group != -1 ? group : "N/A");
+            handler.handleMessage(cmd1, msg);
+        }
+    }
+
+    /**
+     * Handles an incoming im message
+     *
+     * @param msg the message received
+     * @param feature the device feature
+     */
+    protected void handleIMMessage(Msg msg, DeviceFeature feature) throws FieldException {
+        byte cmd = msg.getCommand();
+        MessageHandler handler = feature.getOrDefaultMsgHandler(cmd);
+        logger.debug("{}:{}->{} IM", getDevice().getAddress(), feature.getName(), handler.getClass().getSimpleName());
+        handler.handleMessage(cmd, msg);
+    }
+
+    /**
+     * Dispatches message
+     *
+     * @param msg Message to dispatch
+     * @return true if this message was found to be a reply to a direct message,
+     *         and was claimed by one of the handlers
+     */
+    public abstract boolean dispatch(Msg msg);
+
+    public static class DefaultDispatcher extends MessageDispatcher {
+        DefaultDispatcher(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            try {
+                if (msg.isAllLinkCleanupAckOrNack()) {
+                    // Had cases when a KeypadLinc would send an ALL_LINK_CLEANUP_ACK
+                    // in response to a direct status query message
+                    return false;
+                }
+                if (msg.isAllLinkBroadcastOrCleanup() || msg.isBroadcast()) {
+                    handleBroadcastMessage(msg, feature);
+                    return false;
+                }
+                if (msg.isDirect() || feature.isMyDirectAck(msg)) {
+                    // handle DIRECT and my ACK messages queried by this feature
+                    handleDirectMessage(msg, feature);
+                }
+                return feature.isMyDirectAckOrNack(msg);
+            } catch (FieldException e) {
+                logger.warn("error parsing, dropping msg {}", msg);
+            }
+            return false;
+        }
+    }
+
+    public static class DefaultGroupDispatcher extends MessageDispatcher {
+        DefaultGroupDispatcher(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            try {
+                if (feature.isMyDirectAck(msg)) {
+                    // handle my DIRECT ACK messages queried by this feature
+                    handleDirectMessage(msg, feature);
+                    // get connected features to handle my DIRECT ACK messages
+                    for (DeviceFeature connectedFeature : feature.getConnectedFeatures()) {
+                        handleDirectMessage(msg, connectedFeature);
+                    }
+                }
+                return feature.isMyDirectAckOrNack(msg);
+            } catch (FieldException e) {
+                logger.warn("error parsing, dropping msg {}", msg);
+            }
+            return false;
+        }
+    }
+
+    public static class PollGroupDispatcher extends MessageDispatcher {
+        PollGroupDispatcher(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            if (feature.isMyDirectAckOrNack(msg)) {
+                logger.debug("{}:{} got poll {}", getDevice().getAddress(), feature.getName(), msg.getType());
+                return true;
+            }
+            return false;
+        }
+    }
+
+    public static class PassThroughDispatcher extends MessageDispatcher {
+        PassThroughDispatcher(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            try {
+                byte cmd1 = msg.getByte("command1");
+                int group = msg.getGroup();
+                MessageHandler handler = feature.getDefaultMsgHandler();
+                if (handler.canHandle(msg)) {
+                    logger.debug("{}:{}->{} {} group:{}", getDevice().getAddress(), feature.getName(),
+                            handler.getClass().getSimpleName(), msg.getType(), group != -1 ? group : "N/A");
+                    handler.handleMessage(cmd1, msg);
+                }
+            } catch (FieldException e) {
+                logger.warn("error parsing, dropping msg {}", msg);
+            }
+            return false;
+        }
+    }
+
+    public static class IMDispatcher extends MessageDispatcher {
+        IMDispatcher(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            try {
+                if (feature.isMyReply(msg)) {
+                    if (msg.isReplyAck()) {
+                        handleIMMessage(msg, feature);
+                    }
+                    return true;
+                }
+            } catch (FieldException e) {
+                logger.warn("error parsing, dropping msg {}", msg);
+            }
+            return false;
+        }
+    }
+
+    public static class IMGroupDispatcher extends MessageDispatcher {
+        IMGroupDispatcher(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            try {
+                if (feature.isMyReply(msg)) {
+                    if (msg.isReplyAck()) {
+                        // get connected features to handle my reply ACK messages
+                        for (DeviceFeature connectedFeature : feature.getConnectedFeatures()) {
+                            handleIMMessage(msg, connectedFeature);
+                        }
+                    }
+                    return true;
+                }
+            } catch (FieldException e) {
+                logger.warn("error parsing, dropping msg {}", msg);
+            }
+            return false;
+        }
+    }
+
+    public static class X10Dispatcher extends MessageDispatcher {
+        X10Dispatcher(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            try {
+                byte cmd = msg.getByte("rawX10");
+                MessageHandler handler = feature.getOrDefaultMsgHandler(cmd);
+                logger.debug("{}:{}->{} X10", getX10Device().getAddress(), feature.getName(),
+                        handler.getClass().getSimpleName());
+                handler.handleMessage(cmd, msg);
+            } catch (FieldException e) {
+                logger.warn("error parsing, dropping msg {}", msg);
+            }
+            return false;
+        }
+    }
+
+    /**
+     * Drop all incoming messages silently
+     */
+    public static class NoOpDispatcher extends MessageDispatcher {
+        NoOpDispatcher(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public boolean dispatch(Msg msg) {
+            return false;
+        }
+    }
+
+    /**
+     * Factory method for creating a dispatcher of a given name using java reflection
+     *
+     * @param name the name of the dispatcher to create
+     * @param parameters the parameters of the handler to create
+     * @param feature the feature for which to create the dispatcher
+     * @return the handler which was created
+     */
+    public static @Nullable <T extends MessageDispatcher> T makeHandler(String name, Map<String, String> parameters,
+            DeviceFeature feature) {
+        try {
+            String className = MessageDispatcher.class.getName() + "$" + name;
+            @SuppressWarnings("unchecked")
+            Class<? extends T> classRef = (Class<? extends T>) Class.forName(className);
+            @Nullable
+            T handler = classRef.getDeclaredConstructor(DeviceFeature.class).newInstance(feature);
+            handler.setParameters(parameters);
+            return handler;
+        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
+                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
+            return null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/MessageHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/MessageHandler.java
new file mode 100644 (file)
index 0000000..0cddd0a
--- /dev/null
@@ -0,0 +1,1985 @@
+/**
+ * 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.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Map;
+import java.util.Set;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Dimensionless;
+import javax.measure.quantity.Energy;
+import javax.measure.quantity.Power;
+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.device.DeviceFeature;
+import org.openhab.binding.insteon.internal.device.DeviceType;
+import org.openhab.binding.insteon.internal.device.DeviceTypeRegistry;
+import org.openhab.binding.insteon.internal.device.InsteonEngine;
+import org.openhab.binding.insteon.internal.device.RampRate;
+import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.ButtonEvent;
+import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.FanLincFanSpeed;
+import org.openhab.binding.insteon.internal.device.feature.FeatureEnums.IMButtonEvent;
+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.ThermostatSystemState;
+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.GroupMessageStateMachine.GroupMessageType;
+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.binding.insteon.internal.utils.ParameterParser;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+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.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A message handler processes incoming Insteon messages
+ *
+ * @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 MessageHandler extends BaseFeatureHandler {
+    private static final Set<Integer> SUPPORTED_GROUP_COMMANDS = Set.of(0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
+            0x2E);
+
+    protected final Logger logger = LoggerFactory.getLogger(MessageHandler.class);
+
+    public MessageHandler(DeviceFeature feature) {
+        super(feature);
+    }
+
+    /**
+     * Returns handler id
+     *
+     * @return handler id based on command and group parameters
+     */
+    public String getId() {
+        int command = getParameterAsInteger("command", -1);
+        int group = getGroup();
+        return MessageHandler.generateId(command, group);
+    }
+
+    /**
+     * Returns handler group
+     *
+     * @return handler group based on feature or handler group parameter, if supports group, otherwise -1
+     */
+    public int getGroup() {
+        int command = getParameterAsInteger("command", -1);
+        // return -1 if handler doesn't support groups
+        if (!MessageHandler.supportsGroup(command)) {
+            return -1;
+        }
+        int group = ParameterParser.getParameterAsOrDefault(parameters.get("group"), Integer.class, -1);
+        // return handler group parameter if non-standard
+        if (group > 1) {
+            return group;
+        }
+        // return feature group parameter if defined, otherwise handler group parameter
+        return feature.getParameterAsInteger("group", group);
+    }
+
+    /**
+     * Returns if can handle a given message
+     *
+     * @param msg the message to be handled
+     * @return true if handler not duplicate, valid and matches filter parameters
+     */
+    public boolean canHandle(Msg msg) {
+        if (isDuplicate(msg)) {
+            logger.trace("{}:{} ignoring msg as duplicate", getDevice().getAddress(), feature.getName());
+            return false;
+        } else if (!isValid(msg)) {
+            logger.trace("{}:{} ignoring msg as not valid", getDevice().getAddress(), feature.getName());
+            return false;
+        } else if (!matchesFilters(msg)) {
+            logger.trace("{}:{} ignoring msg as unmatch filters", getDevice().getAddress(), feature.getName());
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Returns if an incoming message is a duplicate
+     *
+     * @param msg the received message
+     * @return true if the broadcast message is a duplicate
+     */
+    protected boolean isDuplicate(Msg msg) {
+        try {
+            if (msg.isAllLinkBroadcastOrCleanup()) {
+                byte cmd1 = msg.getByte("command1");
+                long timestamp = msg.getTimestamp();
+                int group = msg.getGroup();
+                GroupMessageType type = msg.isAllLinkBroadcast() ? GroupMessageType.BCAST : GroupMessageType.CLEAN;
+                if (msg.isAllLinkSuccessReport()) {
+                    cmd1 = msg.getInsteonAddress("toAddress").getHighByte();
+                    type = GroupMessageType.SUCCESS;
+                }
+                return getInsteonDevice().isDuplicateGroupMsg(cmd1, timestamp, group, type);
+            } else if (msg.isBroadcast()) {
+                byte cmd1 = msg.getByte("command1");
+                long timestamp = msg.getTimestamp();
+                return getInsteonDevice().isDuplicateBroadcastMsg(cmd1, timestamp);
+            }
+        } catch (IllegalArgumentException e) {
+            logger.warn("cannot parse msg: {}", msg, e);
+        } catch (FieldException e) {
+            logger.warn("cannot parse msg: {}", msg, e);
+        }
+        return false;
+    }
+
+    /**
+     * Returns if an incoming DIRECT message is valid
+     *
+     * @param msg the received DIRECT message
+     * @return true if this message is valid
+     */
+    protected boolean isValid(Msg msg) {
+        if (msg.isDirect()) {
+            int ext = getParameterAsInteger("ext", -1);
+            // extended message crc is only included in incoming message when using the newer 2-byte method
+            if (ext == 2) {
+                return msg.hasValidCRC2();
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns if message matches the filter parameters
+     *
+     * @param msg message to check
+     * @return true if message matches
+     */
+    protected boolean matchesFilters(Msg msg) {
+        try {
+            int ext = getParameterAsInteger("ext", -1);
+            if (ext != -1) {
+                if ((!msg.isExtended() && ext != 0) || (msg.isExtended() && ext != 1 && ext != 2)) {
+                    return false;
+                }
+                if (!matchesParameter(msg, "command1", "cmd1")) {
+                    return false;
+                }
+            }
+            if (!matchesParameter(msg, "command2", "cmd2")) {
+                return false;
+            }
+            if (!matchesParameter(msg, "userData1", "d1")) {
+                return false;
+            }
+            if (!matchesParameter(msg, "userData2", "d2")) {
+                return false;
+            }
+            if (!matchesParameter(msg, "userData3", "d3")) {
+                return false;
+            }
+        } catch (FieldException e) {
+            logger.warn("error matching message: {}", msg, e);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Returns if parameter matches value
+     *
+     * @param msg message to check
+     * @param field field name to match
+     * @param param name of parameter to match
+     * @return true if parameter matches
+     * @throws FieldException if field not there
+     */
+    private boolean matchesParameter(Msg msg, String field, String param) throws FieldException {
+        int mp = getParameterAsInteger(param, -1);
+        // parameter not filtered for, declare this a match!
+        if (mp == -1) {
+            return true;
+        }
+        byte value = msg.getByte(field);
+        return value == mp;
+    }
+
+    /**
+     * Handles incoming message. The cmd1 parameter
+     * has been extracted earlier already (to make a decision which message handler to call),
+     * and is passed in as an argument so cmd1 does not have to be extracted from the message again.
+     *
+     * @param cmd1 the insteon cmd1 field
+     * @param msg the received insteon message
+     */
+    public abstract void handleMessage(byte cmd1, Msg msg);
+
+    /**
+     * Default message handler
+     */
+    public static class DefaultMsgHandler extends MessageHandler {
+        DefaultMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            if (logger.isDebugEnabled()) {
+                logger.debug("{}: ignoring unimpl message with cmd1 {}", nm(), HexUtils.getHexString(cmd1));
+            }
+        }
+    }
+
+    /**
+     * No-op message handler
+     */
+    public static class NoOpMsgHandler extends MessageHandler {
+        NoOpMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            if (logger.isTraceEnabled()) {
+                logger.trace("{}: ignoring message with cmd1 {}", nm(), HexUtils.getHexString(cmd1));
+            }
+        }
+    }
+
+    /**
+     * Trigger poll message handler
+     */
+    public static class TriggerPollMsgHandler extends MessageHandler {
+        TriggerPollMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            // trigger poll with delay based on parameter, defaulting to 0 ms
+            long delay = getParameterAsLong("delay", 0L);
+            feature.triggerPoll(delay);
+        }
+    }
+
+    /**
+     * Custom state abstract message handler based of parameters
+     */
+    public abstract static class CustomMsgHandler extends MessageHandler {
+        CustomMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            try {
+                // extract raw value from message
+                int raw = getRawValue(msg);
+                // apply mask and right shift bit manipulation
+                int cooked = (raw & getParameterAsInteger("mask", 0xFF)) >> getParameterAsInteger("rshift", 0);
+                // multiply with factor and add offset
+                double value = cooked * getParameterAsDouble("factor", 1.0) + getParameterAsDouble("offset", 0.0);
+                // get state to update
+                State state = getState(cmd1, value);
+                // store extracted cooked message value
+                feature.setLastMsgValue(value);
+                // update state if defined
+                if (state != null) {
+                    logger.debug("{}: device {} {} is {}", nm(), getInsteonDevice().getAddress(), feature.getName(),
+                            state);
+                    feature.updateState(state);
+                }
+            } catch (FieldException e) {
+                logger.warn("{}: error parsing msg {}", nm(), msg, e);
+            }
+        }
+
+        private int getRawValue(Msg msg) throws FieldException {
+            // determine data field name based on parameter, default to cmd2 if is standard message
+            String field = getParameterAsString("field", !msg.isExtended() ? "command2" : "");
+            if (field.isEmpty()) {
+                throw new FieldException("handler misconfigured, no field parameter specified!");
+            }
+            if (field.startsWith("address") && !msg.isBroadcast() && !msg.isAllLinkBroadcast()) {
+                throw new FieldException("not broadcast msg, cannot use address bytes!");
+            }
+            // return raw value based on field name
+            switch (field) {
+                case "group":
+                    return msg.getGroup();
+                case "addressHighByte":
+                    // return broadcast address high byte value
+                    return msg.getInsteonAddress("toAddress").getHighByte() & 0xFF;
+                case "addressMiddleByte":
+                    // return broadcast address middle byte value
+                    return msg.getInsteonAddress("toAddress").getMiddleByte() & 0xFF;
+                case "addressLowByte":
+                    // return broadcast address low byte value
+                    return msg.getInsteonAddress("toAddress").getLowByte() & 0xFF;
+                default:
+                    // return integer value starting from field name up to 4-bytes in size based on parameter
+                    return msg.getInt(field, getParameterAsInteger("num_bytes", 1));
+            }
+        }
+
+        protected abstract @Nullable State getState(byte cmd1, double value);
+    }
+
+    /**
+     * Custom bitmask message handler based of parameters
+     */
+    public static class CustomBitmaskMsgHandler extends CustomMsgHandler {
+        CustomBitmaskMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            State state = null;
+            // get bit number based on parameter
+            int bit = getBitNumber();
+            // get bit state from bitmask value, if bit defined
+            if (bit != -1) {
+                boolean isSet = BinaryUtils.isBitSet((int) value, bit);
+                state = getBitState(isSet);
+            } else {
+                logger.debug("{}: invalid bit number defined for {}", nm(), feature.getName());
+            }
+            return state;
+        }
+
+        protected int getBitNumber() {
+            int bit = getParameterAsInteger("bit", -1);
+            // return bit if valid (0-7), otherwise -1
+            return bit >= 0 && bit <= 7 ? bit : -1;
+        }
+
+        protected State getBitState(boolean isSet) {
+            return OnOffType.from(isSet ^ getParameterAsBoolean("inverted", false));
+        }
+    }
+
+    /**
+     * Custom cache message handler based of parameters
+     */
+    public static class CustomCacheMsgHandler extends CustomMsgHandler {
+        CustomCacheMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            // only cache extracted message value
+            // mostly used for hidden features which are used by others
+            return null;
+        }
+    }
+
+    /**
+     * Custom decimal type message handler based of parameters
+     */
+    public static class CustomDecimalMsgHandler extends CustomMsgHandler {
+        CustomDecimalMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return new DecimalType(value);
+        }
+    }
+
+    /**
+     * Custom on/off type message handler based of parameters
+     */
+    public static class CustomOnOffMsgHandler extends CustomMsgHandler {
+        CustomOnOffMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int onLevel = getParameterAsInteger("on", 0xFF);
+            int offLevel = getParameterAsInteger("off", 0x00);
+            return value == onLevel ? OnOffType.ON : value == offLevel ? OnOffType.OFF : null;
+        }
+    }
+
+    /**
+     * Custom percent type message handler based of parameters
+     */
+    public static class CustomPercentMsgHandler extends CustomMsgHandler {
+        CustomPercentMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int minValue = getParameterAsInteger("min", 0x00);
+            int maxValue = getParameterAsInteger("max", 0xFF);
+            double clampValue = Math.max(minValue, Math.min(maxValue, value));
+            int level = (int) Math.round((clampValue - minValue) / (maxValue - minValue) * 100);
+            return new PercentType(level);
+        }
+    }
+
+    /**
+     * Custom dimensionless quantity type message handler based of parameters
+     */
+    public static class CustomDimensionlessMsgHandler extends CustomMsgHandler {
+        CustomDimensionlessMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int minValue = getParameterAsInteger("min", 0);
+            int maxValue = getParameterAsInteger("max", 100);
+            double clampValue = Math.max(minValue, Math.min(maxValue, value));
+            int level = (int) Math.round((clampValue - minValue) * 100 / (maxValue - minValue));
+            return new QuantityType<Dimensionless>(level, Units.PERCENT);
+        }
+    }
+
+    /**
+     * Custom temperature quantity type message handler based of parameters
+     */
+    public static class CustomTemperatureMsgHandler extends CustomMsgHandler {
+        CustomTemperatureMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            Unit<Temperature> unit = getTemperatureUnit();
+            return new QuantityType<Temperature>(value, unit);
+        }
+
+        protected Unit<Temperature> 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 message handler based of parameters
+     */
+    public static class CustomTimeMsgHandler extends CustomMsgHandler {
+        CustomTimeMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            Unit<Time> unit = getTimeUnit();
+            return new QuantityType<Time>(value, unit);
+        }
+
+        protected Unit<Time> getTimeUnit() {
+            String scale = getParameterAsString("scale", "");
+            switch (scale) {
+                case "hour":
+                    return Units.HOUR;
+                case "minute":
+                    return Units.MINUTE;
+                case "second":
+                    return Units.SECOND;
+                default:
+                    logger.debug("{}: no valid time scale parameter found, defaulting to: SECONDS", nm());
+                    return Units.SECOND;
+            }
+        }
+    }
+
+    /**
+     * Database delta reply message handler
+     */
+    public static class DatabaseDeltaReplyHandler extends MessageHandler {
+        DatabaseDeltaReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            try {
+                int delta = msg.getInt("command2");
+                // update link db delta
+                getInsteonDevice().getLinkDB().updateDatabaseDelta(delta);
+            } catch (FieldException e) {
+                logger.warn("{}: error parsing msg: {}", nm(), msg, e);
+            }
+        }
+    }
+
+    /**
+     * Insteon engine reply message handler
+     */
+    public static class InsteonEngineReplyHandler extends MessageHandler {
+        InsteonEngineReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            try {
+                int version = msg.getInt("command2");
+                InsteonEngine engine = InsteonEngine.valueOf(version);
+                // set device insteon engine
+                getInsteonDevice().setInsteonEngine(engine);
+                // continue device polling
+                getInsteonDevice().doPoll(0L);
+            } catch (FieldException e) {
+                logger.warn("{}: error parsing msg: {}", nm(), msg, e);
+            }
+        }
+    }
+
+    /**
+     * Ping reply message handler
+     */
+    public static class PingReplyHandler extends MessageHandler {
+        PingReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            logger.debug("{}: successfully pinged device {}", nm(), getInsteonDevice().getAddress());
+        }
+    }
+
+    /**
+     * Heartbeat monitor message handler
+     */
+    public static class HeartbeatMonitorMsgHandler extends MessageHandler {
+        HeartbeatMonitorMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            // reset device heartbeat monitor on all link broadcast or cleanup message not replayed
+            if (msg.isAllLinkBroadcastOrCleanup() && !msg.isReplayed()) {
+                getInsteonDevice().resetHeartbeatMonitor();
+            }
+        }
+    }
+
+    /**
+     * Last time message handler
+     */
+    public static class LastTimeMsgHandler extends MessageHandler {
+        LastTimeMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            Instant instant = Instant.ofEpochMilli(msg.getTimestamp());
+            ZonedDateTime timestamp = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault());
+            ZonedDateTime lastTimestamp = getLastTimestamp();
+            // set last time if not defined yet or message timestamp is greater than last value
+            if (lastTimestamp == null || timestamp.compareTo(lastTimestamp) > 0) {
+                feature.updateState(new DateTimeType(timestamp));
+            }
+        }
+
+        private @Nullable ZonedDateTime getLastTimestamp() {
+            State state = feature.getState();
+            return state instanceof DateTimeType datetime ? datetime.getZonedDateTime() : null;
+        }
+    }
+
+    /**
+     * Button event message handler
+     */
+    public static class ButtonEventMsgHandler extends MessageHandler {
+        ButtonEventMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected boolean isDuplicate(Msg msg) {
+            // Disable duplicate elimination based on parameter because
+            // some button events such as hold or release have no cleanup or success messages.
+            return getParameterAsBoolean("duplicate", super.isDuplicate(msg));
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            try {
+                byte cmd2 = msg.getByte("command2");
+                ButtonEvent event = ButtonEvent.valueOf(cmd1, cmd2);
+                logger.debug("{}: device {} {} received event {}", nm(), getInsteonDevice().getAddress(),
+                        feature.getName(), event);
+                feature.triggerEvent(event.toString());
+                feature.pollRelatedDevices(0L);
+            } catch (FieldException e) {
+                logger.warn("{}: error parsing msg: {}", nm(), msg, e);
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected button event: {}", nm(), HexUtils.getHexString(cmd1));
+            }
+        }
+    }
+
+    /**
+     * Status request reply message handler
+     */
+    public static class StatusRequestReplyHandler extends CustomMsgHandler {
+        StatusRequestReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            // update link db delta if is my request status reply message (0x19)
+            if (feature.getQueryCommand() == 0x19) {
+                getInsteonDevice().getLinkDB().updateDatabaseDelta(cmd1 & 0xFF);
+            }
+            super.handleMessage(cmd1, msg);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return null;
+        }
+    }
+
+    /**
+     * On/Off abstract message handler
+     */
+    public abstract static class OnOffMsgHandler extends MessageHandler {
+        OnOffMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            String mode = getParameterAsString("mode", "REGULAR");
+            State state = getState(mode);
+            if (state != null) {
+                logger.debug("{}: device {} is {} ({})", nm(), getInsteonDevice().getAddress(), state, mode);
+                feature.updateState(state);
+            }
+        }
+
+        protected abstract @Nullable State getState(String mode);
+    }
+
+    /**
+     * Dimmer on message handler
+     */
+    public static class DimmerOnMsgHandler extends OnOffMsgHandler {
+        DimmerOnMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(String mode) {
+            switch (mode) {
+                case "FAST":
+                    // set to 100% for fast on change
+                    return PercentType.HUNDRED;
+                default:
+                    // set to device on level if the current state not at that level already, defaulting to 100%
+                    // this is due to subsequent dimmer on button press cycling between on level and 100%
+                    State onLevel = getInsteonDevice().getFeatureState(FEATURE_ON_LEVEL);
+                    State state = feature.getState();
+                    return onLevel instanceof PercentType && !state.equals(onLevel) ? onLevel : PercentType.HUNDRED;
+            }
+        }
+    }
+
+    /**
+     * Dimmer off message handler
+     */
+    public static class DimmerOffMsgHandler extends OnOffMsgHandler {
+        DimmerOffMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(String mode) {
+            return PercentType.ZERO;
+        }
+    }
+
+    /**
+     * Dimmer request reply message handler
+     */
+    public static class DimmerRequestReplyHandler extends StatusRequestReplyHandler {
+        DimmerRequestReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            int queryCmd = feature.getQueryCommand();
+            // 1) trigger poll if is my bright/dim or manual change stop command reply
+            // 2) handle fast on/off message if is my fast on/off command reply
+            // 3) handle ramp dimmer message if is my ramp rate on/off command reply
+            // 4) handle my standard/instant on/off command reply ignoring manual change start messages
+            if (queryCmd == 0x15 || queryCmd == 0x16 || queryCmd == 0x18) {
+                feature.triggerPoll(0L);
+            } else if (queryCmd == 0x12 || queryCmd == 0x14) {
+                handleFastOnOffMessage(cmd1, msg);
+            } else if (queryCmd == 0x2E || queryCmd == 0x2F || queryCmd == 0x34 || queryCmd == 0x35) {
+                handleRampDimmerMessage(cmd1, msg);
+            } else if (queryCmd != 0x17) {
+                super.handleMessage(cmd1, msg);
+            }
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int level = (int) Math.round(value * 100 / 255.0);
+            return new PercentType(level);
+        }
+
+        private void handleFastOnOffMessage(byte cmd1, Msg msg) {
+            FastOnOffMsgHandler handler = new FastOnOffMsgHandler(feature);
+            handler.setParameters(parameters);
+            handler.handleMessage(cmd1, msg);
+        }
+
+        private void handleRampDimmerMessage(byte cmd1, Msg msg) {
+            RampDimmerMsgHandler handler = new RampDimmerMsgHandler(feature);
+            handler.setParameters(parameters);
+            handler.handleMessage(cmd1, msg);
+        }
+    }
+
+    /**
+     * Fast on/off message handler
+     */
+    public static class FastOnOffMsgHandler extends CustomMsgHandler {
+        FastOnOffMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            switch (cmd1) {
+                case 0x14:
+                    return PercentType.ZERO;
+                case 0x12:
+                    return PercentType.HUNDRED;
+                default:
+                    logger.warn("{}: got unexpected command value: {}", nm(), HexUtils.getHexString(cmd1));
+                    return null;
+            }
+        }
+    }
+
+    /**
+     * Ramp dimmer message handler
+     */
+    public static class RampDimmerMsgHandler extends CustomMsgHandler {
+        RampDimmerMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            switch (cmd1) {
+                case 0x2F:
+                case 0x35:
+                    return PercentType.ZERO;
+                case 0x2E:
+                case 0x34:
+                    int highByte = ((int) value) >> 4;
+                    int level = (int) Math.round((highByte * 16 + 0x0F) * 100 / 255.0);
+                    return new PercentType(level);
+                default:
+                    logger.warn("{}: got unexpected command value: {}", nm(), HexUtils.getHexString(cmd1));
+                    return null;
+            }
+        }
+    }
+
+    /**
+     * Switch on message handler
+     */
+    public static class SwitchOnMsgHandler extends OnOffMsgHandler {
+        SwitchOnMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(String mode) {
+            return OnOffType.ON;
+        }
+    }
+
+    /**
+     * Switch off message handler
+     */
+    public static class SwitchOffMsgHandler extends OnOffMsgHandler {
+        SwitchOffMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(String mode) {
+            return OnOffType.OFF;
+        }
+    }
+
+    /**
+     * Switch request reply message handler
+     */
+    public static class SwitchRequestReplyHandler extends StatusRequestReplyHandler {
+        SwitchRequestReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int level = (int) value;
+            State state = null;
+            if (level == 0x00 || level == 0xFF) {
+                state = OnOffType.from(level == 0xFF);
+            } else {
+                logger.warn("{}: ignoring unexpected level received {}", nm(), HexUtils.getHexString(level));
+            }
+            return state;
+        }
+    }
+
+    /**
+     * Keypad button on message handler
+     */
+    public static class KeypadButtonOnMsgHandler extends SwitchOnMsgHandler {
+        KeypadButtonOnMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            super.handleMessage(cmd1, msg);
+            // trigger poll to account for button group changes
+            feature.triggerPoll(0L);
+        }
+    }
+
+    /**
+     * Keypad button off message handler
+     */
+    public static class KeypadButtonOffMsgHandler extends SwitchOffMsgHandler {
+        KeypadButtonOffMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            super.handleMessage(cmd1, msg);
+            // trigger poll to account for button group changes
+            feature.triggerPoll(0L);
+        }
+    }
+
+    /**
+     * Keypad button reply message handler
+     */
+    public static class KeypadButtonReplyHandler extends CustomBitmaskMsgHandler {
+        KeypadButtonReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            // trigger poll if is my command reply message (0x2E)
+            if (feature.getQueryCommand() == 0x2E) {
+                feature.triggerPoll(0L);
+            } else {
+                super.handleMessage(cmd1, msg);
+            }
+        }
+
+        @Override
+        protected int getBitNumber() {
+            int bit = feature.getGroup() - 1;
+            // return bit if representing keypad button 2-8, otherwise -1
+            return bit >= 1 && bit <= 7 ? bit : -1;
+        }
+    }
+
+    /**
+     * Keypad button toggle mode message handler
+     */
+    public static class KeypadButtonToggleModeMsgHandler extends MessageHandler {
+        KeypadButtonToggleModeMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            try {
+                int bit = feature.getGroup() - 1;
+                if (bit < 0 || bit > 7) {
+                    logger.debug("{}: invalid bit number defined for {}", nm(), feature.getName());
+                } else {
+                    int value = msg.getByte("userData10") << 8 | msg.getByte("userData13");
+                    KeypadButtonToggleMode mode = KeypadButtonToggleMode.valueOf(value, bit);
+                    logger.debug("{}: device {} {} is {}", nm(), getInsteonDevice().getAddress(), feature.getName(),
+                            mode);
+                    feature.setLastMsgValue(value);
+                    feature.updateState(new StringType(mode.toString()));
+                }
+            } catch (FieldException e) {
+                logger.warn("{}: error parsing msg: {}", nm(), msg, e);
+            }
+        }
+    }
+
+    /**
+     * Operating flags reply message handler
+     */
+    public static class OpFlagsReplyHandler extends CustomBitmaskMsgHandler {
+        OpFlagsReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            // trigger poll if is my command reply message (0x20)
+            if (feature.getQueryCommand() == 0x20) {
+                feature.triggerPoll(0L);
+            } else {
+                super.handleMessage(cmd1, msg);
+            }
+        }
+    }
+
+    /**
+     * Link operating flags reply message handler
+     */
+    public static class LinkOpFlagsReplyHandler extends OpFlagsReplyHandler {
+        LinkOpFlagsReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            super.handleMessage(cmd1, msg);
+            // update default links
+            getInsteonDevice().updateDefaultLinks();
+        }
+    }
+
+    /**
+     * Heartbeat on/off operating flag reply message handler
+     */
+    public static class HeartbeatOnOffReplyHandler extends OpFlagsReplyHandler {
+        HeartbeatOnOffReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            super.handleMessage(cmd1, msg);
+            // reset device heartbeat monitor
+            getInsteonDevice().resetHeartbeatMonitor();
+        }
+    }
+
+    /**
+     * Keypad button config operating flag reply message handler
+     */
+    public static class KeypadButtonConfigReplyHandler extends OpFlagsReplyHandler {
+        KeypadButtonConfigReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected State getBitState(boolean is8Button) {
+            KeypadButtonConfig config = KeypadButtonConfig.from(is8Button);
+            // update device type based on button count
+            updateDeviceType(config.getCount());
+            // return button config state
+            return new StringType(config.toString());
+        }
+
+        private void updateDeviceType(int buttonCount) {
+            DeviceType deviceType = getInsteonDevice().getType();
+            if (deviceType == null) {
+                logger.warn("{}: unknown device type for {}", nm(), getInsteonDevice().getAddress());
+            } else {
+                String name = deviceType.getName().replaceAll(".$", String.valueOf(buttonCount));
+                DeviceType newType = DeviceTypeRegistry.getInstance().getDeviceType(name);
+                if (newType == null) {
+                    logger.warn("{}: unknown device type {}", nm(), name);
+                } else {
+                    getInsteonDevice().updateType(newType);
+                }
+            }
+        }
+    }
+
+    /**
+     * LED brightness message handler
+     */
+    public static class LEDBrightnessMsgHandler extends CustomMsgHandler {
+        LEDBrightnessMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int level = (int) Math.round(value * 100 / 127.0);
+            State state = getInsteonDevice().getFeatureState(FEATURE_LED_ON_OFF);
+            return OnOffType.OFF.equals(state) ? PercentType.ZERO : new PercentType(level);
+        }
+    }
+
+    /**
+     * Ramp rate message handler
+     */
+    public static class RampRateMsgHandler extends CustomMsgHandler {
+        RampRateMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            RampRate rampRate = RampRate.valueOf((int) value);
+            return new QuantityType<Time>(rampRate.getTimeInSeconds(), Units.SECOND);
+        }
+    }
+
+    /**
+     * Sensor abstract message handler
+     */
+    public abstract static class SensorMsgHandler extends CustomMsgHandler {
+        SensorMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            super.handleMessage(cmd1, msg);
+            // poll battery powered sensor device while awake
+            if (getInsteonDevice().isBatteryPowered()) {
+                // no delay for all link cleanup, all link success report or replayed messages
+                // otherise, 1500ms for all link broadcast message allowing cleanup msg to be be processed beforehand
+                long delay = msg.isAllLinkCleanup() || msg.isAllLinkSuccessReport() || msg.isReplayed() ? 0L : 1500L;
+                getInsteonDevice().doPoll(delay);
+            }
+            // poll related devices
+            feature.pollRelatedDevices(0L);
+        }
+    }
+
+    /**
+     * Contact open message handler
+     */
+    public static class ContactOpenMsgHandler extends SensorMsgHandler {
+        ContactOpenMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return OpenClosedType.OPEN;
+        }
+    }
+
+    /**
+     * Contact closed message handler
+     */
+    public static class ContactClosedMsgHandler extends SensorMsgHandler {
+        ContactClosedMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return OpenClosedType.CLOSED;
+        }
+    }
+
+    /**
+     * Contact request reply message handler
+     */
+    public static class ContactRequestReplyHandler extends StatusRequestReplyHandler {
+        ContactRequestReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return value == 0 ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
+        }
+    }
+
+    /**
+     * Wireless sensor open message handler
+     */
+    public static class WirelessSensorOpenMsgHandler extends SensorMsgHandler {
+        WirelessSensorOpenMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return OpenClosedType.OPEN;
+        }
+    }
+
+    /**
+     * Wireless sensor closed message handler
+     */
+    public static class WirelessSensorClosedMsgHandler extends SensorMsgHandler {
+        WirelessSensorClosedMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return OpenClosedType.CLOSED;
+        }
+    }
+
+    /**
+     * Wireless sensor on message handler
+     */
+    public static class WirelessSensorOnMsgHandler extends SensorMsgHandler {
+        WirelessSensorOnMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return OnOffType.ON;
+        }
+    }
+
+    /**
+     * Wireless sensor off message handler
+     */
+    public static class WirelessSensorOffMsgHandler extends SensorMsgHandler {
+        WirelessSensorOffMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return OnOffType.OFF;
+        }
+    }
+
+    /**
+     * Motion sensor 2 battery powered reply message handler
+     */
+    public static class MotionSensor2BatteryPoweredReplyHandler extends CustomMsgHandler {
+        MotionSensor2BatteryPoweredReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            // stage flag bit 1 = USB Powered
+            boolean isBatteryPowered = !BinaryUtils.isBitSet((int) value, 1);
+            // update device based on battery powered flag
+            updateDeviceFlag(isBatteryPowered);
+            // return battery powered state
+            return OnOffType.from(isBatteryPowered);
+        }
+
+        private void updateDeviceFlag(boolean isBatteryPowered) {
+            // update device batteryPowered flag
+            getInsteonDevice().setFlag("batteryPowered", isBatteryPowered);
+            // stop device polling if battery powered, otherwise start it
+            if (isBatteryPowered) {
+                getInsteonDevice().stopPolling();
+            } else {
+                getInsteonDevice().startPolling();
+            }
+        }
+    }
+
+    /**
+     * Motion sensor 2 temperature message handler
+     */
+    public static class MotionSensor2TemperatureMsgHandler extends CustomMsgHandler {
+        MotionSensor2TemperatureMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            boolean isBatteryPowered = getInsteonDevice().isBatteryPowered();
+            // temperature (°F) = 0.73 * value - 20.53 (battery powered); 0.72 * value - 24.61 (usb powered)
+            double temperature = isBatteryPowered ? 0.73 * value - 20.53 : 0.72 * value - 24.61;
+            return new QuantityType<Temperature>(temperature, ImperialUnits.FAHRENHEIT);
+        }
+    }
+
+    /**
+     * Heartbeat interval message handler
+     */
+    public static class HeartbeatIntervalMsgHandler extends CustomMsgHandler {
+        HeartbeatIntervalMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            super.handleMessage(cmd1, msg);
+            // reset device heartbeat monitor
+            getInsteonDevice().resetHeartbeatMonitor();
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int interval = getInterval((int) value);
+            return interval > 0 ? new QuantityType<Time>(interval, Units.MINUTE) : null;
+        }
+
+        private int getInterval(int value) {
+            int preset = getParameterAsInteger("preset", 0);
+            int increment = getParameterAsInteger("increment", 0);
+            return value == 0x00 ? preset : value * increment;
+        }
+    }
+
+    /**
+     * FanLinc fan mode reply message handler
+     */
+    public static class FanLincFanReplyHandler extends CustomMsgHandler {
+        FanLincFanReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            try {
+                FanLincFanSpeed speed = FanLincFanSpeed.valueOf((int) value);
+                return new StringType(speed.toString());
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected fan speed reply value: {}", nm(), HexUtils.getHexString((int) value));
+                return UnDefType.UNDEF;
+            }
+        }
+    }
+
+    /**
+     * I/O linc momentary duration message handler
+     */
+    public static class IOLincMomentaryDurationMsgHandler extends CustomMsgHandler {
+        IOLincMomentaryDurationMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int duration = getDuration((int) value);
+            return new QuantityType<Time>(duration, Units.SECOND);
+        }
+
+        private int getDuration(int value) {
+            int prescaler = value >> 8; // high byte
+            int delay = value & 0xFF; // low byte
+            if (delay == 0) {
+                delay = 255;
+            }
+            return delay * prescaler / 10;
+        }
+    }
+
+    /**
+     * I/O linc relay mode reply message handler
+     */
+    public static class IOLincRelayModeReplyHandler extends CustomMsgHandler {
+        IOLincRelayModeReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            // trigger poll if is my command reply message (0x20)
+            if (feature.getQueryCommand() == 0x20) {
+                feature.triggerPoll(5000L); // 5000ms delay to allow all op flag commands to be processed
+            } else {
+                super.handleMessage(cmd1, msg);
+            }
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            IOLincRelayMode mode = IOLincRelayMode.valueOf((int) value);
+            return new StringType(mode.toString());
+        }
+    }
+
+    /**
+     * Micro module operation mode reply message handler
+     */
+    public static class MicroModuleOpModeReplyHandler extends CustomMsgHandler {
+        MicroModuleOpModeReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            // trigger poll if is my command reply message (0x20)
+            if (feature.getQueryCommand() == 0x20) {
+                feature.triggerPoll(2000L); // 2000ms delay to allow all op flag commands to be processed
+            } else {
+                super.handleMessage(cmd1, msg);
+            }
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            MicroModuleOpMode mode = MicroModuleOpMode.valueOf((int) value);
+            return new StringType(mode.toString());
+        }
+    }
+
+    /**
+     * Outlet switch reply message handler
+     *
+     * 0x00 = Both Outlets Off
+     * 0x01 = Only Top Outlet On
+     * 0x02 = Only Bottom Outlet On
+     * 0x03 = Both Outlets On
+     */
+    public static class OutletSwitchReplyHandler extends CustomMsgHandler {
+        OutletSwitchReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return OnOffType.from(value == feature.getGroup() || value == 0x03);
+        }
+    }
+
+    /**
+     * Power meter energy message handler
+     */
+    public static class PowerMeterEnergyMsgHandler extends CustomMsgHandler {
+        PowerMeterEnergyMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            BigDecimal energy = getEnergy((int) value);
+            return new QuantityType<Energy>(energy, Units.KILOWATT_HOUR);
+        }
+
+        private BigDecimal getEnergy(int value) {
+            return (value >> 24) < 254
+                    ? new BigDecimal(value * 65535.0 / (1000 * 60 * 60 * 60)).setScale(4, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+        }
+    }
+
+    /**
+     * Power meter power message handler
+     */
+    public static class PowerMeterPowerMsgHandler extends CustomMsgHandler {
+        PowerMeterPowerMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int power = getPower((int) value);
+            return new QuantityType<Power>(power, Units.WATT);
+        }
+
+        private int getPower(int power) {
+            return power > 32767 ? power - 65535 : power;
+        }
+    }
+
+    /**
+     * Siren request reply message handler
+     */
+    public static class SirenRequesteplyHandler extends StatusRequestReplyHandler {
+        SirenRequesteplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int level = (int) value;
+            return OnOffType.from(level != 0x00);
+        }
+    }
+
+    /**
+     * Siren armed reply message handler
+     */
+    public static class SirenArmedReplyHandler extends CustomMsgHandler {
+        SirenArmedReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            boolean isArmed = BinaryUtils.isBitSet((int) value, 6) || BinaryUtils.isBitSet((int) value, 7);
+            return OnOffType.from(isArmed);
+        }
+    }
+
+    /**
+     * Siren alert type message handler
+     */
+    public static class SirenAlertTypeMsgHandler extends CustomMsgHandler {
+        SirenAlertTypeMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            try {
+                SirenAlertType type = SirenAlertType.valueOf((int) value);
+                return new StringType(type.toString());
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected alert type value: {}", nm(), (int) value);
+                return UnDefType.UNDEF;
+            }
+        }
+    }
+
+    /**
+     * Sprinkler valve message handler
+     */
+    public static class SprinklerValveMsgHandler extends CustomMsgHandler {
+        SprinklerValveMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int valve = getParameterAsInteger("valve", -1);
+            if (valve < 0 || valve > 8) {
+                logger.debug("{}: invalid valve number defined for {}", nm(), feature.getName());
+                return UnDefType.UNDEF;
+            }
+            boolean isValveOn = BinaryUtils.isBitSet((int) value, 7) && (((int) value) & 0x07) == valve
+                    || BinaryUtils.isBitSet((int) value, 6) && valve == 7;
+            return OnOffType.from(isValveOn);
+        }
+    }
+
+    /**
+     * Sprinkler program message handler
+     */
+    public static class SprinklerProgramMsgHandler extends CustomMsgHandler {
+        SprinklerProgramMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            int program = getParameterAsInteger("program", -1);
+            if (program < 0 || program > 4) {
+                logger.debug("{}: invalid program number defined for {}", nm(), feature.getName());
+                return UnDefType.UNDEF;
+            }
+            boolean isProgramOn = BinaryUtils.isBitSet((int) value, 5) && (((int) value) & 0x18) >> 3 == program;
+            return OnOffType.from(isProgramOn);
+        }
+    }
+
+    /**
+     * Thermostat fan mode message handler
+     */
+    public static class ThermostatFanModeMsgHandler extends CustomMsgHandler {
+        ThermostatFanModeMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            try {
+                ThermostatFanMode mode = ThermostatFanMode.fromStatus((int) value);
+                return new StringType(mode.toString());
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected fan mode status: {}", nm(), HexUtils.getHexString((int) value));
+                return UnDefType.UNDEF;
+            }
+        }
+    }
+
+    /**
+     * Thermostat fan mode reply message handler
+     */
+    public static class ThermostatFanModeReplyHandler extends CustomMsgHandler {
+        ThermostatFanModeReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            try {
+                ThermostatFanMode mode = ThermostatFanMode.valueOf((int) value);
+                return new StringType(mode.toString());
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected fan mode reply: {}", nm(), HexUtils.getHexString((int) value));
+                return UnDefType.UNDEF;
+            }
+        }
+    }
+
+    /**
+     * Thermostat humidifier dehumidifying message handler
+     */
+    public static class ThermostatHumidifierDehumidifyingMsgHandler extends CustomMsgHandler {
+        ThermostatHumidifierDehumidifyingMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return new StringType(ThermostatSystemState.DEHUMIDIFYING.toString());
+        }
+    }
+
+    /**
+     * Thermostat humidifier humidifying message handler
+     */
+    public static class ThermostatHumidifierHumidifyingMsgHandler extends CustomMsgHandler {
+        ThermostatHumidifierHumidifyingMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return new StringType(ThermostatSystemState.HUMIDIFYING.toString());
+        }
+    }
+
+    /**
+     * Thermostat humidifier off message handler
+     */
+    public static class ThermostatHumidifierOffMsgHandler extends CustomMsgHandler {
+        ThermostatHumidifierOffMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return new StringType(ThermostatSystemState.OFF.toString());
+        }
+    }
+
+    /**
+     * Termostat system mode message handler
+     */
+    public static class ThermostatSystemModeMsgHandler extends CustomMsgHandler {
+        ThermostatSystemModeMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            try {
+                ThermostatSystemMode mode = ThermostatSystemMode.fromStatus((int) value);
+                return new StringType(mode.toString());
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected system mode status: {}", nm(), HexUtils.getHexString((int) value));
+                return UnDefType.UNDEF;
+            }
+        }
+    }
+
+    /**
+     * Thermostat system mode reply message handler
+     */
+    public static class ThermostatSystemModeReplyHandler extends CustomMsgHandler {
+        ThermostatSystemModeReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            try {
+                ThermostatSystemMode mode = ThermostatSystemMode.valueOf((int) value);
+                return new StringType(mode.toString());
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected system mode reply: {}", nm(), HexUtils.getHexString((int) value));
+                return UnDefType.UNDEF;
+            }
+        }
+    }
+
+    /**
+     * Thermostat system cooling message handler
+     */
+    public static class ThermostatSystemCoolingMsgHandler extends CustomMsgHandler {
+        ThermostatSystemCoolingMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return new StringType(ThermostatSystemState.COOLING.toString());
+        }
+    }
+
+    /**
+     * Thermostat system heating message handler
+     */
+    public static class ThermostatSystemHeatingMsgHandler extends CustomMsgHandler {
+        ThermostatSystemHeatingMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return new StringType(ThermostatSystemState.HEATING.toString());
+        }
+    }
+
+    /**
+     * Thermostat system off message handler
+     */
+    public static class ThermostatSystemOffMsgHandler extends CustomMsgHandler {
+        ThermostatSystemOffMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            return new StringType(ThermostatSystemState.OFF.toString());
+        }
+    }
+
+    /**
+     * Thermostat temperature scale message handler
+     */
+    public static class ThermostatTemperatureScaleMsgHandler extends CustomBitmaskMsgHandler {
+        ThermostatTemperatureScaleMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected State getBitState(boolean isCelsius) {
+            ThermostatTemperatureScale format = ThermostatTemperatureScale.from(isCelsius);
+            return new StringType(format.toString());
+        }
+    }
+
+    /**
+     * Thermostat time format message handler
+     */
+    public static class ThermostatTimeFormatMsgHandler extends CustomBitmaskMsgHandler {
+        ThermostatTimeFormatMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected State getBitState(boolean is24Hr) {
+            ThermostatTimeFormat format = ThermostatTimeFormat.from(is24Hr);
+            return new StringType(format.toString());
+        }
+    }
+
+    /**
+     * Venstar thermostat system mode message handler
+     */
+    public static class VenstarSystemModeMsgHandler extends CustomMsgHandler {
+        VenstarSystemModeMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            try {
+                VenstarSystemMode mode = VenstarSystemMode.fromStatus((int) value);
+                return new StringType(mode.toString());
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected system mode status: {}", nm(), HexUtils.getHexString((int) value));
+                return UnDefType.UNDEF;
+            }
+        }
+    }
+
+    /**
+     * Venstar thermostat system mode message handler
+     */
+    public static class VenstarSystemModeReplyHandler extends CustomMsgHandler {
+        VenstarSystemModeReplyHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected @Nullable State getState(byte cmd1, double value) {
+            try {
+                VenstarSystemMode mode = VenstarSystemMode.valueOf((int) value);
+                return new StringType(mode.toString());
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected system mode reply: {}", nm(), HexUtils.getHexString((int) value));
+                return UnDefType.UNDEF;
+            }
+        }
+    }
+
+    /**
+     * Venstar thermostat temperature message handler
+     */
+    public static class VenstarTemperatureMsgHandler extends CustomTemperatureMsgHandler {
+        VenstarTemperatureMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        protected Unit<Temperature> getTemperatureUnit() {
+            try {
+                // use temperature scale current state to determine temperature unit, defaulting to fahrenheit
+                State state = getInsteonDevice().getFeatureState(FEATURE_TEMPERATURE_SCALE);
+                if (state != null
+                        && ThermostatTemperatureScale.valueOf(state.toString()) == ThermostatTemperatureScale.CELSIUS) {
+                    return SIUnits.CELSIUS;
+                }
+            } catch (IllegalArgumentException e) {
+                logger.debug("{}: unable to determine temperature unit, defaulting to: FAHRENHEIT", nm());
+            }
+            return ImperialUnits.FAHRENHEIT;
+        }
+    }
+
+    /**
+     * IM button event message handler
+     */
+    public static class IMButtonEventMsgHandler extends MessageHandler {
+        IMButtonEventMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            try {
+                int cmd = msg.getInt("buttonEvent");
+                int button = getParameterAsInteger("button", 1);
+                int mask = (button - 1) << 4;
+                IMButtonEvent event = IMButtonEvent.valueOf(cmd ^ mask);
+                logger.debug("{}: IM {} received event {}", nm(), feature.getName(), event);
+                feature.triggerEvent(event.toString());
+            } catch (FieldException e) {
+                logger.warn("{}: error parsing msg {}", nm(), msg, e);
+            } catch (IllegalArgumentException e) {
+                logger.warn("{}: got unexpected button event", nm(), e);
+            }
+        }
+    }
+
+    /**
+     * IM config message handler
+     */
+    public static class IMConfigMsgHandler extends MessageHandler {
+        IMConfigMsgHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            try {
+                int flags = msg.getInt("IMConfigurationFlags");
+                int bit = getParameterAsInteger("bit", -1);
+                if (bit < 3 || bit > 7) {
+                    logger.debug("{}: invalid bit number defined for {}", nm(), feature.getName());
+                    return;
+                }
+                boolean isSet = BinaryUtils.isBitSet(flags, bit);
+                State state = OnOffType.from(isSet ^ getParameterAsBoolean("inverted", false));
+                logger.debug("{}: IM {} is {}", nm(), feature.getName(), state);
+                feature.setLastMsgValue(flags);
+                feature.updateState(state);
+            } catch (FieldException e) {
+                logger.warn("{}: error parsing msg {}", nm(), msg, e);
+            }
+        }
+    }
+
+    /**
+     * Process X10 messages that are generated when another controller
+     * changes the state of an X10 device.
+     */
+    public static class X10OnHandler extends MessageHandler {
+        X10OnHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            logger.debug("{}: device {} is ON", nm(), getX10Device().getAddress());
+            feature.updateState(OnOffType.ON);
+        }
+    }
+
+    public static class X10OffHandler extends MessageHandler {
+        X10OffHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            logger.debug("{}: device {} is OFF", nm(), getX10Device().getAddress());
+            feature.updateState(OnOffType.OFF);
+        }
+    }
+
+    public static class X10BrightHandler extends MessageHandler {
+        X10BrightHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            logger.debug("{}: ignoring brighten message for device {}", nm(), getX10Device().getAddress());
+        }
+    }
+
+    public static class X10DimHandler extends MessageHandler {
+        X10DimHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            logger.debug("{}: ignoring dim message for device {}", nm(), getX10Device().getAddress());
+        }
+    }
+
+    public static class X10OpenHandler extends MessageHandler {
+        X10OpenHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            logger.debug("{}: device {} is OPEN", nm(), getX10Device().getAddress());
+            feature.updateState(OpenClosedType.OPEN);
+        }
+    }
+
+    public static class X10ClosedHandler extends MessageHandler {
+        X10ClosedHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public void handleMessage(byte cmd1, Msg msg) {
+            logger.debug("{}: device {} is CLOSED", nm(), getX10Device().getAddress());
+            feature.updateState(OpenClosedType.CLOSED);
+        }
+    }
+
+    /**
+     * Factory method for dermining if a message handler command supports group
+     *
+     * @param command the handler command
+     * @return true if handler supports group, otherwise false
+     */
+    public static boolean supportsGroup(int command) {
+        return SUPPORTED_GROUP_COMMANDS.contains(command);
+    }
+
+    /**
+     * Factory method for generating a message handler id
+     *
+     * @param command the handler command
+     * @param group the handler group
+     * @return the generated handler id
+     */
+    public static String generateId(int command, int group) {
+        if (command == -1) {
+            return "default";
+        }
+        String id = HexUtils.getHexString(command);
+        if (group != -1) {
+            id += ":" + group;
+        }
+        return id;
+    }
+
+    /**
+     * Factory method for creating a default message handler
+     *
+     * @param feature the feature for which to create the handler
+     * @return the default message handler which was created
+     */
+    public static DefaultMsgHandler makeDefaultHandler(DeviceFeature feature) {
+        return new DefaultMsgHandler(feature);
+    }
+
+    /**
+     * Factory method for creating a message handler for a given name using java reflection
+     *
+     * @param name the name of the handler to create
+     * @param parameters the parameters of the handler to create
+     * @param feature the feature for which to create the handler
+     * @return the handler which was created
+     */
+    public static @Nullable <T extends MessageHandler> T makeHandler(String name, Map<String, String> parameters,
+            DeviceFeature feature) {
+        try {
+            String className = MessageHandler.class.getName() + "$" + name;
+            @SuppressWarnings("unchecked")
+            Class<? extends T> classRef = (Class<? extends T>) Class.forName(className);
+            @Nullable
+            T handler = classRef.getDeclaredConstructor(DeviceFeature.class).newInstance(feature);
+            handler.setParameters(parameters);
+            return handler;
+        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
+                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
+            return null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/PollHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/device/feature/PollHandler.java
new file mode 100644 (file)
index 0000000..b8faac2
--- /dev/null
@@ -0,0 +1,151 @@
+/**
+ * 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.lang.reflect.InvocationTargetException;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+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.InvalidMessageTypeException;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A PollHandler creates an Insteon message to query a particular
+ * DeviceFeature of 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 abstract class PollHandler extends BaseFeatureHandler {
+    protected final Logger logger = LoggerFactory.getLogger(PollHandler.class);
+
+    public PollHandler(DeviceFeature feature) {
+        super(feature);
+    }
+
+    /**
+     * Creates Insteon message that can be used to poll a device feature
+     *
+     * @return Insteon query message or null if creation failed
+     */
+    public abstract @Nullable Msg makeMsg();
+
+    public static class FlexPollHandler extends PollHandler {
+        FlexPollHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public @Nullable Msg makeMsg() {
+            Msg msg = null;
+            InsteonAddress address = getInsteonDevice().getAddress();
+            int cmd1 = getParameterAsInteger("cmd1", 0);
+            int cmd2 = getParameterAsInteger("cmd2", 0);
+            int ext = getParameterAsInteger("ext", -1);
+            long quietTime = getParameterAsLong("quiet", -1);
+            try {
+                // make message based on feature parameters
+                if (ext == 0) {
+                    msg = Msg.makeStandardMessage(address, (byte) cmd1, (byte) cmd2);
+                } else if (ext == 1 || ext == 2) {
+                    // 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) };
+                    boolean setCRC = getInsteonDevice().getInsteonEngine().supportsChecksum();
+                    if (ext == 1) {
+                        msg = Msg.makeExtendedMessage(address, (byte) cmd1, (byte) cmd2, data, setCRC);
+                    } else {
+                        msg = Msg.makeExtendedMessageCRC2(address, (byte) cmd1, (byte) cmd2, data);
+                    }
+                } else {
+                    logger.warn("{}: handler misconfigured, no valid ext field specified", nm());
+                }
+                // override default message quiet time if parameter specified
+                if (msg != null && quietTime >= 0) {
+                    msg.setQuietTime(quietTime);
+                }
+            } catch (FieldException e) {
+                logger.warn("error setting field in msg: ", e);
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("invalid message ", e);
+            }
+            return msg;
+        }
+    }
+
+    public static class IMPollHandler extends PollHandler {
+        IMPollHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public @Nullable Msg makeMsg() {
+            Msg msg = null;
+            int cmd = getParameterAsInteger("cmd", 0);
+            try {
+                msg = Msg.makeMessage((byte) cmd);
+                byte[] data = msg.getData();
+                int headerLength = msg.getHeaderLength();
+                for (int i = headerLength; i < data.length; i++) {
+                    data[i] = (byte) getParameterAsInteger("d" + (i - headerLength + 1), 0);
+                }
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("invalid message ", e);
+            }
+            return msg;
+        }
+    }
+
+    public static class NoPollHandler extends PollHandler {
+        NoPollHandler(DeviceFeature feature) {
+            super(feature);
+        }
+
+        @Override
+        public @Nullable Msg makeMsg() {
+            return null;
+        }
+    }
+
+    /**
+     * Factory method for creating handlers of a given name using java reflection
+     *
+     * @param name the name of the handler to create
+     * @param parameters the parameters of the handler to create
+     * @param feature the feature for which to create the handler
+     * @return the handler which was created
+     */
+    public static @Nullable <T extends PollHandler> T makeHandler(String name, Map<String, String> parameters,
+            DeviceFeature feature) {
+        try {
+            String className = PollHandler.class.getName() + "$" + name;
+            @SuppressWarnings("unchecked")
+            Class<? extends T> classRef = (Class<? extends T>) Class.forName(className);
+            @Nullable
+            T handler = classRef.getDeclaredConstructor(DeviceFeature.class).newInstance(feature);
+            handler.setParameters(parameters);
+            return handler;
+        } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
+                | InvocationTargetException | NoSuchMethodException | SecurityException e) {
+            return null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/discovery/InsteonDeviceDiscoveryService.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/discovery/InsteonDeviceDiscoveryService.java
deleted file mode 100644 (file)
index 87a9866..0000000
+++ /dev/null
@@ -1,74 +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.discovery;
-
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.insteon.internal.InsteonBindingConstants;
-import org.openhab.binding.insteon.internal.handler.InsteonNetworkHandler;
-import org.openhab.core.config.discovery.AbstractDiscoveryService;
-import org.openhab.core.config.discovery.DiscoveryResultBuilder;
-import org.openhab.core.thing.ThingUID;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * The {@link InsteonDeviceDiscoveryService} is responsible for device discovery.
- *
- * @author Rob Nielsen - Initial contribution
- */
-@NonNullByDefault
-public class InsteonDeviceDiscoveryService extends AbstractDiscoveryService {
-    private static final String ADDRESS = "address";
-
-    private final Logger logger = LoggerFactory.getLogger(InsteonDeviceDiscoveryService.class);
-
-    public InsteonDeviceDiscoveryService(InsteonNetworkHandler handler) {
-        super(new HashSet<>(Arrays.asList(InsteonBindingConstants.DEVICE_THING_TYPE)), 0, false);
-
-        handler.setInsteonDeviceDiscoveryService(this);
-
-        logger.debug("Initializing InsteonNetworkDiscoveryService");
-    }
-
-    @Override
-    protected void startScan() {
-    }
-
-    public void addInsteonDevices(List<String> addresses, ThingUID bridgeUid) {
-        for (String address : addresses) {
-            String[] parts = address.split("\\.");
-            if (parts.length != 3) {
-                logger.warn("Address {} must be in the format XX.XX.XX", address);
-
-                continue;
-            }
-
-            String name = parts[0] + parts[1] + parts[2];
-            ThingUID uid = new ThingUID(InsteonBindingConstants.DEVICE_THING_TYPE, bridgeUid, name);
-            Map<String, Object> properties = new HashMap<>();
-            properties.put(ADDRESS, address);
-
-            thingDiscovered(
-                    DiscoveryResultBuilder.create(uid).withProperties(properties).withLabel("Insteon Device  " + name)
-                            .withBridge(bridgeUid).withRepresentationProperty(ADDRESS).build());
-
-            logger.debug("Added Insteon device {} with the address {}", name, address);
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/discovery/InsteonDiscoveryService.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/discovery/InsteonDiscoveryService.java
new file mode 100644 (file)
index 0000000..b3af9b2
--- /dev/null
@@ -0,0 +1,157 @@
+/**
+ * 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.discovery;
+
+import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+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.handler.InsteonBridgeHandler;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link InsteonDiscoveryService} is responsible for insteon devices & scenes discovery.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class InsteonDiscoveryService extends AbstractDiscoveryService {
+    private static final int SCAN_TIMEOUT = 2; // in seconds
+
+    private final Logger logger = LoggerFactory.getLogger(InsteonDiscoveryService.class);
+
+    private final InsteonBridgeHandler handler;
+
+    public InsteonDiscoveryService(InsteonBridgeHandler handler) {
+        super(DISCOVERABLE_THING_TYPES_UIDS, SCAN_TIMEOUT, false);
+        this.handler = handler;
+
+        logger.debug("initializing discovery service for bridge {}", handler.getThing().getUID());
+
+        handler.setDiscoveryService(this);
+    }
+
+    @Override
+    protected void startScan() {
+        logger.debug("starting manual scan on bridge {}", handler.getThing().getUID());
+
+        discoverMissingThings();
+    }
+
+    public void discoverInsteonDevice(InsteonAddress address, @Nullable ProductData productData) {
+        InsteonModem modem = handler.getModem();
+        if (handler.isDeviceDiscoveryEnabled() && modem != null && modem.getDB().hasEntry(address)
+                && !modem.hasDevice(address)) {
+            addInsteonDevice(address, productData);
+        } else {
+            removeInsteonDevice(address);
+        }
+    }
+
+    public void discoverInsteonScene(int group) {
+        InsteonModem modem = handler.getModem();
+        if (handler.isSceneDiscoveryEnabled() && modem != null && modem.getDB().hasBroadcastGroup(group)
+                && !modem.hasScene(group)) {
+            addInsteonScene(group);
+        } else {
+            removeInsteonScene(group);
+        }
+    }
+
+    public void discoverMissingThings() {
+        InsteonModem modem = handler.getModem();
+        if (modem == null) {
+            logger.debug("modem not initialized, scanning aborted.");
+        } else if (!modem.getDB().isComplete()) {
+            logger.debug("modem database not complete, scanning aborted.");
+        } else {
+            long startTime = Instant.now().toEpochMilli();
+
+            if (handler.isDeviceDiscoveryEnabled()) {
+                modem.getDB().getDevices().stream().filter(address -> !modem.hasDevice(address)).forEach(address -> {
+                    logger.debug("device {} in the modem database, but not configured", address);
+                    addInsteonDevice(address, handler.getProductData(address));
+                });
+            } else {
+                logger.debug("device discovery is disabled, no device will be discovered.");
+            }
+
+            if (handler.isSceneDiscoveryEnabled()) {
+                modem.getDB().getBroadcastGroups().stream().filter(group -> !modem.hasScene(group)).forEach(group -> {
+                    logger.debug("scene {} in the modem database, but not configured", group);
+                    addInsteonScene(group);
+                });
+            } else {
+                logger.debug("scene discovery is disabled, no scene will be discovered.");
+            }
+
+            removeOlderResults(startTime, handler.getThing().getUID());
+        }
+    }
+
+    private void addInsteonDevice(InsteonAddress address, @Nullable ProductData productData) {
+        ThingUID bridgeUID = handler.getThing().getUID();
+        String id = address.toString().replace(".", "").toLowerCase();
+        ThingUID thingUID = new ThingUID(THING_TYPE_DEVICE, bridgeUID, id);
+        String label = Optional.ofNullable(productData).map(ProductData::getLabel).orElse("Insteon Device " + address);
+        Map<String, Object> properties = new HashMap<>();
+        properties.put(PROPERTY_DEVICE_ADDRESS, address.toString());
+
+        thingDiscovered(DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUID).withLabel(label)
+                .withProperties(properties).withRepresentationProperty(PROPERTY_DEVICE_ADDRESS).build());
+
+        logger.debug("added Insteon device {} to inbox", address);
+    }
+
+    private void addInsteonScene(int group) {
+        ThingUID bridgeUID = handler.getThing().getUID();
+        String id = Integer.toString(group);
+        ThingUID thingUID = new ThingUID(THING_TYPE_SCENE, bridgeUID, id);
+        String label = "Insteon Scene " + group;
+        Map<String, Object> properties = new HashMap<>();
+        properties.put(PROPERTY_SCENE_GROUP, group);
+
+        thingDiscovered(DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUID).withLabel(label)
+                .withProperties(properties).withRepresentationProperty(PROPERTY_SCENE_GROUP).build());
+
+        logger.debug("added Insteon scene {} to inbox", group);
+    }
+
+    private void removeInsteonDevice(InsteonAddress address) {
+        ThingUID bridgeUID = handler.getThing().getUID();
+        String id = address.toString().replace(".", "").toLowerCase();
+        ThingUID thingUID = new ThingUID(THING_TYPE_DEVICE, bridgeUID, id);
+
+        thingRemoved(thingUID);
+    }
+
+    private void removeInsteonScene(int group) {
+        ThingUID bridgeUID = handler.getThing().getUID();
+        String id = Integer.toString(group);
+        ThingUID thingUID = new ThingUID(THING_TYPE_SCENE, bridgeUID, id);
+
+        thingRemoved(thingUID);
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/discovery/InsteonLegacyDiscoveryService.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/discovery/InsteonLegacyDiscoveryService.java
new file mode 100644 (file)
index 0000000..521913b
--- /dev/null
@@ -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.discovery;
+
+import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.handler.InsteonLegacyNetworkHandler;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link InsteonLegacyDiscoveryService} is responsible for legacy device discovery.
+ *
+ * @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class InsteonLegacyDiscoveryService extends AbstractDiscoveryService {
+
+    private final Logger logger = LoggerFactory.getLogger(InsteonLegacyDiscoveryService.class);
+
+    private final InsteonLegacyNetworkHandler handler;
+
+    public InsteonLegacyDiscoveryService(InsteonLegacyNetworkHandler handler) {
+        super(DISCOVERABLE_LEGACY_THING_TYPES_UIDS, 0, false);
+        this.handler = handler;
+
+        logger.debug("Initializing InsteonLegacyDiscoveryService");
+
+        handler.setInsteonDiscoveryService(this);
+    }
+
+    @Override
+    protected void startScan() {
+    }
+
+    public void addInsteonDevices(List<InsteonAddress> addresses) {
+        for (InsteonAddress address : addresses) {
+            ThingUID bridgeUID = handler.getThing().getUID();
+            String id = address.toString().replace(".", "");
+            ThingUID thingUID = new ThingUID(THING_TYPE_LEGACY_DEVICE, bridgeUID, id);
+            String label = "Insteon Device (Legacy) " + address;
+            Map<String, Object> properties = new HashMap<>();
+            properties.put(PROPERTY_DEVICE_ADDRESS, address.toString());
+
+            thingDiscovered(DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUID).withLabel(label)
+                    .withProperties(properties).withRepresentationProperty(PROPERTY_DEVICE_ADDRESS).build());
+
+            logger.debug("added Insteon device {} to inbox", address);
+        }
+    }
+
+    public void removeAllResults() {
+        removeOlderResults(Instant.now().toEpochMilli(), handler.getThing().getUID());
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Driver.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Driver.java
deleted file mode 100644 (file)
index e101044..0000000
+++ /dev/null
@@ -1,108 +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.driver;
-
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.locks.ReentrantLock;
-
-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.message.MsgListener;
-import org.openhab.core.io.transport.serial.SerialPortManager;
-
-/**
- * The driver class manages the modem port.
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class Driver {
-    private Port port;
-    private String portName;
-    private DriverListener listener;
-    private Map<InsteonAddress, ModemDBEntry> modemDBEntries = new HashMap<>();
-    private ReentrantLock modemDBEntriesLock = new ReentrantLock();
-
-    public Driver(String portName, DriverListener listener, @Nullable SerialPortManager serialPortManager,
-            ScheduledExecutorService scheduler) {
-        this.listener = listener;
-        this.portName = portName;
-
-        port = new Port(portName, this, serialPortManager, scheduler);
-    }
-
-    public boolean isReady() {
-        return port.isRunning();
-    }
-
-    public Map<InsteonAddress, ModemDBEntry> lockModemDBEntries() {
-        modemDBEntriesLock.lock();
-        return modemDBEntries;
-    }
-
-    public void unlockModemDBEntries() {
-        modemDBEntriesLock.unlock();
-    }
-
-    public void addMsgListener(MsgListener listener) {
-        port.addListener(listener);
-    }
-
-    public void removeListener(MsgListener listener) {
-        port.removeListener(listener);
-    }
-
-    public void start() {
-        port.start();
-    }
-
-    public void stop() {
-        port.stop();
-    }
-
-    public void writeMessage(Msg m) throws IOException {
-        port.writeMessage(m);
-    }
-
-    public String getPortName() {
-        return portName;
-    }
-
-    public boolean isRunning() {
-        return port.isRunning();
-    }
-
-    public boolean isMsgForUs(@Nullable InsteonAddress toAddr) {
-        return port.getAddress().equals(toAddr);
-    }
-
-    public void modemDBComplete(Port port) {
-        if (isModemDBComplete()) {
-            listener.driverCompletelyInitialized();
-        }
-    }
-
-    public boolean isModemDBComplete() {
-        return port.isModemDBComplete();
-    }
-
-    public void disconnected() {
-        listener.disconnected();
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/DriverListener.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/DriverListener.java
deleted file mode 100644 (file)
index f30310a..0000000
+++ /dev/null
@@ -1,35 +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.driver;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * Interface for classes that want to listen to notifications from
- * the driver.
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public interface DriverListener {
-    /**
-     * Notification that querying of the modems on all ports has successfully completed.
-     */
-    void driverCompletelyInitialized();
-
-    /**
-     * Notification that the driver was disconnected
-     */
-    void disconnected();
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/IOStream.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/IOStream.java
deleted file mode 100644 (file)
index f752d4a..0000000
+++ /dev/null
@@ -1,184 +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.driver;
-
-import java.io.EOFException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.insteon.internal.driver.hub.HubIOStream;
-import org.openhab.core.io.transport.serial.SerialPortManager;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Abstract class for implementation for I/O stream with anything that looks
- * like a PLM (e.g. the insteon hubs, serial/usb connection etc)
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Daniel Pfrommer - openHAB 1 insteonplm binding
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public abstract class IOStream {
-    private static final Logger logger = LoggerFactory.getLogger(IOStream.class);
-    protected @Nullable InputStream in = null;
-    protected @Nullable OutputStream out = null;
-    private volatile boolean stopped = false;
-
-    public void start() {
-        stopped = false;
-    }
-
-    public void stop() {
-        stopped = true;
-    }
-
-    /**
-     * read data from iostream
-     *
-     * @param b byte array (output)
-     * @param offset offset for placement into byte array
-     * @param readSize size to read
-     * @return number of bytes read
-     */
-    public int read(byte[] b, int offset, int readSize) throws InterruptedException, IOException {
-        int len = 0;
-        while (!stopped && len < 1) {
-            InputStream in = this.in;
-            if (in != null) {
-                len = in.read(b, offset, readSize);
-            } else {
-                throw new IOException("in is null");
-            }
-            if (len == -1) {
-                throw new EOFException();
-            }
-
-            if (Thread.interrupted()) {
-                throw new InterruptedException();
-            }
-        }
-        return (len);
-    }
-
-    /**
-     * Write data to iostream
-     *
-     * @param b byte array to write
-     */
-    public void write(byte @Nullable [] b) throws IOException {
-        OutputStream out = this.out;
-        if (out != null) {
-            out.write(b);
-        } else {
-            throw new IOException("out is null");
-        }
-    }
-
-    /**
-     * Opens the IOStream
-     *
-     * @return true if open was successful, false if not
-     */
-    public abstract boolean open();
-
-    /**
-     * Closes the IOStream
-     */
-    public abstract void close();
-
-    /**
-     * Creates an IOStream from an allowed config string:
-     *
-     * /dev/ttyXYZ (serial port like e.g. usb: /dev/ttyUSB0 or alias /dev/insteon)
-     *
-     * /hub2/user:password@myinsteonhub.mydomain.com:25105,poll_time=1000 (insteon hub2 (2014))
-     *
-     * /hub/myinsteonhub.mydomain.com:9761
-     *
-     * /tcp/serialportserver.mydomain.com:port (serial port exposed via tcp, eg. ser2net)
-     *
-     * @param config
-     * @return reference to IOStream
-     */
-
-    public static IOStream create(@Nullable SerialPortManager serialPortManager, String config) {
-        if (config.startsWith("/hub2/")) {
-            return makeHub2014Stream(config);
-        } else if (config.startsWith("/hub/") || config.startsWith("/tcp/")) {
-            return makeTCPStream(config);
-        } else {
-            return new SerialIOStream(serialPortManager, config);
-        }
-    }
-
-    private static HubIOStream makeHub2014Stream(String config) {
-        @Nullable
-        String user = null;
-        @Nullable
-        String pass = null;
-        int pollTime = 1000; // poll time in milliseconds
-
-        // Get rid of the /hub2/ part and split off options at the end
-        String[] parts = config.substring(6).split(",");
-
-        // Parse the first part, the address
-        String[] adr = parts[0].split("@");
-        String[] hostPort;
-        if (adr.length > 1) {
-            String[] userPass = adr[0].split(":");
-            user = userPass[0];
-            pass = userPass[1];
-            hostPort = adr[1].split(":");
-        } else {
-            hostPort = parts[0].split(":");
-        }
-        HostPort hp = new HostPort(hostPort, 25105);
-        // check if additional options are given
-        if (parts.length > 1) {
-            if (parts[1].trim().startsWith("poll_time")) {
-                pollTime = Integer.parseInt(parts[1].split("=")[1].trim());
-            }
-        }
-        return new HubIOStream(hp.host, hp.port, pollTime, user, pass);
-    }
-
-    private static TcpIOStream makeTCPStream(String config) {
-        // Get rid of the /hub/ part and split off options at the end, if any
-        String[] parts = config.substring(5).split(",");
-        String[] hostPort = parts[0].split(":");
-        HostPort hp = new HostPort(hostPort, 9761);
-        return new TcpIOStream(hp.host, hp.port);
-    }
-
-    private static class HostPort {
-        public String host = "localhost";
-        public int port = -1;
-
-        HostPort(String[] hostPort, int defaultPort) {
-            port = defaultPort;
-            host = hostPort[0];
-            try {
-                if (hostPort.length > 1) {
-                    port = Integer.parseInt(hostPort[1]);
-                }
-            } catch (NumberFormatException e) {
-                logger.warn("bad format for port {} ", hostPort[1], e);
-            }
-        }
-    }
-}
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/driver/ModemDBEntry.java
deleted file mode 100644 (file)
index 129662f..0000000
+++ /dev/null
@@ -1,106 +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.driver;
-
-import java.util.ArrayList;
-import java.util.Collections;
-
-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;
-
-/**
- * The ModemDBEntry class holds a modem device type record
- * an xml file.
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class ModemDBEntry {
-    private @Nullable InsteonAddress address = null;
-    private boolean isModem;
-    private @Nullable Port port = null;
-    private ArrayList<Msg> linkRecords = new ArrayList<>();
-    private ArrayList<Byte> controls = new ArrayList<>();
-    private ArrayList<Byte> respondsTo = new ArrayList<>();
-
-    public ModemDBEntry(InsteonAddress aAddr, boolean isModem) {
-        this.address = aAddr;
-        this.isModem = isModem;
-    }
-
-    public boolean isModem() {
-        return isModem;
-    }
-
-    public ArrayList<Msg> getLinkRecords() {
-        return linkRecords;
-    }
-
-    public void addLinkRecord(Msg m) {
-        linkRecords.add(m);
-    }
-
-    public void addControls(byte c) {
-        controls.add(c);
-    }
-
-    public ArrayList<Byte> getControls() {
-        return controls;
-    }
-
-    public void addRespondsTo(byte r) {
-        respondsTo.add(r);
-    }
-
-    public ArrayList<Byte> getRespondsTo() {
-        return respondsTo;
-    }
-
-    public void setPort(Port p) {
-        port = p;
-    }
-
-    public @Nullable Port getPort() {
-        return port;
-    }
-
-    @Override
-    public String toString() {
-        String s = "addr:" + address + "|controls:[" + toGroupString(controls) + "]|responds_to:["
-                + toGroupString(respondsTo) + "]|link_recors";
-        for (Msg m : linkRecords) {
-            s += ":(" + m + ")";
-        }
-        return s;
-    }
-
-    private String toGroupString(ArrayList<Byte> group) {
-        ArrayList<Byte> sorted = new ArrayList<>(group);
-        Collections.sort(sorted);
-
-        StringBuilder buf = new StringBuilder();
-        for (Byte b : sorted) {
-            if (buf.length() > 0) {
-                buf.append(",");
-            }
-            buf.append("0x");
-            buf.append(Utils.getHexString(b));
-        }
-
-        return buf.toString();
-    }
-}
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/driver/Poller.java
deleted file mode 100644 (file)
index 4e6b898..0000000
+++ /dev/null
@@ -1,310 +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.driver;
-
-import java.sql.Date;
-import java.util.Iterator;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-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;
-
-/**
- * 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
- */
-@NonNullByDefault
-public class Poller {
-    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 @Nullable Thread pollThread = null;
-    private TreeSet<PQEntry> pollQueue = new TreeSet<>();
-    private boolean keepRunning = true;
-
-    /**
-     * Constructor
-     */
-    private Poller() {
-    }
-
-    /**
-     * Get size of poll queue
-     *
-     * @return number of devices being polled
-     */
-    public int getSizeOfQueue() {
-        return (pollQueue.size());
-    }
-
-    /**
-     * Register a device for polling.
-     *
-     * @param d device to register for polling
-     * @param aNumDev approximate number of total devices
-     */
-    public void startPolling(InsteonDevice d, int aNumDev) {
-        logger.debug("start polling device {}", d);
-        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);
-            pollQueue.notify();
-        }
-    }
-
-    /**
-     * Start polling a given device
-     *
-     * @param d reference to the device to be polled
-     */
-    public void stopPolling(InsteonDevice d) {
-        synchronized (pollQueue) {
-            for (Iterator<PQEntry> i = pollQueue.iterator(); i.hasNext();) {
-                if (i.next().getDevice().getAddress().equals(d.getAddress())) {
-                    i.remove();
-                    logger.debug("stopped polling device {}", d);
-                }
-            }
-        }
-    }
-
-    /**
-     * Starts the poller thread
-     */
-    public void start() {
-        if (pollThread == null) {
-            pollThread = new Thread(new PollQueueReader());
-            setParamsAndStart(pollThread);
-        }
-    }
-
-    private void setParamsAndStart(@Nullable Thread thread) {
-        if (thread != null) {
-            thread.setName("OH-binding-" + InsteonBindingConstants.BINDING_ID + "-pollQueueReader");
-            thread.setDaemon(true);
-            thread.start();
-        }
-    }
-
-    /**
-     * Stops the poller thread
-     */
-    public void stop() {
-        logger.debug("stopping poller!");
-        synchronized (pollQueue) {
-            pollQueue.clear();
-            keepRunning = false;
-            pollQueue.notify();
-        }
-        try {
-            Thread pollThread = this.pollThread;
-            if (pollThread != null) {
-                pollThread.join();
-                this.pollThread = null;
-            }
-            keepRunning = true;
-        } catch (InterruptedException e) {
-            logger.debug("got interrupted on exit: {}", e.getMessage());
-        }
-    }
-
-    /**
-     * 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 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);
-        logger.trace("added entry {} originally aimed at time {}", ne, String.format("%tc", new Date(time)));
-        pollQueue.add(ne);
-    }
-
-    /**
-     * 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 d device to poll (for logging)
-     * @param aTime 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<PQEntry> ts = pollQueue.tailSet(new PQEntry(d, aTime - MIN_MSEC_BETWEEN_POLLS));
-        if (ts.isEmpty()) {
-            // all entries in the poll queue are ahead of the new element,
-            // go ahead and simply add it to the end
-            expTime = aTime;
-        } else {
-            Iterator<PQEntry> pqi = ts.iterator();
-            PQEntry prev = pqi.next();
-            if (prev.getExpirationTime() > aTime + MIN_MSEC_BETWEEN_POLLS) {
-                // there is a time slot free before the head of the tail set
-                expTime = aTime;
-            } 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) {
-                        // found gap
-                        logger.trace("dev {} time {} found slot between {} and {}", d, aTime, tprev, tcurr);
-                        break;
-                    }
-                    prev = pqe;
-                }
-                expTime = prev.getExpirationTime() + MIN_MSEC_BETWEEN_POLLS;
-            }
-        }
-        return expTime;
-    }
-
-    private class PollQueueReader implements Runnable {
-        @Override
-        public void run() {
-            logger.debug("starting poll thread.");
-            synchronized (pollQueue) {
-                while (keepRunning) {
-                    try {
-                        readPollQueue();
-                    } catch (InterruptedException e) {
-                        logger.warn("poll queue reader thread interrupted!");
-                        break;
-                    }
-                }
-            }
-            logger.debug("poll thread exiting");
-        }
-
-        /**
-         * Waits for first element of poll queue to become current,
-         * then process it.
-         *
-         * @throws InterruptedException
-         */
-        private void readPollQueue() throws InterruptedException {
-            while (pollQueue.isEmpty() && keepRunning) {
-                pollQueue.wait();
-            }
-            if (!keepRunning) {
-                return;
-            }
-            // 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);
-            } else { // queue entry has expired, process it!
-                logger.trace("entry {} expired at time {}", pqe, now);
-                processQueue(now);
-            }
-        }
-
-        /**
-         * 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 processQueue(long now) {
-            processQueue(now, pollQueue.pollFirst());
-        }
-
-        private void processQueue(long now, @Nullable PQEntry pqe) {
-            if (pqe != null) {
-                pqe.getDevice().doPoll(0);
-                addToPollQueue(pqe.getDevice(), now + pqe.getDevice().getPollInterval());
-            }
-        }
-    }
-
-    /**
-     * A poll queue entry corresponds to a single device that needs
-     * to be polled.
-     *
-     * @author Bernd Pfrommer - Initial contribution
-     *
-     */
-    private static class PQEntry implements Comparable<PQEntry> {
-        private InsteonDevice dev;
-        private long expirationTime;
-
-        PQEntry(InsteonDevice dev, long time) {
-            this.dev = dev;
-            this.expirationTime = time;
-        }
-
-        long getExpirationTime() {
-            return expirationTime;
-        }
-
-        InsteonDevice getDevice() {
-            return dev;
-        }
-
-        @Override
-        public int compareTo(PQEntry b) {
-            return (int) (expirationTime - b.expirationTime);
-        }
-
-        @Override
-        public String toString() {
-            return dev.getAddress().toString() + "/" + String.format("%tc", new Date(expirationTime));
-        }
-    }
-
-    /**
-     * Singleton pattern instance() method
-     *
-     * @return the poller instance
-     */
-    public static synchronized Poller instance() {
-        poller.start();
-        return (poller);
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Port.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/Port.java
deleted file mode 100644 (file)
index 85b0bd1..0000000
+++ /dev/null
@@ -1,536 +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.driver;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.insteon.internal.device.DeviceType;
-import org.openhab.binding.insteon.internal.device.DeviceTypeLoader;
-import org.openhab.binding.insteon.internal.device.InsteonAddress;
-import org.openhab.binding.insteon.internal.device.InsteonDevice;
-import org.openhab.binding.insteon.internal.device.ModemDBBuilder;
-import org.openhab.binding.insteon.internal.handler.InsteonDeviceHandler;
-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.MsgFactory;
-import org.openhab.binding.insteon.internal.message.MsgListener;
-import org.openhab.binding.insteon.internal.utils.Utils;
-import org.openhab.core.io.transport.serial.SerialPortManager;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * The Port class represents a port, that is a connection to either an Insteon modem either through
- * a serial or USB port, or via an Insteon Hub.
- * It does the initialization of the port, and (via its inner classes IOStreamReader and IOStreamWriter)
- * manages the reading/writing of messages on the Insteon network.
- *
- * The IOStreamReader and IOStreamWriter class combined implement the somewhat tricky flow control protocol.
- * In combination with the MsgFactory class, the incoming data stream is turned into a Msg structure
- * for further processing by the upper layers (MsgListeners).
- *
- * A write queue is maintained to pace the flow of outgoing messages. Sending messages back-to-back
- * can lead to dropped messages.
- *
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Daniel Pfrommer - openHAB 1 insteonplm binding
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class Port {
-    private final Logger logger = LoggerFactory.getLogger(Port.class);
-
-    /**
-     * The ReplyType is used to keep track of the state of the serial port receiver
-     */
-    enum ReplyType {
-        GOT_ACK,
-        WAITING_FOR_ACK,
-        GOT_NACK
-    }
-
-    private IOStream ioStream;
-    private String devName;
-    private String logName;
-    private Modem modem;
-    private IOStreamReader reader;
-    private IOStreamWriter writer;
-    private final int readSize = 1024; // read buffer size
-    private @Nullable Thread readThread = null;
-    private @Nullable Thread writeThread = null;
-    private boolean running = false;
-    private boolean modemDBComplete = false;
-    private MsgFactory msgFactory = new MsgFactory();
-    private Driver driver;
-    private ModemDBBuilder mdbb;
-    private ArrayList<MsgListener> listeners = new ArrayList<>();
-    private LinkedBlockingQueue<Msg> writeQueue = new LinkedBlockingQueue<>();
-    private AtomicBoolean disconnected = new AtomicBoolean(false);
-
-    /**
-     * Constructor
-     *
-     * @param devName the name of the port, i.e. '/dev/insteon'
-     * @param d The Driver object that manages this port
-     */
-    public Port(String devName, Driver d, @Nullable SerialPortManager serialPortManager,
-            ScheduledExecutorService scheduler) {
-        this.devName = devName;
-        this.driver = d;
-        this.logName = Utils.redactPassword(devName);
-        this.modem = new Modem();
-        addListener(modem);
-        this.ioStream = IOStream.create(serialPortManager, devName);
-        this.reader = new IOStreamReader();
-        this.writer = new IOStreamWriter();
-        this.mdbb = new ModemDBBuilder(this, scheduler);
-    }
-
-    public boolean isModem(InsteonAddress a) {
-        return modem.getAddress().equals(a);
-    }
-
-    public synchronized boolean isModemDBComplete() {
-        return (modemDBComplete);
-    }
-
-    public boolean isRunning() {
-        return running;
-    }
-
-    public InsteonAddress getAddress() {
-        return modem.getAddress();
-    }
-
-    public String getDeviceName() {
-        return devName;
-    }
-
-    public Driver getDriver() {
-        return driver;
-    }
-
-    public void addListener(MsgListener l) {
-        synchronized (listeners) {
-            if (!listeners.contains(l)) {
-                listeners.add(l);
-            }
-        }
-    }
-
-    public void removeListener(MsgListener l) {
-        synchronized (listeners) {
-            if (listeners.remove(l)) {
-                logger.debug("removed listener from port");
-            }
-        }
-    }
-
-    /**
-     * Clear modem database that has been queried so far.
-     */
-    public void clearModemDB() {
-        logger.debug("clearing modem db!");
-        Map<InsteonAddress, ModemDBEntry> dbes = getDriver().lockModemDBEntries();
-        for (Entry<InsteonAddress, ModemDBEntry> entry : dbes.entrySet()) {
-            if (!entry.getValue().isModem()) {
-                dbes.remove(entry.getKey());
-            }
-        }
-        getDriver().unlockModemDBEntries();
-    }
-
-    /**
-     * Starts threads necessary for reading and writing
-     */
-    public void start() {
-        logger.debug("starting port {}", logName);
-        if (running) {
-            logger.debug("port {} already running, not started again", logName);
-            return;
-        }
-
-        writeQueue.clear();
-        if (!ioStream.open()) {
-            logger.debug("failed to open port {}", logName);
-            return;
-        }
-        ioStream.start();
-        readThread = new Thread(reader);
-        setParamsAndStart(readThread, "Reader");
-        writeThread = new Thread(writer);
-        setParamsAndStart(writeThread, "Writer");
-
-        if (!mdbb.isComplete()) {
-            modem.initialize();
-            mdbb.start(); // start downloading the device list
-        }
-
-        running = true;
-        disconnected.set(false);
-    }
-
-    private void setParamsAndStart(@Nullable Thread thread, String type) {
-        if (thread != null) {
-            thread.setName("OH-binding-Insteon " + logName + " " + type);
-            thread.setDaemon(true);
-            thread.start();
-        }
-    }
-
-    /**
-     * Stops all threads
-     */
-    public void stop() {
-        if (!running) {
-            logger.debug("port {} not running, no need to stop it", logName);
-            return;
-        }
-
-        running = false;
-        ioStream.stop();
-        ioStream.close();
-
-        Thread readThread = this.readThread;
-        if (readThread != null) {
-            readThread.interrupt();
-        }
-        Thread writeThread = this.writeThread;
-        if (writeThread != null) {
-            writeThread.interrupt();
-        }
-        logger.debug("waiting for read thread to exit for port {}", logName);
-        try {
-            if (readThread != null) {
-                readThread.join();
-            }
-        } catch (InterruptedException e) {
-            logger.debug("got interrupted waiting for read thread to exit.");
-        }
-        logger.debug("waiting for write thread to exit for port {}", logName);
-        try {
-            if (writeThread != null) {
-                writeThread.join();
-            }
-        } catch (InterruptedException e) {
-            logger.debug("got interrupted waiting for write thread to exit.");
-        }
-        this.readThread = null;
-        this.writeThread = null;
-
-        logger.debug("all threads for port {} stopped.", logName);
-    }
-
-    /**
-     * Adds message to the write queue
-     *
-     * @param m message to be added to the write queue
-     * @throws IOException
-     */
-    public void writeMessage(@Nullable Msg m) throws IOException {
-        if (m == null) {
-            logger.warn("trying to write null message!");
-            throw new IOException("trying to write null message!");
-        }
-        if (m.getData() == null) {
-            logger.warn("trying to write message without data!");
-            throw new IOException("trying to write message without data!");
-        }
-        try {
-            writeQueue.add(m);
-            logger.trace("enqueued msg: {}", m);
-        } catch (IllegalStateException e) {
-            logger.warn("cannot write message {}, write queue is full!", m);
-        }
-    }
-
-    /**
-     * Gets called by the modem database builder when the modem database is complete
-     */
-    public void modemDBComplete() {
-        synchronized (this) {
-            modemDBComplete = true;
-        }
-        driver.modemDBComplete(this);
-    }
-
-    public void disconnected() {
-        if (isRunning()) {
-            if (!disconnected.getAndSet(true)) {
-                logger.warn("port {} disconnected", logName);
-                driver.disconnected();
-            }
-        }
-    }
-
-    /**
-     * The IOStreamReader uses the MsgFactory to turn the incoming bytes into
-     * Msgs for the listeners. It also communicates with the IOStreamWriter
-     * to implement flow control (tell the IOStreamWriter that it needs to retransmit,
-     * or the reply message has been received correctly).
-     *
-     * @author Bernd Pfrommer - Initial contribution
-     */
-    class IOStreamReader implements Runnable {
-
-        private ReplyType reply = ReplyType.GOT_ACK;
-        private Object replyLock = new Object();
-
-        /**
-         * Helper function for implementing synchronization between reader and writer
-         *
-         * @return reference to the RequesReplyLock
-         */
-        public Object getRequestReplyLock() {
-            return replyLock;
-        }
-
-        @Override
-        public void run() {
-            logger.debug("starting reader...");
-            byte[] buffer = new byte[2 * readSize];
-            try {
-                for (int len = -1; (len = ioStream.read(buffer, 0, readSize)) > 0;) {
-                    msgFactory.addData(buffer, len);
-                    processMessages();
-                }
-            } catch (InterruptedException e) {
-                logger.debug("reader thread got interrupted!");
-            } catch (IOException e) {
-                logger.debug("got an io exception in the reader thread");
-                disconnected();
-            }
-            logger.debug("reader thread exiting!");
-        }
-
-        private void processMessages() {
-            // must call processData() until msgFactory done fully processing buffer
-            while (!msgFactory.isDone()) {
-                try {
-                    Msg msg = msgFactory.processData();
-                    if (msg != null) {
-                        toAllListeners(msg);
-                        notifyWriter(msg);
-                    }
-                } catch (IOException e) {
-                    // got bad data from modem,
-                    // unblock those waiting for ack
-                    synchronized (getRequestReplyLock()) {
-                        if (reply == ReplyType.WAITING_FOR_ACK) {
-                            logger.debug("got bad data back, must assume message was acked.");
-                            reply = ReplyType.GOT_ACK;
-                            getRequestReplyLock().notify();
-                        }
-                    }
-                }
-            }
-        }
-
-        private void notifyWriter(Msg msg) {
-            synchronized (getRequestReplyLock()) {
-                if (reply == ReplyType.WAITING_FOR_ACK) {
-                    if (!msg.isUnsolicited()) {
-                        reply = (msg.isPureNack() ? ReplyType.GOT_NACK : ReplyType.GOT_ACK);
-                        logger.trace("signaling receipt of ack: {}", (reply == ReplyType.GOT_ACK));
-                        getRequestReplyLock().notify();
-                    } else if (msg.isPureNack()) {
-                        reply = ReplyType.GOT_NACK;
-                        logger.trace("signaling receipt of pure nack");
-                        getRequestReplyLock().notify();
-                    } else {
-                        logger.trace("got unsolicited message");
-                    }
-                }
-            }
-        }
-
-        @SuppressWarnings("unchecked")
-        private void toAllListeners(Msg msg) {
-            // When we deliver the message, the recipient
-            // may in turn call removeListener() or addListener(),
-            // thereby corrupting the very same list we are iterating
-            // through. That's why we make a copy of it, and
-            // iterate through the copy.
-            ArrayList<MsgListener> tempList = null;
-            synchronized (listeners) {
-                tempList = (ArrayList<MsgListener>) listeners.clone();
-            }
-            for (MsgListener l : tempList) {
-                l.msg(msg); // deliver msg to listener
-            }
-        }
-
-        /**
-         * Blocking wait for ack or nack from modem.
-         * Called by IOStreamWriter for flow control.
-         *
-         * @return true if retransmission is necessary
-         */
-        public boolean waitForReply() {
-            reply = ReplyType.WAITING_FOR_ACK;
-            while (reply == ReplyType.WAITING_FOR_ACK) {
-                try {
-                    logger.trace("writer waiting for ack.");
-                    // There have been cases observed, in particular for
-                    // the Hub, where we get no ack or nack back, causing the binding
-                    // to hang in the wait() below, because unsolicited messages
-                    // do not trigger a notify(). For this reason we request retransmission
-                    // if the wait() times out.
-                    getRequestReplyLock().wait(30000); // be patient for 30 msec
-                    if (reply == ReplyType.WAITING_FOR_ACK) { // timeout expired without getting ACK or NACK
-                        logger.trace("writer timeout expired, asking for retransmit!");
-                        reply = ReplyType.GOT_NACK;
-                        break;
-                    } else {
-                        logger.trace("writer got ack: {}", (reply == ReplyType.GOT_ACK));
-                    }
-                } catch (InterruptedException e) {
-                    break; // done for the day...
-                }
-            }
-            return (reply == ReplyType.GOT_NACK);
-        }
-    }
-
-    /**
-     * Writes messages to the port. Flow control is implemented following Insteon
-     * documents to avoid over running the modem.
-     *
-     * @author Bernd Pfrommer - Initial contribution
-     */
-    class IOStreamWriter implements Runnable {
-        private static final int WAIT_TIME = 200; // milliseconds
-
-        @Override
-        public void run() {
-            logger.debug("starting writer...");
-            while (true) {
-                try {
-                    // this call blocks until the lock on the queue is released
-                    logger.trace("writer checking message queue");
-                    Msg msg = writeQueue.take();
-                    if (msg.getData() == null) {
-                        logger.warn("found null message in write queue!");
-                    } else {
-                        logger.debug("writing ({}): {}", msg.getQuietTime(), msg);
-                        // To debug race conditions during startup (i.e. make the .items
-                        // file definitions be available *before* the modem link records,
-                        // slow down the modem traffic with the following statement:
-                        // Thread.sleep(500);
-                        synchronized (reader.getRequestReplyLock()) {
-                            ioStream.write(msg.getData());
-                            while (reader.waitForReply()) {
-                                Thread.sleep(WAIT_TIME);
-                                logger.trace("retransmitting msg: {}", msg);
-                                ioStream.write(msg.getData());
-                            }
-                        }
-                        // if rate limited, need to sleep now.
-                        if (msg.getQuietTime() > 0) {
-                            Thread.sleep(msg.getQuietTime());
-                        }
-                    }
-                } catch (InterruptedException e) {
-                    logger.debug("got interrupted exception in write thread");
-                    break;
-                } catch (IOException e) {
-                    logger.debug("got an io exception in the write thread");
-                    disconnected();
-                    break;
-                }
-            }
-            logger.debug("writer thread exiting!");
-        }
-    }
-
-    /**
-     * Class to get info about the modem
-     */
-    class Modem implements MsgListener {
-        private @Nullable InsteonDevice device = null;
-
-        InsteonAddress getAddress() {
-            InsteonDevice device = this.device;
-            return (device == null) ? new InsteonAddress() : (device.getAddress());
-        }
-
-        @Nullable
-        InsteonDevice getDevice() {
-            return device;
-        }
-
-        @Override
-        public void msg(Msg msg) {
-            try {
-                if (msg.isPureNack()) {
-                    return;
-                }
-                if (msg.getByte("Cmd") == 0x60) {
-                    // add the modem to the device list
-                    InsteonAddress a = new InsteonAddress(msg.getAddress("IMAddress"));
-                    DeviceTypeLoader instance = DeviceTypeLoader.instance();
-                    if (instance != null) {
-                        DeviceType dt = instance.getDeviceType(InsteonDeviceHandler.PLM_PRODUCT_KEY);
-                        if (dt == null) {
-                            logger.warn("unknown modem product key: {} for modem: {}.",
-                                    InsteonDeviceHandler.PLM_PRODUCT_KEY, a);
-                        } else {
-                            device = InsteonDevice.makeDevice(dt);
-                            initDevice(a, device);
-                            mdbb.updateModemDB(a, Port.this, null, true);
-                        }
-                    } else {
-                        logger.warn("device type loader instance is null");
-                    }
-                    // can unsubscribe now
-                    removeListener(this);
-                }
-            } catch (FieldException e) {
-                logger.warn("error parsing im info reply field: ", e);
-            }
-        }
-
-        private void initDevice(InsteonAddress a, @Nullable InsteonDevice device) {
-            if (device != null) {
-                device.setAddress(a);
-                device.setProductKey(InsteonDeviceHandler.PLM_PRODUCT_KEY);
-                device.setDriver(driver);
-                device.setIsModem(true);
-                logger.debug("found modem {} in device_types: {}", a, device.toString());
-            } else {
-                logger.warn("device is null");
-            }
-        }
-
-        public void initialize() {
-            try {
-                Msg m = Msg.makeMessage("GetIMInfo");
-                writeMessage(m);
-            } catch (IOException e) {
-                logger.warn("modem init failed!", e);
-            } catch (InvalidMessageTypeException e) {
-                logger.warn("invalid message", e);
-            }
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/SerialIOStream.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/SerialIOStream.java
deleted file mode 100644 (file)
index 066ec22..0000000
+++ /dev/null
@@ -1,154 +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.driver;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.core.io.transport.serial.PortInUseException;
-import org.openhab.core.io.transport.serial.SerialPort;
-import org.openhab.core.io.transport.serial.SerialPortIdentifier;
-import org.openhab.core.io.transport.serial.SerialPortManager;
-import org.openhab.core.io.transport.serial.UnsupportedCommOperationException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Implements IOStream for serial devices.
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Daniel Pfrommer - openHAB 1 insteonplm binding
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class SerialIOStream extends IOStream {
-    private final Logger logger = LoggerFactory.getLogger(SerialIOStream.class);
-    private @Nullable SerialPort port = null;
-    private final String appName = "PLM";
-    private int baudRate = 19200;
-    private String devName;
-    private boolean validConfig = true;
-    private @Nullable SerialPortManager serialPortManager;
-
-    public SerialIOStream(@Nullable SerialPortManager serialPortManager, String config) {
-        this.serialPortManager = serialPortManager;
-
-        String[] parts = config.split(",");
-        devName = parts[0];
-        for (int i = 1; i < parts.length; i++) {
-            String parameter = parts[i];
-            String[] paramParts = parameter.split("=");
-            if (paramParts.length != 2) {
-                logger.warn("{} invalid parameter format '{}', must be 'key=value'.", config, parameter);
-
-                validConfig = false;
-            } else {
-                String key = paramParts[0];
-                String value = paramParts[1];
-                if ("baudRate".equals(key)) {
-                    try {
-                        baudRate = Integer.parseInt(value);
-                    } catch (NumberFormatException e) {
-                        logger.warn("{} baudRate {} must be an integer.", config, value);
-
-                        validConfig = false;
-                    }
-                } else {
-                    logger.warn("{} invalid parameter '{}'.", config, parameter);
-
-                    validConfig = false;
-                }
-            }
-        }
-    }
-
-    @Override
-    public boolean open() {
-        if (!validConfig) {
-            logger.warn("{} has an invalid configuration.", devName);
-            return false;
-        }
-
-        try {
-            SerialPortManager serialPortManager = this.serialPortManager;
-            if (serialPortManager == null) {
-                logger.warn("serial port manager is null.");
-                return false;
-            }
-            SerialPortIdentifier spi = serialPortManager.getIdentifier(devName);
-            if (spi == null) {
-                logger.warn("{} is not a valid serial port.", devName);
-                return false;
-            }
-
-            port = spi.open(appName, 1000);
-            open(port);
-            logger.debug("successfully opened port {}", devName);
-            return true;
-        } catch (IOException e) {
-            logger.warn("cannot open port: {}, got IOException {}", devName, e.getMessage());
-        } catch (PortInUseException e) {
-            logger.warn("cannot open port: {}, it is in use!", devName);
-        } catch (UnsupportedCommOperationException e) {
-            logger.warn("got unsupported operation {} on port {}", e.getMessage(), devName);
-        }
-
-        return false;
-    }
-
-    private void open(@Nullable SerialPort port) throws UnsupportedCommOperationException, IOException {
-        if (port != null) {
-            port.setSerialPortParams(baudRate, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE);
-            port.setFlowControlMode(SerialPort.FLOWCONTROL_NONE);
-            logger.debug("setting {} baud rate to {}", devName, baudRate);
-            port.enableReceiveThreshold(1);
-            port.enableReceiveTimeout(1000);
-            in = port.getInputStream();
-            out = port.getOutputStream();
-        } else {
-            logger.warn("port is null");
-        }
-    }
-
-    @Override
-    public void close() {
-        InputStream in = this.in;
-        if (in != null) {
-            try {
-                in.close();
-            } catch (IOException e) {
-                logger.warn("failed to close input stream", e);
-            }
-            this.in = null;
-        }
-
-        OutputStream out = this.out;
-        if (out != null) {
-            try {
-                out.close();
-            } catch (IOException e) {
-                logger.warn("failed to close output stream", e);
-            }
-            this.out = null;
-        }
-
-        SerialPort port = this.port;
-        if (port != null) {
-            port.close();
-            this.port = null;
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/TcpIOStream.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/TcpIOStream.java
deleted file mode 100644 (file)
index 5cfe94b..0000000
+++ /dev/null
@@ -1,111 +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.driver;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.Socket;
-import java.net.UnknownHostException;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Implements IOStream for the older hubs (pre 2014).
- * Also works for serial ports exposed via tcp, eg. ser2net
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- *
- */
-@NonNullByDefault
-public class TcpIOStream extends IOStream {
-    private final Logger logger = LoggerFactory.getLogger(TcpIOStream.class);
-
-    private @Nullable String host = null;
-    private int port = -1;
-    private @Nullable Socket socket = null;
-
-    /**
-     * Constructor
-     *
-     * @param host host name of hub device
-     * @param port port to connect to
-     */
-    public TcpIOStream(String host, int port) {
-        this.host = host;
-        this.port = port;
-    }
-
-    @Override
-    public boolean open() {
-        if (host == null || port < 0) {
-            logger.warn("tcp connection to hub not properly configured!");
-            return (false);
-        }
-        try {
-            socket = new Socket(host, port);
-            open(socket);
-        } catch (UnknownHostException e) {
-            logger.warn("unknown host name: {}", host);
-            return (false);
-        } catch (IOException e) {
-            logger.warn("cannot open connection to {} port {}: {}", host, port, e.getMessage());
-            return (false);
-        }
-        return true;
-    }
-
-    private void open(@Nullable Socket socket) throws IOException {
-        if (socket != null) {
-            in = socket.getInputStream();
-            out = socket.getOutputStream();
-        }
-    }
-
-    @Override
-    public void close() {
-        InputStream in = this.in;
-        if (in != null) {
-            try {
-                in.close();
-            } catch (IOException e) {
-                logger.warn("failed to close input stream", e);
-            }
-            this.in = null;
-        }
-
-        OutputStream out = this.out;
-        if (out != null) {
-            try {
-                out.close();
-            } catch (IOException e) {
-                logger.warn("failed to close output stream", e);
-            }
-            this.out = null;
-        }
-
-        Socket socket = this.socket;
-        if (socket != null) {
-            try {
-                socket.close();
-            } catch (IOException e) {
-                logger.warn("failed to close the socket", e);
-            }
-            this.socket = null;
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/hub/HubIOStream.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/hub/HubIOStream.java
deleted file mode 100644 (file)
index ab544ca..0000000
+++ /dev/null
@@ -1,422 +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.driver.hub;
-
-import java.io.BufferedInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.net.HttpURLConnection;
-import java.net.URL;
-import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
-
-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.driver.IOStream;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Implements IOStream for a Hub 2014 device
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- *
- */
-@NonNullByDefault
-public class HubIOStream extends IOStream implements Runnable {
-    private final Logger logger = LoggerFactory.getLogger(HubIOStream.class);
-
-    private static final String BS_START = "<BS>";
-    private static final String BS_END = "</BS>";
-
-    /** time between polls (in milliseconds */
-    private int pollTime = 1000;
-
-    private String baseUrl;
-    private @Nullable String auth = null;
-
-    private @Nullable Thread pollThread = null;
-
-    // index of the last byte we have read in the buffer
-    private int bufferIdx = -1;
-
-    private boolean polling;
-
-    /**
-     * Constructor for HubIOStream
-     *
-     * @param host host name of hub device
-     * @param port port to connect to
-     * @param pollTime time between polls (in milliseconds)
-     * @param user hub user name
-     * @param pass hub password
-     */
-    public HubIOStream(String host, int port, int pollTime, @Nullable String user, @Nullable String pass) {
-        this.pollTime = pollTime;
-
-        StringBuilder s = new StringBuilder();
-        s.append("http://");
-        s.append(host);
-        if (port != -1) {
-            s.append(":").append(port);
-        }
-        baseUrl = s.toString();
-
-        if (user != null && pass != null) {
-            auth = "Basic " + Base64.getEncoder().encodeToString((user + ":" + pass).getBytes(StandardCharsets.UTF_8));
-        }
-    }
-
-    @Override
-    public boolean open() {
-        try {
-            clearBuffer();
-        } catch (IOException e) {
-            logger.warn("open failed: {}", e.getMessage());
-            return false;
-        }
-
-        in = new HubInputStream();
-        out = new HubOutputStream();
-
-        polling = true;
-        pollThread = new Thread(this);
-        setParamsAndStart(pollThread);
-
-        return true;
-    }
-
-    private void setParamsAndStart(@Nullable Thread thread) {
-        if (thread != null) {
-            thread.setName("OH-binding-" + InsteonBindingConstants.BINDING_ID + "-hubPoller");
-            thread.setDaemon(true);
-            thread.start();
-        }
-    }
-
-    @Override
-    public void close() {
-        polling = false;
-
-        if (pollThread != null) {
-            pollThread = null;
-        }
-
-        InputStream in = this.in;
-        if (in != null) {
-            try {
-                in.close();
-            } catch (IOException e) {
-                logger.warn("failed to close input stream", e);
-            }
-            this.in = null;
-        }
-
-        OutputStream out = this.out;
-        if (out != null) {
-            try {
-                out.close();
-            } catch (IOException e) {
-                logger.warn("failed to close output stream", e);
-            }
-            this.out = null;
-        }
-    }
-
-    /**
-     * Fetches the latest status buffer from the Hub
-     *
-     * @return string with status buffer
-     * @throws IOException
-     */
-    private synchronized String bufferStatus() throws IOException {
-        String result = getURL("/buffstatus.xml");
-
-        int start = result.indexOf(BS_START);
-        if (start == -1) {
-            throw new IOException("malformed bufferstatus.xml");
-        }
-        start += BS_START.length();
-
-        int end = result.indexOf(BS_END, start);
-        if (end == -1) {
-            throw new IOException("malformed bufferstatus.xml");
-        }
-
-        return result.substring(start, end).trim();
-    }
-
-    /**
-     * Sends command to Hub to clear the status buffer
-     *
-     * @throws IOException
-     */
-    private synchronized void clearBuffer() throws IOException {
-        logger.trace("clearing buffer");
-        getURL("/1?XB=M=1");
-        bufferIdx = 0;
-    }
-
-    /**
-     * Sends Insteon message (byte array) as a readable ascii string to the Hub
-     *
-     * @param msg byte array representing the Insteon message
-     * @throws IOException in case of I/O error
-     */
-    public synchronized void write(ByteBuffer msg) throws IOException {
-        poll(); // fetch the status buffer before we send out commands
-
-        StringBuilder b = new StringBuilder();
-        while (msg.remaining() > 0) {
-            b.append(String.format("%02x", msg.get()));
-        }
-        String hexMSG = b.toString();
-        logger.trace("writing a message");
-        getURL("/3?" + hexMSG + "=I=3");
-        bufferIdx = 0;
-    }
-
-    /**
-     * Polls the Hub web interface to fetch the status buffer
-     *
-     * @throws IOException if something goes wrong with I/O
-     */
-    public synchronized void poll() throws IOException {
-        String buffer = bufferStatus(); // fetch via http call
-        logger.trace("poll: {}", buffer);
-        //
-        // The Hub maintains a ring buffer where the last two digits (in hex!) represent
-        // the position of the last byte read.
-        //
-        String data = buffer.substring(0, buffer.length() - 2); // pure data w/o index pointer
-
-        int nIdx = -1;
-        try {
-            nIdx = Integer.parseInt(buffer.substring(buffer.length() - 2, buffer.length()), 16);
-        } catch (NumberFormatException e) {
-            bufferIdx = -1;
-            logger.warn("invalid buffer size received in line: {}", buffer);
-            return;
-        }
-
-        if (bufferIdx == -1) {
-            // this is the first call or first call after error, no need for buffer copying
-            bufferIdx = nIdx;
-            return; // XXX why return here????
-        }
-
-        if (allZeros(data)) {
-            logger.trace("skip cleared buffer");
-            bufferIdx = 0;
-            return;
-        }
-
-        StringBuilder msg = new StringBuilder();
-        if (nIdx < bufferIdx) {
-            String msgStart = data.substring(bufferIdx, data.length());
-            String msgEnd = data.substring(0, nIdx);
-            if (allZeros(msgStart)) {
-                logger.trace("discard cleared buffer wrap around msg start");
-                msgStart = "";
-            }
-
-            msg.append(msgStart + msgEnd);
-            logger.trace("wrap around: copying new data on: {}", msg.toString());
-        } else {
-            msg.append(data.substring(bufferIdx, nIdx));
-            logger.trace("no wrap:      appending new data: {}", msg.toString());
-        }
-        if (msg.length() != 0) {
-            ByteBuffer buf = ByteBuffer.wrap(hexStringToByteArray(msg.toString()));
-            InputStream in = this.in;
-            if (in != null) {
-                ((HubInputStream) in).handle(buf);
-            } else {
-                logger.warn("in is null");
-            }
-        }
-        bufferIdx = nIdx;
-    }
-
-    private boolean allZeros(String s) {
-        return "0".repeat(s.length()).equals(s);
-    }
-
-    /**
-     * Helper method to fetch url from http server
-     *
-     * @param resource the url
-     * @return contents returned by http server
-     * @throws IOException
-     */
-    private String getURL(String resource) throws IOException {
-        String url = baseUrl + resource;
-
-        HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
-        try {
-            connection.setConnectTimeout(30000);
-            connection.setReadTimeout(10000);
-            connection.setUseCaches(false);
-            connection.setDoInput(true);
-            connection.setDoOutput(false);
-            if (auth != null) {
-                connection.setRequestProperty("Authorization", auth);
-            }
-
-            logger.debug("getting {}", url);
-
-            int responseCode = connection.getResponseCode();
-            if (responseCode != 200) {
-                if (responseCode == 401) {
-                    logger.warn(
-                            "Bad username or password. See the label on the bottom of the hub for the correct login information.");
-                    throw new IOException("login credentials are incorrect");
-                } else {
-                    String message = url + " failed with the response code: " + responseCode;
-                    logger.warn(message);
-                    throw new IOException(message);
-                }
-            }
-
-            return getData(connection.getInputStream());
-        } finally {
-            connection.disconnect();
-        }
-    }
-
-    private String getData(InputStream is) throws IOException {
-        BufferedInputStream bis = new BufferedInputStream(is);
-        try {
-            ByteArrayOutputStream baos = new ByteArrayOutputStream();
-            byte[] buffer = new byte[1024];
-            int length = 0;
-            while ((length = bis.read(buffer)) != -1) {
-                baos.write(buffer, 0, length);
-            }
-
-            String s = baos.toString();
-            return s;
-        } finally {
-            bis.close();
-        }
-    }
-
-    /**
-     * Entry point for thread
-     */
-    @Override
-    public void run() {
-        while (polling) {
-            try {
-                poll();
-            } catch (IOException e) {
-                logger.warn("got exception while polling: {}", e.toString());
-            }
-            try {
-                Thread.sleep(pollTime);
-            } catch (InterruptedException e) {
-                break;
-            }
-        }
-    }
-
-    /**
-     * Helper function to convert an ascii hex string (received from hub)
-     * into a byte array
-     *
-     * @param s string received from hub
-     * @return simple byte array
-     */
-    public static byte[] hexStringToByteArray(String s) {
-        int len = s.length();
-        byte[] bytes = new byte[len / 2];
-        for (int i = 0; i < len; i += 2) {
-            bytes[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16));
-        }
-
-        return bytes;
-    }
-
-    /**
-     * Implements an InputStream for the Hub 2014
-     *
-     * @author Daniel Pfrommer - Initial contribution
-     *
-     */
-    public class HubInputStream extends InputStream {
-
-        // A buffer to keep bytes while we are waiting for the inputstream to read
-        private ReadByteBuffer buffer = new ReadByteBuffer(1024);
-
-        public HubInputStream() {
-        }
-
-        public void handle(ByteBuffer b) throws IOException {
-            // Make sure we cleanup as much space as possible
-            buffer.makeCompact();
-            buffer.add(b.array());
-        }
-
-        @Override
-        public int read() throws IOException {
-            return buffer.get();
-        }
-
-        @Override
-        public int read(byte @Nullable [] b, int off, int len) throws IOException {
-            return buffer.get(b, off, len);
-        }
-
-        @Override
-        public void close() throws IOException {
-            buffer.done();
-        }
-    }
-
-    /**
-     * Implements an OutputStream for the Hub 2014
-     *
-     * @author Daniel Pfrommer - Initial contribution
-     *
-     */
-    public class HubOutputStream extends OutputStream {
-        private ByteArrayOutputStream out = new ByteArrayOutputStream();
-
-        @Override
-        public void write(int b) {
-            out.write(b);
-            flushBuffer();
-        }
-
-        @Override
-        public void write(byte @Nullable [] b, int off, int len) {
-            out.write(b, off, len);
-            flushBuffer();
-        }
-
-        private void flushBuffer() {
-            ByteBuffer buffer = ByteBuffer.wrap(out.toByteArray());
-            try {
-                HubIOStream.this.write(buffer);
-            } catch (IOException e) {
-                logger.warn("failed to write to hub: {}", e.toString());
-            }
-            out.reset();
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/hub/ReadByteBuffer.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/driver/hub/ReadByteBuffer.java
deleted file mode 100644 (file)
index c9c8ae2..0000000
+++ /dev/null
@@ -1,158 +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.driver.hub;
-
-import java.io.IOException;
-import java.util.Arrays;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-/**
- * ReadByteBuffer buffer class
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class ReadByteBuffer {
-    private byte buf[]; // the actual buffer
-    private int count; // number of valid bytes
-    private int index = 0; // current read index
-    private boolean done = false;
-
-    /**
-     * Constructor for ByteArrayIO with dynamic size
-     *
-     * @param size initial size, but will grow dynamically
-     */
-    public ReadByteBuffer(int size) {
-        this.buf = new byte[size];
-    }
-
-    /**
-     * Done reading bytes
-     */
-    public synchronized void done() {
-        done = true;
-        notifyAll();
-    }
-
-    /**
-     * Number of unread bytes
-     *
-     * @return number of bytes not yet read
-     */
-    public synchronized int remaining() {
-        return count - index;
-    }
-
-    /**
-     * Blocking read of a single byte
-     *
-     * @return byte read
-     * @throws IOException
-     */
-    public synchronized byte get() throws IOException {
-        while (!done && remaining() < 1) {
-            try {
-                wait();
-            } catch (InterruptedException e) {
-                throw new IOException("interrupted");
-            }
-        }
-
-        if (done) {
-            throw new IOException("done");
-        }
-
-        return buf[index++];
-    }
-
-    /**
-     * Blocking read of multiple bytes
-     *
-     * @param bytes destination array for bytes read
-     * @param off offset into dest array
-     * @param len max number of bytes to read into dest array
-     * @return number of bytes actually read
-     * @throws IOException
-     */
-    public synchronized int get(byte @Nullable [] bytes, int off, int len) throws IOException {
-        while (!done && remaining() < 1) {
-            try {
-                wait();
-            } catch (InterruptedException e) {
-                throw new IOException("interrupted");
-            }
-        }
-
-        if (done) {
-            throw new IOException("done");
-        }
-
-        int b = Math.min(len, remaining());
-        if (bytes != null) {
-            System.arraycopy(buf, index, bytes, off, b);
-        }
-        index += b;
-        return b;
-    }
-
-    /**
-     * Adds bytes to the byte buffer
-     *
-     * @param b byte array with new bytes
-     * @param off starting offset into buffer
-     * @param len number of bytes to add
-     */
-    private synchronized void add(byte b[], int off, int len) {
-        if ((off < 0) || (off > b.length) || (len < 0) || ((off + len) > b.length) || ((off + len) < 0)) {
-            throw new IndexOutOfBoundsException();
-        } else if (len == 0) {
-            return;
-        }
-        int nCount = count + len;
-        if (nCount > buf.length) {
-            // dynamically grow the array
-            buf = Arrays.copyOf(buf, Math.max(buf.length << 1, nCount));
-        }
-        // append new data to end of buffer
-        System.arraycopy(b, off, buf, count, len);
-        count = nCount;
-        notifyAll();
-    }
-
-    /**
-     * Adds bytes to the byte buffer
-     *
-     * @param b the new bytes to be added
-     */
-    public void add(byte[] b) {
-        add(b, 0, b.length);
-    }
-
-    /**
-     * Shrink the buffer to smallest size possible
-     */
-    public synchronized void makeCompact() {
-        if (index == 0) {
-            return;
-        }
-        byte[] newBuf = new byte[remaining()];
-        System.arraycopy(buf, index, newBuf, 0, newBuf.length);
-        index = 0;
-        count = newBuf.length;
-        buf = newBuf;
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonBaseThingHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonBaseThingHandler.java
new file mode 100644 (file)
index 0000000..42c245c
--- /dev/null
@@ -0,0 +1,380 @@
+/**
+ * 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.handler;
+
+import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+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.config.InsteonChannelConfiguration;
+import org.openhab.binding.insteon.internal.device.Device;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+import org.openhab.binding.insteon.internal.device.DeviceType;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.device.ProductData;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.openhab.core.thing.type.ChannelKind;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link InsteonBaseThingHandler} represents an insteon base thing handler.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public abstract class InsteonBaseThingHandler extends BaseThingHandler implements InsteonThingHandler {
+    private static final Pattern CHANNEL_ID_PATTERN = Pattern.compile("-([a-z])");
+    private static final Pattern FEATURE_NAME_PATTERN = Pattern.compile("(?!^)(?=[A-Z])");
+
+    protected final Logger logger = LoggerFactory.getLogger(getClass());
+
+    private Map<ChannelUID, InsteonChannelHandler> channelHandlers = new ConcurrentHashMap<>();
+
+    public InsteonBaseThingHandler(Thing thing) {
+        super(thing);
+    }
+
+    public @Nullable InsteonModem getModem() {
+        return Optional.ofNullable(getInsteonBridgeHandler()).map(InsteonBridgeHandler::getModem).orElse(null);
+    }
+
+    protected @Nullable InsteonBridgeHandler getInsteonBridgeHandler() {
+        return Optional.ofNullable(getBridge()).map(Bridge::getHandler).filter(InsteonBridgeHandler.class::isInstance)
+                .map(InsteonBridgeHandler.class::cast).orElse(null);
+    }
+
+    @Override
+    public void channelLinked(ChannelUID channelUID) {
+        logger.debug("channel {} linked", channelUID);
+
+        InsteonModem modem = getModem();
+        if (modem == null || !modem.getDB().isComplete()) {
+            logger.debug("channel {} linking skipped because modem database not complete yet.", channelUID);
+            return;
+        }
+
+        if (channelHandlers.containsKey(channelUID)) {
+            logger.debug("channel {} linking skipped because it is already configured", channelUID);
+            return;
+        }
+
+        Channel channel = getThing().getChannel(channelUID.getId());
+        if (channel == null) {
+            logger.warn("channel {} unknown, it will be ignored", channelUID);
+            return;
+        }
+
+        Device device = getDevice();
+        if (device == null || device.getFeatures().isEmpty()) {
+            logger.debug("channel {} references uninitialized device, it will be ignored", channelUID);
+            return;
+        }
+
+        ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
+        if (channelTypeUID == null) {
+            logger.debug("channel {} references unknown channel type uid, it will be ignored", channelUID);
+            return;
+        }
+
+        String featureName = channelIdToFeatureName(channelTypeUID.getId());
+        DeviceFeature feature = device.getFeature(featureName);
+        if (feature == null) {
+            logger.warn("channel {} references unknown feature {} for device {}, it will be ignored", channelUID,
+                    featureName, device.getAddress());
+            return;
+        }
+
+        InsteonChannelHandler channelHandler = InsteonChannelHandler.makeHandler(channel, feature, this);
+        channelHandlers.put(channelUID, channelHandler);
+
+        if (logger.isDebugEnabled()) {
+            logger.debug("{}", getChannelInfo(channelUID));
+        }
+    }
+
+    @Override
+    public void channelUnlinked(ChannelUID channelUID) {
+        logger.debug("channel {} unlinked", channelUID);
+
+        InsteonChannelHandler channelHandler = channelHandlers.remove(channelUID);
+        if (channelHandler != null) {
+            channelHandler.dispose();
+        }
+    }
+
+    @Override
+    public void dispose() {
+        channelHandlers.clear();
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        logger.debug("channel {} received command {}", channelUID, command);
+
+        ThingStatus status = getThing().getStatus();
+        if (status != ThingStatus.ONLINE) {
+            logger.debug("thing {} not ready to handle commands, it will be ignored", getThing().getUID());
+            return;
+        }
+
+        InsteonChannelHandler channelHandler = channelHandlers.get(channelUID);
+        if (channelHandler == null) {
+            logger.warn("unable to find channel handler for {}", channelUID);
+        } else {
+            channelHandler.handleCommand(command);
+        }
+    }
+
+    @Override
+    public void updateState(ChannelUID channelUID, State state) {
+        logger.debug("publishing state {} on {}", state, channelUID);
+
+        super.updateState(channelUID, state);
+    }
+
+    @Override
+    public void triggerChannel(ChannelUID channelUID, String event) {
+        logger.debug("triggering event {} on {}", event, channelUID);
+
+        super.triggerChannel(channelUID, event);
+    }
+
+    @Override
+    public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
+        if (getThing().getStatus() != ThingStatus.OFFLINE
+                || getThing().getStatusInfo().getStatusDetail() == ThingStatusDetail.BRIDGE_OFFLINE) {
+            updateStatus();
+        }
+    }
+
+    @Override
+    public void bridgeThingDisposed() {
+        // can be overridden by subclasses
+    }
+
+    @Override
+    public void bridgeThingUpdated(InsteonBridgeConfiguration config, InsteonModem modem) {
+        // can be overridden by subclasses
+    }
+
+    @Override
+    public String getThingId() {
+        return getThing().getUID().getId();
+    }
+
+    public String getThingInfo() {
+        String thingId = getThingId();
+        String config = getConfigInfo();
+        String channels = getThing().getChannels().stream().map(Channel::getUID).map(ChannelUID::getId)
+                .collect(Collectors.joining(","));
+
+        StringBuilder builder = new StringBuilder(thingId);
+        builder.append(":");
+        builder.append(config);
+        if (!channels.isEmpty()) {
+            builder.append(" channels=");
+            builder.append(channels);
+        }
+        builder.append(" status=");
+        builder.append(getThing().getStatus());
+
+        return builder.toString();
+    }
+
+    protected abstract String getConfigInfo();
+
+    public Map<String, String> getChannelsInfo() {
+        return getThing().getChannels().stream().map(Channel::getUID)
+                .collect(Collectors.toMap(ChannelUID::getAsString, this::getChannelInfo));
+    }
+
+    private String getChannelInfo(ChannelUID channelUID) {
+        Channel channel = getThing().getChannel(channelUID.getId());
+        if (channel == null) {
+            return "unknown channel " + channelUID;
+        }
+        ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
+        if (channelTypeUID == null) {
+            return "unknown channel type uid for " + channelUID;
+        }
+        InsteonChannelConfiguration config = channel.getConfiguration().as(InsteonChannelConfiguration.class);
+        String featureName = channelIdToFeatureName(channelTypeUID.getId());
+
+        StringBuilder builder = new StringBuilder(channelUID.getAsString());
+        builder.append(config);
+        builder.append(" feature=");
+        builder.append(featureName);
+        builder.append(" kind=");
+        builder.append(channel.getKind());
+        builder.append(" isLinked=");
+        builder.append(isLinked(channelUID));
+
+        return builder.toString();
+    }
+
+    protected String channelIdToFeatureName(String channelId) {
+        return CHANNEL_ID_PATTERN.matcher(channelId).replaceAll(matchResult -> matchResult.group(1).toUpperCase());
+    }
+
+    protected String featureNameToChannelId(String featureName) {
+        return FEATURE_NAME_PATTERN.matcher(featureName).replaceAll("-").toLowerCase();
+    }
+
+    protected void initializeChannels(Device device) {
+        DeviceType deviceType = device.getType();
+        if (deviceType == null) {
+            return;
+        }
+
+        String deviceTypeName = deviceType.getName();
+        List<Channel> channels = new ArrayList<>();
+
+        for (DeviceFeature feature : device.getFeatures()) {
+            String featureName = feature.getName();
+            if (feature.isGroupFeature()) {
+                logger.trace("{} is a group feature for {}. It will not be added as a channel.", featureName,
+                        deviceTypeName);
+            } else if (feature.isHiddenFeature()) {
+                logger.trace("{} is a hidden feature for {}. It will not be added as a channel.", featureName,
+                        deviceTypeName);
+            } else {
+                String channelId = featureNameToChannelId(featureName);
+                Channel channel = createChannel(channelId);
+                if (channel != null) {
+                    logger.trace("adding channel {}", channel.getUID());
+                    channels.add(channel);
+                } else {
+                    logger.warn("unable to create channel {} for {}", channelId, deviceTypeName);
+                }
+                // add existing custom channels with the same channel type id but different channel id
+                for (Channel customChannel : getCustomChannels(channelId)) {
+                    logger.trace("adding custom channel {}", customChannel.getUID());
+                    channels.add(customChannel);
+                }
+            }
+        }
+
+        updateThing(editThing().withChannels(channels).build());
+    }
+
+    private @Nullable Channel createChannel(String channelId) {
+        ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelId);
+        ChannelTypeUID channelTypeUID = new ChannelTypeUID(BINDING_ID, channelId);
+        Channel channel = getThing().getChannel(channelUID);
+        ThingHandlerCallback callback = getCallback();
+        // create channel if not already available
+        if (channel == null && callback != null) {
+            channel = callback.createChannelBuilder(channelUID, channelTypeUID).build();
+        }
+        return channel;
+    }
+
+    private List<Channel> getCustomChannels(String channelId) {
+        ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelId);
+        ChannelTypeUID channelTypeUID = new ChannelTypeUID(BINDING_ID, channelId);
+        return getThing().getChannels().stream().filter(
+                channel -> channelTypeUID.equals(channel.getChannelTypeUID()) && !channelUID.equals(channel.getUID()))
+                .toList();
+    }
+
+    private void linkChannels() {
+        getThing().getChannels().stream()
+                .filter(channel -> isLinked(channel.getUID()) || channel.getKind() == ChannelKind.TRIGGER)
+                .filter(channel -> !channelHandlers.containsKey(channel.getUID())).map(Channel::getUID)
+                .forEach(this::channelLinked);
+    }
+
+    @Override
+    public void refresh() {
+        InsteonModem modem = getModem();
+        if (modem != null && modem.getDB().isComplete()) {
+            if (logger.isDebugEnabled()) {
+                logger.debug("{}", getThingInfo());
+            }
+            linkChannels();
+        }
+
+        updateStatus();
+    }
+
+    public abstract void updateStatus();
+
+    public void updateProperties(Device device) {
+        Map<String, String> properties = editProperties();
+
+        String serialNumber = device.getAddress().toString().replace(".", "");
+        properties.put(Thing.PROPERTY_SERIAL_NUMBER, serialNumber);
+
+        ProductData productData = device.getProductData();
+        if (productData != null) {
+            String vendor = productData.getVendor();
+            if (vendor != null) {
+                properties.put(Thing.PROPERTY_VENDOR, vendor);
+            }
+            String model = productData.getModel();
+            if (model != null) {
+                properties.put(Thing.PROPERTY_MODEL_ID, model);
+            }
+            int hardware = productData.getHardwareVersion();
+            if (hardware != 0) {
+                properties.put(Thing.PROPERTY_HARDWARE_VERSION, HexUtils.getHexString(hardware));
+            }
+            int firmware = productData.getFirmwareVersion();
+            if (firmware != 0) {
+                properties.put(Thing.PROPERTY_FIRMWARE_VERSION, HexUtils.getHexString(firmware));
+            }
+            String productId = productData.getProductId();
+            if (productId != null) {
+                properties.put(PROPERTY_PRODUCT_ID, productId);
+            }
+            DeviceType deviceType = productData.getDeviceType();
+            if (deviceType != null) {
+                properties.put(PROPERTY_DEVICE_TYPE, deviceType.getName());
+            }
+        }
+
+        logger.trace("updating properties for {} to {}", getThing().getUID(), properties);
+
+        updateProperties(properties);
+    }
+
+    protected void cancelJob(@Nullable ScheduledFuture<?> job, boolean interrupt) {
+        if (job != null) {
+            job.cancel(interrupt);
+            job = null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonBridgeHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonBridgeHandler.java
new file mode 100644 (file)
index 0000000..6e8f63e
--- /dev/null
@@ -0,0 +1,382 @@
+/**
+ * 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.handler;
+
+import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
+
+import java.util.Optional;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+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.config.InsteonHub1Configuration;
+import org.openhab.binding.insteon.internal.config.InsteonHub2Configuration;
+import org.openhab.binding.insteon.internal.config.InsteonPLMConfiguration;
+import org.openhab.binding.insteon.internal.device.Device;
+import org.openhab.binding.insteon.internal.device.DeviceAddress;
+import org.openhab.binding.insteon.internal.device.DeviceCache;
+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.discovery.InsteonDiscoveryService;
+import org.openhab.core.io.transport.serial.SerialPortManager;
+import org.openhab.core.storage.Storage;
+import org.openhab.core.storage.StorageService;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingRegistry;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BridgeHandler;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.builder.BridgeBuilder;
+
+/**
+ * The {@link InsteonBridgeHandler} represents an insteon bridge handler.
+ *
+ * @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class InsteonBridgeHandler extends InsteonBaseThingHandler implements BridgeHandler {
+    private static final int DEVICE_STATISTICS_INTERVAL = 600; // seconds
+    private static final int RETRY_INTERVAL = 30; // seconds
+    private static final int START_DELAY = 5; // seconds
+
+    private @Nullable InsteonModem modem;
+    private @Nullable InsteonDiscoveryService discoveryService;
+    private @Nullable ScheduledFuture<?> connectJob;
+    private @Nullable ScheduledFuture<?> reconnectJob;
+    private @Nullable ScheduledFuture<?> resetJob;
+    private @Nullable ScheduledFuture<?> statisticsJob;
+    private SerialPortManager serialPortManager;
+    private Storage<DeviceCache> storage;
+    private ThingRegistry thingRegistry;
+
+    public InsteonBridgeHandler(Bridge bridge, SerialPortManager serialPortManager, StorageService storageService,
+            ThingRegistry thingRegistry) {
+        super(bridge);
+        this.serialPortManager = serialPortManager;
+        this.storage = storageService.getStorage(bridge.getUID().toString(), DeviceCache.class.getClassLoader());
+        this.thingRegistry = thingRegistry;
+    }
+
+    @Override
+    public Bridge getThing() {
+        return (Bridge) super.getThing();
+    }
+
+    @Override
+    public @Nullable InsteonModem getDevice() {
+        return getModem();
+    }
+
+    @Override
+    public @Nullable InsteonModem getModem() {
+        return modem;
+    }
+
+    public @Nullable ProductData getProductData(DeviceAddress address) {
+        return Optional.ofNullable(getDeviceCache(address)).map(DeviceCache::getProductData)
+                .orElse(Optional.ofNullable(modem).map(modem -> modem.getProductData(address)).orElse(null));
+    }
+
+    protected InsteonBridgeConfiguration getBridgeConfig() {
+        ThingTypeUID thingTypeUID = getThing().getThingTypeUID();
+        if (THING_TYPE_HUB1.equals(thingTypeUID)) {
+            return getConfigAs(InsteonHub1Configuration.class);
+        } else if (THING_TYPE_HUB2.equals(thingTypeUID)) {
+            return getConfigAs(InsteonHub2Configuration.class);
+        } else if (THING_TYPE_PLM.equals(thingTypeUID)) {
+            return getConfigAs(InsteonPLMConfiguration.class);
+        } else {
+            throw new UnsupportedOperationException("Unsupported bridge configuration");
+        }
+    }
+
+    public int getDevicePollInterval() {
+        return getBridgeConfig().getDevicePollInterval();
+    }
+
+    public boolean isDeviceDiscoveryEnabled() {
+        return getBridgeConfig().isDeviceDiscoveryEnabled();
+    }
+
+    public boolean isSceneDiscoveryEnabled() {
+        return getBridgeConfig().isSceneDiscoveryEnabled();
+    }
+
+    public boolean isDeviceSyncEnabled() {
+        return getBridgeConfig().isDeviceSyncEnabled();
+    }
+
+    protected @Nullable InsteonDiscoveryService getDiscoveryService() {
+        return discoveryService;
+    }
+
+    public void setDiscoveryService(InsteonDiscoveryService discoveryService) {
+        this.discoveryService = discoveryService;
+    }
+
+    public @Nullable DeviceCache getDeviceCache(DeviceAddress address) {
+        return storage.get(address.toString());
+    }
+
+    public void loadDeviceCache(Device device) {
+        DeviceCache cache = getDeviceCache(device.getAddress());
+        if (cache != null) {
+            cache.load(device);
+        }
+    }
+
+    public void storeDeviceCache(DeviceAddress address, DeviceCache cache) {
+        storage.put(address.toString(), cache);
+    }
+
+    @Override
+    public void initialize() {
+        logger.debug("starting bridge {}", getThing().getUID());
+
+        InsteonBridgeConfiguration config = getBridgeConfig();
+        if (isDuplicateBridge(config)) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Duplicate bridge.");
+            return;
+        }
+
+        InsteonLegacyNetworkHandler legacyHandler = getLegacyNetworkHandler(config);
+        if (legacyHandler != null) {
+            logger.info("Disabling Insteon legacy network bridge {} in favor of bridge {}",
+                    legacyHandler.getThing().getUID(), getThing().getUID());
+            legacyHandler.disable();
+        }
+
+        InsteonModem modem = InsteonModem.makeModem(this, config, scheduler, serialPortManager);
+        this.modem = modem;
+
+        if (isInitialized()) {
+            getChildHandlers().forEach(handler -> handler.bridgeThingUpdated(config, modem));
+        }
+
+        updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Connecting to modem.");
+
+        scheduler.execute(() -> {
+            connectJob = scheduler.scheduleWithFixedDelay(() -> {
+                if (!modem.connect()) {
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                            "Unable to connect to modem.");
+                    return;
+                }
+
+                statisticsJob = scheduler.scheduleWithFixedDelay(() -> modem.logDeviceStatistics(), 0,
+                        DEVICE_STATISTICS_INTERVAL, TimeUnit.SECONDS);
+
+                cancelJob(connectJob, false);
+            }, START_DELAY, RETRY_INTERVAL, TimeUnit.SECONDS);
+        });
+    }
+
+    @Override
+    public void dispose() {
+        logger.debug("shutting down bridge {}", getThing().getUID());
+
+        cancelJob(connectJob, true);
+        cancelJob(reconnectJob, true);
+        cancelJob(resetJob, true);
+        cancelJob(statisticsJob, true);
+
+        getChildHandlers().forEach(InsteonThingHandler::bridgeThingDisposed);
+
+        InsteonModem modem = getModem();
+        if (modem != null) {
+            if (modem.isInitialized()) {
+                storeDeviceCache(modem.getAddress(), DeviceCache.builder().withProductData(modem.getProductData())
+                        .withDatabase(modem.getDB()).withFeatures(modem.getFeatures()).build());
+            }
+            modem.stopPolling();
+            modem.disconnect();
+        }
+        this.modem = null;
+
+        super.dispose();
+    }
+
+    @Override
+    protected BridgeBuilder editThing() {
+        return BridgeBuilder.create(thing.getThingTypeUID(), thing.getUID()).withBridge(thing.getBridgeUID())
+                .withChannels(thing.getChannels()).withConfiguration(thing.getConfiguration())
+                .withLabel(thing.getLabel()).withLocation(thing.getLocation()).withProperties(thing.getProperties());
+    }
+
+    @Override
+    public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
+        logger.debug("added thing {}", childThing.getUID());
+    }
+
+    @Override
+    public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
+        logger.debug("removed thing {}", childThing.getUID());
+    }
+
+    @Override
+    protected String getConfigInfo() {
+        return getBridgeConfig().toString();
+    }
+
+    @Override
+    public void updateStatus() {
+        InsteonModem modem = getModem();
+        if (modem == null || !modem.isInitialized()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unable to determine modem.");
+            return;
+        }
+
+        if (!modem.getDB().isComplete()) {
+            updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Loading modem database.");
+            return;
+        }
+
+        updateStatus(ThingStatus.ONLINE);
+    }
+
+    public Stream<InsteonThingHandler> getChildHandlers() {
+        return getThing().getThings().stream().filter(Thing::isEnabled).map(Thing::getHandler)
+                .filter(InsteonThingHandler.class::isInstance).map(InsteonThingHandler.class::cast);
+    }
+
+    private @Nullable InsteonLegacyNetworkHandler getLegacyNetworkHandler(InsteonBridgeConfiguration config) {
+        return thingRegistry.getAll().stream().filter(Thing::isEnabled).map(Thing::getHandler)
+                .filter(InsteonLegacyNetworkHandler.class::isInstance).map(InsteonLegacyNetworkHandler.class::cast)
+                .filter(handler -> config.equals(handler.getBridgeConfig())).findFirst().orElse(null);
+    }
+
+    private boolean isDuplicateBridge(InsteonBridgeConfiguration config) {
+        return thingRegistry.getAll().stream().filter(Thing::isEnabled).map(Thing::getHandler)
+                .filter(InsteonBridgeHandler.class::isInstance).map(InsteonBridgeHandler.class::cast)
+                .anyMatch(handler -> !this.equals(handler) && config.equals(handler.getBridgeConfig()));
+    }
+
+    private void cleanUpStorage() {
+        storage.getKeys().stream().filter(InsteonAddress::isValid).map(InsteonAddress::new)
+                .forEach(this::cleanUpStorage);
+    }
+
+    private void cleanUpStorage(InsteonAddress address) {
+        InsteonModem modem = getModem();
+        if (modem != null && modem.getDB().isComplete() && !modem.getDB().hasEntry(address)
+                && !modem.getAddress().equals(address)) {
+            storage.remove(address.toString());
+        }
+    }
+
+    protected void discoverInsteonDevice(InsteonAddress address, @Nullable ProductData productData) {
+        InsteonDiscoveryService discoveryService = getDiscoveryService();
+        if (discoveryService != null) {
+            scheduler.execute(() -> discoveryService.discoverInsteonDevice(address, productData));
+        }
+    }
+
+    protected void discoverInsteonScene(int group) {
+        InsteonDiscoveryService discoveryService = getDiscoveryService();
+        if (discoveryService != null) {
+            scheduler.execute(() -> discoveryService.discoverInsteonScene(group));
+        }
+    }
+
+    protected void discoverMissingThings() {
+        InsteonDiscoveryService discoveryService = getDiscoveryService();
+        if (discoveryService != null) {
+            scheduler.execute(() -> discoveryService.discoverMissingThings());
+        }
+    }
+
+    public void reconnect(InsteonModem modem) {
+        reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
+            if (!modem.reconnect()) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                        "Unable to reconnect to modem.");
+                return;
+            }
+
+            cancelJob(reconnectJob, false);
+            updateStatus();
+        }, 0, RETRY_INTERVAL, TimeUnit.SECONDS);
+    }
+
+    public void reset(long delay) {
+        scheduler.execute(() -> {
+            logger.trace("resetting bridge {}", getThing().getUID());
+
+            dispose();
+
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.DUTY_CYCLE, "Resetting bridge.");
+
+            resetJob = scheduler.schedule(() -> {
+                initialize();
+                cancelJob(resetJob, false);
+            }, delay, TimeUnit.SECONDS);
+        });
+    }
+
+    /**
+     * Notifies that the modem has been discovered
+     *
+     * @param modem the discovered modem
+     */
+    public void modemDiscovered(InsteonModem modem) {
+        modem.setPollInterval(getDevicePollInterval());
+
+        initializeChannels(modem);
+        updateProperties(modem);
+        loadDeviceCache(modem);
+
+        if (!modem.getDB().isComplete()) {
+            modem.getDB().load();
+        }
+
+        updateStatus();
+    }
+
+    /**
+     * Notifies that the modem database has completed
+     */
+    public void modemDBCompleted() {
+        discoverMissingThings();
+        cleanUpStorage();
+    }
+
+    /**
+     * Notifies that a modem database link has been updated
+     *
+     * @param address the link address
+     * @param group the link group
+     */
+    public void modemDBLinkUpdated(InsteonAddress address, int group) {
+        discoverInsteonDevice(address, getProductData(address));
+        discoverInsteonScene(group);
+        cleanUpStorage(address);
+    }
+
+    /**
+     * Notifies that a modem database product data has been updated
+     *
+     * @param address the device address
+     * @param productData the product data
+     */
+    public void modemDBProductDataUpdated(InsteonAddress address, ProductData productData) {
+        discoverInsteonDevice(address, productData);
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonChannelHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonChannelHandler.java
new file mode 100644 (file)
index 0000000..3ada909
--- /dev/null
@@ -0,0 +1,86 @@
+/**
+ * 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.handler;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.config.InsteonChannelConfiguration;
+import org.openhab.binding.insteon.internal.device.DeviceFeature;
+import org.openhab.binding.insteon.internal.device.feature.FeatureListener;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * The {@link InsteonChannelHandler} represents an insteon channel handler.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class InsteonChannelHandler implements FeatureListener {
+    private ChannelUID channelUID;
+    private InsteonChannelConfiguration config;
+    private DeviceFeature feature;
+    private InsteonThingHandler handler;
+
+    public InsteonChannelHandler(ChannelUID channelUID, InsteonChannelConfiguration config, DeviceFeature feature,
+            InsteonThingHandler handler) {
+        this.channelUID = channelUID;
+        this.config = config;
+        this.feature = feature;
+        this.handler = handler;
+
+        feature.registerListener(this);
+    }
+
+    public void dispose() {
+        feature.unregisterListener(this);
+    }
+
+    public void handleCommand(Command command) {
+        feature.handleCommand(config, command);
+    }
+
+    @Override
+    public void stateUpdated(State state) {
+        handler.updateState(channelUID, state);
+    }
+
+    @Override
+    public void eventTriggered(String event) {
+        handler.triggerChannel(channelUID, event);
+    }
+
+    /**
+     * Factory method for creating a InsteonChannelHandler from a channel uid, feature and parameters
+     *
+     * @param channel the channel
+     * @param feature the device feature
+     * @param thingHandler the thing handler
+     * @return the newly created InsteonChannelHandler
+     */
+    public static InsteonChannelHandler makeHandler(Channel channel, DeviceFeature feature,
+            InsteonThingHandler thingHandler) {
+        ChannelUID channelUID = channel.getUID();
+        InsteonChannelConfiguration config = channel.getConfiguration().as(InsteonChannelConfiguration.class);
+        InsteonChannelHandler channelHandler = new InsteonChannelHandler(channelUID, config, feature, thingHandler);
+
+        State state = feature.getState();
+        if (state != UnDefType.NULL) {
+            thingHandler.updateState(channelUID, state);
+        }
+
+        return channelHandler;
+    }
+}
index 45f80a2f7d98be3173e967efe64df14c813d0fae..bd415cf4c5d1593b4158ed826f5528f0c15409bc 100644 (file)
  */
 package org.openhab.binding.insteon.internal.handler;
 
-import java.lang.reflect.Type;
-import java.math.BigDecimal;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
+import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
+
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.insteon.internal.InsteonBinding;
-import org.openhab.binding.insteon.internal.InsteonBindingConstants;
-import org.openhab.binding.insteon.internal.config.InsteonChannelConfiguration;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.InsteonStateDescriptionProvider;
+import org.openhab.binding.insteon.internal.config.InsteonBridgeConfiguration;
 import org.openhab.binding.insteon.internal.config.InsteonDeviceConfiguration;
-import org.openhab.binding.insteon.internal.device.DeviceFeature;
-import org.openhab.binding.insteon.internal.device.DeviceTypeLoader;
+import org.openhab.binding.insteon.internal.device.Device;
+import org.openhab.binding.insteon.internal.device.DeviceCache;
+import org.openhab.binding.insteon.internal.device.DeviceType;
 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.core.config.core.Configuration;
 import org.openhab.core.thing.Bridge;
 import org.openhab.core.thing.Channel;
 import org.openhab.core.thing.ChannelUID;
 import org.openhab.core.thing.Thing;
 import org.openhab.core.thing.ThingStatus;
 import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.binding.BaseThingHandler;
-import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BridgeHandler;
 import org.openhab.core.thing.type.ChannelTypeUID;
-import org.openhab.core.types.Command;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import com.google.gson.Gson;
-import com.google.gson.JsonParseException;
-import com.google.gson.reflect.TypeToken;
+import org.openhab.core.types.StateOption;
+import org.openhab.core.util.StringUtils;
 
 /**
- * The {@link InsteonDeviceHandler} is responsible for handling commands, which are
- * sent to one of the channels.
+ * The {@link InsteonDeviceHandler} represents an Insteon device handler.
  *
- * @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Initial contribution
  */
 @NonNullByDefault
-public class InsteonDeviceHandler extends BaseThingHandler {
-
-    private static final Set<String> ALL_CHANNEL_IDS = Collections.unmodifiableSet(Stream.of(
-            InsteonBindingConstants.AC_DELAY, InsteonBindingConstants.BACKLIGHT_DURATION,
-            InsteonBindingConstants.BATTERY_LEVEL, InsteonBindingConstants.BATTERY_PERCENT,
-            InsteonBindingConstants.BATTERY_WATERMARK_LEVEL, InsteonBindingConstants.BEEP,
-            InsteonBindingConstants.BOTTOM_OUTLET, InsteonBindingConstants.BUTTON_A, InsteonBindingConstants.BUTTON_B,
-            InsteonBindingConstants.BUTTON_C, InsteonBindingConstants.BUTTON_D, InsteonBindingConstants.BUTTON_E,
-            InsteonBindingConstants.BUTTON_F, InsteonBindingConstants.BUTTON_G, InsteonBindingConstants.BUTTON_H,
-            InsteonBindingConstants.BROADCAST_ON_OFF, InsteonBindingConstants.CONTACT,
-            InsteonBindingConstants.COOL_SET_POINT, InsteonBindingConstants.DIMMER, InsteonBindingConstants.FAN,
-            InsteonBindingConstants.FAN_MODE, InsteonBindingConstants.FAST_ON_OFF,
-            InsteonBindingConstants.FAST_ON_OFF_BUTTON_A, InsteonBindingConstants.FAST_ON_OFF_BUTTON_B,
-            InsteonBindingConstants.FAST_ON_OFF_BUTTON_C, InsteonBindingConstants.FAST_ON_OFF_BUTTON_D,
-            InsteonBindingConstants.FAST_ON_OFF_BUTTON_E, InsteonBindingConstants.FAST_ON_OFF_BUTTON_F,
-            InsteonBindingConstants.FAST_ON_OFF_BUTTON_G, InsteonBindingConstants.FAST_ON_OFF_BUTTON_H,
-            InsteonBindingConstants.HEAT_SET_POINT, InsteonBindingConstants.HUMIDITY,
-            InsteonBindingConstants.HUMIDITY_HIGH, InsteonBindingConstants.HUMIDITY_LOW,
-            InsteonBindingConstants.IS_COOLING, InsteonBindingConstants.IS_HEATING,
-            InsteonBindingConstants.KEYPAD_BUTTON_A, InsteonBindingConstants.KEYPAD_BUTTON_B,
-            InsteonBindingConstants.KEYPAD_BUTTON_C, InsteonBindingConstants.KEYPAD_BUTTON_D,
-            InsteonBindingConstants.KEYPAD_BUTTON_E, InsteonBindingConstants.KEYPAD_BUTTON_F,
-            InsteonBindingConstants.KEYPAD_BUTTON_G, InsteonBindingConstants.KEYPAD_BUTTON_H,
-            InsteonBindingConstants.KWH, InsteonBindingConstants.LAST_HEARD_FROM,
-            InsteonBindingConstants.LED_BRIGHTNESS, InsteonBindingConstants.LED_ONOFF,
-            InsteonBindingConstants.LIGHT_DIMMER, InsteonBindingConstants.LIGHT_LEVEL,
-            InsteonBindingConstants.LIGHT_LEVEL_ABOVE_THRESHOLD, InsteonBindingConstants.LOAD_DIMMER,
-            InsteonBindingConstants.LOAD_SWITCH, InsteonBindingConstants.LOAD_SWITCH_FAST_ON_OFF,
-            InsteonBindingConstants.LOAD_SWITCH_MANUAL_CHANGE, InsteonBindingConstants.LOWBATTERY,
-            InsteonBindingConstants.MANUAL_CHANGE, InsteonBindingConstants.MANUAL_CHANGE_BUTTON_A,
-            InsteonBindingConstants.MANUAL_CHANGE_BUTTON_B, InsteonBindingConstants.MANUAL_CHANGE_BUTTON_C,
-            InsteonBindingConstants.MANUAL_CHANGE_BUTTON_D, InsteonBindingConstants.MANUAL_CHANGE_BUTTON_E,
-            InsteonBindingConstants.MANUAL_CHANGE_BUTTON_F, InsteonBindingConstants.MANUAL_CHANGE_BUTTON_G,
-            InsteonBindingConstants.MANUAL_CHANGE_BUTTON_H, InsteonBindingConstants.NOTIFICATION,
-            InsteonBindingConstants.ON_LEVEL, InsteonBindingConstants.RAMP_DIMMER, InsteonBindingConstants.RAMP_RATE,
-            InsteonBindingConstants.RESET, InsteonBindingConstants.STAGE1_DURATION, InsteonBindingConstants.SWITCH,
-            InsteonBindingConstants.SYSTEM_MODE, InsteonBindingConstants.TAMPER_SWITCH,
-            InsteonBindingConstants.TEMPERATURE, InsteonBindingConstants.TEMPERATURE_LEVEL,
-            InsteonBindingConstants.TOP_OUTLET, InsteonBindingConstants.UPDATE, InsteonBindingConstants.WATTS)
-            .collect(Collectors.toSet()));
-
-    public static final String BROADCAST_GROUPS = "broadcastGroups";
-    public static final String BROADCAST_ON_OFF = "broadcastonoff";
-    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";
-
-    private final Logger logger = LoggerFactory.getLogger(InsteonDeviceHandler.class);
-
-    private @NonNullByDefault({}) InsteonDeviceConfiguration config;
-    private boolean deviceLinked = true;
-
-    public InsteonDeviceHandler(Thing thing) {
+public class InsteonDeviceHandler extends InsteonBaseThingHandler {
+    private static final int HEARTBEAT_TIMEOUT_BUFFER = 5; // in minutes
+    private static final int INIT_DELAY = 100; // in milliseconds
+    private static final int RESET_DELAY = 1000; // in milliseconds
+
+    private @Nullable InsteonDevice device;
+    private @Nullable ScheduledFuture<?> heartbeatJob;
+    private InsteonStateDescriptionProvider stateDescriptionProvider;
+
+    public InsteonDeviceHandler(Thing thing, InsteonStateDescriptionProvider stateDescriptionProvider) {
         super(thing);
+        this.stateDescriptionProvider = stateDescriptionProvider;
+    }
+
+    @Override
+    public @Nullable InsteonDevice getDevice() {
+        return device;
     }
 
     @Override
     public void initialize() {
-        config = getConfigAs(InsteonDeviceConfiguration.class);
-        deviceLinked = true;
+        InsteonDeviceConfiguration config = getConfigAs(InsteonDeviceConfiguration.class);
 
         scheduler.execute(() -> {
-            final Bridge bridge = getBridge();
+            Bridge bridge = getBridge();
             if (bridge == null) {
-                String msg = "An Insteon network bridge has not been selected for this device.";
-                logger.warn("{} {}", thing.getUID().getAsString(), msg);
-
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No bridge selected.");
                 return;
             }
 
-            InsteonDeviceConfiguration config = this.config;
-            if (config == null) {
-                String msg = "Insteon device configuration is null.";
-                logger.warn("{} {}", thing.getUID().getAsString(), msg);
-
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+            if (bridge.getThingTypeUID().equals(THING_TYPE_LEGACY_NETWORK)) {
+                changeThingType(THING_TYPE_LEGACY_DEVICE, bridge.getHandler());
                 return;
             }
-            String address = config.getAddress();
-            if (!InsteonAddress.isValid(address)) {
-                String msg = "Unable to start Insteon device, the insteon or X10 address '" + address
-                        + "' is invalid. It must be in the format 'AB.CD.EF' or 'H.U' (X10).";
-                logger.warn("{} {}", thing.getUID().getAsString(), msg);
 
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+            if (!InsteonAddress.isValid(config.getAddress())) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                        "Invalid device address, it must be formatted as 'AB.CD.EF'.");
                 return;
             }
 
-            DeviceTypeLoader instance = DeviceTypeLoader.instance();
-            if (instance == null) {
-                String msg = "Device type loader is null.";
-                logger.warn("{} {}", thing.getUID().getAsString(), msg);
-
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+            InsteonModem modem = getModem();
+            InsteonAddress address = new InsteonAddress(config.getAddress());
+            if (modem != null && modem.hasDevice(address)) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Duplicate device.");
                 return;
             }
 
-            String productKey = config.getProductKey();
-            if (instance.getDeviceType(productKey) == null) {
-                String msg = "Unable to start Insteon device, invalid product key '" + productKey + "'.";
-                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+            InsteonDevice device = createDevice(address, modem);
+            this.device = device;
 
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
-                return;
+            if (modem != null) {
+                modem.addDevice(device);
             }
 
-            String deviceConfig = config.getDeviceConfig();
-            Map<String, Object> deviceConfigMap;
-            if (deviceConfig != null) {
-                Type mapType = new TypeToken<Map<String, Object>>() {
-                }.getType();
-                try {
-                    deviceConfigMap = Objects.requireNonNull(new Gson().fromJson(deviceConfig, mapType));
-                } catch (JsonParseException e) {
-                    String msg = "The device configuration parameter is not valid JSON.";
-                    logger.warn("{} {}", thing.getUID().getAsString(), msg);
-
-                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
-                    return;
-                }
-            } else {
-                deviceConfigMap = Collections.emptyMap();
-            }
+            initializeChannels(device);
+            updateProperties(device);
+            refresh();
+        });
+    }
 
-            InsteonBinding insteonBinding = getInsteonBinding();
-            InsteonAddress insteonAddress = new InsteonAddress(address);
-            if (insteonBinding.getDevice(insteonAddress) != null) {
-                String msg = "A device already exists with the address '" + address + "'.";
-                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+    private void changeThingType(ThingTypeUID thingTypeUID, @Nullable BridgeHandler bridgeHandler) {
+        if (bridgeHandler instanceof InsteonLegacyNetworkHandler legacyNetworkHandler) {
+            Map<ChannelUID, Configuration> channelConfigs = getThing().getChannels().stream()
+                    .collect(Collectors.toMap(Channel::getUID, Channel::getConfiguration));
 
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
-                return;
-            }
+            legacyNetworkHandler.addChannelConfigs(channelConfigs);
+        }
 
-            InsteonDevice device = insteonBinding.makeNewDevice(insteonAddress, productKey, deviceConfigMap);
-            if (device == null) {
-                String msg = "Unable to create a device with the product key '" + productKey + "' with the address'"
-                        + address + "'.";
-                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+        changeThingType(thingTypeUID, getConfig());
+    }
 
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
-                return;
-            }
+    private InsteonDevice createDevice(InsteonAddress address, @Nullable InsteonModem modem) {
+        InsteonDevice device;
+        InsteonBridgeHandler handler = getInsteonBridgeHandler();
+        if (handler != null) {
+            device = InsteonDevice.makeDevice(address, modem, handler.getProductData(address));
+            device.setPollInterval(handler.getDevicePollInterval());
+            device.setIsDeviceSyncEnabled(handler.isDeviceSyncEnabled());
+            handler.loadDeviceCache(device);
+        } else {
+            device = InsteonDevice.makeDevice(address, modem, null);
+        }
+        device.setHandler(this);
+        device.initialize();
+        return device;
+    }
 
-            ThingHandlerCallback callback = getCallback();
-            if (callback == null) {
-                String msg = "Unable to get thing handler callback.";
-                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+    @Override
+    protected void initializeChannels(Device device) {
+        DeviceType deviceType = device.getType();
+        if (deviceType == null) {
+            return;
+        }
 
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
-                return;
-            }
+        super.initializeChannels(device);
 
-            Map<String, Channel> channelMap = new HashMap<>();
-            String thingId = getThing().getUID().getAsString();
-            for (String channelId : ALL_CHANNEL_IDS) {
-                String feature = channelId.toLowerCase();
-
-                if (productKey.equals(HIDDEN_DOOR_SENSOR_PRODUCT_KEY)) {
-                    if (feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_LEVEL)
-                            || feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_WATERMARK_LEVEL)) {
-                        feature = DATA;
-                    }
-                } else if (productKey.equals(MOTION_SENSOR_PRODUCT_KEY)) {
-                    if (feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_LEVEL)
-                            || feature.equalsIgnoreCase(InsteonBindingConstants.LIGHT_LEVEL)) {
-                        feature = DATA;
-                    }
-                } else if (productKey.equals(MOTION_SENSOR_II_PRODUCT_KEY)) {
-                    if (feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_LEVEL)
-                            || feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_PERCENT)
-                            || feature.equalsIgnoreCase(InsteonBindingConstants.LIGHT_LEVEL)
-                            || feature.equalsIgnoreCase(InsteonBindingConstants.TEMPERATURE_LEVEL)) {
-                        feature = DATA;
-                    }
-                } else if (productKey.equals(PLM_PRODUCT_KEY)) {
-                    String[] parts = feature.split("#");
-                    if (parts.length == 2 && parts[0].equalsIgnoreCase(InsteonBindingConstants.BROADCAST_ON_OFF)
-                            && parts[1].matches("^\\d+$")) {
-                        feature = BROADCAST_ON_OFF;
-                    }
-                } else if (productKey.equals(POWER_METER_PRODUCT_KEY)) {
-                    if (feature.equalsIgnoreCase(InsteonBindingConstants.KWH)
-                            || feature.equalsIgnoreCase(InsteonBindingConstants.RESET)
-                            || feature.equalsIgnoreCase(InsteonBindingConstants.UPDATE)
-                            || feature.equalsIgnoreCase(InsteonBindingConstants.WATTS)) {
-                        feature = METER;
-                    }
-                }
+        getThing().getChannels().forEach(channel -> setChannelCustomSettings(channel, deviceType.getName()));
+    }
 
-                DeviceFeature f = device.getFeature(feature);
-                if (f != null) {
-                    if (!f.isFeatureGroup()) {
-                        if (channelId.equals(InsteonBindingConstants.BROADCAST_ON_OFF)) {
-                            Set<String> broadcastChannels = new HashSet<>();
-                            for (Channel channel : thing.getChannels()) {
-                                String id = channel.getUID().getId();
-                                if (id.startsWith(InsteonBindingConstants.BROADCAST_ON_OFF)) {
-                                    channelMap.put(id, channel);
-                                    broadcastChannels.add(id);
-                                }
-                            }
-
-                            Object groups = deviceConfigMap.get(BROADCAST_GROUPS);
-                            if (groups != null) {
-                                boolean valid = false;
-                                if (groups instanceof List<?> list) {
-                                    valid = true;
-                                    for (Object o : list) {
-                                        if (o instanceof Double && (Double) o % 1 == 0) {
-                                            String id = InsteonBindingConstants.BROADCAST_ON_OFF + "#"
-                                                    + ((Double) o).intValue();
-                                            if (!broadcastChannels.contains(id)) {
-                                                ChannelUID channelUID = new ChannelUID(thing.getUID(), id);
-                                                ChannelTypeUID channelTypeUID = new ChannelTypeUID(
-                                                        InsteonBindingConstants.BINDING_ID,
-                                                        InsteonBindingConstants.SWITCH);
-                                                Channel channel = callback
-                                                        .createChannelBuilder(channelUID, channelTypeUID).withLabel(id)
-                                                        .build();
-
-                                                channelMap.put(id, channel);
-                                                broadcastChannels.add(id);
-                                            }
-                                        } else {
-                                            valid = false;
-                                            break;
-                                        }
-                                    }
-                                }
-
-                                if (!valid) {
-                                    String msg = "The value for key " + BROADCAST_GROUPS
-                                            + " must be an array of integers in the device configuration parameter.";
-                                    logger.warn("{} {}", thing.getUID().getAsString(), msg);
-
-                                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
-                                    return;
-                                }
-                            }
-                        } else {
-                            ChannelUID channelUID = new ChannelUID(thing.getUID(), channelId);
-                            ChannelTypeUID channelTypeUID = new ChannelTypeUID(InsteonBindingConstants.BINDING_ID,
-                                    channelId);
-                            Channel channel = thing.getChannel(channelUID);
-                            if (channel == null) {
-                                channel = callback.createChannelBuilder(channelUID, channelTypeUID).build();
-                            }
-
-                            channelMap.put(channelId, channel);
-                        }
-                    } else {
-                        logger.debug("{} is a feature group for {}. It will not be added as a channel.", feature,
-                                productKey);
-                    }
-                }
-            }
+    private void setChannelCustomSettings(Channel channel, String deviceTypeName) {
+        ChannelUID channelUID = channel.getUID();
+        ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
+        if (channelTypeUID == null) {
+            return;
+        }
 
-            if (!channelMap.isEmpty() || device.isModem()) {
-                List<Channel> channels = new ArrayList<>();
-                StringBuilder channelList = new StringBuilder();
-                if (!channelMap.isEmpty()) {
-                    List<String> channelIds = new ArrayList<>(channelMap.keySet());
-                    Collections.sort(channelIds);
-                    channelIds.forEach(channelId -> {
-                        Channel channel = channelMap.get(channelId);
-                        if (channel != null) {
-                            channels.add(channel);
-
-                            if (channelList.length() > 0) {
-                                channelList.append(", ");
-                            }
-                            channelList.append(channelId);
-                        }
-                    });
-
-                    updateThing(editThing().withChannels(channels).build());
-                }
+        String key = deviceTypeName + ":" + channelIdToFeatureName(channelTypeUID.getId());
+        String[] stateDescriptionOptions = CUSTOM_STATE_DESCRIPTION_OPTIONS.get(key);
+        if (stateDescriptionOptions == null) {
+            return;
+        }
 
-                StringBuilder builder = new StringBuilder(thingId);
-                builder.append(" address = ");
-                builder.append(address);
-                builder.append(" productKey = ");
-                builder.append(productKey);
-                builder.append(" channels = ");
-                builder.append(channelList.toString());
-                String msg = builder.toString();
-                logger.debug("{}", msg);
-
-                getInsteonNetworkHandler().initialized(getThing().getUID(), msg);
-
-                channels.forEach(channel -> {
-                    if (isLinked(channel.getUID())) {
-                        channelLinked(channel.getUID());
-                    }
-                });
-
-                if (ThingStatus.ONLINE == bridge.getStatus()) {
-                    if (deviceLinked) {
-                        updateStatus(ThingStatus.ONLINE);
-                    }
-                } else {
-                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
-                }
-            } else {
-                String msg = "Product key '" + productKey
-                        + "' does not have any features that match existing channels.";
+        List<StateOption> options = Stream.of(stateDescriptionOptions).map(value -> new StateOption(value,
+                StringUtils.capitalizeByWhitespace(value.replace("_", " ").toLowerCase()))).toList();
 
-                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+        logger.trace("setting state options for {} to {}", channelUID, options);
 
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
-            }
-        });
+        stateDescriptionProvider.setStateOptions(channelUID, options);
     }
 
     @Override
     public void dispose() {
-        InsteonDeviceConfiguration config = this.config;
-        if (config != null) {
-            String address = config.getAddress();
-            if (getBridge() != null && InsteonAddress.isValid(address)) {
-                getInsteonBinding().removeDevice(new InsteonAddress(address));
-
-                logger.debug("removed {} address = {}", getThing().getUID().getAsString(), address);
-            }
-
-            InsteonNetworkHandler handler = null;
-            try {
-                handler = getInsteonNetworkHandler();
-            } catch (IllegalArgumentException e) {
+        InsteonDevice device = getDevice();
+        if (device != null) {
+            device.stopPolling();
+
+            InsteonModem modem = getModem();
+            if (modem != null) {
+                modem.deleteSceneEntries(device);
+                modem.removeDevice(device);
             }
 
-            if (handler != null) {
-                handler.disposed(getThing().getUID());
+            InsteonBridgeHandler handler = getInsteonBridgeHandler();
+            if (handler != null && device.hasModemDBEntry()) {
+                handler.storeDeviceCache(device.getAddress(),
+                        DeviceCache.builder().withProductData(device.getProductData())
+                                .withInsteonEngine(device.getInsteonEngine()).withDatabase(device.getLinkDB())
+                                .withFeatures(device.getFeatures()).build());
             }
         }
+        this.device = null;
+
+        stopHeartbeatMonitor();
 
         super.dispose();
     }
 
     @Override
-    public void handleCommand(ChannelUID channelUID, Command command) {
-        if (ThingStatus.ONLINE == getThing().getStatus()) {
-            logger.debug("channel {} was triggered with the command {}", channelUID.getAsString(), command);
+    public void refresh() {
+        resetHeartbeatMonitor();
 
-            getInsteonBinding().sendCommand(channelUID.getAsString(), command);
-        } else {
-            logger.debug("the command {} for channel {} was ignored because the thing is not ONLINE", command,
-                    channelUID.getAsString());
+        super.refresh();
+    }
+
+    @Override
+    public void bridgeThingDisposed() {
+        InsteonDevice device = getDevice();
+        if (device != null) {
+            device.stopPolling();
+            device.setModem(null);
         }
     }
 
     @Override
-    public void channelLinked(ChannelUID channelUID) {
-        if (getInsteonNetworkHandler().isChannelLinked(channelUID)) {
+    public void bridgeThingUpdated(InsteonBridgeConfiguration config, InsteonModem modem) {
+        InsteonDevice device = getDevice();
+        if (device != null) {
+            device.setPollInterval(config.getDevicePollInterval());
+            device.setIsDeviceSyncEnabled(config.isDeviceSyncEnabled());
+            device.setModem(modem);
+
+            modem.addDevice(device);
+        }
+    }
+
+    public void deviceLinkDBUpdated(InsteonDevice device) {
+        if (device.getLinkDB().isComplete()) {
+            resetHeartbeatMonitor();
+
+            InsteonModem modem = getModem();
+            if (modem != null) {
+                modem.updateSceneEntries(device);
+            }
+        }
+        updateStatus();
+    }
+
+    @Override
+    protected String getConfigInfo() {
+        return getConfigAs(InsteonDeviceConfiguration.class).toString();
+    }
+
+    @Override
+    public void updateStatus() {
+        Bridge bridge = getBridge();
+        if (bridge == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No bridge selected.");
             return;
         }
 
-        Map<String, String> params = new HashMap<>();
-        Channel channel = getThing().getChannel(channelUID.getId());
-        if (channel == null) {
-            logger.warn("channel is null");
+        if (bridge.getStatus() == ThingStatus.OFFLINE) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
             return;
         }
 
-        Map<String, Object> channelProperties = channel.getConfiguration().getProperties();
-        for (String key : channelProperties.keySet()) {
-            Object value = channelProperties.get(key);
-            if (value instanceof String stringValue) {
-                params.put(key, stringValue);
-            } else if (value instanceof BigDecimal decimalValue) {
-                String s = decimalValue.toPlainString();
-                params.put(key, s);
-            } else {
-                logger.warn("not a string or big decimal value key '{}' value '{}' {}", key, value,
-                        value != null ? value.getClass().getName() : "unknown");
-            }
+        InsteonModem modem = getModem();
+        if (modem == null || !modem.getDB().isComplete()) {
+            updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Waiting for modem database.");
+            return;
         }
 
-        String feature = channelUID.getId().toLowerCase();
-        InsteonDeviceConfiguration config = this.config;
-        if (config == null) {
-            logger.warn("insteon device config is null");
+        InsteonDevice device = getDevice();
+        if (device == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unable to determine device.");
+            return;
+        }
+
+        if (!device.hasModemDBEntry()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "Device not found in modem database.");
             return;
         }
-        String productKey = config.getProductKey();
-        if (productKey.equals(HIDDEN_DOOR_SENSOR_PRODUCT_KEY)) {
-            if (feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_LEVEL)) {
-                params.put(FIELD, FIELD_BATTERY_LEVEL);
-                feature = DATA;
-            } else if (feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_WATERMARK_LEVEL)) {
-                params.put(FIELD, FIELD_BATTERY_WATERMARK_LEVEL);
-                feature = DATA;
-            }
-        } else if (productKey.equals(MOTION_SENSOR_PRODUCT_KEY)) {
-            if (feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_LEVEL)) {
-                params.put(FIELD, FIELD_BATTERY_LEVEL);
-                feature = DATA;
-            } else if (feature.equalsIgnoreCase(InsteonBindingConstants.LIGHT_LEVEL)) {
-                params.put(FIELD, FIELD_LIGHT_LEVEL);
-                feature = DATA;
-            }
-        } else if (productKey.equals(MOTION_SENSOR_II_PRODUCT_KEY)) {
-            if (feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_LEVEL)) {
-                params.put(FIELD, FIELD_BATTERY_LEVEL);
-                feature = DATA;
-            } else if (feature.equalsIgnoreCase(InsteonBindingConstants.BATTERY_PERCENT)) {
-                params.put(FIELD, FIELD_BATTERY_PERCENTAGE);
-                feature = DATA;
-            } else if (feature.equalsIgnoreCase(InsteonBindingConstants.LIGHT_LEVEL)) {
-                params.put(FIELD, FIELD_LIGHT_LEVEL);
-                feature = DATA;
-            } else if (feature.equalsIgnoreCase(InsteonBindingConstants.TEMPERATURE_LEVEL)) {
-                params.put(FIELD, FIELD_TEMPERATURE_LEVEL);
-                feature = DATA;
-            }
-        } else if (productKey.equals(PLM_PRODUCT_KEY)) {
-            String[] parts = feature.split("#");
-            if (parts.length == 2 && parts[0].equalsIgnoreCase(InsteonBindingConstants.BROADCAST_ON_OFF)
-                    && parts[1].matches("^\\d+$")) {
-                params.put(GROUP, parts[1]);
-                feature = BROADCAST_ON_OFF;
-            }
-        } else if (productKey.equals(POWER_METER_PRODUCT_KEY)) {
-            if (feature.equalsIgnoreCase(InsteonBindingConstants.KWH)) {
-                params.put(FIELD, FIELD_KWH);
-            } else if (feature.equalsIgnoreCase(InsteonBindingConstants.WATTS)) {
-                params.put(FIELD, FIELD_WATTS);
-            } else if (feature.equalsIgnoreCase(InsteonBindingConstants.RESET)) {
-                params.put(CMD, CMD_RESET);
-            } else if (feature.equalsIgnoreCase(InsteonBindingConstants.UPDATE)) {
-                params.put(CMD, CMD_UPDATE);
-            }
 
-            feature = METER;
+        if (!device.isResponding() && !device.isBatteryPowered()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Device not responding.");
+            return;
+        }
+
+        if (device.getProductData() == null) {
+            updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Waiting for product data.");
+            return;
         }
 
-        InsteonChannelConfiguration bindingConfig = new InsteonChannelConfiguration(channelUID, feature,
-                new InsteonAddress(config.getAddress()), productKey, params);
-        getInsteonBinding().addFeatureListener(bindingConfig);
+        if (device.getType() == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unsupported device.");
+            return;
+        }
 
-        StringBuilder builder = new StringBuilder(channelUID.getAsString());
-        builder.append(" feature = ");
-        builder.append(feature);
-        builder.append(" parameters = ");
-        builder.append(params);
-        String msg = builder.toString();
-        logger.debug("{}", msg);
+        if (!device.getLinkDB().isComplete()) {
+            updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Waiting for link database.");
+            return;
+        }
 
-        getInsteonNetworkHandler().linked(channelUID, msg);
+        updateStatus(ThingStatus.ONLINE);
     }
 
-    @Override
-    public void channelUnlinked(ChannelUID channelUID) {
-        getInsteonBinding().removeFeatureListener(channelUID);
-        getInsteonNetworkHandler().unlinked(channelUID);
+    public void updateProperties(InsteonDevice device) {
+        InsteonEngine engine = device.getInsteonEngine();
+        if (engine != InsteonEngine.UNKNOWN) {
+            updateProperty(PROPERTY_ENGINE_VERSION, engine.name());
+        }
 
-        logger.debug("channel {} unlinked ", channelUID.getAsString());
+        super.updateProperties(device);
     }
 
-    public InsteonAddress getInsteonAddress() {
-        return new InsteonAddress(config.getAddress());
-    }
+    public void reset(InsteonDevice oldDevice) {
+        scheduler.schedule(() -> {
+            logger.debug("resetting thing {}", getThing().getUID());
+
+            dispose();
+            initialize();
 
-    public void deviceNotLinked() {
-        String msg = "device with the address '" + config.getAddress()
-                + "' was not found in the modem database. Did you forget to link?";
-        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+            scheduler.schedule(() -> {
+                InsteonDevice device = getDevice();
+                if (device != null) {
+                    device.replayMessages(oldDevice.getStoredMessages());
+                }
+            }, INIT_DELAY, TimeUnit.MILLISECONDS);
 
-        deviceLinked = false;
+        }, RESET_DELAY, TimeUnit.MILLISECONDS);
     }
 
-    private InsteonNetworkHandler getInsteonNetworkHandler() {
-        Bridge bridge = getBridge();
-        if (bridge == null) {
-            throw new IllegalArgumentException("insteon network bridge is null");
+    public void resetHeartbeatMonitor() {
+        if (stopHeartbeatMonitor()) {
+            updateStatus();
+        }
+
+        InsteonDevice device = getDevice();
+        if (device == null || !device.hasModemDBEntry() || !device.hasHeartbeat()) {
+            return;
         }
-        InsteonNetworkHandler handler = (InsteonNetworkHandler) bridge.getHandler();
-        if (handler == null) {
-            throw new IllegalArgumentException("insteon network handler is null");
+
+        if (device.getMissingLinks().contains(FEATURE_HEARTBEAT)) {
+            logger.warn("heartbeat link missing, timeout monitor disabled for {}", getThing().getUID());
+            return;
+        }
+
+        int timeout = device.getHeartbeatTimeout();
+        if (timeout > 0) {
+            logger.debug("setting heartbeat timeout monitor to {} min for {}", timeout, getThing().getUID());
+
+            heartbeatJob = scheduler.schedule(() -> {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Heartbeat timed out.");
+            }, timeout + HEARTBEAT_TIMEOUT_BUFFER, TimeUnit.MINUTES);
         }
-        return handler;
     }
 
-    private InsteonBinding getInsteonBinding() {
-        return getInsteonNetworkHandler().getInsteonBinding();
+    private boolean stopHeartbeatMonitor() {
+        boolean hasTimedOut = false;
+        ScheduledFuture<?> heartbeatJob = this.heartbeatJob;
+        if (heartbeatJob != null) {
+            hasTimedOut = heartbeatJob.isDone();
+            heartbeatJob.cancel(true);
+            this.heartbeatJob = null;
+        }
+        return hasTimedOut;
     }
 }
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonLegacyDeviceHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonLegacyDeviceHandler.java
new file mode 100644 (file)
index 0000000..d32264a
--- /dev/null
@@ -0,0 +1,516 @@
+/**
+ * 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.handler;
+
+import static org.openhab.binding.insteon.internal.InsteonLegacyBindingConstants.*;
+
+import java.lang.reflect.Type;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+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.config.InsteonLegacyChannelConfiguration;
+import org.openhab.binding.insteon.internal.config.InsteonLegacyDeviceConfiguration;
+import org.openhab.binding.insteon.internal.device.DeviceAddress;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.LegacyDevice;
+import org.openhab.binding.insteon.internal.device.LegacyDeviceFeature;
+import org.openhab.binding.insteon.internal.device.LegacyDeviceTypeLoader;
+import org.openhab.binding.insteon.internal.device.X10Address;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.util.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * The {@link InsteonLegacyDeviceHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class InsteonLegacyDeviceHandler extends BaseThingHandler {
+
+    private static final String CHANNEL_TYPE_ID_PREFIX = "legacy";
+
+    private final Logger logger = LoggerFactory.getLogger(InsteonLegacyDeviceHandler.class);
+
+    private @NonNullByDefault({}) InsteonLegacyDeviceConfiguration config;
+    private boolean deviceLinked = true;
+
+    public InsteonLegacyDeviceHandler(Thing thing) {
+        super(thing);
+    }
+
+    @Override
+    public void initialize() {
+        config = getConfigAs(InsteonLegacyDeviceConfiguration.class);
+        deviceLinked = true;
+
+        scheduler.execute(() -> {
+            final Bridge bridge = getBridge();
+            if (bridge == null) {
+                String msg = "An Insteon network bridge has not been selected for this device.";
+                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                return;
+            }
+
+            InsteonLegacyDeviceConfiguration config = this.config;
+            if (config == null) {
+                String msg = "Insteon device configuration is null.";
+                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                return;
+            }
+
+            DeviceAddress address = getDeviceAddress();
+            if (address == null) {
+                String msg = "Unable to start Insteon device, the Insteon or X10 address '" + config.getAddress()
+                        + "' is invalid. It must be in the format 'AB.CD.EF' or 'H.U' (X10).";
+                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                return;
+            }
+
+            String productKey = config.getProductKey();
+            if (LegacyDeviceTypeLoader.instance().getDeviceType(productKey) == null) {
+                String msg = "Unable to start Insteon device, invalid product key '" + productKey + "'.";
+                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                return;
+            }
+
+            String deviceConfig = config.getDeviceConfig();
+            Map<String, Object> deviceConfigMap;
+            if (deviceConfig != null) {
+                Type mapType = new TypeToken<Map<String, Object>>() {
+                }.getType();
+                try {
+                    deviceConfigMap = Objects.requireNonNull(new Gson().fromJson(deviceConfig, mapType));
+                } catch (JsonParseException e) {
+                    String msg = "The device configuration parameter is not valid JSON.";
+                    logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                    return;
+                }
+            } else {
+                deviceConfigMap = Collections.emptyMap();
+            }
+
+            InsteonLegacyBinding insteonBinding = getInsteonBinding();
+            if (insteonBinding.getDevice(address) != null) {
+                String msg = "A device already exists with the address '" + address + "'.";
+                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                return;
+            }
+
+            LegacyDevice device = insteonBinding.makeNewDevice(address, productKey, deviceConfigMap);
+            if (device == null) {
+                String msg = "Unable to create a device with the product key '" + productKey + "' with the address'"
+                        + address + "'.";
+                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                return;
+            }
+
+            ThingHandlerCallback callback = getCallback();
+            if (callback == null) {
+                String msg = "Unable to get thing handler callback.";
+                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                return;
+            }
+
+            Map<String, Channel> channelMap = new HashMap<>();
+            String thingId = getThing().getUID().getAsString();
+            for (String channelId : ALL_CHANNEL_IDS) {
+                String featureName = channelId.toLowerCase();
+
+                if (productKey.equals(HIDDEN_DOOR_SENSOR_PRODUCT_KEY)) {
+                    if (featureName.equalsIgnoreCase(BATTERY_LEVEL)
+                            || featureName.equalsIgnoreCase(BATTERY_WATERMARK_LEVEL)) {
+                        featureName = DATA;
+                    }
+                } else if (productKey.equals(MOTION_SENSOR_PRODUCT_KEY)) {
+                    if (featureName.equalsIgnoreCase(BATTERY_LEVEL) || featureName.equalsIgnoreCase(LIGHT_LEVEL)) {
+                        featureName = DATA;
+                    }
+                } else if (productKey.equals(MOTION_SENSOR_II_PRODUCT_KEY)) {
+                    if (featureName.equalsIgnoreCase(BATTERY_LEVEL) || featureName.equalsIgnoreCase(BATTERY_PERCENT)
+                            || featureName.equalsIgnoreCase(LIGHT_LEVEL)
+                            || featureName.equalsIgnoreCase(TEMPERATURE_LEVEL)) {
+                        featureName = DATA;
+                    }
+                } else if (productKey.equals(PLM_PRODUCT_KEY)) {
+                    String[] parts = featureName.split("#");
+                    if (parts.length == 2 && parts[0].equalsIgnoreCase(BROADCAST_ON_OFF)
+                            && parts[1].matches("^\\d+$")) {
+                        featureName = parts[0];
+                    }
+                } else if (productKey.equals(POWER_METER_PRODUCT_KEY)) {
+                    if (featureName.equalsIgnoreCase(KWH) || featureName.equalsIgnoreCase(RESET)
+                            || featureName.equalsIgnoreCase(UPDATE) || featureName.equalsIgnoreCase(WATTS)) {
+                        featureName = METER;
+                    }
+                }
+
+                LegacyDeviceFeature feature = device.getFeature(featureName);
+                if (feature != null) {
+                    if (!feature.isFeatureGroup()) {
+                        if (channelId.equalsIgnoreCase(BROADCAST_ON_OFF)) {
+                            Set<String> broadcastChannels = new HashSet<>();
+                            for (Channel channel : thing.getChannels()) {
+                                String id = channel.getUID().getId();
+                                if (id.startsWith(BROADCAST_ON_OFF)) {
+                                    channelMap.put(id, channel);
+                                    broadcastChannels.add(id);
+                                }
+                            }
+
+                            Object groups = deviceConfigMap.get(BROADCAST_GROUPS);
+                            if (groups != null) {
+                                boolean valid = false;
+                                if (groups instanceof List<?> list) {
+                                    valid = true;
+                                    for (Object value : list) {
+                                        if (value instanceof Double doubleValue && doubleValue % 1 == 0) {
+                                            String id = BROADCAST_ON_OFF + "#" + doubleValue.intValue();
+                                            if (!broadcastChannels.contains(id)) {
+                                                channelMap.put(id, createChannel(id, BROADCAST_ON_OFF, callback));
+                                                broadcastChannels.add(id);
+                                            }
+                                        } else {
+                                            valid = false;
+                                            break;
+                                        }
+                                    }
+                                }
+
+                                if (!valid) {
+                                    String msg = "The value for key " + BROADCAST_GROUPS
+                                            + " must be an array of integers in the device configuration parameter.";
+                                    logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                                    return;
+                                }
+                            }
+                        } else {
+                            channelMap.put(channelId, createChannel(channelId, channelId, callback));
+                        }
+                    } else {
+                        logger.debug("{} is a feature group for {}. It will not be added as a channel.", featureName,
+                                productKey);
+                    }
+                }
+            }
+
+            if (!channelMap.isEmpty() || device.isModem()) {
+                List<Channel> channels = new ArrayList<>();
+                StringBuilder channelList = new StringBuilder();
+                if (!channelMap.isEmpty()) {
+                    List<String> channelIds = new ArrayList<>(channelMap.keySet());
+                    Collections.sort(channelIds);
+                    channelIds.forEach(channelId -> {
+                        Channel channel = channelMap.get(channelId);
+                        if (channel != null) {
+                            channels.add(channel);
+
+                            if (channelList.length() > 0) {
+                                channelList.append(", ");
+                            }
+                            channelList.append(channelId);
+                        }
+                    });
+
+                    updateThing(editThing().withChannels(channels).build());
+                }
+
+                StringBuilder builder = new StringBuilder(thingId);
+                builder.append(" address = ");
+                builder.append(address);
+                builder.append(" productKey = ");
+                builder.append(productKey);
+                builder.append(" channels = ");
+                builder.append(channelList.toString());
+                String msg = builder.toString();
+                logger.debug("{}", msg);
+
+                getInsteonNetworkHandler().initialized(getThing().getUID(), msg);
+
+                channels.forEach(channel -> {
+                    if (isLinked(channel.getUID())) {
+                        channelLinked(channel.getUID());
+                    }
+                });
+
+                if (ThingStatus.ONLINE == bridge.getStatus()) {
+                    if (deviceLinked) {
+                        updateStatus(ThingStatus.ONLINE);
+                    }
+                } else {
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+                }
+            } else {
+                String msg = "Product key '" + productKey
+                        + "' does not have any features that match existing channels.";
+
+                logger.warn("{} {}", thing.getUID().getAsString(), msg);
+
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+            }
+        });
+    }
+
+    @Override
+    public void dispose() {
+        InsteonLegacyDeviceConfiguration config = this.config;
+        if (config != null) {
+            DeviceAddress address = getDeviceAddress();
+            if (getBridge() != null && address != null) {
+                getInsteonBinding().removeDevice(address);
+
+                logger.debug("removed {} address = {}", getThing().getUID().getAsString(), address);
+            }
+
+            InsteonLegacyNetworkHandler handler = null;
+            try {
+                handler = getInsteonNetworkHandler();
+            } catch (IllegalArgumentException e) {
+            }
+
+            if (handler != null) {
+                handler.disposed(getThing().getUID());
+            }
+        }
+
+        super.dispose();
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        if (ThingStatus.ONLINE == getThing().getStatus()) {
+            logger.debug("channel {} was triggered with the command {}", channelUID.getAsString(), command);
+
+            getInsteonBinding().sendCommand(channelUID.getAsString(), command);
+        } else {
+            logger.debug("the command {} for channel {} was ignored because the thing is not ONLINE", command,
+                    channelUID.getAsString());
+        }
+    }
+
+    @Override
+    public void channelLinked(ChannelUID channelUID) {
+        if (getInsteonNetworkHandler().isChannelLinked(channelUID)) {
+            return;
+        }
+
+        Map<String, String> params = new HashMap<>();
+        Channel channel = getThing().getChannel(channelUID.getId());
+        if (channel == null) {
+            logger.warn("channel is null");
+            return;
+        }
+
+        Map<String, Object> channelProperties = channel.getConfiguration().getProperties();
+        for (String key : channelProperties.keySet()) {
+            Object value = channelProperties.get(key);
+            if (value instanceof String stringValue) {
+                params.put(key, stringValue);
+            } else if (value instanceof BigDecimal decimalValue) {
+                String s = decimalValue.toPlainString();
+                params.put(key, s);
+            } else {
+                logger.warn("not a string or big decimal value key '{}' value '{}' {}", key, value,
+                        value != null ? value.getClass().getName() : "unknown");
+            }
+        }
+
+        String feature = channelUID.getId().toLowerCase();
+        InsteonLegacyDeviceConfiguration config = this.config;
+        if (config == null) {
+            logger.warn("insteon device config is null");
+            return;
+        }
+        String productKey = config.getProductKey();
+        if (productKey.equals(HIDDEN_DOOR_SENSOR_PRODUCT_KEY)) {
+            if (feature.equalsIgnoreCase(BATTERY_LEVEL)) {
+                params.put(FIELD, FIELD_BATTERY_LEVEL);
+                feature = DATA;
+            } else if (feature.equalsIgnoreCase(BATTERY_WATERMARK_LEVEL)) {
+                params.put(FIELD, FIELD_BATTERY_WATERMARK_LEVEL);
+                feature = DATA;
+            }
+        } else if (productKey.equals(MOTION_SENSOR_PRODUCT_KEY)) {
+            if (feature.equalsIgnoreCase(BATTERY_LEVEL)) {
+                params.put(FIELD, FIELD_BATTERY_LEVEL);
+                feature = DATA;
+            } else if (feature.equalsIgnoreCase(LIGHT_LEVEL)) {
+                params.put(FIELD, FIELD_LIGHT_LEVEL);
+                feature = DATA;
+            }
+        } else if (productKey.equals(MOTION_SENSOR_II_PRODUCT_KEY)) {
+            if (feature.equalsIgnoreCase(BATTERY_LEVEL)) {
+                params.put(FIELD, FIELD_BATTERY_LEVEL);
+                feature = DATA;
+            } else if (feature.equalsIgnoreCase(BATTERY_PERCENT)) {
+                params.put(FIELD, FIELD_BATTERY_PERCENTAGE);
+                feature = DATA;
+            } else if (feature.equalsIgnoreCase(LIGHT_LEVEL)) {
+                params.put(FIELD, FIELD_LIGHT_LEVEL);
+                feature = DATA;
+            } else if (feature.equalsIgnoreCase(TEMPERATURE_LEVEL)) {
+                params.put(FIELD, FIELD_TEMPERATURE_LEVEL);
+                feature = DATA;
+            }
+        } else if (productKey.equals(PLM_PRODUCT_KEY)) {
+            String[] parts = feature.split("#");
+            if (parts.length == 2 && parts[0].equalsIgnoreCase(BROADCAST_ON_OFF) && parts[1].matches("^\\d+$")) {
+                params.put(GROUP, parts[1]);
+                feature = parts[0];
+            }
+        } else if (productKey.equals(POWER_METER_PRODUCT_KEY)) {
+            if (feature.equalsIgnoreCase(KWH)) {
+                params.put(FIELD, FIELD_KWH);
+            } else if (feature.equalsIgnoreCase(WATTS)) {
+                params.put(FIELD, FIELD_WATTS);
+            } else if (feature.equalsIgnoreCase(RESET)) {
+                params.put(CMD, CMD_RESET);
+            } else if (feature.equalsIgnoreCase(UPDATE)) {
+                params.put(CMD, CMD_UPDATE);
+            }
+
+            feature = METER;
+        }
+
+        DeviceAddress address = getDeviceAddress();
+        if (address == null) {
+            logger.warn("device address is null");
+            return;
+        }
+        InsteonLegacyChannelConfiguration bindingConfig = new InsteonLegacyChannelConfiguration(channelUID, feature,
+                address, productKey, params);
+        getInsteonBinding().addFeatureListener(bindingConfig);
+
+        StringBuilder builder = new StringBuilder(channelUID.getAsString());
+        builder.append(" feature = ");
+        builder.append(feature);
+        builder.append(" parameters = ");
+        builder.append(params);
+        String msg = builder.toString();
+        logger.debug("{}", msg);
+
+        getInsteonNetworkHandler().linked(channelUID, msg);
+    }
+
+    @Override
+    public void channelUnlinked(ChannelUID channelUID) {
+        getInsteonBinding().removeFeatureListener(channelUID);
+        getInsteonNetworkHandler().unlinked(channelUID);
+
+        logger.debug("channel {} unlinked ", channelUID.getAsString());
+    }
+
+    public @Nullable DeviceAddress getDeviceAddress() {
+        InsteonLegacyDeviceConfiguration config = this.config;
+        if (config != null) {
+            String address = config.getAddress();
+            if (InsteonAddress.isValid(address)) {
+                return new InsteonAddress(address);
+            } else if (X10Address.isValid(address)) {
+                return new X10Address(address);
+            }
+        }
+        return null;
+    }
+
+    public void deviceNotLinked() {
+        String msg = "device with the address '" + config.getAddress()
+                + "' was not found in the modem database. Did you forget to link?";
+        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+
+        deviceLinked = false;
+    }
+
+    private Channel createChannel(String channelId, String channelTypeId, ThingHandlerCallback callback) {
+        ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelId);
+        ChannelTypeUID channelTypeUID = new ChannelTypeUID(InsteonBindingConstants.BINDING_ID,
+                CHANNEL_TYPE_ID_PREFIX + StringUtils.capitalize(channelTypeId));
+        Configuration channelConfig = getChannelConfig(channelUID);
+        Channel channel = getThing().getChannel(channelUID);
+        if (channel == null) {
+            channel = callback.createChannelBuilder(channelUID, channelTypeUID).withConfiguration(channelConfig)
+                    .build();
+        }
+        return channel;
+    }
+
+    private Configuration getChannelConfig(ChannelUID channelUID) {
+        try {
+            return getInsteonNetworkHandler().getChannelConfig(channelUID);
+        } catch (IllegalArgumentException e) {
+            return new Configuration();
+        }
+    }
+
+    private InsteonLegacyNetworkHandler getInsteonNetworkHandler() {
+        Bridge bridge = getBridge();
+        if (bridge == null) {
+            throw new IllegalArgumentException("insteon network bridge is null");
+        }
+        InsteonLegacyNetworkHandler handler = (InsteonLegacyNetworkHandler) bridge.getHandler();
+        if (handler == null) {
+            throw new IllegalArgumentException("insteon network handler is null");
+        }
+        return handler;
+    }
+
+    private InsteonLegacyBinding getInsteonBinding() {
+        return getInsteonNetworkHandler().getInsteonBinding();
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonLegacyNetworkHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonLegacyNetworkHandler.java
new file mode 100644 (file)
index 0000000..f04c693
--- /dev/null
@@ -0,0 +1,329 @@
+/**
+ * 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.handler;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentHashMap;
+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.InsteonLegacyBinding;
+import org.openhab.binding.insteon.internal.config.InsteonBridgeConfiguration;
+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.discovery.InsteonLegacyDiscoveryService;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.transport.serial.SerialPortManager;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingManager;
+import org.openhab.core.thing.ThingRegistry;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link InsteonLegacyNetworkHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Rob Nielsen - Initial contribution
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class InsteonLegacyNetworkHandler extends BaseBridgeHandler {
+    private static final int DRIVER_INITIALIZED_TIME_IN_SECONDS = 1;
+    private static final int LOG_DEVICE_STATISTICS_DELAY_IN_SECONDS = 600;
+    private static final int RETRY_DELAY_IN_SECONDS = 30;
+    private static final int SETTLE_TIME_IN_SECONDS = 5;
+
+    private final Logger logger = LoggerFactory.getLogger(InsteonLegacyNetworkHandler.class);
+
+    private @Nullable InsteonLegacyBinding insteonBinding;
+    private @Nullable InsteonLegacyDiscoveryService insteonDiscoveryService;
+    private @Nullable ScheduledFuture<?> driverInitializedJob = null;
+    private @Nullable ScheduledFuture<?> pollingJob = null;
+    private @Nullable ScheduledFuture<?> reconnectJob = null;
+    private @Nullable ScheduledFuture<?> settleJob = null;
+    private long lastInsteonDeviceCreatedTimestamp = 0;
+    private SerialPortManager serialPortManager;
+    private ThingManager thingManager;
+    private ThingRegistry thingRegistry;
+    private Map<String, String> deviceInfo = new ConcurrentHashMap<>();
+    private Map<String, String> channelInfo = new ConcurrentHashMap<>();
+    private Map<ChannelUID, Configuration> channelConfigs = new ConcurrentHashMap<>();
+
+    public InsteonLegacyNetworkHandler(Bridge bridge, SerialPortManager serialPortManager, ThingManager thingManager,
+            ThingRegistry thingRegistry) {
+        super(bridge);
+        this.serialPortManager = serialPortManager;
+        this.thingManager = thingManager;
+        this.thingRegistry = thingRegistry;
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+    }
+
+    @Override
+    public void initialize() {
+        logger.debug("Starting Insteon bridge");
+
+        InsteonLegacyNetworkConfiguration config = getConfigAs(InsteonLegacyNetworkConfiguration.class);
+        if (!config.isParsable()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "Unable to parse port configuration.");
+            return;
+        }
+
+        InsteonBridgeHandler handler = getBridgeHandler(config.parse());
+        if (handler != null) {
+            logger.info("Disabling Insteon legacy network bridge {} in favor of bridge {}", getThing().getUID(),
+                    handler.getThing().getUID());
+            disable();
+            return;
+        }
+
+        insteonBinding = new InsteonLegacyBinding(this, config, serialPortManager, scheduler);
+        updateStatus(ThingStatus.UNKNOWN);
+
+        // hold off on starting to poll until devices that already are defined as things are added.
+        // wait SETTLE_TIME_IN_SECONDS to start then check every second afterwards until it has been at
+        // least SETTLE_TIME_IN_SECONDS since last device was created.
+        settleJob = scheduler.scheduleWithFixedDelay(() -> {
+            // check to see if it has been at least SETTLE_TIME_IN_SECONDS since last device was created
+            if (System.currentTimeMillis() - lastInsteonDeviceCreatedTimestamp > SETTLE_TIME_IN_SECONDS * 1000) {
+                // settle time has expired start polling
+                InsteonLegacyBinding insteonBinding = this.insteonBinding;
+                if (insteonBinding != null && insteonBinding.startPolling()) {
+                    pollingJob = scheduler.scheduleWithFixedDelay(() -> {
+                        insteonBinding.logDeviceStatistics();
+                    }, 0, LOG_DEVICE_STATISTICS_DELAY_IN_SECONDS, TimeUnit.SECONDS);
+
+                    // wait until driver is initialized before setting network to ONLINE
+                    driverInitializedJob = scheduler.scheduleWithFixedDelay(() -> {
+                        if (insteonBinding.isDriverInitialized()) {
+                            logger.debug("driver is initialized");
+
+                            insteonBinding.setIsActive(true);
+
+                            updateStatus(ThingStatus.ONLINE);
+
+                            ScheduledFuture<?> driverInitializedJob = this.driverInitializedJob;
+                            if (driverInitializedJob != null) {
+                                driverInitializedJob.cancel(false);
+                                this.driverInitializedJob = null;
+                            }
+                        } else {
+                            logger.debug("driver is not initialized yet");
+                        }
+                    }, 0, DRIVER_INITIALIZED_TIME_IN_SECONDS, TimeUnit.SECONDS);
+                } else {
+                    String msg = "Initialization failed, unable to start the Insteon bridge with the port '"
+                            + config.getRedactedPort() + "'.";
+                    logger.warn(msg);
+
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
+                }
+
+                ScheduledFuture<?> settleJob = this.settleJob;
+                if (settleJob != null) {
+                    settleJob.cancel(false);
+                    this.settleJob = null;
+                }
+            }
+        }, SETTLE_TIME_IN_SECONDS, 1, TimeUnit.SECONDS);
+    }
+
+    @Override
+    public void dispose() {
+        logger.debug("Shutting down Insteon bridge");
+
+        ScheduledFuture<?> driverInitializedJob = this.driverInitializedJob;
+        if (driverInitializedJob != null) {
+            driverInitializedJob.cancel(true);
+            this.driverInitializedJob = null;
+        }
+
+        ScheduledFuture<?> pollingJob = this.pollingJob;
+        if (pollingJob != null) {
+            pollingJob.cancel(true);
+            this.pollingJob = null;
+        }
+
+        ScheduledFuture<?> reconnectJob = this.reconnectJob;
+        if (reconnectJob != null) {
+            reconnectJob.cancel(true);
+            this.reconnectJob = null;
+        }
+
+        ScheduledFuture<?> settleJob = this.settleJob;
+        if (settleJob != null) {
+            settleJob.cancel(true);
+            this.settleJob = null;
+        }
+
+        InsteonLegacyBinding insteonBinding = this.insteonBinding;
+        if (insteonBinding != null) {
+            insteonBinding.shutdown();
+            this.insteonBinding = null;
+        }
+
+        deviceInfo.clear();
+        channelInfo.clear();
+
+        super.dispose();
+    }
+
+    @Override
+    public void updateState(ChannelUID channelUID, State state) {
+        super.updateState(channelUID, state);
+    }
+
+    public void bindingDisconnected() {
+        reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
+            InsteonLegacyBinding insteonBinding = this.insteonBinding;
+            if (insteonBinding != null && insteonBinding.reconnect()) {
+                updateStatus(ThingStatus.ONLINE);
+                ScheduledFuture<?> reconnectJob = this.reconnectJob;
+                if (reconnectJob != null) {
+                    reconnectJob.cancel(false);
+                    this.reconnectJob = null;
+                }
+            } else {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Port disconnected.");
+            }
+        }, 0, RETRY_DELAY_IN_SECONDS, TimeUnit.SECONDS);
+    }
+
+    public void disable() {
+        scheduler.execute(() -> {
+            InsteonLegacyDiscoveryService insteonDiscoveryService = this.insteonDiscoveryService;
+            if (insteonDiscoveryService != null) {
+                insteonDiscoveryService.removeAllResults();
+            }
+
+            thingManager.setEnabled(getThing().getUID(), false);
+        });
+    }
+
+    public void insteonDeviceWasCreated() {
+        lastInsteonDeviceCreatedTimestamp = System.currentTimeMillis();
+    }
+
+    public @Nullable InsteonBridgeConfiguration getBridgeConfig() {
+        try {
+            return getConfigAs(InsteonLegacyNetworkConfiguration.class).parse();
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    private @Nullable InsteonBridgeHandler getBridgeHandler(InsteonBridgeConfiguration config) {
+        return thingRegistry.getAll().stream().filter(Thing::isEnabled).map(Thing::getHandler)
+                .filter(InsteonBridgeHandler.class::isInstance).map(InsteonBridgeHandler.class::cast)
+                .filter(handler -> config.equals(handler.getBridgeConfig())).findFirst().orElse(null);
+    }
+
+    public InsteonLegacyBinding getInsteonBinding() {
+        InsteonLegacyBinding insteonBinding = this.insteonBinding;
+        if (insteonBinding != null) {
+            return insteonBinding;
+        } else {
+            throw new IllegalArgumentException("insteon binding is null");
+        }
+    }
+
+    public void setInsteonDiscoveryService(InsteonLegacyDiscoveryService insteonDiscoveryService) {
+        this.insteonDiscoveryService = insteonDiscoveryService;
+    }
+
+    public void addMissingDevices(List<InsteonAddress> missing) {
+        scheduler.execute(() -> {
+            InsteonLegacyDiscoveryService insteonDiscoveryService = this.insteonDiscoveryService;
+            if (insteonDiscoveryService != null) {
+                insteonDiscoveryService.addInsteonDevices(missing);
+            }
+        });
+    }
+
+    public void deviceNotLinked(DeviceAddress addr) {
+        getThing().getThings().stream().forEach((thing) -> {
+            InsteonLegacyDeviceHandler handler = (InsteonLegacyDeviceHandler) thing.getHandler();
+            if (handler != null && addr.equals(handler.getDeviceAddress())) {
+                handler.deviceNotLinked();
+                return;
+            }
+        });
+    }
+
+    public void displayDevices(Console console) {
+        display(console, deviceInfo);
+    }
+
+    public void displayChannels(Console console) {
+        display(console, channelInfo);
+    }
+
+    public void displayLocalDatabase(Console console) {
+        InsteonLegacyBinding insteonBinding = this.insteonBinding;
+        if (insteonBinding != null) {
+            Map<String, String> databaseInfo = insteonBinding.getDatabaseInfo();
+            console.println("local database contains " + databaseInfo.size() + " entries");
+            display(console, databaseInfo);
+        }
+    }
+
+    public void initialized(ThingUID uid, String msg) {
+        deviceInfo.put(uid.getAsString(), msg);
+    }
+
+    public void disposed(ThingUID uid) {
+        deviceInfo.remove(uid.getAsString());
+    }
+
+    public boolean isChannelLinked(ChannelUID uid) {
+        return channelInfo.containsKey(uid.getAsString());
+    }
+
+    public void linked(ChannelUID uid, String msg) {
+        channelInfo.put(uid.getAsString(), msg);
+    }
+
+    public void unlinked(ChannelUID uid) {
+        channelInfo.remove(uid.getAsString());
+    }
+
+    public Configuration getChannelConfig(ChannelUID channelUID) {
+        return channelConfigs.getOrDefault(channelUID, new Configuration());
+    }
+
+    public void addChannelConfigs(Map<ChannelUID, Configuration> channelConfigs) {
+        this.channelConfigs.putAll(channelConfigs);
+    }
+
+    private void display(Console console, Map<String, String> info) {
+        info.entrySet().stream().sorted(Entry.comparingByKey()).map(Entry::getValue).forEach(console::println);
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonNetworkHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonNetworkHandler.java
deleted file mode 100644 (file)
index e1f8d1d..0000000
+++ /dev/null
@@ -1,284 +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.handler;
-
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.concurrent.ConcurrentHashMap;
-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.InsteonBinding;
-import org.openhab.binding.insteon.internal.config.InsteonNetworkConfiguration;
-import org.openhab.binding.insteon.internal.device.InsteonAddress;
-import org.openhab.binding.insteon.internal.discovery.InsteonDeviceDiscoveryService;
-import org.openhab.binding.insteon.internal.utils.Utils;
-import org.openhab.core.io.console.Console;
-import org.openhab.core.io.transport.serial.SerialPortManager;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.ThingStatus;
-import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.ThingUID;
-import org.openhab.core.thing.binding.BaseBridgeHandler;
-import org.openhab.core.types.Command;
-import org.openhab.core.types.State;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * The {@link InsteonNetworkHandler} is responsible for handling commands, which are
- * sent to one of the channels.
- *
- * @author Rob Nielsen - Initial contribution
- */
-@NonNullByDefault
-public class InsteonNetworkHandler extends BaseBridgeHandler {
-    private static final int DRIVER_INITIALIZED_TIME_IN_SECONDS = 1;
-    private static final int LOG_DEVICE_STATISTICS_DELAY_IN_SECONDS = 600;
-    private static final int RETRY_DELAY_IN_SECONDS = 30;
-    private static final int SETTLE_TIME_IN_SECONDS = 5;
-
-    private final Logger logger = LoggerFactory.getLogger(InsteonNetworkHandler.class);
-
-    private @Nullable InsteonBinding insteonBinding;
-    private @Nullable InsteonDeviceDiscoveryService insteonDeviceDiscoveryService;
-    private @Nullable ScheduledFuture<?> driverInitializedJob = null;
-    private @Nullable ScheduledFuture<?> pollingJob = null;
-    private @Nullable ScheduledFuture<?> reconnectJob = null;
-    private @Nullable ScheduledFuture<?> settleJob = null;
-    private long lastInsteonDeviceCreatedTimestamp = 0;
-    private @Nullable SerialPortManager serialPortManager;
-    private Map<String, String> deviceInfo = new ConcurrentHashMap<>();
-    private Map<String, String> channelInfo = new ConcurrentHashMap<>();
-
-    public static ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
-
-    public InsteonNetworkHandler(Bridge bridge, @Nullable SerialPortManager serialPortManager) {
-        super(bridge);
-        this.serialPortManager = serialPortManager;
-    }
-
-    @Override
-    public void handleCommand(ChannelUID channelUID, Command command) {
-    }
-
-    @Override
-    public void initialize() {
-        logger.debug("Starting Insteon bridge");
-        InsteonNetworkConfiguration config = getConfigAs(InsteonNetworkConfiguration.class);
-
-        SerialPortManager serialPortManager = this.serialPortManager;
-        if (serialPortManager == null) {
-            String msg = "Initialization failed, serial port manager is null.";
-            logger.warn(msg);
-
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
-
-            return;
-        }
-        insteonBinding = new InsteonBinding(this, config, serialPortManager, scheduler);
-        updateStatus(ThingStatus.UNKNOWN);
-
-        // hold off on starting to poll until devices that already are defined as things are added.
-        // wait SETTLE_TIME_IN_SECONDS to start then check every second afterwards until it has been at
-        // least SETTLE_TIME_IN_SECONDS since last device was created.
-        settleJob = scheduler.scheduleWithFixedDelay(() -> {
-            // check to see if it has been at least SETTLE_TIME_IN_SECONDS since last device was created
-            if (System.currentTimeMillis() - lastInsteonDeviceCreatedTimestamp > SETTLE_TIME_IN_SECONDS * 1000) {
-                // settle time has expired start polling
-                InsteonBinding insteonBinding = this.insteonBinding;
-                if (insteonBinding != null && insteonBinding.startPolling()) {
-                    pollingJob = scheduler.scheduleWithFixedDelay(() -> {
-                        insteonBinding.logDeviceStatistics();
-                    }, 0, LOG_DEVICE_STATISTICS_DELAY_IN_SECONDS, TimeUnit.SECONDS);
-
-                    // wait until driver is initialized before setting network to ONLINE
-                    driverInitializedJob = scheduler.scheduleWithFixedDelay(() -> {
-                        if (insteonBinding.isDriverInitialized()) {
-                            logger.debug("driver is initialized");
-
-                            insteonBinding.setIsActive(true);
-
-                            updateStatus(ThingStatus.ONLINE);
-
-                            ScheduledFuture<?> driverInitializedJob = this.driverInitializedJob;
-                            if (driverInitializedJob != null) {
-                                driverInitializedJob.cancel(false);
-                                this.driverInitializedJob = null;
-                            }
-                        } else {
-                            logger.debug("driver is not initialized yet");
-                        }
-                    }, 0, DRIVER_INITIALIZED_TIME_IN_SECONDS, TimeUnit.SECONDS);
-                } else {
-                    String msg = "Initialization failed, unable to start the Insteon bridge with the port '"
-                            + Utils.redactPassword(config.getPort()) + "'.";
-                    logger.warn(msg);
-
-                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
-                }
-
-                ScheduledFuture<?> settleJob = this.settleJob;
-                if (settleJob != null) {
-                    settleJob.cancel(false);
-                    this.settleJob = null;
-                }
-            }
-        }, SETTLE_TIME_IN_SECONDS, 1, TimeUnit.SECONDS);
-    }
-
-    @Override
-    public void dispose() {
-        logger.debug("Shutting down Insteon bridge");
-
-        ScheduledFuture<?> driverInitializedJob = this.driverInitializedJob;
-        if (driverInitializedJob != null) {
-            driverInitializedJob.cancel(true);
-            this.driverInitializedJob = null;
-        }
-
-        ScheduledFuture<?> pollingJob = this.pollingJob;
-        if (pollingJob != null) {
-            pollingJob.cancel(true);
-            this.pollingJob = null;
-        }
-
-        ScheduledFuture<?> reconnectJob = this.reconnectJob;
-        if (reconnectJob != null) {
-            reconnectJob.cancel(true);
-            this.reconnectJob = null;
-        }
-
-        ScheduledFuture<?> settleJob = this.settleJob;
-        if (settleJob != null) {
-            settleJob.cancel(true);
-            this.settleJob = null;
-        }
-
-        InsteonBinding insteonBinding = this.insteonBinding;
-        if (insteonBinding != null) {
-            insteonBinding.shutdown();
-            this.insteonBinding = null;
-        }
-
-        deviceInfo.clear();
-        channelInfo.clear();
-
-        super.dispose();
-    }
-
-    @Override
-    public void updateState(ChannelUID channelUID, State state) {
-        super.updateState(channelUID, state);
-    }
-
-    public void bindingDisconnected() {
-        reconnectJob = scheduler.scheduleWithFixedDelay(() -> {
-            InsteonBinding insteonBinding = this.insteonBinding;
-            if (insteonBinding != null && insteonBinding.reconnect()) {
-                updateStatus(ThingStatus.ONLINE);
-                ScheduledFuture<?> reconnectJob = this.reconnectJob;
-                if (reconnectJob != null) {
-                    reconnectJob.cancel(false);
-                    this.reconnectJob = null;
-                }
-            } else {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Port disconnected.");
-            }
-        }, 0, RETRY_DELAY_IN_SECONDS, TimeUnit.SECONDS);
-    }
-
-    public void insteonDeviceWasCreated() {
-        lastInsteonDeviceCreatedTimestamp = System.currentTimeMillis();
-    }
-
-    public InsteonBinding getInsteonBinding() {
-        InsteonBinding insteonBinding = this.insteonBinding;
-        if (insteonBinding != null) {
-            return insteonBinding;
-        } else {
-            throw new IllegalArgumentException("insteon binding is null");
-        }
-    }
-
-    public void setInsteonDeviceDiscoveryService(InsteonDeviceDiscoveryService insteonDeviceDiscoveryService) {
-        this.insteonDeviceDiscoveryService = insteonDeviceDiscoveryService;
-    }
-
-    public void addMissingDevices(List<String> missing) {
-        scheduler.execute(() -> {
-            InsteonDeviceDiscoveryService insteonDeviceDiscoveryService = this.insteonDeviceDiscoveryService;
-            if (insteonDeviceDiscoveryService != null) {
-                insteonDeviceDiscoveryService.addInsteonDevices(missing, getThing().getUID());
-            }
-        });
-    }
-
-    public void deviceNotLinked(InsteonAddress addr) {
-        getThing().getThings().stream().forEach((thing) -> {
-            InsteonDeviceHandler handler = (InsteonDeviceHandler) thing.getHandler();
-            if (handler != null && addr.equals(handler.getInsteonAddress())) {
-                handler.deviceNotLinked();
-                return;
-            }
-        });
-    }
-
-    public void displayDevices(Console console) {
-        display(console, deviceInfo);
-    }
-
-    public void displayChannels(Console console) {
-        display(console, channelInfo);
-    }
-
-    public void displayLocalDatabase(Console console) {
-        InsteonBinding insteonBinding = this.insteonBinding;
-        if (insteonBinding != null) {
-            Map<String, String> databaseInfo = insteonBinding.getDatabaseInfo();
-            console.println("local database contains " + databaseInfo.size() + " entries");
-            display(console, databaseInfo);
-        }
-    }
-
-    public void initialized(ThingUID uid, String msg) {
-        deviceInfo.put(uid.getAsString(), msg);
-    }
-
-    public void disposed(ThingUID uid) {
-        deviceInfo.remove(uid.getAsString());
-    }
-
-    public boolean isChannelLinked(ChannelUID uid) {
-        return channelInfo.containsKey(uid.getAsString());
-    }
-
-    public void linked(ChannelUID uid, String msg) {
-        channelInfo.put(uid.getAsString(), msg);
-    }
-
-    public void unlinked(ChannelUID uid) {
-        channelInfo.remove(uid.getAsString());
-    }
-
-    private void display(Console console, Map<String, String> info) {
-        info.entrySet().stream() //
-                .sorted(Entry.comparingByKey()) //
-                .map(Entry::getValue) //
-                .forEach(console::println);
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonSceneHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonSceneHandler.java
new file mode 100644 (file)
index 0000000..82a9cb8
--- /dev/null
@@ -0,0 +1,190 @@
+/**
+ * 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.handler;
+
+import static org.openhab.binding.insteon.internal.InsteonBindingConstants.*;
+
+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.config.InsteonSceneConfiguration;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.device.InsteonScene;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * The {@link InsteonSceneHandler} represents an insteon scene handler.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class InsteonSceneHandler extends InsteonBaseThingHandler {
+    private @Nullable InsteonScene scene;
+
+    public InsteonSceneHandler(Thing thing) {
+        super(thing);
+    }
+
+    @Override
+    public @Nullable InsteonModem getDevice() {
+        return getModem();
+    }
+
+    public @Nullable InsteonScene getScene() {
+        return scene;
+    }
+
+    @Override
+    public void initialize() {
+        InsteonSceneConfiguration config = getConfigAs(InsteonSceneConfiguration.class);
+
+        scheduler.execute(() -> {
+            if (getBridge() == null) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No bridge selected.");
+                return;
+            }
+
+            int group = config.getGroup();
+            if (!InsteonScene.isValidGroup(group)) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                        "Invalid scene group, it must be between " + InsteonScene.GROUP_MIN + " and "
+                                + InsteonScene.GROUP_MAX + ".");
+                return;
+            }
+
+            InsteonModem modem = getModem();
+            if (modem != null && modem.hasScene(group)) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Duplicate scene.");
+                return;
+            }
+
+            InsteonScene scene = createScene(group, modem);
+            this.scene = scene;
+
+            if (modem != null) {
+                modem.addScene(scene);
+            }
+
+            updateChannels(scene);
+            refresh();
+        });
+    }
+
+    private InsteonScene createScene(int group, @Nullable InsteonModem modem) {
+        InsteonScene scene = InsteonScene.makeScene(group, modem);
+        scene.setHandler(this);
+        scene.initialize();
+        return scene;
+    }
+
+    private void updateChannels(InsteonScene scene) {
+        getThing().getChannels().stream().map(Channel::getConfiguration)
+                .forEach(config -> config.put(PARAMETER_GROUP, scene.getGroup()));
+    }
+
+    @Override
+    public void dispose() {
+        InsteonScene scene = getScene();
+        if (scene != null) {
+            scene.deleteEntries();
+
+            InsteonModem modem = getModem();
+            if (modem != null) {
+                modem.removeScene(scene);
+            }
+        }
+        this.scene = null;
+
+        super.dispose();
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        InsteonScene scene = getScene();
+        if (scene != null) {
+            scene.resetState();
+        }
+
+        super.handleCommand(channelUID, command);
+    }
+
+    @Override
+    public void bridgeThingDisposed() {
+        InsteonScene scene = getScene();
+        if (scene != null) {
+            scene.setModem(null);
+        }
+    }
+
+    @Override
+    public void bridgeThingUpdated(InsteonBridgeConfiguration config, InsteonModem modem) {
+        InsteonScene scene = getScene();
+        if (scene != null) {
+            scene.setModem(modem);
+
+            modem.addScene(scene);
+        }
+    }
+
+    @Override
+    protected String getConfigInfo() {
+        return getConfigAs(InsteonSceneConfiguration.class).toString();
+    }
+
+    @Override
+    public void updateStatus() {
+        Bridge bridge = getBridge();
+        if (bridge == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No bridge selected.");
+            return;
+        }
+
+        if (bridge.getStatus() == ThingStatus.OFFLINE) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+            return;
+        }
+
+        InsteonModem modem = getModem();
+        if (modem == null || !modem.getDB().isComplete()) {
+            updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Waiting for modem database.");
+            return;
+        }
+
+        InsteonScene scene = getScene();
+        if (scene == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unable to determine scene.");
+            return;
+        }
+
+        if (!scene.hasModemDBEntry()) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "Scene not found in modem database.");
+            return;
+        }
+
+        updateStatus(ThingStatus.ONLINE);
+    }
+
+    public void updateState(State state) {
+        getThing().getChannels().stream().map(Channel::getUID)
+                .filter(channelUID -> FEATURE_SCENE_ON_OFF.equals(channelUID.getId())).findFirst()
+                .ifPresent(channelUID -> updateState(channelUID, state));
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonThingHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/InsteonThingHandler.java
new file mode 100644 (file)
index 0000000..675c32c
--- /dev/null
@@ -0,0 +1,94 @@
+/**
+ * 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.handler;
+
+import java.util.Map;
+
+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.Device;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.types.State;
+
+/**
+ * The {@link InsteonThingHandler} represents the insteon thing handler interface.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public interface InsteonThingHandler extends ThingHandler {
+    /**
+     * Returns the thing device
+     *
+     * @return the device
+     */
+    public @Nullable Device getDevice();
+
+    /**
+     * Returns the thing id
+     *
+     * @return the thing id
+     */
+    public String getThingId();
+
+    /**
+     * Returns the thing information
+     *
+     * @return the thing information
+     */
+    public String getThingInfo();
+
+    /**
+     * Returns a map of all channels information
+     *
+     * @return a map of all channels information
+     */
+    public Map<String, String> getChannelsInfo();
+
+    /**
+     * Updates a channel state
+     *
+     * @param channelUID the channel uid
+     * @param state the channel state
+     */
+    public void updateState(ChannelUID channelUID, State state);
+
+    /**
+     * Triggers a channel event
+     *
+     * @param channelUID the channel uid
+     * @param event the channel event name
+     */
+    public void triggerChannel(ChannelUID channelUID, String event);
+
+    /**
+     * Notifies that the bridge thing has been disposed
+     */
+    public void bridgeThingDisposed();
+
+    /**
+     * Notifies that the bridge thing has been updated
+     *
+     * @param config the bridge config
+     * @param modem the bridge modem
+     */
+    public void bridgeThingUpdated(InsteonBridgeConfiguration config, InsteonModem modem);
+
+    /**
+     * Refreshes the thing
+     */
+    public void refresh();
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/X10DeviceHandler.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/handler/X10DeviceHandler.java
new file mode 100644 (file)
index 0000000..7232879
--- /dev/null
@@ -0,0 +1,167 @@
+/**
+ * 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.handler;
+
+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.config.X10DeviceConfiguration;
+import org.openhab.binding.insteon.internal.device.DeviceType;
+import org.openhab.binding.insteon.internal.device.DeviceTypeRegistry;
+import org.openhab.binding.insteon.internal.device.InsteonModem;
+import org.openhab.binding.insteon.internal.device.ProductData;
+import org.openhab.binding.insteon.internal.device.X10Address;
+import org.openhab.binding.insteon.internal.device.X10Device;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+
+/**
+ * The {@link X10DeviceHandler} represents an x10 device handler.
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class X10DeviceHandler extends InsteonBaseThingHandler {
+    private @Nullable X10Device device;
+
+    public X10DeviceHandler(Thing thing) {
+        super(thing);
+    }
+
+    @Override
+    public @Nullable X10Device getDevice() {
+        return device;
+    }
+
+    @Override
+    public void initialize() {
+        X10DeviceConfiguration config = getConfigAs(X10DeviceConfiguration.class);
+
+        scheduler.execute(() -> {
+            if (getBridge() == null) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No bridge selected.");
+                return;
+            }
+
+            String houseCode = config.getHouseCode();
+            if (!X10Address.isValidHouseCode(houseCode)) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                        "Invalid X10 house code, it must be between A and P.");
+                return;
+            }
+
+            int unitCode = config.getUnitCode();
+            if (!X10Address.isValidUnitCode(unitCode)) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                        "Invalid X10 unit code, it must be between 1 and 16.");
+                return;
+            }
+
+            DeviceType deviceType = DeviceTypeRegistry.getInstance().getDeviceType(config.getDeviceType());
+            if (deviceType == null || !deviceType.getName().startsWith("X10")) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid device type.");
+                return;
+            }
+
+            InsteonModem modem = getModem();
+            X10Address address = new X10Address(houseCode, unitCode);
+            if (modem != null && modem.hasDevice(address)) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Duplicate device.");
+                return;
+            }
+
+            ProductData productData = ProductData.makeX10Product(deviceType.getName());
+            X10Device device = createDevice(address, modem, productData);
+            this.device = device;
+
+            if (modem != null) {
+                modem.addDevice(device);
+            }
+
+            initializeChannels(device);
+            updateProperties(device);
+            refresh();
+        });
+    }
+
+    private X10Device createDevice(X10Address address, @Nullable InsteonModem modem, ProductData productData) {
+        X10Device device = X10Device.makeDevice(address, modem, productData);
+        device.setHandler(this);
+        return device;
+    }
+
+    @Override
+    public void dispose() {
+        X10Device device = getDevice();
+        InsteonModem modem = getModem();
+        if (device != null && modem != null) {
+            modem.removeDevice(device);
+        }
+        this.device = null;
+
+        super.dispose();
+    }
+
+    @Override
+    public void bridgeThingDisposed() {
+        X10Device device = getDevice();
+        if (device != null) {
+            device.setModem(null);
+        }
+    }
+
+    @Override
+    public void bridgeThingUpdated(InsteonBridgeConfiguration config, InsteonModem modem) {
+        X10Device device = getDevice();
+        if (device != null) {
+            device.setModem(modem);
+
+            modem.addDevice(device);
+        }
+    }
+
+    @Override
+    protected String getConfigInfo() {
+        return getConfigAs(X10DeviceConfiguration.class).toString();
+    }
+
+    @Override
+    public void updateStatus() {
+        Bridge bridge = getBridge();
+        if (bridge == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No bridge selected.");
+            return;
+        }
+
+        if (bridge.getStatus() == ThingStatus.OFFLINE) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+            return;
+        }
+
+        InsteonModem modem = getModem();
+        if (modem == null || !modem.getDB().isComplete()) {
+            updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Waiting for modem database.");
+            return;
+        }
+
+        X10Device device = getDevice();
+        if (device == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unable to determine device.");
+            return;
+        }
+
+        updateStatus(ThingStatus.ONLINE);
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/DataType.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/DataType.java
deleted file mode 100644 (file)
index 2971c9f..0000000
+++ /dev/null
@@ -1,85 +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.message;
-
-import java.util.HashMap;
-import java.util.Map;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * Defines the data types that can be used in the fields of a message.
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public enum DataType {
-    BYTE("byte", 1),
-    INT("int", 4),
-    FLOAT("float", 4),
-    ADDRESS("address", 3),
-    INVALID("INVALID", -1);
-
-    private static Map<String, DataType> typeMap = new HashMap<>();
-
-    private int size;
-    private String name;
-
-    static {
-        typeMap.put(BYTE.getName(), BYTE);
-        typeMap.put(INT.getName(), INT);
-        typeMap.put(FLOAT.getName(), FLOAT);
-        typeMap.put(ADDRESS.getName(), ADDRESS);
-    }
-
-    /**
-     * Constructor
-     *
-     * @param name the name of the data type
-     * @param size the size (in bytes) of this data type
-     */
-    DataType(String name, int size) {
-        this.size = size;
-        this.name = name;
-    }
-
-    /**
-     * @return the size (in bytes) of this data type
-     */
-    public int getSize() {
-        return size;
-    }
-
-    /**
-     * @return clear text string with the name
-     */
-    public String getName() {
-        return name;
-    }
-
-    /**
-     * Turns a string into the corresponding data type
-     *
-     * @param name the string to translate to a type
-     * @return the data type corresponding to the name string, or null if not found
-     */
-    public static DataType getDataType(String name) {
-        DataType dataType = typeMap.get(name);
-        if (dataType != null) {
-            return dataType;
-        } else {
-            throw new IllegalArgumentException("Unable to find data type for " + name);
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/Field.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/Field.java
deleted file mode 100644 (file)
index 371e54e..0000000
+++ /dev/null
@@ -1,212 +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.message;
-
-import java.util.Objects;
-
-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.Utils;
-
-/**
- * An Insteon message has several fields with known type and offset
- * within the message. This class represents a single field, and
- * holds name, type, and offset (but not value!).
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public final class Field {
-    private final String name;
-    private final int offset;
-    private final DataType type;
-
-    public String getName() {
-        return name;
-    }
-
-    public int getOffset() {
-        return offset;
-    }
-
-    public DataType getType() {
-        return type;
-    }
-
-    public Field(String name, DataType type, int off) {
-        this.name = name;
-        this.type = type;
-        this.offset = off;
-    }
-
-    private void check(int arrayLen, DataType t) throws FieldException {
-        checkSpace(arrayLen);
-        checkType(t);
-    }
-
-    private void checkSpace(int arrayLen) throws FieldException {
-        if (offset + type.getSize() > arrayLen) {
-            throw new FieldException("field write beyond end of msg");
-        }
-    }
-
-    private void checkType(DataType t) throws FieldException {
-        if (type != t) {
-            throw new FieldException("field write type mismatch!");
-        }
-    }
-
-    @Override
-    public String toString() {
-        return getName() + " Type: " + getType() + " Offset " + getOffset();
-    }
-
-    public String toString(byte[] array) {
-        String s = name + ":";
-        try {
-            switch (type) {
-                case BYTE:
-                    s += Utils.getHexByte(getByte(array));
-                    break;
-                case INT:
-                    s += Integer.toString(getInt(array));
-                    break;
-                case ADDRESS:
-                    s += getAddress(array).toString();
-                    break;
-                default:
-                    break;
-            }
-        } catch (FieldException e) {
-            // will just return empty string
-        }
-        return s;
-    }
-
-    public void set(byte[] array, Object o) throws FieldException {
-        switch (getType()) {
-            case BYTE:
-                setByte(array, (Byte) o);
-                break;
-            case INT:
-                setInt(array, (Integer) o);
-                break;
-            // case FLOAT: setFloat(array, (float) o); break;
-            case ADDRESS:
-                setAddress(array, (InsteonAddress) o);
-                break;
-            default:
-                throw new FieldException("Not implemented data type " + getType() + "!");
-        }
-    }
-
-    /**
-     * Writes a byte value to a byte array, at the proper offset.
-     * Use this function to set the value of a field within a message.
-     *
-     * @param array the destination array
-     * @param b the value you want to set the byte to
-     * @throws FieldException
-     */
-    public void setByte(byte[] array, byte b) throws FieldException {
-        check(array.length, DataType.BYTE);
-        array[offset] = b;
-    }
-
-    /**
-     * Writes the value of an integer field to a byte array
-     * Use this function to set the value of a field within a message.
-     *
-     * @param array the destination array
-     * @param i the integer value to set
-     */
-    public void setInt(byte[] array, int i) throws FieldException {
-        check(array.length, DataType.INT);
-        array[offset] = (byte) ((i >>> 24) & 0xFF);
-        array[offset + 1] = (byte) ((i >>> 16) & 0xFF);
-        array[offset + 2] = (byte) ((i >>> 8) & 0xFF);
-        array[offset + 3] = (byte) ((i >>> 0) & 0xFF);
-    }
-
-    /**
-     * Writes the value of an InsteonAddress to a message array.
-     * Use this function to set the value of a field within a message.
-     *
-     * @param array the destination array
-     * @param adr the insteon address value to set
-     */
-
-    public void setAddress(byte[] array, InsteonAddress adr) throws FieldException {
-        check(array.length, DataType.ADDRESS);
-        adr.storeBytes(array, offset);
-    }
-
-    /**
-     * Fetch a byte from the array at the field position
-     *
-     * @param array the array to fetch from
-     * @return the byte value of the field
-     */
-    public byte getByte(byte[] array) throws FieldException {
-        check(array.length, DataType.BYTE);
-        return array[offset];
-    }
-
-    /**
-     * Fetch an int from the array at the field position
-     *
-     * @param array the array to fetch from
-     * @return the int value of the field
-     */
-    public int getInt(byte[] array) throws FieldException {
-        check(array.length, DataType.INT);
-        byte b1 = array[offset];
-        byte b2 = array[offset + 1];
-        byte b3 = array[offset + 2];
-        byte b4 = array[offset + 3];
-        return ((b1 << 24) + (b2 << 16) + (b3 << 8) + (b4 << 0));
-    }
-
-    /**
-     * Fetch an insteon address from the field position
-     *
-     * @param array the array to fetch from
-     * @return the address
-     */
-
-    public InsteonAddress getAddress(byte[] array) throws FieldException {
-        check(array.length, DataType.ADDRESS);
-        InsteonAddress adr = new InsteonAddress();
-        adr.loadBytes(array, offset);
-        return adr;
-    }
-
-    /**
-     * Equals test
-     */
-    @Override
-    public boolean equals(@Nullable Object o) {
-        if (o instanceof Field f) {
-            return (f.getName().equals(getName())) && (f.getOffset() == getOffset());
-        } else {
-            return false;
-        }
-    }
-
-    @Override
-    public int hashCode() {
-        return Objects.hash(getName(), getOffset());
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/FieldException.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/FieldException.java
deleted file mode 100644 (file)
index 7cf30e8..0000000
+++ /dev/null
@@ -1,43 +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.message;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * Exception to be thrown if there is an error processing a field, for
- * example type mismatch, out of bounds etc.
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class FieldException extends Exception {
-    private static final long serialVersionUID = -4749311173073727318L;
-
-    public FieldException() {
-        super();
-    }
-
-    public FieldException(String m) {
-        super(m);
-    }
-
-    public FieldException(String m, Throwable cause) {
-        super(m, cause);
-    }
-
-    public FieldException(Throwable cause) {
-        super(cause);
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/InvalidMessageTypeException.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/InvalidMessageTypeException.java
deleted file mode 100644 (file)
index cb242ed..0000000
+++ /dev/null
@@ -1,29 +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.message;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * Exception to be thrown from Msg class
- *
- * @author Rob Nielsen - Initial contribution
- */
-@NonNullByDefault
-public class InvalidMessageTypeException extends Exception {
-    private static final long serialVersionUID = -7582457696582413074L;
-
-    public InvalidMessageTypeException(String message) {
-        super(message);
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/Msg.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/Msg.java
deleted file mode 100644 (file)
index cbadcc9..0000000
+++ /dev/null
@@ -1,602 +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.message;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.Arrays;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.Map;
-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.utils.Utils;
-import org.openhab.binding.insteon.internal.utils.Utils.ParsingException;
-import org.osgi.framework.FrameworkUtil;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Contains an Insteon Message consisting of the raw data, and the message definition.
- * For more info, see the public Insteon Developer's Guide, 2nd edition,
- * and the Insteon Modem Developer's Guide.
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Daniel Pfrommer - openHAB 1 insteonplm binding
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class Msg {
-    private static final Logger logger = LoggerFactory.getLogger(Msg.class);
-
-    /**
-     * Represents the direction of the message from the host's view.
-     * The host is the machine to which the modem is attached.
-     */
-    public enum Direction {
-        TO_MODEM("TO_MODEM"),
-        FROM_MODEM("FROM_MODEM");
-
-        private static Map<String, Direction> map = new HashMap<>();
-
-        private String directionString;
-
-        static {
-            map.put(TO_MODEM.getDirectionString(), TO_MODEM);
-            map.put(FROM_MODEM.getDirectionString(), FROM_MODEM);
-        }
-
-        Direction(String dirString) {
-            this.directionString = dirString;
-        }
-
-        public String getDirectionString() {
-            return directionString;
-        }
-
-        public static Direction getDirectionFromString(String dir) {
-            Direction direction = map.get(dir);
-            if (direction != null) {
-                return direction;
-            } else {
-                throw new IllegalArgumentException("Unable to find direction for " + dir);
-            }
-        }
-    }
-
-    // has the structure of all known messages
-    private static final Map<String, Msg> MSG_MAP = new HashMap<>();
-    // maps between command number and the length of the header
-    private static final Map<Integer, Integer> HEADER_MAP = new HashMap<>();
-    // has templates for all message from modem to host
-    private static final Map<Integer, Msg> REPLY_MAP = new HashMap<>();
-
-    private int headerLength = -1;
-    private byte[] data;
-    private MsgDefinition definition = new MsgDefinition();
-    private Direction direction = Direction.TO_MODEM;
-    private long quietTime = 0;
-
-    /**
-     * Constructor
-     *
-     * @param headerLength length of message header (in bytes)
-     * @param data byte array with message
-     * @param dataLength length of byte array data (in bytes)
-     * @param dir direction of the message (from/to modem)
-     */
-    public Msg(int headerLength, byte[] data, int dataLength, Direction dir) {
-        this.headerLength = headerLength;
-        this.direction = dir;
-        this.data = new byte[dataLength];
-        System.arraycopy(data, 0, this.data, 0, dataLength);
-    }
-
-    /**
-     * Copy constructor, needed to make a copy of the templates when
-     * generating messages from them.
-     *
-     * @param m the message to make a copy of
-     */
-    public Msg(Msg m) {
-        headerLength = m.headerLength;
-        data = m.data.clone();
-        // the message definition usually doesn't change, but just to be sure...
-        definition = new MsgDefinition(m.definition);
-        direction = m.direction;
-    }
-
-    static {
-        // Use xml msg loader to load configs
-        try {
-            InputStream stream = FrameworkUtil.getBundle(Msg.class).getResource("/msg_definitions.xml").openStream();
-            if (stream != null) {
-                Map<String, Msg> msgs = XMLMessageReader.readMessageDefinitions(stream);
-                MSG_MAP.putAll(msgs);
-            } else {
-                logger.warn("could not get message definition resource!");
-            }
-        } catch (IOException e) {
-            logger.warn("i/o error parsing xml insteon message definitions", e);
-        } catch (ParsingException e) {
-            logger.warn("parse error parsing xml insteon message definitions", e);
-        } catch (FieldException e) {
-            logger.warn("got field exception while parsing xml insteon message definitions", e);
-        }
-        buildHeaderMap();
-        buildLengthMap();
-    }
-
-    //
-    // ------------------ simple getters and setters -----------------
-    //
-
-    /**
-     * Experience has shown that if Insteon messages are sent in close succession,
-     * only the first one will make it. The quiet time parameter says how long to
-     * wait after a message before the next one can be sent.
-     *
-     * @return the time (in milliseconds) to pause after message has been sent
-     */
-    public long getQuietTime() {
-        return quietTime;
-    }
-
-    public byte @Nullable [] getData() {
-        return data;
-    }
-
-    public int getLength() {
-        return data.length;
-    }
-
-    public int getHeaderLength() {
-        return headerLength;
-    }
-
-    public Direction getDirection() {
-        return direction;
-    }
-
-    public MsgDefinition getDefinition() {
-        return definition;
-    }
-
-    public byte getCommandNumber() {
-        return data.length < 2 ? -1 : data[1];
-    }
-
-    public boolean isPureNack() {
-        return data.length == 2 && data[1] == 0x15;
-    }
-
-    public boolean isExtended() {
-        if (getLength() < 2) {
-            return false;
-        }
-        if (!definition.containsField("messageFlags")) {
-            return (false);
-        }
-        try {
-            byte flags = getByte("messageFlags");
-            return ((flags & 0x10) == 0x10);
-        } catch (FieldException e) {
-            // do nothing
-        }
-        return false;
-    }
-
-    public boolean isUnsolicited() {
-        // if the message has an ACK/NACK, it is in response to our message,
-        // otherwise it is out-of-band, i.e. unsolicited
-        return !definition.containsField("ACK/NACK");
-    }
-
-    public boolean isEcho() {
-        return isPureNack() || !isUnsolicited();
-    }
-
-    public boolean isOfType(MsgType mt) {
-        try {
-            MsgType t = MsgType.fromValue(getByte("messageFlags"));
-            return (t == mt);
-        } catch (FieldException e) {
-            return false;
-        }
-    }
-
-    public boolean isBroadcast() {
-        return isOfType(MsgType.ALL_LINK_BROADCAST) || isOfType(MsgType.BROADCAST);
-    }
-
-    public boolean isCleanup() {
-        return isOfType(MsgType.ALL_LINK_CLEANUP);
-    }
-
-    public boolean isAllLink() {
-        return isOfType(MsgType.ALL_LINK_BROADCAST) || isOfType(MsgType.ALL_LINK_CLEANUP);
-    }
-
-    public boolean isAckOfDirect() {
-        return isOfType(MsgType.ACK_OF_DIRECT);
-    }
-
-    public boolean isAllLinkCleanupAckOrNack() {
-        return isOfType(MsgType.ALL_LINK_CLEANUP_ACK) || isOfType(MsgType.ALL_LINK_CLEANUP_NACK);
-    }
-
-    public boolean isX10() {
-        try {
-            int cmd = getByte("Cmd") & 0xff;
-            if (cmd == 0x63 || cmd == 0x52) {
-                return true;
-            }
-        } catch (FieldException e) {
-        }
-        return false;
-    }
-
-    public void setDefinition(MsgDefinition d) {
-        definition = d;
-    }
-
-    public void setQuietTime(long t) {
-        quietTime = t;
-    }
-
-    public void addField(Field f) {
-        definition.addField(f);
-    }
-
-    public @Nullable InsteonAddress getAddr(String name) {
-        @Nullable
-        InsteonAddress a = null;
-        try {
-            a = definition.getField(name).getAddress(data);
-        } catch (FieldException e) {
-            // do nothing, we'll return null
-        }
-        return a;
-    }
-
-    public int getHopsLeft() throws FieldException {
-        int hops = (getByte("messageFlags") & 0x0c) >> 2;
-        return hops;
-    }
-
-    /**
-     * Will put a byte at the specified key
-     *
-     * @param key the string key in the message definition
-     * @param value the byte to put
-     */
-    public void setByte(@Nullable String key, byte value) throws FieldException {
-        Field f = definition.getField(key);
-        f.setByte(data, value);
-    }
-
-    /**
-     * Will put an int at the specified field key
-     *
-     * @param key the name of the field
-     * @param value the int to put
-     */
-    public void setInt(String key, int value) throws FieldException {
-        Field f = definition.getField(key);
-        f.setInt(data, value);
-    }
-
-    /**
-     * Will put address bytes at the field
-     *
-     * @param key the name of the field
-     * @param adr the address to put
-     */
-    public void setAddress(String key, InsteonAddress adr) throws FieldException {
-        Field f = definition.getField(key);
-        f.setAddress(data, adr);
-    }
-
-    /**
-     * Will fetch a byte
-     *
-     * @param key the name of the field
-     * @return the byte
-     */
-    public byte getByte(String key) throws FieldException {
-        return (definition.getField(key).getByte(data));
-    }
-
-    /**
-     * Will fetch a byte array starting at a certain field
-     *
-     * @param key the name of the first field
-     * @param numBytes of bytes to get
-     * @return the byte array
-     */
-    public byte[] getBytes(String key, int numBytes) throws FieldException {
-        int offset = definition.getField(key).getOffset();
-        if (offset < 0 || offset + numBytes > data.length) {
-            throw new FieldException("data index out of bounds!");
-        }
-        byte[] section = new byte[numBytes];
-        byte[] data = this.data;
-        System.arraycopy(data, offset, section, 0, numBytes);
-        return section;
-    }
-
-    /**
-     * Will fetch address from field
-     *
-     * @param field the filed name to fetch
-     * @return the address
-     */
-    public InsteonAddress getAddress(String field) throws FieldException {
-        return (definition.getField(field).getAddress(data));
-    }
-
-    /**
-     * Fetch 3-byte (24bit) from message
-     *
-     * @param key1 the key of the msb
-     * @param key2 the key of the second msb
-     * @param key3 the key of the lsb
-     * @return the integer
-     */
-    public int getInt24(String key1, String key2, String key3) throws FieldException {
-        int i = (definition.getField(key1).getByte(data) << 16) & (definition.getField(key2).getByte(data) << 8)
-                & definition.getField(key3).getByte(data);
-        return i;
-    }
-
-    public String toHexString() {
-        return Utils.getHexString(data);
-    }
-
-    /**
-     * Sets the userData fields from a byte array
-     *
-     * @param arg
-     */
-    public void setUserData(byte[] arg) {
-        byte[] data = Arrays.copyOf(arg, 14); // appends zeros if short
-        try {
-            setByte("userData1", data[0]);
-            setByte("userData2", data[1]);
-            setByte("userData3", data[2]);
-            setByte("userData4", data[3]);
-            setByte("userData5", data[4]);
-            setByte("userData6", data[5]);
-            setByte("userData7", data[6]);
-            setByte("userData8", data[7]);
-            setByte("userData9", data[8]);
-            setByte("userData10", data[9]);
-            setByte("userData11", data[10]);
-            setByte("userData12", data[11]);
-            setByte("userData13", data[12]);
-            setByte("userData14", data[13]);
-        } catch (FieldException e) {
-            logger.warn("got field exception on msg {}:", e.getMessage());
-        }
-    }
-
-    /**
-     * Calculate and set the CRC with the older 1-byte method
-     *
-     * @return the calculated crc
-     */
-    public int setCRC() {
-        int crc;
-        try {
-            crc = getByte("command1") + getByte("command2");
-            byte[] bytes = getBytes("userData1", 13); // skip userData14!
-            for (byte b : bytes) {
-                crc += b;
-            }
-            crc = ((~crc) + 1) & 0xFF;
-            setByte("userData14", (byte) (crc & 0xFF));
-        } catch (FieldException e) {
-            logger.warn("got field exception on msg {}:", this, e);
-            crc = 0;
-        }
-        return crc;
-    }
-
-    /**
-     * Calculate and set the CRC with the newer 2-byte method
-     *
-     * @return the calculated crc
-     */
-    public int setCRC2() {
-        int crc = 0;
-        try {
-            byte[] bytes = getBytes("command1", 14);
-            for (int loop = 0; loop < bytes.length; loop++) {
-                int b = bytes[loop] & 0xFF;
-                for (int bit = 0; bit < 8; bit++) {
-                    int fb = b & 0x01;
-                    if ((crc & 0x8000) == 0) {
-                        fb = fb ^ 0x01;
-                    }
-                    if ((crc & 0x4000) == 0) {
-                        fb = fb ^ 0x01;
-                    }
-                    if ((crc & 0x1000) == 0) {
-                        fb = fb ^ 0x01;
-                    }
-                    if ((crc & 0x0008) == 0) {
-                        fb = fb ^ 0x01;
-                    }
-                    crc = ((crc << 1) | fb) & 0xFFFF;
-                    b = b >> 1;
-                }
-            }
-            setByte("userData13", (byte) ((crc >> 8) & 0xFF));
-            setByte("userData14", (byte) (crc & 0xFF));
-        } catch (FieldException e) {
-            logger.warn("got field exception on msg {}:", this, e);
-            crc = 0;
-        }
-        return crc;
-    }
-
-    @Override
-    public String toString() {
-        String s = (direction == Direction.TO_MODEM) ? "OUT:" : "IN:";
-        // need to first sort the fields by offset
-        Comparator<Field> cmp = new Comparator<>() {
-            @Override
-            public int compare(Field f1, Field f2) {
-                return f1.getOffset() - f2.getOffset();
-            }
-        };
-        TreeSet<Field> fields = new TreeSet<>(cmp);
-        for (Field f : definition.getFields().values()) {
-            fields.add(f);
-        }
-        for (Field f : fields) {
-            if (f.getName().equals("messageFlags")) {
-                byte b;
-                try {
-                    b = f.getByte(data);
-                    MsgType t = MsgType.fromValue(b);
-                    s += f.toString(data) + "=" + t.toString() + ":" + (b & 0x03) + ":" + ((b & 0x0c) >> 2) + "|";
-                } catch (FieldException e) {
-                    logger.warn("toString error: ", e);
-                } catch (IllegalArgumentException e) {
-                    logger.warn("toString msg type error: ", e);
-                }
-            } else {
-                s += f.toString(data) + "|";
-            }
-        }
-        return s;
-    }
-
-    /**
-     * Factory method to create Msg from raw byte stream received from the
-     * serial port.
-     *
-     * @param buf the raw received bytes
-     * @param msgLen length of received buffer
-     * @param isExtended whether it is an extended message or not
-     * @return message, or null if the Msg cannot be created
-     */
-    public static @Nullable Msg createMessage(byte[] buf, int msgLen, boolean isExtended) {
-        if (buf.length < 2) {
-            return null;
-        }
-        Msg template = REPLY_MAP.get(cmdToKey(buf[1], isExtended));
-        if (template == null) {
-            return null; // cannot find lookup map
-        }
-        if (msgLen != template.getLength()) {
-            logger.warn("expected msg {} len {}, got {}", template.getCommandNumber(), template.getLength(), msgLen);
-            return null;
-        }
-        Msg msg = new Msg(template.getHeaderLength(), buf, msgLen, Direction.FROM_MODEM);
-        msg.setDefinition(template.getDefinition());
-        return (msg);
-    }
-
-    /**
-     * Finds the header length from the insteon command in the received message
-     *
-     * @param cmd the insteon command received in the message
-     * @return the length of the header to expect
-     */
-    public static int getHeaderLength(byte cmd) {
-        Integer len = HEADER_MAP.get((int) cmd);
-        if (len == null) {
-            return (-1); // not found
-        }
-        return len;
-    }
-
-    /**
-     * Tries to determine the length of a received Insteon message.
-     *
-     * @param b Insteon message command received
-     * @param isExtended flag indicating if it is an extended message
-     * @return message length, or -1 if length cannot be determined
-     */
-    public static int getMessageLength(byte b, boolean isExtended) {
-        int key = cmdToKey(b, isExtended);
-        Msg msg = REPLY_MAP.get(key);
-        if (msg == null) {
-            return -1;
-        }
-        return msg.getLength();
-    }
-
-    /**
-     * From bytes received thus far, tries to determine if an Insteon
-     * message is extended or standard.
-     *
-     * @param buf the received bytes
-     * @param len the number of bytes received so far
-     * @param headerLength the known length of the header
-     * @return true if it is definitely extended, false if cannot be
-     *         determined or if it is a standard message
-     */
-    public static boolean isExtended(byte[] buf, int len, int headerLength) {
-        if (headerLength <= 2) {
-            return false;
-        } // extended messages are longer
-        if (len < headerLength) {
-            return false;
-        } // not enough data to tell if extended
-        byte flags = buf[headerLength - 1]; // last byte says flags
-        boolean isExtended = (flags & 0x10) == 0x10; // bit 4 is the message
-        return (isExtended);
-    }
-
-    /**
-     * Creates Insteon message (for sending) of a given type
-     *
-     * @param type the type of message to create, as defined in the xml file
-     * @return reference to message created
-     * @throws InvalidMessageTypeException if there is no such message type known
-     */
-    public static Msg makeMessage(String type) throws InvalidMessageTypeException {
-        Msg m = MSG_MAP.get(type);
-        if (m == null) {
-            throw new InvalidMessageTypeException("unknown message type: " + type);
-        }
-        return new Msg(m);
-    }
-
-    private static int cmdToKey(byte cmd, boolean isExtended) {
-        return (cmd + (isExtended ? 256 : 0));
-    }
-
-    private static void buildHeaderMap() {
-        for (Msg m : MSG_MAP.values()) {
-            if (m.getDirection() == Direction.FROM_MODEM) {
-                HEADER_MAP.put((int) m.getCommandNumber(), m.getHeaderLength());
-            }
-        }
-    }
-
-    private static void buildLengthMap() {
-        for (Msg m : MSG_MAP.values()) {
-            if (m.getDirection() == Direction.FROM_MODEM) {
-                int key = cmdToKey(m.getCommandNumber(), m.isExtended());
-                REPLY_MAP.put(key, m);
-            }
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgDefinition.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgDefinition.java
deleted file mode 100644 (file)
index 7a6bbac..0000000
+++ /dev/null
@@ -1,72 +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.message;
-
-import java.util.HashMap;
-import java.util.Map;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-
-/**
- * Definition (layout) of an Insteon message. Says which bytes go where.
- * For more info, see the public Insteon Developer's Guide, 2nd edition,
- * and the Insteon Modem Developer's Guide.
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class MsgDefinition {
-    private Map<String, Field> fields = new HashMap<>();
-
-    MsgDefinition() {
-    }
-
-    /*
-     * Copy constructor, needed to make a copy of a message
-     *
-     * @param m the definition to copy
-     */
-    MsgDefinition(MsgDefinition m) {
-        fields = new HashMap<>(m.fields);
-    }
-
-    public Map<String, Field> getFields() {
-        return fields;
-    }
-
-    public boolean containsField(String name) {
-        return fields.containsKey(name);
-    }
-
-    public void addField(Field field) {
-        fields.put(field.getName(), field);
-    }
-
-    /**
-     * Finds field of a given name
-     *
-     * @param name name of the field to search for
-     * @return reference to field
-     * @throws FieldException if no such field can be found
-     */
-    public Field getField(@Nullable String name) throws FieldException {
-        @Nullable
-        Field f = fields.get(name);
-        if (f == null) {
-            throw new FieldException("field " + name + " not found");
-        }
-        return f;
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgFactory.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgFactory.java
deleted file mode 100644 (file)
index 649ce2e..0000000
+++ /dev/null
@@ -1,170 +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.message;
-
-import java.io.IOException;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.insteon.internal.utils.Utils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * This class takes data coming from the serial port and turns it
- * into a message. For that, it has to figure out the length of the
- * message from the header, and read enough bytes until it hits the
- * message boundary. The code is tricky, partly because the Insteon protocol is.
- * Most of the time the command code (second byte) is enough to determine the length
- * of the incoming message, but sometimes one has to look deeper into the message
- * to determine if it is a standard or extended message (their lengths differ).
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class MsgFactory {
-    private final Logger logger = LoggerFactory.getLogger(MsgFactory.class);
-    // no idea what the max msg length could be, but
-    // I doubt it'll ever be larger than 4k
-    private static final int MAX_MSG_LEN = 4096;
-    private byte[] buf = new byte[MAX_MSG_LEN];
-    private int end = 0; // offset of end of buffer
-    private boolean done = true; // done fully processing buffer flag
-
-    /**
-     * Constructor
-     */
-    public MsgFactory() {
-    }
-
-    /**
-     * Indicates if no more complete message available in the buffer to be processed
-     *
-     * @return buffer data fully processed flag
-     */
-    public boolean isDone() {
-        return done;
-    }
-
-    /**
-     * Adds incoming data to the data buffer. First call addData(), then call processData()
-     *
-     * @param data data to be added
-     * @param len length of data to be added
-     */
-    public void addData(byte[] data, int len) {
-        int l = len;
-        if (l + end > MAX_MSG_LEN) {
-            logger.warn("truncating excessively long message!");
-            l = MAX_MSG_LEN - end;
-        }
-        // indicate new data can be processed if length > 0
-        if (l > 0) {
-            done = false;
-        }
-        // append the new data to the one we already have
-        System.arraycopy(data, 0, buf, end, l);
-        end += l;
-        // copy the incoming data to the end of the buffer
-        logger.trace("read buffer: len {} data: {}", end, Utils.getHexString(buf, end));
-    }
-
-    /**
-     * After data has been added, this method processes it.
-     * processData() needs to be called until it returns null, indicating that no
-     * more messages can be formed from the data buffer.
-     *
-     * @return a valid message, or null if the message is not complete
-     * @throws IOException if data was received with unknown command codes
-     */
-    public @Nullable Msg processData() throws IOException {
-        Msg msg = null;
-        // handle the case where we get a pure nack
-        if (end > 0 && buf[0] == 0x15) {
-            logger.trace("got pure nack!");
-            removeFromBuffer(1);
-            try {
-                msg = Msg.makeMessage("PureNACK");
-                return msg;
-            } catch (InvalidMessageTypeException e) {
-                return null;
-            }
-        }
-        // drain the buffer until the first byte is 0x02
-        if (end > 0 && buf[0] != 0x02) {
-            bail("incoming message does not start with 0x02");
-        }
-        // Now see if we have enough data for a complete message.
-        // If not, we return null, and expect this method to be called again
-        // when more data has come in.
-        if (end > 1) {
-            // we have some data, but do we have enough to read the entire header?
-            int headerLength = Msg.getHeaderLength(buf[1]);
-            boolean isExtended = Msg.isExtended(buf, end, headerLength);
-            logger.trace("header length expected: {} extended: {}", headerLength, isExtended);
-            if (headerLength < 0) {
-                removeFromBuffer(1); // get rid of the leading 0x02 so draining works
-                bail("got unknown command code " + Utils.getHexByte(buf[0]));
-            } else if (headerLength >= 2) {
-                if (end >= headerLength) {
-                    // only when the header is complete do we know that isExtended is correct!
-                    int msgLen = Msg.getMessageLength(buf[1], isExtended);
-                    logger.trace("msgLen expected: {}", msgLen);
-                    if (msgLen < 0) {
-                        // Cannot make sense out of the combined command code & isExtended flag.
-                        removeFromBuffer(1);
-                        bail("got unknown command code/ext flag " + Utils.getHexByte(buf[0]));
-                    } else if (msgLen > 0) {
-                        if (end >= msgLen) {
-                            msg = Msg.createMessage(buf, msgLen, isExtended);
-                            removeFromBuffer(msgLen);
-                        }
-                    } else { // should never happen
-                        logger.warn("invalid message length, internal error!");
-                    }
-                }
-            } else { // should never happen
-                logger.warn("invalid header length, internal error!");
-            }
-        }
-        // indicate no more messages available in buffer if empty or undefined message
-        if (end == 0 || msg == null) {
-            logger.trace("done processing current buffer data");
-            done = true;
-        }
-        logger.trace("keeping buffer len {} data: {}", end, Utils.getHexString(buf, end));
-        return msg;
-    }
-
-    private void bail(String txt) throws IOException {
-        drainBuffer(); // this will drain until end or it finds the next 0x02
-        logger.debug("bad data received: {}", txt);
-        throw new IOException(txt);
-    }
-
-    private void drainBuffer() {
-        while (end > 0 && buf[0] != 0x02) {
-            removeFromBuffer(1);
-        }
-    }
-
-    private void removeFromBuffer(int len) {
-        int l = len;
-        if (l > end) {
-            l = end;
-        }
-        System.arraycopy(buf, l, buf, 0, end + 1 - l);
-        end -= l;
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgListener.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgListener.java
deleted file mode 100644 (file)
index 8de4abc..0000000
+++ /dev/null
@@ -1,31 +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.message;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * Interface to receive Insteon messages from the modem.
- *
- * @author Bernd Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public interface MsgListener {
-    /**
-     * Invoked whenever a valid message comes in from the modem
-     *
-     * @param msg the message received
-     */
-    void msg(Msg msg);
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgType.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/MsgType.java
deleted file mode 100644 (file)
index ebfe32a..0000000
+++ /dev/null
@@ -1,82 +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.message;
-
-import java.util.HashMap;
-import java.util.Map;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * Represents insteon message type flags
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public enum MsgType {
-    /*
-     * From the official Insteon docs: the message flags are as follows:
-     *
-     * Bit 0 max hops low bit
-     * Bit 1 max hops high bit
-     * Bit 2 hops left low bit
-     * Bit 3 hops left high bit
-     * Bit 4 0: is standard message, 1: is extended message
-     * Bit 5 ACK
-     * Bit 6 0: not link related, 1: is ALL-Link message
-     * Bit 7 Broadcast/NAK
-     */
-    BROADCAST(0x80),
-    DIRECT(0x00),
-    ACK_OF_DIRECT(0x20),
-    NACK_OF_DIRECT(0xa0),
-    ALL_LINK_BROADCAST(0xc0),
-    ALL_LINK_CLEANUP(0x40),
-    ALL_LINK_CLEANUP_ACK(0x60),
-    ALL_LINK_CLEANUP_NACK(0xe0),
-    INVALID(0xff); // should never happen
-
-    private static Map<Integer, MsgType> hash = new HashMap<>();
-
-    private byte byteValue = 0;
-
-    /**
-     * Constructor
-     *
-     * @param b byte with insteon message type flags set
-     */
-    MsgType(int b) {
-        this.byteValue = (byte) b;
-    }
-
-    static {
-        for (MsgType t : MsgType.values()) {
-            int i = t.getByteValue() & 0xff;
-            hash.put(i, t);
-        }
-    }
-
-    private int getByteValue() {
-        return byteValue;
-    }
-
-    public static MsgType fromValue(byte b) throws IllegalArgumentException {
-        int i = b & 0xe0;
-        MsgType mt = hash.get(i);
-        if (mt == null) {
-            throw new IllegalArgumentException("msg type of byte value " + i + " not found");
-        }
-        return mt;
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/XMLMessageReader.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/message/XMLMessageReader.java
deleted file mode 100644 (file)
index e8991a1..0000000
+++ /dev/null
@@ -1,181 +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.message;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Map.Entry;
-
-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.Pair;
-import org.openhab.binding.insteon.internal.utils.Utils.DataTypeParser;
-import org.openhab.binding.insteon.internal.utils.Utils.ParsingException;
-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 Msg definitions from an XML file
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class XMLMessageReader {
-    /**
-     * Reads the message definitions from an xml file
-     *
-     * @param input input stream from which to read
-     * @return what was read from file: the map between clear text string and Msg objects
-     * @throws IOException couldn't read file etc
-     * @throws ParsingException something wrong with the file format
-     * @throws FieldException something wrong with the field definition
-     */
-    public static Map<String, Msg> readMessageDefinitions(InputStream input)
-            throws IOException, ParsingException, FieldException {
-        Map<String, Msg> messageMap = new HashMap<>();
-        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();
-
-            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) {
-                    if ("msg".equals(node.getNodeName())) {
-                        Pair<String, Msg> msgDef = readMessageDefinition((Element) node);
-                        messageMap.put(msgDef.getKey(), msgDef.getValue());
-                    }
-                }
-            }
-        } catch (SAXException e) {
-            throw new ParsingException("Failed to parse XML!", e);
-        } catch (ParserConfigurationException e) {
-            throw new ParsingException("Got parser config exception! ", e);
-        }
-        return messageMap;
-    }
-
-    private static Pair<String, Msg> readMessageDefinition(Element msg) throws FieldException, ParsingException {
-        int length = 0;
-        int hlength = 0;
-        LinkedHashMap<Field, Object> fieldMap = new LinkedHashMap<>();
-        String dir = msg.getAttribute("direction");
-        String name = msg.getAttribute("name");
-        Msg.Direction direction = Msg.Direction.getDirectionFromString(dir);
-
-        if (msg.hasAttribute("length")) {
-            length = Integer.parseInt(msg.getAttribute("length"));
-        }
-
-        NodeList nodes = msg.getChildNodes();
-
-        int offset = 0;
-
-        for (int i = 0; i < nodes.getLength(); i++) {
-            Node node = nodes.item(i);
-            if (node.getNodeType() == Node.ELEMENT_NODE) {
-                if ("header".equals(node.getNodeName())) {
-                    int o = readHeaderElement((Element) node, fieldMap);
-                    hlength = o;
-                    // Increment the offset by the header length
-                    offset += o;
-                } else {
-                    Pair<Field, Object> field = readField((Element) node, offset);
-                    fieldMap.put(field.getKey(), field.getValue());
-                    // Increment the offset
-                    offset += field.getKey().getType().getSize();
-                }
-            }
-        }
-        if (offset != length) {
-            throw new ParsingException(
-                    "Actual msg length " + offset + " differs from given msg length " + length + "!");
-        }
-        if (length == 0) {
-            length = offset;
-        }
-
-        return new Pair<>(name, createMsg(fieldMap, length, hlength, direction));
-    }
-
-    private static int readHeaderElement(Element header, LinkedHashMap<Field, Object> fields) throws ParsingException {
-        int offset = 0;
-        int headerLen = Integer.parseInt(header.getAttribute("length"));
-
-        NodeList nodes = header.getChildNodes();
-        for (int i = 0; i < nodes.getLength(); i++) {
-            Node node = nodes.item(i);
-            if (node.getNodeType() == Node.ELEMENT_NODE) {
-                Pair<Field, Object> definition = readField((Element) node, offset);
-                offset += definition.getKey().getType().getSize();
-                fields.put(definition.getKey(), definition.getValue());
-            }
-        }
-        if (headerLen != offset) {
-            throw new ParsingException(
-                    "Actual header length " + offset + " differs from given length " + headerLen + "!");
-        }
-        return headerLen;
-    }
-
-    private static Pair<Field, Object> readField(Element field, int offset) {
-        DataType dType = DataType.getDataType(field.getTagName());
-        // Will return blank if no name attribute
-        String name = field.getAttribute("name");
-        Field f = new Field(name, dType, offset);
-        // Now we have field, only need value
-        String sVal = field.getTextContent();
-        Object val = DataTypeParser.parseDataType(dType, sVal);
-        return new Pair<>(f, val);
-    }
-
-    private static Msg createMsg(HashMap<Field, Object> values, int length, int headerLength, Msg.Direction dir)
-            throws FieldException {
-        Msg msg = new Msg(headerLength, new byte[length], length, dir);
-        for (Entry<Field, Object> e : values.entrySet()) {
-            Field f = e.getKey();
-            byte[] data = msg.getData();
-            if (data != null) {
-                f.set(data, e.getValue());
-            } else {
-                throw new FieldException("data is null");
-            }
-            if (!"".equals(f.getName())) {
-                msg.addField(f);
-            }
-        }
-        return msg;
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/HubIOStream.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/HubIOStream.java
new file mode 100644 (file)
index 0000000..a7fb1c2
--- /dev/null
@@ -0,0 +1,373 @@
+/**
+ * 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.transport;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Objects;
+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.utils.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Implements IOStream for an Insteon Hub 2
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ *
+ */
+@NonNullByDefault
+public class HubIOStream extends IOStream {
+    private final Logger logger = LoggerFactory.getLogger(HubIOStream.class);
+
+    private static final String BS_START = "<BS>";
+    private static final String BS_END = "</BS>";
+
+    private String host;
+    private int port;
+    private String auth;
+    private int pollInterval;
+    private ScheduledExecutorService scheduler;
+    private @Nullable ScheduledFuture<?> job;
+    // index of the last byte we have read in the buffer
+    private int bufferIdx = -1;
+
+    /**
+     * Constructor
+     *
+     * @param host host name of hub device
+     * @param port port to connect to
+     * @param username hub user name
+     * @param password hub password
+     * @param pollInterval hub poll interval (in milliseconds)
+     * @param scheduler the scheduler
+     */
+    public HubIOStream(String host, int port, String username, String password, int pollInterval,
+            ScheduledExecutorService scheduler) {
+        this.host = host;
+        this.port = port;
+        this.auth = Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
+        this.pollInterval = pollInterval;
+        this.scheduler = scheduler;
+    }
+
+    @Override
+    public boolean isOpen() {
+        return job != null;
+    }
+
+    @Override
+    public boolean open() {
+        if (isOpen()) {
+            logger.warn("hub stream is already open");
+            return false;
+        }
+
+        try {
+            clearBuffer();
+        } catch (IOException e) {
+            logger.warn("open failed: {}", e.getMessage());
+            return false;
+        }
+
+        in = new HubInputStream();
+        out = new HubOutputStream();
+
+        job = scheduler.scheduleWithFixedDelay(() -> {
+            try {
+                poll();
+            } catch (IOException e) {
+                logger.debug("failed to poll hub", e);
+                close();
+            }
+        }, 0, pollInterval, TimeUnit.MILLISECONDS);
+
+        return true;
+    }
+
+    @Override
+    public void close() {
+        ScheduledFuture<?> job = this.job;
+        if (job != null) {
+            job.cancel(true);
+            this.job = null;
+        }
+
+        InputStream in = this.in;
+        if (in != null) {
+            try {
+                in.close();
+            } catch (IOException e) {
+                logger.debug("failed to close input stream", e);
+            }
+            this.in = null;
+        }
+
+        OutputStream out = this.out;
+        if (out != null) {
+            try {
+                out.close();
+            } catch (IOException e) {
+                logger.debug("failed to close output stream", e);
+            }
+            this.out = null;
+        }
+    }
+
+    /**
+     * Fetches the latest status buffer from the Hub
+     *
+     * @return string with status buffer
+     * @throws IOException
+     */
+    private synchronized String bufferStatus() throws IOException {
+        String result = getURL("/buffstatus.xml");
+
+        int start = result.indexOf(BS_START);
+        if (start == -1) {
+            throw new IOException("malformed bufferstatus.xml");
+        }
+        start += BS_START.length();
+
+        int end = result.indexOf(BS_END, start);
+        if (end == -1) {
+            throw new IOException("malformed bufferstatus.xml");
+        }
+
+        return result.substring(start, end).trim();
+    }
+
+    /**
+     * Sends command to Hub to clear the status buffer
+     *
+     * @throws IOException
+     */
+    private synchronized void clearBuffer() throws IOException {
+        logger.trace("clearing buffer");
+        getURL("/1?XB=M=1");
+        bufferIdx = 0;
+    }
+
+    /**
+     * Sends Insteon message (byte array) as a readable ascii string to the Hub
+     *
+     * @param msg byte array representing the Insteon message
+     * @throws IOException in case of I/O error
+     */
+    public synchronized void write(ByteBuffer msg) throws IOException {
+        poll(); // fetch the status buffer before we send out commands
+
+        StringBuilder b = new StringBuilder();
+        while (msg.remaining() > 0) {
+            b.append(String.format("%02x", msg.get()));
+        }
+        String hexMsg = b.toString();
+        logger.trace("writing a message");
+        getURL("/3?" + hexMsg + "=I=3");
+        bufferIdx = 0;
+    }
+
+    /**
+     * Polls the Hub web interface to fetch the status buffer
+     *
+     * @throws IOException if something goes wrong with I/O
+     */
+    private synchronized void poll() throws IOException {
+        String buffer = bufferStatus(); // fetch via http call
+        logger.trace("poll: {}", buffer);
+        // The Hub maintains a ring buffer where the last two digits (in hex!) represent
+        // the position of the last byte read.
+        String data = buffer.substring(0, buffer.length() - 2); // pure data w/o index pointer
+
+        int nIdx = -1;
+        try {
+            nIdx = Integer.parseInt(buffer.substring(buffer.length() - 2, buffer.length()), 16);
+        } catch (NumberFormatException e) {
+            bufferIdx = -1;
+            logger.debug("invalid buffer size received in line: {}", buffer);
+            return;
+        }
+
+        if (bufferIdx == -1) {
+            // this is the first call or first call after error, no need for buffer copying
+            bufferIdx = nIdx;
+            return;
+        }
+
+        if (isClearedBuffer(data)) {
+            logger.trace("skip cleared buffer");
+            bufferIdx = 0;
+            return;
+        }
+
+        StringBuilder msg = new StringBuilder();
+        if (nIdx < bufferIdx) {
+            String msgStart = data.substring(bufferIdx, data.length());
+            String msgEnd = data.substring(0, nIdx);
+            if (isClearedBuffer(msgStart)) {
+                logger.trace("discard cleared buffer wrap around msg start");
+                msgStart = "";
+            }
+
+            msg.append(msgStart + msgEnd);
+            logger.trace("wrap around: copying new data on: {}", msg);
+        } else {
+            msg.append(data.substring(bufferIdx, nIdx));
+            logger.trace("no wrap:      appending new data: {}", msg);
+        }
+        if (msg.length() != 0) {
+            byte[] array = HexUtils.toByteArray(msg.toString());
+            ByteBuffer buf = ByteBuffer.wrap(array);
+            if (in instanceof HubInputStream hubInput) {
+                hubInput.handle(buf);
+            } else {
+                logger.debug("hub input stream is null");
+            }
+        }
+        bufferIdx = nIdx;
+    }
+
+    /**
+     * Returns if is cleared buffer
+     *
+     * @param data buffer data to check
+     * @return true if all zeros in buffer
+     */
+    private boolean isClearedBuffer(String data) {
+        return "0".repeat(data.length()).equals(data);
+    }
+
+    /**
+     * Helper method to fetch url from http server
+     *
+     * @param resource the url
+     * @return contents returned by http server
+     * @throws IOException
+     */
+    private String getURL(String resource) throws IOException {
+        String url = "http://" + host + ":" + port + resource;
+
+        HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+        try {
+            connection.setConnectTimeout(30000);
+            connection.setReadTimeout(10000);
+            connection.setUseCaches(false);
+            connection.setDoInput(true);
+            connection.setDoOutput(false);
+            connection.setRequestProperty("Authorization", "Basic " + auth);
+
+            logger.trace("getting {}", url);
+
+            int responseCode = connection.getResponseCode();
+            if (responseCode != 200) {
+                if (responseCode == 401) {
+                    throw new IOException(
+                            "Bad username or password. See the label on the bottom of the hub for the correct login information.");
+                } else {
+                    throw new IOException(url + " failed with the response code: " + responseCode);
+                }
+            }
+
+            return getData(connection.getInputStream());
+        } finally {
+            connection.disconnect();
+        }
+    }
+
+    private String getData(InputStream is) throws IOException {
+        BufferedInputStream bis = new BufferedInputStream(is);
+        try {
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            byte[] buffer = new byte[1024];
+            int length = 0;
+            while ((length = bis.read(buffer)) != -1) {
+                baos.write(buffer, 0, length);
+            }
+
+            String s = baos.toString();
+            return s;
+        } finally {
+            bis.close();
+        }
+    }
+
+    /**
+     * Implements an InputStream for an Insteon Hub 2
+     */
+    public class HubInputStream extends InputStream {
+        // A buffer to keep bytes while we are waiting for the inputstream to read
+        private ReadByteBuffer buffer = new ReadByteBuffer(1024);
+
+        public void handle(ByteBuffer b) throws IOException {
+            // Make sure we cleanup as much space as possible
+            buffer.makeCompact();
+            buffer.add(b.array());
+        }
+
+        @Override
+        public int read() throws IOException {
+            return buffer.get();
+        }
+
+        @Override
+        public int read(byte @Nullable [] b, int off, int len) throws IOException {
+            Objects.requireNonNull(b);
+            return buffer.get(b, off, len);
+        }
+
+        @Override
+        public void close() throws IOException {
+            buffer.close();
+        }
+    }
+
+    /**
+     * Implements an OutputStream for an Insteon Hub 2
+     */
+    public class HubOutputStream extends OutputStream {
+        private ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+        @Override
+        public void write(int b) throws IOException {
+            out.write(b);
+            flushBuffer();
+        }
+
+        @Override
+        public void write(byte @Nullable [] b, int off, int len) throws IOException {
+            out.write(b, off, len);
+            flushBuffer();
+        }
+
+        private void flushBuffer() throws IOException {
+            ByteBuffer buffer = ByteBuffer.wrap(out.toByteArray());
+            HubIOStream.this.write(buffer);
+            out.reset();
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/IOStream.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/IOStream.java
new file mode 100644 (file)
index 0000000..3005f6a
--- /dev/null
@@ -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.transport;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+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.config.InsteonHub1Configuration;
+import org.openhab.binding.insteon.internal.config.InsteonHub2Configuration;
+import org.openhab.binding.insteon.internal.config.InsteonPLMConfiguration;
+import org.openhab.core.io.transport.serial.SerialPortManager;
+
+/**
+ * Abstract class for implementation for I/O stream with anything that looks
+ * like a PLM (e.g. the insteon hubs, serial/usb connection etc)
+ *
+ * @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 abstract class IOStream {
+
+    protected @Nullable InputStream in;
+    protected @Nullable OutputStream out;
+
+    /**
+     * Reads data from IOStream
+     *
+     * @param b byte array (output)
+     * @return number of bytes read
+     */
+    public int read(byte @Nullable [] b) throws InterruptedException, IOException {
+        int len = 0;
+        while (len == 0) {
+            if (!isOpen()) {
+                throw new IOException("io stream not open");
+            }
+
+            InputStream in = this.in;
+            if (in != null) {
+                len = in.read(b);
+            } else {
+                throw new IOException("input stream not defined");
+            }
+
+            if (Thread.interrupted()) {
+                throw new InterruptedException();
+            }
+
+            if (len == -1) {
+                throw new EOFException();
+            }
+        }
+        return len;
+    }
+
+    /**
+     * Writes data to IOStream
+     *
+     * @param b byte array to write
+     */
+    public void write(byte @Nullable [] b) throws InterruptedException, IOException {
+        if (!isOpen()) {
+            throw new IOException("io stream not open");
+        }
+
+        OutputStream out = this.out;
+        if (out != null) {
+            out.write(b);
+        } else {
+            throw new IOException("output stream not defined");
+        }
+    }
+
+    /**
+     * Returns if IOStream is open
+     *
+     * @return true if stream is open, false if not
+     */
+    public abstract boolean isOpen();
+
+    /**
+     * Opens the IOStream
+     *
+     * @return true if open was successful, false if not
+     */
+    public abstract boolean open();
+
+    /**
+     * Closes the IOStream
+     */
+    public abstract void close();
+
+    /**
+     * Creates an IOStream from an insteon bridge config object
+     *
+     * @param config
+     * @param scheduler
+     * @param serialPortManager
+     * @return reference to IOStream
+     */
+    public static IOStream create(InsteonBridgeConfiguration config, ScheduledExecutorService scheduler,
+            SerialPortManager serialPortManager) {
+        if (config instanceof InsteonHub1Configuration hub1Config) {
+            return makeTcpIOStream(hub1Config);
+        } else if (config instanceof InsteonHub2Configuration hub2Config) {
+            return makeHubIOStream(hub2Config, scheduler);
+        } else if (config instanceof InsteonPLMConfiguration plmConfig) {
+            return makeSerialIOStream(plmConfig, serialPortManager);
+        } else {
+            throw new UnsupportedOperationException("unsupported bridge configuration");
+        }
+    }
+
+    private static HubIOStream makeHubIOStream(InsteonHub2Configuration config, ScheduledExecutorService scheduler) {
+        String host = config.getHostname();
+        int port = config.getPort();
+        String user = config.getUsername();
+        String pass = config.getPassword();
+        int pollInterval = config.getHubPollInterval();
+        return new HubIOStream(host, port, user, pass, pollInterval, scheduler);
+    }
+
+    private static SerialIOStream makeSerialIOStream(InsteonPLMConfiguration config,
+            SerialPortManager serialPortManager) {
+        String name = config.getSerialPort();
+        int baudRate = config.getBaudRate();
+        return new SerialIOStream(name, baudRate, serialPortManager);
+    }
+
+    private static TcpIOStream makeTcpIOStream(InsteonHub1Configuration config) {
+        String host = config.getHostname();
+        int port = config.getPort();
+        return new TcpIOStream(host, port);
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyDriver.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyDriver.java
new file mode 100644 (file)
index 0000000..166994f
--- /dev/null
@@ -0,0 +1,108 @@
+/**
+ * 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.transport;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.config.InsteonLegacyNetworkConfiguration;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.database.LegacyModemDBEntry;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.openhab.core.io.transport.serial.SerialPortManager;
+
+/**
+ * The driver class manages the modem port.
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class LegacyDriver {
+    private LegacyPort port;
+    private LegacyDriverListener listener;
+    private Map<InsteonAddress, LegacyModemDBEntry> modemDBEntries = new HashMap<>();
+    private ReentrantLock modemDBEntriesLock = new ReentrantLock();
+
+    public LegacyDriver(InsteonLegacyNetworkConfiguration config, LegacyDriverListener listener,
+            SerialPortManager serialPortManager, ScheduledExecutorService scheduler) {
+        this.listener = listener;
+
+        this.port = new LegacyPort(config, this, serialPortManager, scheduler);
+    }
+
+    public boolean isReady() {
+        return port.isRunning();
+    }
+
+    public Map<InsteonAddress, LegacyModemDBEntry> lockModemDBEntries() {
+        modemDBEntriesLock.lock();
+        return modemDBEntries;
+    }
+
+    public void unlockModemDBEntries() {
+        modemDBEntriesLock.unlock();
+    }
+
+    public void addPortListener(LegacyPortListener listener) {
+        port.addListener(listener);
+    }
+
+    public void removePortListener(LegacyPortListener listener) {
+        port.removeListener(listener);
+    }
+
+    public void start() {
+        port.start();
+    }
+
+    public void stop() {
+        port.stop();
+    }
+
+    public void writeMessage(Msg m) throws IOException {
+        port.writeMessage(m);
+    }
+
+    public String getPortName() {
+        return port.getName();
+    }
+
+    public boolean isRunning() {
+        return port.isRunning();
+    }
+
+    public boolean isMsgForUs(@Nullable InsteonAddress toAddr) {
+        return port.getAddress().equals(toAddr);
+    }
+
+    public void modemDBComplete(LegacyPort port) {
+        if (isModemDBComplete()) {
+            listener.driverCompletelyInitialized();
+        }
+    }
+
+    public boolean isModemDBComplete() {
+        return port.isModemDBComplete();
+    }
+
+    public void disconnected() {
+        listener.disconnected();
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyDriverListener.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyDriverListener.java
new file mode 100644 (file)
index 0000000..3709141
--- /dev/null
@@ -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.transport;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Interface for classes that want to listen to notifications from
+ * the driver.
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ */
+@NonNullByDefault
+public interface LegacyDriverListener {
+    /**
+     * Notification that querying of the modems on all ports has successfully completed.
+     */
+    void driverCompletelyInitialized();
+
+    /**
+     * Notification that the driver was disconnected
+     */
+    void disconnected();
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyPort.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyPort.java
new file mode 100644 (file)
index 0000000..664a1f8
--- /dev/null
@@ -0,0 +1,531 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.insteon.internal.transport;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.InsteonLegacyBindingConstants;
+import org.openhab.binding.insteon.internal.config.InsteonLegacyNetworkConfiguration;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.LegacyDevice;
+import org.openhab.binding.insteon.internal.device.LegacyDeviceType;
+import org.openhab.binding.insteon.internal.device.LegacyDeviceTypeLoader;
+import org.openhab.binding.insteon.internal.device.database.LegacyModemDBBuilder;
+import org.openhab.binding.insteon.internal.device.database.LegacyModemDBEntry;
+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.MsgFactory;
+import org.openhab.core.io.transport.serial.SerialPortManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The Port class represents a port, that is a connection to either an Insteon modem either through
+ * a serial or USB port, or via an Insteon Hub.
+ * It does the initialization of the port, and (via its inner classes IOStreamReader and IOStreamWriter)
+ * manages the reading/writing of messages on the Insteon network.
+ *
+ * The IOStreamReader and IOStreamWriter class combined implement the somewhat tricky flow control protocol.
+ * In combination with the MsgFactory class, the incoming data stream is turned into a Msg structure
+ * for further processing by the upper layers (MsgListeners).
+ *
+ * A write queue is maintained to pace the flow of outgoing messages. Sending messages back-to-back
+ * can lead to dropped messages.
+ *
+ *
+ * @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 LegacyPort {
+    private final Logger logger = LoggerFactory.getLogger(LegacyPort.class);
+
+    /**
+     * The ReplyType is used to keep track of the state of the serial port receiver
+     */
+    enum ReplyType {
+        GOT_ACK,
+        WAITING_FOR_ACK,
+        GOT_NACK
+    }
+
+    private IOStream ioStream;
+    private String name;
+    private Modem modem;
+    private IOStreamReader reader;
+    private IOStreamWriter writer;
+    private final int readSize = 1024; // read buffer size
+    private @Nullable Thread readThread = null;
+    private @Nullable Thread writeThread = null;
+    private boolean running = false;
+    private boolean modemDBComplete = false;
+    private MsgFactory msgFactory = new MsgFactory();
+    private LegacyDriver driver;
+    private LegacyModemDBBuilder mdbb;
+    private ArrayList<LegacyPortListener> listeners = new ArrayList<>();
+    private LinkedBlockingQueue<Msg> writeQueue = new LinkedBlockingQueue<>();
+    private AtomicBoolean disconnected = new AtomicBoolean(false);
+
+    /**
+     * Constructor
+     *
+     * @param config the network bridge config
+     * @param driver the driver that manages this port
+     * @param serialPortManager the serial port manager
+     * @param scheduler the scheduler
+     */
+    public LegacyPort(InsteonLegacyNetworkConfiguration config, LegacyDriver driver,
+            SerialPortManager serialPortManager, ScheduledExecutorService scheduler) {
+        this.name = config.getRedactedPort();
+        this.driver = driver;
+        this.modem = new Modem();
+        addListener(modem);
+        this.ioStream = IOStream.create(config.parse(), scheduler, serialPortManager);
+        this.reader = new IOStreamReader();
+        this.writer = new IOStreamWriter();
+        this.mdbb = new LegacyModemDBBuilder(this, scheduler);
+    }
+
+    public boolean isModem(InsteonAddress a) {
+        return modem.getAddress().equals(a);
+    }
+
+    public synchronized boolean isModemDBComplete() {
+        return (modemDBComplete);
+    }
+
+    public boolean isRunning() {
+        return running;
+    }
+
+    public InsteonAddress getAddress() {
+        return modem.getAddress();
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public LegacyDriver getDriver() {
+        return driver;
+    }
+
+    public void addListener(LegacyPortListener l) {
+        synchronized (listeners) {
+            if (!listeners.contains(l)) {
+                listeners.add(l);
+            }
+        }
+    }
+
+    public void removeListener(LegacyPortListener l) {
+        synchronized (listeners) {
+            if (listeners.remove(l)) {
+                logger.debug("removed listener from port");
+            }
+        }
+    }
+
+    /**
+     * Clear modem database that has been queried so far.
+     */
+    public void clearModemDB() {
+        logger.debug("clearing modem db!");
+        Map<InsteonAddress, LegacyModemDBEntry> dbes = getDriver().lockModemDBEntries();
+        for (Entry<InsteonAddress, LegacyModemDBEntry> entry : dbes.entrySet()) {
+            if (!entry.getValue().isModem()) {
+                dbes.remove(entry.getKey());
+            }
+        }
+        getDriver().unlockModemDBEntries();
+    }
+
+    /**
+     * Starts threads necessary for reading and writing
+     */
+    public void start() {
+        logger.debug("starting port {}", name);
+        if (running) {
+            logger.debug("port {} already running, not started again", name);
+            return;
+        }
+
+        writeQueue.clear();
+        if (!ioStream.open()) {
+            logger.debug("failed to open port {}", name);
+            return;
+        }
+
+        readThread = new Thread(reader);
+        setParamsAndStart(readThread, "Reader");
+        writeThread = new Thread(writer);
+        setParamsAndStart(writeThread, "Writer");
+
+        if (!mdbb.isComplete()) {
+            modem.initialize();
+            mdbb.start(); // start downloading the device list
+        }
+
+        running = true;
+        disconnected.set(false);
+    }
+
+    private void setParamsAndStart(@Nullable Thread thread, String type) {
+        if (thread != null) {
+            thread.setName("OH-binding-Insteon " + name + " " + type);
+            thread.setDaemon(true);
+            thread.start();
+        }
+    }
+
+    /**
+     * Stops all threads
+     */
+    public void stop() {
+        if (!running) {
+            logger.debug("port {} not running, no need to stop it", name);
+            return;
+        }
+
+        running = false;
+
+        if (mdbb.isRunning()) {
+            mdbb.stop();
+        }
+
+        if (ioStream.isOpen()) {
+            ioStream.close();
+        }
+
+        Thread readThread = this.readThread;
+        if (readThread != null) {
+            readThread.interrupt();
+        }
+        Thread writeThread = this.writeThread;
+        if (writeThread != null) {
+            writeThread.interrupt();
+        }
+        logger.debug("waiting for read thread to exit for port {}", name);
+        try {
+            if (readThread != null) {
+                readThread.join();
+            }
+        } catch (InterruptedException e) {
+            logger.debug("got interrupted waiting for read thread to exit.");
+        }
+        logger.debug("waiting for write thread to exit for port {}", name);
+        try {
+            if (writeThread != null) {
+                writeThread.join();
+            }
+        } catch (InterruptedException e) {
+            logger.debug("got interrupted waiting for write thread to exit.");
+        }
+        this.readThread = null;
+        this.writeThread = null;
+
+        logger.debug("all threads for port {} stopped.", name);
+    }
+
+    /**
+     * Adds message to the write queue
+     *
+     * @param msg message to be added to the write queue
+     * @throws IOException
+     */
+    public void writeMessage(@Nullable Msg msg) throws IOException {
+        if (msg == null) {
+            logger.warn("trying to write null message!");
+            throw new IOException("trying to write null message!");
+        }
+        try {
+            writeQueue.add(msg);
+            logger.trace("enqueued msg: {}", msg);
+        } catch (IllegalStateException e) {
+            logger.warn("cannot write message {}, write queue is full!", msg);
+        }
+    }
+
+    /**
+     * Gets called by the modem database builder when the modem database is complete
+     */
+    public void modemDBComplete() {
+        synchronized (this) {
+            modemDBComplete = true;
+        }
+        driver.modemDBComplete(this);
+    }
+
+    public void disconnected() {
+        if (isRunning()) {
+            if (!disconnected.getAndSet(true)) {
+                logger.warn("port {} disconnected", name);
+                driver.disconnected();
+            }
+        }
+    }
+
+    /**
+     * The IOStreamReader uses the MsgFactory to turn the incoming bytes into
+     * Msgs for the listeners. It also communicates with the IOStreamWriter
+     * to implement flow control (tell the IOStreamWriter that it needs to retransmit,
+     * or the reply message has been received correctly).
+     *
+     * @author Bernd Pfrommer - Initial contribution
+     */
+    class IOStreamReader implements Runnable {
+
+        private ReplyType reply = ReplyType.GOT_ACK;
+        private Object replyLock = new Object();
+
+        /**
+         * Helper function for implementing synchronization between reader and writer
+         *
+         * @return reference to the RequesReplyLock
+         */
+        public Object getRequestReplyLock() {
+            return replyLock;
+        }
+
+        @Override
+        public void run() {
+            logger.debug("starting reader...");
+            byte[] buffer = new byte[readSize];
+            try {
+                for (int len = -1; (len = ioStream.read(buffer)) > 0;) {
+                    msgFactory.addData(buffer, len);
+                    processMessages();
+                }
+            } catch (InterruptedException e) {
+                logger.debug("reader thread got interrupted!");
+            } catch (IOException e) {
+                logger.debug("got an io exception in the reader thread");
+                disconnected();
+            }
+            logger.debug("reader thread exiting!");
+        }
+
+        private void processMessages() {
+            // must call processData() until msgFactory done fully processing buffer
+            while (!msgFactory.isDone()) {
+                try {
+                    Msg msg = msgFactory.processData();
+                    if (msg != null) {
+                        toAllListeners(msg);
+                        notifyWriter(msg);
+                    }
+                } catch (IOException e) {
+                    // got bad data from modem,
+                    // unblock those waiting for ack
+                    synchronized (getRequestReplyLock()) {
+                        if (reply == ReplyType.WAITING_FOR_ACK) {
+                            logger.debug("got bad data back, must assume message was acked.");
+                            reply = ReplyType.GOT_ACK;
+                            getRequestReplyLock().notify();
+                        }
+                    }
+                }
+            }
+        }
+
+        private void notifyWriter(Msg msg) {
+            synchronized (getRequestReplyLock()) {
+                if (reply == ReplyType.WAITING_FOR_ACK) {
+                    if (msg.isReply()) {
+                        reply = (msg.isPureNack() ? ReplyType.GOT_NACK : ReplyType.GOT_ACK);
+                        logger.trace("signaling receipt of ack: {}", (reply == ReplyType.GOT_ACK));
+                        getRequestReplyLock().notify();
+                    } else if (msg.isPureNack()) {
+                        reply = ReplyType.GOT_NACK;
+                        logger.trace("signaling receipt of pure nack");
+                        getRequestReplyLock().notify();
+                    } else {
+                        logger.trace("got unsolicited message");
+                    }
+                }
+            }
+        }
+
+        @SuppressWarnings("unchecked")
+        private void toAllListeners(Msg msg) {
+            // When we deliver the message, the recipient
+            // may in turn call removeListener() or addListener(),
+            // thereby corrupting the very same list we are iterating
+            // through. That's why we make a copy of it, and
+            // iterate through the copy.
+            ArrayList<LegacyPortListener> tempList = null;
+            synchronized (listeners) {
+                tempList = (ArrayList<LegacyPortListener>) listeners.clone();
+            }
+            for (LegacyPortListener listener : tempList) {
+                listener.msg(msg); // deliver msg to listener
+            }
+        }
+
+        /**
+         * Blocking wait for ack or nack from modem.
+         * Called by IOStreamWriter for flow control.
+         *
+         * @return true if retransmission is necessary
+         */
+        public boolean waitForReply() {
+            reply = ReplyType.WAITING_FOR_ACK;
+            while (reply == ReplyType.WAITING_FOR_ACK) {
+                try {
+                    logger.trace("writer waiting for ack.");
+                    // There have been cases observed, in particular for
+                    // the Hub, where we get no ack or nack back, causing the binding
+                    // to hang in the wait() below, because unsolicited messages
+                    // do not trigger a notify(). For this reason we request retransmission
+                    // if the wait() times out.
+                    getRequestReplyLock().wait(30000); // be patient for 30 msec
+                    if (reply == ReplyType.WAITING_FOR_ACK) { // timeout expired without getting ACK or NACK
+                        logger.trace("writer timeout expired, asking for retransmit!");
+                        reply = ReplyType.GOT_NACK;
+                        break;
+                    } else {
+                        logger.trace("writer got ack: {}", (reply == ReplyType.GOT_ACK));
+                    }
+                } catch (InterruptedException e) {
+                    break; // done for the day...
+                }
+            }
+            return reply == ReplyType.GOT_NACK;
+        }
+    }
+
+    /**
+     * Writes messages to the port. Flow control is implemented following Insteon
+     * documents to avoid over running the modem.
+     *
+     * @author Bernd Pfrommer - Initial contribution
+     */
+    class IOStreamWriter implements Runnable {
+        private static final int WAIT_TIME = 200; // milliseconds
+
+        @Override
+        public void run() {
+            logger.debug("starting writer...");
+            while (true) {
+                try {
+                    // this call blocks until the lock on the queue is released
+                    logger.trace("writer checking message queue");
+                    Msg msg = writeQueue.take();
+                    logger.debug("writing ({}): {}", msg.getQuietTime(), msg);
+                    // To debug race conditions during startup (i.e. make the .items
+                    // file definitions be available *before* the modem link records,
+                    // slow down the modem traffic with the following statement:
+                    // Thread.sleep(500);
+                    synchronized (reader.getRequestReplyLock()) {
+                        ioStream.write(msg.getData());
+                        while (reader.waitForReply()) {
+                            Thread.sleep(WAIT_TIME);
+                            logger.trace("retransmitting msg: {}", msg);
+                            ioStream.write(msg.getData());
+                        }
+                    }
+                    // if rate limited, need to sleep now.
+                    if (msg.getQuietTime() > 0) {
+                        Thread.sleep(msg.getQuietTime());
+                    }
+                } catch (InterruptedException e) {
+                    logger.debug("got interrupted exception in write thread");
+                    break;
+                } catch (IOException e) {
+                    logger.debug("got an io exception in the write thread");
+                    disconnected();
+                    break;
+                }
+            }
+            logger.debug("writer thread exiting!");
+        }
+    }
+
+    /**
+     * Class to get info about the modem
+     */
+    class Modem implements LegacyPortListener {
+        private @Nullable LegacyDevice device = null;
+
+        InsteonAddress getAddress() {
+            LegacyDevice device = this.device;
+            return device == null ? InsteonAddress.UNKNOWN : (InsteonAddress) device.getAddress();
+        }
+
+        @Nullable
+        LegacyDevice getDevice() {
+            return device;
+        }
+
+        @Override
+        public void msg(Msg msg) {
+            try {
+                if (msg.isPureNack()) {
+                    return;
+                }
+                if (msg.getByte("Cmd") == 0x60) {
+                    // add the modem to the device list
+                    InsteonAddress address = msg.getInsteonAddress("IMAddress");
+                    LegacyDeviceType deviceType = LegacyDeviceTypeLoader.instance()
+                            .getDeviceType(InsteonLegacyBindingConstants.PLM_PRODUCT_KEY);
+                    if (deviceType == null) {
+                        logger.warn("unknown modem product key: {} for modem: {}.",
+                                InsteonLegacyBindingConstants.PLM_PRODUCT_KEY, address);
+                    } else {
+                        device = LegacyDevice.makeDevice(deviceType);
+                        initDevice(address, device);
+                        mdbb.updateModemDB(address, LegacyPort.this, null, true);
+                    }
+                    // can unsubscribe now
+                    removeListener(this);
+                }
+            } catch (FieldException e) {
+                logger.warn("error parsing im info reply field: ", e);
+            }
+        }
+
+        private void initDevice(InsteonAddress a, @Nullable LegacyDevice device) {
+            if (device != null) {
+                device.setAddress(a);
+                device.setProductKey(InsteonLegacyBindingConstants.PLM_PRODUCT_KEY);
+                device.setDriver(driver);
+                device.setIsModem(true);
+                logger.debug("found modem {} in device_types: {}", a, device.toString());
+            } else {
+                logger.warn("device is null");
+            }
+        }
+
+        public void initialize() {
+            try {
+                Msg msg = Msg.makeMessage("GetIMInfo");
+                writeMessage(msg);
+            } catch (IOException e) {
+                logger.warn("modem init failed!", e);
+            } catch (InvalidMessageTypeException e) {
+                logger.warn("invalid message", e);
+            }
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyPortListener.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/LegacyPortListener.java
new file mode 100644 (file)
index 0000000..7b27c8f
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * 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.transport;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+
+/**
+ * Interface to receive Insteon messages from the modem.
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ */
+@NonNullByDefault
+public interface LegacyPortListener {
+    /**
+     * Invoked whenever a valid message comes in from the modem
+     *
+     * @param msg the message received
+     */
+    void msg(Msg msg);
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/Port.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/Port.java
new file mode 100644 (file)
index 0000000..4bcfa7f
--- /dev/null
@@ -0,0 +1,344 @@
+/**
+ * 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.transport;
+
+import java.io.IOException;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.LinkedBlockingQueue;
+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.openhab.binding.insteon.internal.config.InsteonBridgeConfiguration;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+import org.openhab.binding.insteon.internal.transport.message.MsgFactory;
+import org.openhab.core.io.transport.serial.SerialPortManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The Port class represents a port, that is a connection to either an Insteon modem either through
+ * a serial or USB port, or via an Insteon Hub.
+ * It does the initialization of the port, and (via its inner classes IOStreamReader and IOStreamWriter)
+ * manages the reading/writing of messages on the Insteon network.
+ *
+ * The IOStreamReader and IOStreamWriter class combined implement the somewhat tricky flow control protocol.
+ * In combination with the MsgFactory class, the incoming data stream is turned into a Msg structure
+ * for further processing by the upper layers (PortListener).
+ *
+ * A write queue is maintained to pace the flow of outgoing messages. Sending messages back-to-back
+ * can lead to dropped messages.
+ *
+ * @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 Port {
+    /**
+     * The ReplyType is used to keep track of the state of the serial port receiver
+     */
+    private static enum ReplyType {
+        GOT_ACK,
+        WAITING_FOR_ACK,
+        GOT_NACK
+    }
+
+    private final Logger logger = LoggerFactory.getLogger(Port.class);
+
+    private String name;
+    private ScheduledExecutorService scheduler;
+    private IOStream ioStream;
+    private IOStreamReader reader;
+    private IOStreamWriter writer;
+    private @Nullable ScheduledFuture<?> readJob;
+    private @Nullable ScheduledFuture<?> writeJob;
+    private MsgFactory msgFactory = new MsgFactory();
+    private Set<PortListener> listeners = new CopyOnWriteArraySet<>();
+    private LinkedBlockingQueue<Msg> writeQueue = new LinkedBlockingQueue<>();
+    private AtomicBoolean connected = new AtomicBoolean(false);
+
+    public Port(InsteonBridgeConfiguration config, ScheduledExecutorService scheduler,
+            SerialPortManager serialPortManager) {
+        this.name = config.getId();
+        this.scheduler = scheduler;
+        this.ioStream = IOStream.create(config, scheduler, serialPortManager);
+        this.reader = new IOStreamReader();
+        this.writer = new IOStreamWriter();
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void registerListener(PortListener listener) {
+        if (listeners.add(listener)) {
+            logger.trace("added listener for {}", listener.getClass().getSimpleName());
+        }
+    }
+
+    public void unregisterListener(PortListener listener) {
+        if (listeners.remove(listener)) {
+            logger.trace("removed listener for {}", listener.getClass().getSimpleName());
+        }
+    }
+
+    /**
+     * Starts threads necessary for reading and writing
+     *
+     * @return true if port is connected, otherwise false
+     */
+    public boolean start() {
+        if (connected.get()) {
+            logger.debug("port {} already connected, no need to start it", name);
+            return true;
+        }
+
+        logger.debug("starting port {}", name);
+
+        writeQueue.clear();
+
+        if (!ioStream.open()) {
+            logger.debug("failed to open port {}", name);
+            return false;
+        }
+
+        readJob = scheduler.schedule(reader, 0, TimeUnit.SECONDS);
+        writeJob = scheduler.schedule(writer, 0, TimeUnit.SECONDS);
+
+        connected.set(true);
+
+        logger.trace("all threads for port {} started.", name);
+
+        return true;
+    }
+
+    /**
+     * Stops all threads
+     */
+    public void stop() {
+        logger.debug("stopping port {}", name);
+
+        connected.set(false);
+
+        if (ioStream.isOpen()) {
+            ioStream.close();
+        }
+
+        ScheduledFuture<?> readJob = this.readJob;
+        if (readJob != null) {
+            readJob.cancel(true);
+            this.readJob = null;
+        }
+
+        ScheduledFuture<?> writeJob = this.writeJob;
+        if (writeJob != null) {
+            writeJob.cancel(true);
+            this.writeJob = null;
+        }
+
+        logger.trace("all threads for port {} stopped.", name);
+    }
+
+    /**
+     * Adds message to the write queue
+     *
+     * @param msg message to be added to the write queue
+     * @throws IOException
+     */
+    public void writeMessage(@Nullable Msg msg) throws IOException {
+        if (msg == null) {
+            throw new IOException("trying to write null message!");
+        }
+        try {
+            writeQueue.add(msg);
+            logger.trace("enqueued msg ({}): {}", writeQueue.size(), msg);
+        } catch (IllegalStateException e) {
+            logger.debug("cannot write message {}, write queue is full!", msg);
+        }
+    }
+
+    /**
+     * Notifies that the port has disconnected
+     */
+    private void disconnected() {
+        if (connected.getAndSet(false)) {
+            logger.warn("port {} disconnected", name);
+            listeners.forEach(PortListener::disconnected);
+        }
+    }
+
+    /**
+     * Notifies that the port has received a message
+     *
+     * @param msg the message received
+     */
+    private void messageReceived(Msg msg) {
+        listeners.forEach(listener -> listener.messageReceived(msg));
+    }
+
+    /**
+     * Notifies that the port has sent a message
+     *
+     * @param msg the message sent
+     */
+    private void messageSent(Msg msg) {
+        listeners.forEach(listener -> listener.messageSent(msg));
+    }
+
+    /**
+     * The IOStreamReader uses the MsgFactory to turn the incoming bytes into
+     * Msgs for the listeners. It also communicates with the IOStreamWriter
+     * to implement flow control (tell the IOStreamWriter that it needs to retransmit,
+     * or the reply message has been received correctly).
+     */
+    private class IOStreamReader implements Runnable {
+        private static final int READ_BUFFER_SIZE = 1024;
+        private static final int REPLY_TIMEOUT_TIME = 30000; // milliseconds
+
+        private ReplyType replyType = ReplyType.GOT_ACK;
+        private Object replyLock = new Object();
+
+        public Object getReplyLock() {
+            return replyLock;
+        }
+
+        @Override
+        public void run() {
+            logger.debug("starting reader thread");
+            byte[] buffer = new byte[READ_BUFFER_SIZE];
+            try {
+                while (!Thread.interrupted()) {
+                    logger.trace("reader checking for input data");
+                    // this call blocks until input data is available
+                    int len = ioStream.read(buffer);
+                    if (len > 0) {
+                        msgFactory.addData(buffer, len);
+                        processMessages();
+                    }
+                }
+            } catch (InterruptedException e) {
+                logger.debug("reader thread got interrupted!");
+            } catch (IOException e) {
+                logger.debug("reader thread got an io exception", e);
+                disconnected();
+            }
+            logger.debug("exiting reader thread!");
+        }
+
+        private void processMessages() {
+            // call msgFactory.processData() until it is done processing buffer
+            while (!msgFactory.isDone()) {
+                try {
+                    Msg msg = msgFactory.processData();
+                    if (msg != null) {
+                        logger.debug("got msg: {}", msg);
+                        messageReceived(msg);
+                        notifyWriter(msg);
+                    }
+                } catch (IOException e) {
+                    // got bad data from modem,
+                    // unblock those waiting for ack
+                    synchronized (replyLock) {
+                        if (replyType == ReplyType.WAITING_FOR_ACK) {
+                            logger.debug("got bad data back, must assume message was acked.");
+                            replyType = ReplyType.GOT_ACK;
+                            replyLock.notify();
+                        }
+                    }
+                }
+            }
+        }
+
+        private void notifyWriter(Msg msg) {
+            synchronized (replyLock) {
+                if (replyType == ReplyType.WAITING_FOR_ACK) {
+                    if (msg.isEcho()) {
+                        replyType = msg.isPureNack() ? ReplyType.GOT_NACK : ReplyType.GOT_ACK;
+                        logger.trace("signaling receipt of ack: {}", replyType == ReplyType.GOT_ACK);
+                        replyLock.notify();
+                    }
+                }
+            }
+        }
+
+        /**
+         * Blocking wait for ack or nack from modem.
+         * Called by IOStreamWriter for flow control.
+         *
+         * @return true if retransmission is necessary
+         * @throws InterruptedException
+         */
+        public boolean waitForReply() throws InterruptedException {
+            logger.trace("waiting for reply ack");
+            replyType = ReplyType.WAITING_FOR_ACK;
+            // There have been cases observed, in particular for
+            // the Hub, where we get no ack or nack back, causing the binding
+            // to hang in the wait() below, because unsolicited messages
+            // do not trigger a notify(). For this reason we request retransmission
+            // if the wait() times out.
+            replyLock.wait(REPLY_TIMEOUT_TIME);
+            if (replyType == ReplyType.WAITING_FOR_ACK) { // timeout expired without getting ACK or NACK
+                logger.trace("reply ack timeout expired, asking for retransmit!");
+                replyType = ReplyType.GOT_NACK;
+            } else {
+                logger.trace("got reply ack: {}", replyType == ReplyType.GOT_ACK);
+            }
+            return replyType == ReplyType.GOT_NACK;
+        }
+    }
+
+    /**
+     * Writes messages to the port. Flow control is implemented following Insteon
+     * documents to avoid overloading the modem.
+     */
+    private class IOStreamWriter implements Runnable {
+        private static final int RETRANSMIT_WAIT_TIME = 200; // milliseconds
+        private static final int WRITE_WAIT_TIME = 500; // milliseconds
+
+        @Override
+        public void run() {
+            logger.debug("starting writer thread");
+            try {
+                while (!Thread.interrupted()) {
+                    logger.trace("writer checking message queue");
+                    // this call blocks until the lock on the queue is released
+                    Msg msg = writeQueue.take();
+                    logger.debug("writing: {}", msg);
+                    synchronized (reader.getReplyLock()) {
+                        ioStream.write(msg.getData());
+                        messageSent(msg);
+                        while (reader.waitForReply()) {
+                            Thread.sleep(RETRANSMIT_WAIT_TIME);
+                            logger.trace("retransmitting msg: {}", msg);
+                            ioStream.write(msg.getData());
+                        }
+                    }
+                    // limit rate by waiting between writes to transport
+                    Thread.sleep(WRITE_WAIT_TIME);
+                }
+            } catch (InterruptedException e) {
+                logger.debug("writer thread got interrupted!");
+            } catch (IOException e) {
+                logger.debug("writer thread got an io exception", e);
+                disconnected();
+            }
+            logger.debug("exiting writer thread!");
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/PortListener.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/PortListener.java
new file mode 100644 (file)
index 0000000..2f7fbb3
--- /dev/null
@@ -0,0 +1,43 @@
+/**
+ * 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.transport;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.transport.message.Msg;
+
+/**
+ * Interface for classes that want to listen to notifications from the modem port
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public interface PortListener {
+    /**
+     * Notifies that the modem port has disconnected
+     */
+    public void disconnected();
+
+    /**
+     * Notifies that the modem port has received a message
+     *
+     * @param msg the message received
+     */
+    public void messageReceived(Msg msg);
+
+    /**
+     * Notifies that the modem port has sent a message
+     *
+     * @param msg the message sent
+     */
+    public void messageSent(Msg msg);
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/ReadByteBuffer.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/ReadByteBuffer.java
new file mode 100644 (file)
index 0000000..9aa0f0f
--- /dev/null
@@ -0,0 +1,156 @@
+/**
+ * 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.transport;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * ReadByteBuffer buffer class
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class ReadByteBuffer {
+    private byte[] buffer; // the actual buffer
+    private int count; // number of valid bytes
+    private int index = 0; // current read index
+    private boolean eof = false;
+
+    /**
+     * Constructor for ByteArrayIO with dynamic size
+     *
+     * @param size initial size, but will grow dynamically
+     */
+    public ReadByteBuffer(int size) {
+        this.buffer = new byte[size];
+    }
+
+    /**
+     * Closes buffer
+     */
+    public synchronized void close() {
+        eof = true;
+        notifyAll();
+    }
+
+    /**
+     * Returns number of unread bytes
+     *
+     * @return number of bytes not yet read
+     */
+    public synchronized int remaining() {
+        return count - index;
+    }
+
+    /**
+     * Blocking read of a single byte
+     *
+     * @return -1 if eof, otherwise next byte read as an integer
+     * @throws IOException
+     */
+    public synchronized int get() throws IOException {
+        while (!eof && remaining() < 1) {
+            try {
+                wait();
+            } catch (InterruptedException e) {
+                throw new IOException("interrupted");
+            }
+        }
+
+        if (eof) {
+            return -1;
+        }
+
+        return (int) buffer[index++];
+    }
+
+    /**
+     * Blocking read of multiple bytes
+     *
+     * @param b destination array for bytes read
+     * @param off offset into dest array
+     * @param len max number of bytes to read into dest array
+     * @return -1 if eof, otherwise number of bytes read
+     * @throws IOException
+     */
+    public synchronized int get(byte[] b, int off, int len) throws IOException {
+        while (!eof && remaining() < 1) {
+            try {
+                wait();
+            } catch (InterruptedException e) {
+                throw new IOException("interrupted");
+            }
+        }
+
+        if (eof) {
+            return -1;
+        }
+
+        int numBytes = Math.min(len, remaining());
+        System.arraycopy(buffer, index, b, off, numBytes);
+        index += numBytes;
+        return numBytes;
+    }
+
+    /**
+     * Adds bytes to the byte buffer
+     *
+     * @param b byte array with new bytes
+     * @param off starting offset into buffer
+     * @param len number of bytes to add
+     */
+    private synchronized void add(byte[] b, int off, int len) {
+        if (off < 0 || len < 0 || off + len > b.length) {
+            throw new IndexOutOfBoundsException();
+        } else if (len == 0) {
+            return;
+        }
+        int newCount = count + len;
+        if (newCount > buffer.length) {
+            // dynamically grow the array
+            buffer = Arrays.copyOf(buffer, Math.max(buffer.length << 1, newCount));
+        }
+        // append new data to end of buffer
+        System.arraycopy(b, off, buffer, count, len);
+        count = newCount;
+        notifyAll();
+    }
+
+    /**
+     * Adds bytes to the byte buffer
+     *
+     * @param b the new bytes to be added
+     */
+    public void add(byte[] b) {
+        add(b, 0, b.length);
+    }
+
+    /**
+     * Shrinks the buffer to smallest size possible
+     */
+    public synchronized void makeCompact() {
+        if (index == 0) {
+            return;
+        }
+        byte[] newBuffer = new byte[remaining()];
+        System.arraycopy(buffer, index, newBuffer, 0, newBuffer.length);
+        index = 0;
+        count = newBuffer.length;
+        buffer = newBuffer;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/SerialIOStream.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/SerialIOStream.java
new file mode 100644 (file)
index 0000000..c7eef63
--- /dev/null
@@ -0,0 +1,122 @@
+/**
+ * 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.transport;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.InsteonBindingConstants;
+import org.openhab.core.io.transport.serial.PortInUseException;
+import org.openhab.core.io.transport.serial.SerialPort;
+import org.openhab.core.io.transport.serial.SerialPortIdentifier;
+import org.openhab.core.io.transport.serial.SerialPortManager;
+import org.openhab.core.io.transport.serial.UnsupportedCommOperationException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Implements IOStream for serial devices
+ *
+ * @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 SerialIOStream extends IOStream {
+    private final Logger logger = LoggerFactory.getLogger(SerialIOStream.class);
+
+    private String name;
+    private int baudRate;
+    private SerialPortManager serialPortManager;
+    private @Nullable SerialPort port;
+
+    public SerialIOStream(String name, int baudRate, SerialPortManager serialPortManager) {
+        this.name = name;
+        this.baudRate = baudRate;
+        this.serialPortManager = serialPortManager;
+    }
+
+    @Override
+    public boolean isOpen() {
+        return port != null;
+    }
+
+    @Override
+    public boolean open() {
+        if (isOpen()) {
+            logger.warn("serial port is already open");
+            return false;
+        }
+
+        try {
+            SerialPortIdentifier spi = serialPortManager.getIdentifier(name);
+            if (spi == null) {
+                logger.warn("{} is not a valid serial port.", name);
+                return false;
+            }
+
+            SerialPort port = spi.open(InsteonBindingConstants.BINDING_ID, 1000);
+            logger.debug("setting {} baud rate to {}", name, baudRate);
+            port.setSerialPortParams(baudRate, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE);
+            port.setFlowControlMode(SerialPort.FLOWCONTROL_NONE);
+            port.enableReceiveThreshold(1);
+            port.enableReceiveTimeout(1000);
+            this.in = port.getInputStream();
+            this.out = port.getOutputStream();
+            this.port = port;
+            logger.debug("successfully opened port {}", name);
+            return true;
+        } catch (IOException e) {
+            logger.warn("cannot open port: {}, got IOException {}", name, e.getMessage());
+        } catch (PortInUseException e) {
+            logger.warn("cannot open port: {}, it is in use!", name);
+        } catch (UnsupportedCommOperationException e) {
+            logger.warn("got unsupported operation {} on port {}", e.getMessage(), name);
+        }
+
+        return false;
+    }
+
+    @Override
+    public void close() {
+        InputStream in = this.in;
+        if (in != null) {
+            try {
+                in.close();
+            } catch (IOException e) {
+                logger.debug("failed to close input stream", e);
+            }
+            this.in = null;
+        }
+
+        OutputStream out = this.out;
+        if (out != null) {
+            try {
+                out.close();
+            } catch (IOException e) {
+                logger.debug("failed to close output stream", e);
+            }
+            this.out = null;
+        }
+
+        SerialPort port = this.port;
+        if (port != null) {
+            port.close();
+            this.port = null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/TcpIOStream.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/TcpIOStream.java
new file mode 100644 (file)
index 0000000..9f6bcb5
--- /dev/null
@@ -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.transport;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.net.UnknownHostException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Implements IOStream for an Insteon Legacy Hub
+ * Also works for serial ports exposed via tcp, eg. ser2net
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class TcpIOStream extends IOStream {
+    private final Logger logger = LoggerFactory.getLogger(TcpIOStream.class);
+
+    private String host;
+    private int port;
+    private @Nullable Socket socket;
+
+    /**
+     * Constructor
+     *
+     * @param host host name of hub device
+     * @param port port to connect to
+     */
+    public TcpIOStream(String host, int port) {
+        this.host = host;
+        this.port = port;
+    }
+
+    @Override
+    public boolean isOpen() {
+        return socket != null;
+    }
+
+    @Override
+    public boolean open() {
+        if (isOpen()) {
+            logger.warn("socket is already open");
+            return false;
+        }
+
+        try {
+            Socket socket = new Socket(host, port);
+            this.in = socket.getInputStream();
+            this.out = socket.getOutputStream();
+            this.socket = socket;
+            return true;
+        } catch (UnknownHostException e) {
+            logger.warn("unknown host name: {}", host);
+        } catch (IOException e) {
+            logger.warn("cannot open connection to {} port {}: {}", host, port, e.getMessage());
+        }
+
+        return false;
+    }
+
+    @Override
+    public void close() {
+        InputStream in = this.in;
+        if (in != null) {
+            try {
+                in.close();
+            } catch (IOException e) {
+                logger.debug("failed to close input stream", e);
+            }
+            this.in = null;
+        }
+
+        OutputStream out = this.out;
+        if (out != null) {
+            try {
+                out.close();
+            } catch (IOException e) {
+                logger.debug("failed to close output stream", e);
+            }
+            this.out = null;
+        }
+
+        Socket socket = this.socket;
+        if (socket != null) {
+            try {
+                socket.close();
+            } catch (IOException e) {
+                logger.debug("failed to close the socket", e);
+            }
+            this.socket = null;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/DataType.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/DataType.java
new file mode 100644 (file)
index 0000000..3b6d753
--- /dev/null
@@ -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.transport.message;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Defines the data types that can be used in the fields of a message.
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public enum DataType {
+    BYTE("byte", 1),
+    ADDRESS("address", 3),
+    INVALID("invalid", -1);
+
+    private static final Map<String, DataType> NAME_MAP = Arrays.stream(values())
+            .collect(Collectors.toUnmodifiableMap(type -> type.name, Function.identity()));
+
+    private final String name;
+    private final int size;
+
+    private DataType(String name, int size) {
+        this.name = name;
+        this.size = size;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getSize() {
+        return size;
+    }
+
+    /**
+     * Factory method for getting a DataType from the data type name
+     *
+     * @param name the data type name
+     * @return the data type
+     */
+    public static DataType get(String name) {
+        return NAME_MAP.getOrDefault(name, DataType.INVALID);
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/Field.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/Field.java
new file mode 100644 (file)
index 0000000..2fbf6ee
--- /dev/null
@@ -0,0 +1,181 @@
+/**
+ * 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.transport.message;
+
+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;
+
+/**
+ * An Insteon message has several fields with known type and offset
+ * within the message. This class represents a single field, and
+ * holds name, type, and offset (but not value!).
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public final class Field {
+    private final String name;
+    private final int offset;
+    private final DataType type;
+
+    public String getName() {
+        return name;
+    }
+
+    public int getOffset() {
+        return offset;
+    }
+
+    public DataType getType() {
+        return type;
+    }
+
+    public Field(String name, DataType type, int offset) {
+        this.name = name;
+        this.type = type;
+        this.offset = offset;
+    }
+
+    private void check(int len, DataType t) throws FieldException {
+        if (offset + type.getSize() > len) {
+            throw new FieldException("field write beyond end of msg");
+        }
+        if (type != t) {
+            throw new FieldException("field write type mismatch!");
+        }
+    }
+
+    public void set(byte[] data, Object o) throws FieldException {
+        switch (type) {
+            case BYTE:
+                setByte(data, (Byte) o);
+                break;
+            case ADDRESS:
+                setAddress(data, (InsteonAddress) o);
+                break;
+            default:
+                throw new FieldException("field data type unknown");
+        }
+    }
+
+    /**
+     * Sets a byte value to a byte array, at the proper offset.
+     * Use this function to set the value of a field within a message.
+     *
+     * @param data the byte array to update
+     * @param b the byte value to set
+     * @throws FieldException
+     */
+    public void setByte(byte[] data, byte b) throws FieldException {
+        check(data.length, DataType.BYTE);
+        data[offset] = b;
+    }
+
+    /**
+     * Sets the value of an InsteonAddress to a message array.
+     * Use this function to set the value of a field within a message.
+     *
+     * @param data the byte array to update
+     * @param address the insteon address value to set
+     * @throws FieldException
+     */
+    public void setAddress(byte[] data, InsteonAddress address) throws FieldException {
+        check(data.length, DataType.ADDRESS);
+        System.arraycopy(address.getBytes(), 0, data, offset, type.getSize());
+    }
+
+    /**
+     * Returns a byte from a byte array at the field position
+     *
+     * @param data the byte array to use
+     * @return the byte
+     * @throws FieldException
+     */
+    public byte getByte(byte[] data) throws FieldException {
+        check(data.length, DataType.BYTE);
+        return data[offset];
+    }
+
+    /**
+     * Returns an insteon address from the field position
+     *
+     * @param data the byte array to use
+     * @return the insteon address
+     * @throws FieldException
+     */
+    public InsteonAddress getAddress(byte[] data) throws FieldException {
+        check(data.length, DataType.ADDRESS);
+        byte[] address = Arrays.copyOfRange(data, offset, offset + type.getSize());
+        return new InsteonAddress(address);
+    }
+
+    /**
+     * Returns a string representation for a given byte array
+     *
+     * @param data the byte array to use
+     * @return the string representation
+     */
+    public String toString(byte[] data) {
+        String s = name + ":";
+        try {
+            switch (type) {
+                case BYTE:
+                    s += HexUtils.getHexString(getByte(data));
+                    break;
+                case ADDRESS:
+                    s += getAddress(data).toString();
+                    break;
+                default:
+                    throw new FieldException("field data type unknown");
+            }
+        } catch (FieldException e) {
+            s += "NULL";
+        }
+        return s;
+    }
+
+    @Override
+    public String toString() {
+        return name + " Type: " + type + " Offset: " + offset;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj == this) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        Field other = (Field) obj;
+        return name.equals(other.name) && offset == other.offset;
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+        result = prime * result + name.hashCode();
+        result = prime * result + offset;
+        return result;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/FieldException.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/FieldException.java
new file mode 100644 (file)
index 0000000..6f6a0c0
--- /dev/null
@@ -0,0 +1,43 @@
+/**
+ * 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.transport.message;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exception to be thrown if there is an error processing a field, for
+ * example type mismatch, out of bounds etc.
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ */
+@NonNullByDefault
+public class FieldException extends Exception {
+    private static final long serialVersionUID = -4749311173073727318L;
+
+    public FieldException() {
+        super();
+    }
+
+    public FieldException(String m) {
+        super(m);
+    }
+
+    public FieldException(String m, Throwable cause) {
+        super(m, cause);
+    }
+
+    public FieldException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/GroupMessageStateMachine.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/GroupMessageStateMachine.java
new file mode 100644 (file)
index 0000000..fceb86a
--- /dev/null
@@ -0,0 +1,193 @@
+/**
+ * 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.transport.message;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Ideally, Insteon ALL LINK messages are received in this order, and
+ * only a single one of each:
+ *
+ * BCAST (a broadcast message from the device to all group members)
+ * CLEAN (a cleanup point-to-point message to ensure more reliable transmission)
+ * SUCCESS (a broadcast report of success or failure of cleanup, with cmd1=0x06)
+ *
+ * But often, the BCAST, CLEAN and SUCCESS messages are retransmitted multiple times,
+ * or (less frequently) messages are lost. The present state machine was developed
+ * to remove duplicates, yet make sure that a single lost message does not cause
+ * the binding to miss an update.
+ *
+ * @formatter:off
+ *                          "SUCCESS"
+ *                         EXPECT_BCAST
+ *                    ^ /                ^ \
+ *           SUCCESS / /                  \ \ [BCAST]
+ *                  / /['CLEAN']  'SUCCESS'\ \
+ *                 / /                      \ \
+ *                / V         CLEAN          \ V
+ * "CLEAN" EXPECT_SUCCESS <-------------- EXPECT_CLEAN "BCAST"
+ *                         -------------->
+ *                            ['BCAST']
+ * @formatter:on
+ *
+ * How to read this diagram:
+ *
+ * Regular, expected, non-duplicate messages do not have any quotes around them,
+ * and lead to the obvious state transitions.
+ *
+ * The types in [square brackets] are transitions that cause a state
+ * update to be published when they occur.
+ *
+ * The presence of double quotes indicates a duplicate that does not lead
+ * to any state transitions, i.e. it is simply ignored.
+ *
+ * Single quotes indicate a message that is the result of a single dropped
+ * message, and leads to a state transition, in some cases even to a state
+ * update to be published.
+ *
+ * For instance at the top of the diagram, if a "SUCCESS" message is received
+ * when in state EXPECT_BCAST, it is considered a duplicate (it has "").
+ *
+ * When in state EXPECT_SUCCESS though, receiving a ['BCAST'] is most likely because
+ * the SUCCESS message was missed, and therefore it is considered the result
+ * of a single lost message (has '' around it). The state changes to EXPECT_CLEAN,
+ * and the message should lead to publishing of a state update (it has [] around it).
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class GroupMessageStateMachine {
+    private static final int GROUP_STATE_TIMEOUT = 10000; // in milliseconds
+
+    /**
+     * The different kinds of Insteon ALL Link (Group) messages that can be received.
+     * Here is a typical sequence:
+     * BCAST:
+     * IN:Cmd:0x50|fromAddress:20.AC.99|toAddress:00.00.01|messageFlags:0xCB=ALL_LINK_BROADCAST:3:2|command1:0x13|
+     * command2:0x00|
+     * CLEAN:
+     * IN:Cmd:0x50|fromAddress:20.AC.99|toAddress:23.9B.65|messageFlags:0x41=ALL_LINK_CLEANUP:1:0|command1:0x13|command2
+     * :0x01|
+     * SUCCESS:
+     * IN:Cmd:0x50|fromAddress:20.AC.99|toAddress:13.03.01|messageFlags:0xCB=ALL_LINK_BROADCAST:3:2|command1:0x06|
+     * command2:0x00|
+     */
+    public static enum GroupMessageType {
+        BCAST,
+        CLEAN,
+        SUCCESS
+    }
+
+    /**
+     * The state of the machine (i.e. what message we are expecting next).
+     * The usual state should be EXPECT_BCAST
+     */
+    private static enum State {
+        EXPECT_BCAST,
+        EXPECT_CLEAN,
+        EXPECT_SUCCESS
+    }
+
+    private final Logger logger = LoggerFactory.getLogger(GroupMessageStateMachine.class);
+
+    private State state = State.EXPECT_BCAST;
+    private boolean duplicate = false;
+    private byte lastCmd1 = 0;
+    private long lastTimestamp = 0;
+
+    public boolean isDuplicate() {
+        return duplicate;
+    }
+
+    public byte getLastCommand() {
+        return lastCmd1;
+    }
+
+    public long getLastTimestamp() {
+        return lastTimestamp;
+    }
+
+    /**
+     * Updates the state machine and determine if not duplicate
+     *
+     * @param address the address of the device that this state machine belongs to
+     * @param group the group that this state machine belongs to
+     * @param cmd1 cmd1 from the message received
+     * @param timestamp timestamp from the message received
+     * @param type the group message type that was received
+     * @return true if the group message is duplicate
+     */
+    public boolean update(InsteonAddress address, int group, byte cmd1, long timestamp, GroupMessageType type) {
+        boolean isNewGroupMsg = cmd1 != lastCmd1 || timestamp > lastTimestamp + GROUP_STATE_TIMEOUT;
+
+        switch (state) {
+            case EXPECT_BCAST:
+                switch (type) {
+                    case BCAST:
+                        duplicate = false;
+                        break;
+                    case CLEAN:
+                    case SUCCESS:
+                        duplicate = !isNewGroupMsg;
+                        break;
+                }
+                break;
+            case EXPECT_CLEAN:
+                switch (type) {
+                    case BCAST:
+                        duplicate = !isNewGroupMsg;
+                        break;
+                    case CLEAN:
+                    case SUCCESS:
+                        duplicate = true;
+                        break;
+                }
+                break;
+            case EXPECT_SUCCESS:
+                switch (type) {
+                    case BCAST:
+                        duplicate = false;
+                        break;
+                    case CLEAN:
+                    case SUCCESS:
+                        duplicate = true;
+                        break;
+                }
+                break;
+        }
+
+        switch (type) {
+            case BCAST:
+                state = State.EXPECT_CLEAN;
+                break;
+            case CLEAN:
+                state = State.EXPECT_SUCCESS;
+                break;
+            case SUCCESS:
+                state = State.EXPECT_BCAST;
+                break;
+        }
+
+        lastCmd1 = cmd1;
+        lastTimestamp = timestamp;
+
+        logger.debug("{} group:{} type:{} state:{} duplicate:{}", address, group, type, state, duplicate);
+
+        return duplicate;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/InvalidMessageTypeException.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/InvalidMessageTypeException.java
new file mode 100644 (file)
index 0000000..9e400e8
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * 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.transport.message;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exception to be thrown from Msg class
+ *
+ * @author Rob Nielsen - Initial contribution
+ */
+@NonNullByDefault
+public class InvalidMessageTypeException extends Exception {
+    private static final long serialVersionUID = -7582457696582413074L;
+
+    public InvalidMessageTypeException(String message) {
+        super(message);
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/LegacyGroupMessageStateMachine.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/LegacyGroupMessageStateMachine.java
new file mode 100644 (file)
index 0000000..9e0dc6e
--- /dev/null
@@ -0,0 +1,207 @@
+/**
+ * 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.transport.message;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.insteon.internal.device.DeviceAddress;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Ideally, Insteon ALL LINK messages are received in this order, and
+ * only a single one of each:
+ *
+ * BCAST (a broadcast message from the device to all group members)
+ * CLEAN (a cleanup point-to-point message to ensure more reliable transmission)
+ * SUCCESS (a broadcast report of success or failure of cleanup, with cmd1=0x06)
+ *
+ * But often, the BCAST, CLEAN and SUCCESS messages are retransmitted multiple times,
+ * or (less frequently) messages are lost. The present state machine was developed
+ * to remove duplicates, yet make sure that a single lost message does not cause
+ * the binding to miss an update.
+ *
+ * @formatter:off
+ *                          "SUCCESS"
+ *                         EXPECT_BCAST
+ *                    ^ /                ^ \
+ *           SUCCESS / /                  \ \ [BCAST]
+ *                  / /['CLEAN']  'SUCCESS'\ \
+ *                 / /                      \ \
+ *                / V         CLEAN          \ V
+ * "CLEAN" EXPECT_SUCCESS <-------------- EXPECT_CLEAN "BCAST"
+ *                         -------------->
+ *                            ['BCAST']
+ * @formatter:on
+ *
+ * How to read this diagram:
+ *
+ * Regular, expected, non-duplicate messages do not have any quotes around them,
+ * and lead to the obvious state transitions.
+ *
+ * The actions in [square brackets] are transitions that cause a state
+ * update to be published when they occur.
+ *
+ * The presence of double quotes indicates a duplicate that does not lead
+ * to any state transitions, i.e. it is simply ignored.
+ *
+ * Single quotes indicate a message that is the result of a single dropped
+ * message, and leads to a state transition, in some cases even to a state
+ * update to be published.
+ *
+ * For instance at the top of the diagram, if a "SUCCESS" message is received
+ * when in state EXPECT_BCAST, it is considered a duplicate (it has "").
+ *
+ * When in state EXPECT_SUCCESS though, receiving a ['BCAST'] is most likely because
+ * the SUCCESS message was missed, and therefore it is considered the result
+ * of a single lost message (has '' around it). The state changes to EXPECT_CLEAN,
+ * and the message should lead to publishing of a state update (it has [] around it).
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ */
+@NonNullByDefault
+public class LegacyGroupMessageStateMachine {
+    private final Logger logger = LoggerFactory.getLogger(LegacyGroupMessageStateMachine.class);
+
+    /**
+     * The different kinds of Insteon ALL Link (Group) messages that can be received.
+     * Here is a typical sequence:
+     * BCAST:
+     * IN:Cmd:0x50|fromAddress:20.AC.99|toAddress:00.00.01|messageFlags:0xCB=ALL_LINK_BROADCAST:3:2|command1:0x13|
+     * command2:0x00|
+     * CLEAN:
+     * IN:Cmd:0x50|fromAddress:20.AC.99|toAddress:23.9B.65|messageFlags:0x41=ALL_LINK_CLEANUP:1:0|command1:0x13|command2
+     * :0x01|
+     * SUCCESS:
+     * IN:Cmd:0x50|fromAddress:20.AC.99|toAddress:13.03.01|messageFlags:0xCB=ALL_LINK_BROADCAST:3:2|command1:0x06|
+     * command2:0x00|
+     */
+    public enum GroupMessage {
+        BCAST,
+        CLEAN,
+        SUCCESS
+    }
+
+    /**
+     * The state of the machine (i.e. what message we are expecting next).
+     * The usual state should be EXPECT_BCAST
+     */
+    private enum State {
+        EXPECT_BCAST,
+        EXPECT_CLEAN,
+        EXPECT_SUCCESS
+    }
+
+    private State state = State.EXPECT_BCAST;
+    private long lastUpdated = 0;
+    private boolean publish = false;
+    private byte lastCmd1 = 0;
+
+    /**
+     * Advance the state machine and determine if update is genuine (no duplicate)
+     *
+     * @param action the group message (action) that was received
+     * @param address the address of the device that this state machine belongs to
+     * @param group the group that this state machine belongs to
+     * @param cmd1 cmd1 from the message received
+     * @return true if the group message is not a duplicate
+     */
+    public boolean action(GroupMessage action, DeviceAddress address, int group, byte cmd1) {
+        publish = false;
+        long currentTime = System.currentTimeMillis();
+        switch (state) {
+            case EXPECT_BCAST:
+                switch (action) {
+                    case BCAST:
+                        publish = true;
+                        break; // missed() move state machine and pub!
+                    case CLEAN:
+                        publish = true;
+                        break; // missed(BCAST)
+                    case SUCCESS:
+                        publish = false;
+                        break;
+                } // missed(BCAST,CLEAN) or dup SUCCESS
+                break;
+            case EXPECT_CLEAN:
+                switch (action) {
+                    case BCAST:
+                        if (lastCmd1 == cmd1) {
+                            if (currentTime > lastUpdated + 30000) {
+                                if (logger.isDebugEnabled()) {
+                                    logger.debug(
+                                            "{} group {} cmd1 {} is not a dup BCAST, received last message over 30000 ms ago",
+                                            address, group, HexUtils.getHexString(cmd1));
+                                }
+                                publish = true;
+                            } else {
+                                publish = false;
+                            }
+                        } else {
+                            if (logger.isDebugEnabled()) {
+                                logger.debug("{} group {} cmd1 {} is not a dup BCAST, last cmd1 {}", address, group,
+                                        HexUtils.getHexString(cmd1), HexUtils.getHexString(lastCmd1));
+                            }
+                            publish = true;
+                        }
+                        break; // missed(CLEAN, SUCCESS) or dup BCAST
+                    case CLEAN:
+                        publish = false;
+                        break; // missed() move state machine, no pub
+                    case SUCCESS:
+                        publish = false;
+                        break;
+                } // missed(CLEAN)
+                break;
+            case EXPECT_SUCCESS:
+                switch (action) {
+                    case BCAST:
+                        publish = true;
+                        break; // missed(SUCCESS)
+                    case CLEAN:
+                        publish = false;
+                        break; // missed(SUCCESS,BCAST) or dup CLEAN
+                    case SUCCESS:
+                        publish = false;
+                        break;
+                } // missed(), move state machine, no pub
+                break;
+        }
+        State oldState = state;
+        switch (action) {
+            case BCAST:
+                state = State.EXPECT_CLEAN;
+                break;
+            case CLEAN:
+                state = State.EXPECT_SUCCESS;
+                break;
+            case SUCCESS:
+                state = State.EXPECT_BCAST;
+                break;
+        }
+
+        lastCmd1 = cmd1;
+        lastUpdated = currentTime;
+        logger.debug("{} group {} state: {} --{}--> {}, publish: {}", address, group, oldState, action, state, publish);
+        return publish;
+    }
+
+    public long getLastUpdated() {
+        return lastUpdated;
+    }
+
+    public boolean getPublish() {
+        return publish;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/Msg.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/Msg.java
new file mode 100644 (file)
index 0000000..4ca24bc
--- /dev/null
@@ -0,0 +1,925 @@
+/**
+ * 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.transport.message;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.device.DeviceAddress;
+import org.openhab.binding.insteon.internal.device.InsteonAddress;
+import org.openhab.binding.insteon.internal.device.X10Address;
+import org.openhab.binding.insteon.internal.device.X10Flag;
+import org.openhab.binding.insteon.internal.utils.BinaryUtils;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Contains an Insteon Message consisting of the raw data, and the message definition.
+ * For more info, see the public Insteon Developer's Guide, 2nd edition,
+ * and the Insteon Modem Developer's Guide.
+ *
+ * @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 Msg {
+
+    public static enum Direction {
+        TO_MODEM,
+        FROM_MODEM
+    }
+
+    private final Logger logger = LoggerFactory.getLogger(Msg.class);
+
+    private byte[] data;
+    private int headerLength;
+    private Direction direction;
+    private MsgDefinition definition = new MsgDefinition();
+    private long quietTime = 0;
+    private boolean replayed = false;
+    private long timestamp = System.currentTimeMillis();
+
+    public Msg(int headerLength, int dataLength, Direction direction) {
+        this.data = new byte[dataLength];
+        this.headerLength = headerLength;
+        this.direction = direction;
+    }
+
+    public Msg(Msg msg, byte[] data, int dataLength) {
+        this.data = Arrays.copyOf(data, dataLength);
+        this.headerLength = msg.headerLength;
+        this.direction = msg.direction;
+        // the message definition usually doesn't change, but just to be sure...
+        this.definition = new MsgDefinition(msg.definition);
+    }
+
+    public Msg(Msg msg) {
+        this(msg, msg.data, msg.data.length);
+    }
+
+    public byte[] getData() {
+        return data;
+    }
+
+    public int getLength() {
+        return data.length;
+    }
+
+    public int getHeaderLength() {
+        return headerLength;
+    }
+
+    public Direction getDirection() {
+        return direction;
+    }
+
+    public MsgDefinition getDefinition() {
+        return definition;
+    }
+
+    public long getQuietTime() {
+        return quietTime;
+    }
+
+    public byte getCommand() {
+        try {
+            return getByte("Cmd");
+        } catch (FieldException e) {
+            return (byte) 0xFF;
+        }
+    }
+
+    public long getTimestamp() {
+        return timestamp;
+    }
+
+    public boolean isPureNack() {
+        return data.length == 2 && data[1] == 0x15;
+    }
+
+    public boolean isExtended() {
+        try {
+            return BinaryUtils.isBitSet(getInt("messageFlags"), 4);
+        } catch (FieldException e) {
+            return false;
+        }
+    }
+
+    public boolean isFromAddress(@Nullable InsteonAddress address) {
+        try {
+            return getInsteonAddress("fromAddress").equals(address);
+        } catch (FieldException e) {
+            return false;
+        }
+    }
+
+    public boolean isInbound() {
+        return direction == Direction.FROM_MODEM;
+    }
+
+    public boolean isOutbound() {
+        return direction == Direction.TO_MODEM;
+    }
+
+    public boolean isEcho() {
+        return isPureNack() || isReply();
+    }
+
+    public boolean isReply() {
+        return containsField("ACK/NACK");
+    }
+
+    public boolean isReplyAck() {
+        try {
+            return getByte("ACK/NACK") == 0x06;
+        } catch (FieldException e) {
+            return false;
+        }
+    }
+
+    public boolean isReplyNack() {
+        try {
+            return getByte("ACK/NACK") == 0x15;
+        } catch (FieldException e) {
+            return false;
+        }
+    }
+
+    public boolean isReplyOf(Msg msg) {
+        return isReply() && Arrays.equals(msg.getData(), Arrays.copyOf(data, msg.getLength()));
+    }
+
+    public boolean isFailureReport() {
+        return getCommand() == 0x5C;
+    }
+
+    public boolean isOfType(MsgType type) {
+        return type == getType();
+    }
+
+    public boolean isBroadcast() {
+        return isOfType(MsgType.BROADCAST);
+    }
+
+    public boolean isAllLinkBroadcast() {
+        return isOfType(MsgType.ALL_LINK_BROADCAST);
+    }
+
+    public boolean isAllLinkCleanup() {
+        return isOfType(MsgType.ALL_LINK_CLEANUP);
+    }
+
+    public boolean isAllLinkBroadcastOrCleanup() {
+        return isOfType(MsgType.ALL_LINK_BROADCAST) || isOfType(MsgType.ALL_LINK_CLEANUP);
+    }
+
+    public boolean isAllLinkCleanupAckOrNack() {
+        return isOfType(MsgType.ALL_LINK_CLEANUP_ACK) || isOfType(MsgType.ALL_LINK_CLEANUP_NACK);
+    }
+
+    public boolean isAllLinkSuccessReport() {
+        try {
+            return isOfType(MsgType.ALL_LINK_BROADCAST) && getByte("command1") == 0x06;
+        } catch (FieldException e) {
+            return false;
+        }
+    }
+
+    public boolean isDirect() {
+        return isOfType(MsgType.DIRECT);
+    }
+
+    public boolean isAckOfDirect() {
+        return isOfType(MsgType.ACK_OF_DIRECT);
+    }
+
+    public boolean isNackOfDirect() {
+        return isOfType(MsgType.NACK_OF_DIRECT);
+    }
+
+    public boolean isAckOrNackOfDirect() {
+        return isOfType(MsgType.ACK_OF_DIRECT) || isOfType(MsgType.NACK_OF_DIRECT);
+    }
+
+    public boolean isInsteon() {
+        return containsField("messageFlags");
+    }
+
+    public boolean isX10() {
+        return containsField("X10Flag");
+    }
+
+    public boolean isX10Address() {
+        try {
+            return getByte("X10Flag") == X10Flag.ADDRESS.code();
+        } catch (FieldException e) {
+            return false;
+        }
+    }
+
+    public boolean isX10Command() {
+        try {
+            return getByte("X10Flag") == X10Flag.COMMAND.code();
+        } catch (FieldException e) {
+            return false;
+        }
+    }
+
+    public boolean isReplayed() {
+        return replayed;
+    }
+
+    public void setDefinition(MsgDefinition definition) {
+        this.definition = definition;
+    }
+
+    public void setQuietTime(long quietTime) {
+        this.quietTime = quietTime;
+    }
+
+    public void setIsReplayed(boolean replayed) {
+        this.replayed = replayed;
+    }
+
+    public void addField(Field f) {
+        definition.addField(f);
+    }
+
+    public boolean containsField(String key) {
+        return definition.containsField(key);
+    }
+
+    public int getHopsLeft() {
+        try {
+            return (getByte("messageFlags") & 0x0C) >> 2;
+        } catch (FieldException e) {
+            return -1;
+        }
+    }
+
+    public int getMaxHops() {
+        try {
+            return getByte("messageFlags") & 0x03;
+        } catch (FieldException e) {
+            return -1;
+        }
+    }
+
+    /**
+     * Sets a byte at a specific field
+     *
+     * @param key the string key in the message definition
+     * @param value the byte to put
+     */
+    public void setByte(String key, byte value) throws FieldException {
+        Field field = definition.getField(key);
+        field.setByte(data, value);
+    }
+
+    /**
+     * Sets address bytes at a specific field
+     *
+     * @param key the name of the field
+     * @param address the address to put
+     */
+    public void setAddress(String key, DeviceAddress address) throws FieldException {
+        Field field = definition.getField(key);
+        if (address instanceof InsteonAddress insteonAddress) {
+            field.setAddress(data, insteonAddress);
+        } else if (address instanceof X10Address x10Address) {
+            field.setByte(data, x10Address.getCode());
+        }
+    }
+
+    /**
+     * Sets a byte array starting at a specific field
+     *
+     * @param key the name of the first field
+     */
+    public void setBytes(String key, byte[] bytes) throws FieldException {
+        int offset = definition.getField(key).getOffset();
+        if (offset < 0 || offset + bytes.length > data.length) {
+            throw new FieldException("data index out of bounds!");
+        }
+        System.arraycopy(bytes, 0, data, offset, bytes.length);
+    }
+
+    /**
+     * Sets a byte array starting at a specific field as an up to 32-bit integer
+     *
+     * @param key the name of the first field
+     * @param value the integer to put
+     * @param numBytes number of bytes to put
+     */
+    public void setInt(String key, int value, int numBytes) throws FieldException {
+        if (numBytes < 1 || numBytes > 4) {
+            throw new FieldException("number of bytes out of bounds!");
+        }
+        byte[] bytes = new byte[numBytes];
+        int shift = 8 * (numBytes - 1);
+        for (int i = 0; i < numBytes; i++) {
+            bytes[i] = (byte) (value >> shift);
+            shift -= 8;
+        }
+        setBytes(key, bytes);
+    }
+
+    /**
+     * Returns a byte from a specific field
+     *
+     * @param key the name of the field
+     * @return the byte
+     */
+    public byte getByte(String key) throws FieldException {
+        return definition.getField(key).getByte(data);
+    }
+
+    /**
+     * Returns the insteon address from a specific field
+     *
+     * @param key the name of the field
+     * @return the insteon address
+     */
+    public InsteonAddress getInsteonAddress(String key) throws FieldException {
+        return definition.getField(key).getAddress(data);
+    }
+
+    /**
+     * Returns the x10 address
+     *
+     * @return the x10 address
+     */
+    public @Nullable X10Address getX10Address() throws FieldException {
+        return isX10Address() ? new X10Address(getByte("rawX10")) : null;
+    }
+
+    /**
+     * Returns a byte array starting from a specific field
+     *
+     * @param key the name of the first field
+     * @param numBytes number of bytes to get
+     * @return the byte array
+     */
+    public byte[] getBytes(String key, int numBytes) throws FieldException {
+        int offset = definition.getField(key).getOffset();
+        if (offset < 0 || offset + numBytes > data.length) {
+            throw new FieldException("data index out of bounds!");
+        }
+        return Arrays.copyOfRange(data, offset, offset + numBytes);
+    }
+
+    /**
+     * Returns a byte array starting from a specific field as an up to 32-bit integer
+     *
+     * @param key the name of the first field
+     * @param numBytes number of bytes to use for conversion
+     * @return the integer
+     */
+    public int getInt(String key, int numBytes) throws FieldException {
+        if (numBytes < 1 || numBytes > 4) {
+            throw new FieldException("number of bytes out of bounds!");
+        }
+        int i = 0;
+        int shift = 8 * (numBytes - 1);
+        for (byte b : getBytes(key, numBytes)) {
+            i |= (b & 0xFF) << shift;
+            shift -= 8;
+        }
+        return i;
+    }
+
+    /**
+     * Returns a byte from a specific field as a 8-bit integer
+     *
+     * @param key the name of the field
+     * @return the integer
+     */
+    public int getInt(String key) throws FieldException {
+        return getByte(key) & 0xFF;
+    }
+
+    /**
+     * Returns a 2-byte array starting from a specific field as a 16-bit integer
+     *
+     * @param key the name of the first field
+     * @return the integer
+     */
+    public int getInt16(String key) throws FieldException {
+        return getInt(key, 2);
+    }
+
+    /**
+     * Returns a 3-byte array starting from a specific field as a 24-bit integer
+     *
+     * @param key the name of the first field
+     * @return the integer
+     */
+    public int getInt24(String key) throws FieldException {
+        return getInt(key, 3);
+    }
+
+    /**
+     * Returns a 4-byte array starting from a specific field as a 32-bit integer
+     *
+     * @param key the name of the first field
+     * @return the integer
+     */
+    public int getInt32(String key) throws FieldException {
+        return getInt(key, 4);
+    }
+
+    /**
+     * Returns a byte as a hex string
+     *
+     * @param key the name of the field
+     * @return the hex string
+     */
+    public String getHexString(String key) throws FieldException {
+        return HexUtils.getHexString(getByte(key));
+    }
+
+    /**
+     * Returns a byte array starting from a certain field as a hex string
+     *
+     * @param key the name of the field
+     * @param numBytes number of bytes to get
+     * @return the hex string
+     */
+    public String getHexString(String key, int numBytes) throws FieldException {
+        return HexUtils.getHexString(getBytes(key, numBytes), numBytes);
+    }
+
+    /**
+     * Returns group based on specific message characteristics
+     *
+     * @return group number if available, otherwise -1
+     */
+    public int getGroup() {
+        try {
+            if (isAllLinkBroadcast()) {
+                return getInsteonAddress("toAddress").getLowByte() & 0xFF;
+            }
+            if (isAllLinkCleanup()) {
+                return getInt("command2");
+            }
+            if (isExtended()) {
+                byte cmd1 = getByte("command1");
+                byte cmd2 = getByte("command2");
+                // group number for specific extended msg located in userData1 byte
+                if (cmd1 == 0x2E && cmd2 == 0x00) {
+                    return getInt("userData1");
+                }
+            }
+        } catch (FieldException e) {
+            logger.warn("got field exception on msg: {}", e.getMessage());
+        }
+        return -1;
+    }
+
+    /**
+     * Returns msg type based on message flags
+     *
+     * @return msg type
+     */
+    public MsgType getType() {
+        try {
+            return MsgType.valueOf(getInt("messageFlags"));
+        } catch (FieldException | IllegalArgumentException e) {
+            return MsgType.INVALID;
+        }
+    }
+
+    /**
+     * Sets the userData fields from a byte array
+     *
+     * @param args list of user data arguments
+     */
+    public void setUserData(byte[] args) {
+        try {
+            for (int i = 0; i < 14; i++) {
+                setByte("userData" + (i + 1), args.length > i ? args[i] : (byte) 0x00);
+            }
+        } catch (FieldException e) {
+            logger.warn("got field exception on msg {}:", e.getMessage());
+        }
+    }
+
+    /**
+     * Calculates the CRC using the older 1-byte method
+     *
+     * @return the calculated crc
+     * @throws FieldException
+     */
+    public int calculateCRC() throws FieldException {
+        int crc = 0;
+        byte[] bytes = getBytes("command1", 15); // skip userData14
+        for (byte b : bytes) {
+            crc += b;
+        }
+        return (~crc + 1) & 0xFF;
+    }
+
+    /**
+     * Calculates the CRC using the newer 2-byte method
+     *
+     * @return the calculated crc
+     * @throws FieldException
+     */
+    public int calculateCRC2() throws FieldException {
+        int crc = 0;
+        byte[] bytes = getBytes("command1", 14); // skip userData13/14
+        for (int loop = 0; loop < bytes.length; loop++) {
+            int b = bytes[loop] & 0xFF;
+            for (int bit = 0; bit < 8; bit++) {
+                int fb = b & 0x01;
+                if ((crc & 0x8000) == 0) {
+                    fb = fb ^ 0x01;
+                }
+                if ((crc & 0x4000) == 0) {
+                    fb = fb ^ 0x01;
+                }
+                if ((crc & 0x1000) == 0) {
+                    fb = fb ^ 0x01;
+                }
+                if ((crc & 0x0008) == 0) {
+                    fb = fb ^ 0x01;
+                }
+                crc = (crc << 1) | fb;
+                b = b >> 1;
+            }
+        }
+        return crc & 0xFFFF;
+    }
+
+    /**
+     * Checks if message has a valid CRC using the older 1-byte method
+     *
+     * @return true if valid
+     */
+    public boolean hasValidCRC() {
+        try {
+            return getInt("userData14") == calculateCRC();
+        } catch (FieldException e) {
+            logger.warn("got field exception on msg {}:", e.getMessage());
+        }
+        return false;
+    }
+
+    /**
+     * Checks if message has a valid CRC using the newer 2-byte method is valid
+     *
+     * @return true if valid
+     */
+    public boolean hasValidCRC2() {
+        try {
+            return getInt16("userData13") == calculateCRC2();
+        } catch (FieldException e) {
+            logger.warn("got field exception on msg {}:", e.getMessage());
+        }
+        return false;
+    }
+
+    /**
+     * Sets the calculated CRC using the older 1-byte method
+     */
+    public void setCRC() {
+        try {
+            int crc = calculateCRC();
+            setByte("userData14", (byte) crc);
+        } catch (FieldException e) {
+            logger.warn("got field exception on msg {}:", e.getMessage());
+        }
+    }
+
+    /**
+     * Sets the calculated CRC using the newer 2-byte method
+     */
+    public void setCRC2() {
+        try {
+            int crc = calculateCRC2();
+            setByte("userData13", (byte) ((crc >> 8) & 0xFF));
+            setByte("userData14", (byte) (crc & 0xFF));
+        } catch (FieldException e) {
+            logger.warn("got field exception on msg {}:", e.getMessage());
+        }
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (obj == this) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        Msg other = (Msg) obj;
+        return Arrays.equals(data, other.data);
+    }
+
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+        result = prime * result + Arrays.hashCode(data);
+        return result;
+    }
+
+    @Override
+    public String toString() {
+        String s = (direction == Direction.TO_MODEM) ? "OUT:" : "IN:";
+        for (Field field : definition.getFields()) {
+            if ("messageFlags".equals(field.getName())) {
+                s += field.toString(data) + "=" + getType() + ":" + getHopsLeft() + ":" + getMaxHops() + "|";
+            } else {
+                s += field.toString(data) + "|";
+            }
+        }
+        return s;
+    }
+
+    /**
+     * Factory method to create Msg from raw byte stream received from the serial port.
+     *
+     * @param buf the raw received bytes
+     * @param msgLen length of received buffer
+     * @param isExtended whether it is an extended message or not
+     * @return message, or null if the Msg cannot be created
+     */
+    public static @Nullable Msg createMessage(byte[] buf, int msgLen, boolean isExtended) {
+        if (buf.length < 2) {
+            return null;
+        }
+        return Optional
+                .ofNullable(MsgDefinitionRegistry.getInstance().getTemplate(buf[1], isExtended, Direction.FROM_MODEM))
+                .filter(template -> template.getLength() == msgLen).map(template -> new Msg(template, buf, msgLen))
+                .orElse(null);
+    }
+
+    /**
+     * Factory method to determine the header length of a received message
+     *
+     * @param cmd the message command received
+     * @return the length of the header to expect
+     */
+    public static int getHeaderLength(byte cmd) {
+        return Optional.ofNullable(MsgDefinitionRegistry.getInstance().getTemplate(cmd, Direction.FROM_MODEM))
+                .map(Msg::getHeaderLength).orElse(-1);
+    }
+
+    /**
+     * Factory method to determine the length of a received message
+     *
+     * @param cmd the message command received
+     * @param isExtended if is an extended message
+     * @return message length, or -1 if length cannot be determined
+     */
+    public static int getMessageLength(byte cmd, boolean isExtended) {
+        return Optional
+                .ofNullable(MsgDefinitionRegistry.getInstance().getTemplate(cmd, isExtended, Direction.FROM_MODEM))
+                .map(Msg::getLength).orElse(-1);
+    }
+
+    /**
+     * Factory method to determine if a message is extended
+     *
+     * @param buf the received bytes
+     * @param len the number of bytes received so far
+     * @param headerLength the known length of the header
+     * @return true if it is definitely extended, false if cannot be
+     *         determined or if it is a standard message
+     */
+    public static boolean isExtended(byte[] buf, int len, int headerLength) {
+        if (headerLength <= 2) {
+            return false;
+        } // extended messages are longer
+        if (len < headerLength) {
+            return false;
+        } // not enough data to tell if extended
+        byte flags = buf[headerLength - 1]; // last byte says flags
+        boolean isExtended = BinaryUtils.isBitSet(flags & 0xFF, 4);
+        return isExtended;
+    }
+
+    /**
+     * Factory method to create a message to send for a given cmd
+     *
+     * @param cmd the message cmd to create, as defined in the xml file
+     * @return the insteon message
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeMessage(byte cmd) throws InvalidMessageTypeException {
+        return Optional.ofNullable(MsgDefinitionRegistry.getInstance().getTemplate(cmd, Direction.TO_MODEM))
+                .map(Msg::new).orElseThrow(() -> new InvalidMessageTypeException(
+                        "unknown message command: " + HexUtils.getHexString(cmd)));
+    }
+
+    /**
+     * Factory method to create an Insteon message to send for a given type
+     *
+     * @param type the message type to create, as defined in the xml file
+     * @return the insteon message
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeMessage(String type) throws InvalidMessageTypeException {
+        return Optional.ofNullable(MsgDefinitionRegistry.getInstance().getTemplate(type)).map(Msg::new)
+                .orElseThrow(() -> new InvalidMessageTypeException("unknown message type: " + type));
+    }
+
+    /**
+     * Factory method to create a broadcast message to send
+     *
+     * @param group the broadcast group to send the message to
+     * @param cmd1 the message command 1 field
+     * @param cmd2 the message command 2 field
+     * @return the broadcast message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeBroadcastMessage(int group, byte cmd1, byte cmd2)
+            throws FieldException, InvalidMessageTypeException {
+        Msg msg = makeMessage("SendStandardMessage");
+        msg.setAddress("toAddress", new InsteonAddress((byte) 0, (byte) 0, (byte) (group & 0xFF)));
+        msg.setByte("messageFlags", (byte) 0xCF);
+        msg.setByte("command1", cmd1);
+        msg.setByte("command2", cmd2);
+        msg.setQuietTime(0L);
+        return msg;
+    }
+
+    /**
+     * Factory method to create a standard message to send
+     *
+     * @param address the address to send the message to
+     * @param cmd1 the message command 1 field
+     * @param cmd2 the message command 2 field
+     * @return the standard message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeStandardMessage(InsteonAddress address, byte cmd1, byte cmd2)
+            throws FieldException, InvalidMessageTypeException {
+        return makeStandardMessage(address, (byte) 0x0F, cmd1, cmd2);
+    }
+
+    /**
+     * Factory method to create a standard message to send
+     *
+     * @param address the address to send the message to
+     * @param flags the message flags field
+     * @param cmd1 the message command 1 field
+     * @param cmd2 the message command 2 field
+     * @return the standard message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeStandardMessage(InsteonAddress address, byte flags, byte cmd1, byte cmd2)
+            throws FieldException, InvalidMessageTypeException {
+        Msg msg = makeMessage("SendStandardMessage");
+        msg.setAddress("toAddress", address);
+        msg.setByte("messageFlags", flags);
+        msg.setByte("command1", cmd1);
+        msg.setByte("command2", cmd2);
+        // set default quiet time accounting for ack response
+        msg.setQuietTime(1000L);
+        return msg;
+    }
+
+    /**
+     * Factory method to create an extended message to send with optional CRC
+     *
+     * @param address the address to send the message to
+     * @param cmd1 the message command 1 field
+     * @param cmd2 the message command 2 field
+     * @param setCRC if the CRC should be set
+     * @return extended message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeExtendedMessage(InsteonAddress address, byte cmd1, byte cmd2, boolean setCRC)
+            throws FieldException, InvalidMessageTypeException {
+        return makeExtendedMessage(address, cmd1, cmd2, new byte[] {}, setCRC);
+    }
+
+    /**
+     * Factory method to create an extended message to send with specific user data and optional CRC
+     *
+     * @param address the address to send the message to
+     * @param cmd1 the message command 1 field
+     * @param cmd2 the message command 2 field
+     * @param data the message user data fields
+     * @param setCRC if the CRC should be set
+     * @return extended message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeExtendedMessage(InsteonAddress address, byte cmd1, byte cmd2, byte[] data, boolean setCRC)
+            throws FieldException, InvalidMessageTypeException {
+        return makeExtendedMessage(address, (byte) 0x1F, cmd1, cmd2, data, setCRC);
+    }
+
+    /**
+     * Factory method to create an extended message to send with specific user data and optional CRC
+     *
+     * @param address the address to send the message to
+     * @param flags the message flags field
+     * @param cmd1 the message command 1 field
+     * @param cmd2 the message command 2 field
+     * @param data the message user data fields
+     * @param setCRC if the CRC should be set
+     * @return extended message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeExtendedMessage(InsteonAddress address, byte flags, byte cmd1, byte cmd2, byte[] data,
+            boolean setCRC) throws FieldException, InvalidMessageTypeException {
+        Msg msg = makeMessage("SendExtendedMessage");
+        msg.setAddress("toAddress", address);
+        msg.setByte("messageFlags", (byte) (flags | 0x10));
+        msg.setByte("command1", cmd1);
+        msg.setByte("command2", cmd2);
+        msg.setUserData(data);
+        if (setCRC) {
+            msg.setCRC();
+        }
+        // set default quiet time accounting for ack followed by direct response messages
+        msg.setQuietTime(2000L);
+        return msg;
+    }
+
+    /**
+     * Factory method to create an extended message to send with specific user data and CRC2
+     *
+     * @param address the address to send the message to
+     * @param cmd1 the message command 1 field
+     * @param cmd2 the message command 2 field
+     * @param data the message user data fields
+     * @return extended message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeExtendedMessageCRC2(InsteonAddress address, byte cmd1, byte cmd2, byte[] data)
+            throws FieldException, InvalidMessageTypeException {
+        Msg msg = Msg.makeExtendedMessage(address, cmd1, cmd2, data, false);
+        msg.setCRC2();
+        return msg;
+    }
+
+    /**
+     * Factory method to create an X10 message to send
+     *
+     * @param cmd the X10 command
+     * @param flag the X10 flag
+     * @return the X10 message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeX10Message(byte cmd, byte flag) throws FieldException, InvalidMessageTypeException {
+        Msg msg = makeMessage("SendX10Message");
+        msg.setByte("rawX10", cmd);
+        msg.setByte("X10Flag", flag);
+        msg.setQuietTime(300L);
+        return msg;
+    }
+
+    /**
+     * Factory method to create an X10 address message to send
+     *
+     * @param address the X10 address
+     * @return the X10 address message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeX10AddressMessage(X10Address address) throws FieldException, InvalidMessageTypeException {
+        return makeX10Message(address.getCode(), X10Flag.ADDRESS.code());
+    }
+
+    /**
+     * Factory method to create an X10 command message to send
+     *
+     * @param cmd the X10 command
+     * @return the X10 command message
+     * @throws FieldException
+     * @throws InvalidMessageTypeException
+     */
+    public static Msg makeX10CommandMessage(byte cmd) throws FieldException, InvalidMessageTypeException {
+        return makeX10Message(cmd, X10Flag.COMMAND.code());
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgDefinition.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgDefinition.java
new file mode 100644 (file)
index 0000000..2d295e4
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * 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.transport.message;
+
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Definition (layout) of an Insteon message. Says which bytes go where.
+ * For more info, see the public Insteon Developer's Guide, 2nd edition,
+ * and the Insteon Modem Developer's Guide.
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class MsgDefinition {
+    private Map<String, Field> fields = new HashMap<>();
+
+    MsgDefinition() {
+    }
+
+    MsgDefinition(MsgDefinition definition) {
+        fields = new HashMap<>(definition.fields);
+    }
+
+    public List<Field> getFields() {
+        return fields.values().stream().sorted(Comparator.comparing(Field::getOffset)).toList();
+    }
+
+    public boolean containsField(String name) {
+        return fields.containsKey(name);
+    }
+
+    public void addField(Field field) {
+        fields.put(field.getName(), field);
+    }
+
+    /**
+     * Finds field of a given name
+     *
+     * @param name name of the field to search for
+     * @return reference to field
+     * @throws FieldException if no such field can be found
+     */
+    public Field getField(String name) throws FieldException {
+        Field field = fields.get(name);
+        if (field == null) {
+            throw new FieldException("field " + name + " not found");
+        }
+        return field;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgDefinitionRegistry.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgDefinitionRegistry.java
new file mode 100644 (file)
index 0000000..445b2c9
--- /dev/null
@@ -0,0 +1,303 @@
+/**
+ * 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.transport.message;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+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.InsteonAddress;
+import org.openhab.binding.insteon.internal.transport.message.Msg.Direction;
+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 MsgDefinitionRegistry} represents the message definition registry
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public class MsgDefinitionRegistry extends InsteonResourceLoader {
+    private static final MsgDefinitionRegistry MSG_DEFINITION_REGISTRY = new MsgDefinitionRegistry();
+    private static final String RESOURCE_NAME = "/msg-definitions.xml";
+
+    private Map<String, Msg> definitions = new LinkedHashMap<>();
+
+    private MsgDefinitionRegistry() {
+        super(RESOURCE_NAME);
+    }
+
+    /**
+     * Returns message template for a given type
+     *
+     * @param type message type to match
+     * @return message template if found, otherwise null
+     */
+    public @Nullable Msg getTemplate(String type) {
+        return definitions.get(type);
+    }
+
+    /**
+     * Returns message template for a given command and direction
+     *
+     * @param cmd message command to match
+     * @param direction message direction to match
+     * @return message template if found, otherwise null
+     */
+    public @Nullable Msg getTemplate(byte cmd, Direction direction) {
+        return getTemplate(cmd, null, direction);
+    }
+
+    /**
+     * Returns message template for a given command, extended flag and direction
+     *
+     * @param cmd message command to match
+     * @param isExtended if message is extended
+     * @param direction message direction to match
+     * @return message template if found, otherwise null
+     */
+    public @Nullable Msg getTemplate(byte cmd, @Nullable Boolean isExtended, Direction direction) {
+        return definitions.values().stream().filter(msg -> msg.getCommand() == cmd && msg.getDirection() == direction
+                && (isExtended == null || msg.isExtended() == isExtended)).findFirst().orElse(null);
+    }
+
+    /**
+     * Returns known message definitions
+     *
+     * @return currently known message definitions
+     */
+    public Map<String, Msg> getDefinitions() {
+        return definitions;
+    }
+
+    /**
+     * Initializes message definition registry
+     */
+    @Override
+    protected void initialize() {
+        super.initialize();
+
+        logger.debug("loaded {} message definitions", definitions.size());
+        if (logger.isTraceEnabled()) {
+            definitions.entrySet().stream()
+                    .map(definition -> String.format("%s->%s", definition.getKey(), definition.getValue()))
+                    .forEach(logger::trace);
+        }
+    }
+
+    /**
+     * Parses message definition 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 ("msg".equals(nodeName)) {
+                    parseMsgDefinition(child);
+                }
+            }
+        }
+    }
+
+    /**
+     * Parses message definition node
+     *
+     * @param element element to parse
+     * @throws SAXException
+     */
+    private void parseMsgDefinition(Element element) throws SAXException {
+        LinkedHashMap<Field, Object> fields = new LinkedHashMap<>();
+        String name = element.getAttribute("name");
+        Direction direction = Direction.valueOf(element.getAttribute("direction"));
+        int length = element.hasAttribute("length") ? Integer.parseInt(element.getAttribute("length")) : 0;
+        int headerLength = 0;
+        int offset = 0;
+
+        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 ("header".equals(nodeName)) {
+                    headerLength = parseHeader(child, fields);
+                    // Increment the offset by the header length
+                    offset += headerLength;
+                } else {
+                    // Increment the offset by the field data type length
+                    offset += parseField(child, offset, fields);
+                }
+            }
+        }
+        if (length == 0) {
+            length = offset;
+        } else if (offset != length) {
+            throw new SAXException("actual msg length " + offset + " differs from given msg length " + length);
+        }
+
+        try {
+            Msg msg = makeMsgTemplate(fields, headerLength, length, direction);
+            definitions.put(name, msg);
+        } catch (FieldException e) {
+            throw new SAXException("failed to create message definition " + name + ":", e);
+        }
+    }
+
+    /**
+     * Parses header node
+     *
+     * @param element element to parse
+     * @param fields fields map to update
+     * @return header length
+     * @throws SAXException
+     */
+    private int parseHeader(Element element, LinkedHashMap<Field, Object> fields) throws SAXException {
+        int length = Integer.parseInt(element.getAttribute("length"));
+        int offset = 0;
+
+        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;
+                // Increment the offset by the field data type length
+                offset += parseField(child, offset, fields);
+            }
+        }
+        if (length != offset) {
+            throw new SAXException("actual header length " + offset + " differs from given length " + length);
+        }
+        return length;
+    }
+
+    /**
+     * Parses field node
+     *
+     * @param element element to parse
+     * @param offset msg offset
+     * @param fields fields map to update
+     * @return field data type length
+     * @throws SAXException
+     */
+    private int parseField(Element element, int offset, LinkedHashMap<Field, Object> fields) throws SAXException {
+        String name = element.getAttribute("name");
+        if (name == null) {
+            throw new SAXException("undefined field name");
+        }
+        DataType dataType = DataType.get(element.getNodeName());
+        Field field = new Field(name, dataType, offset);
+        Object value = getFieldValue(dataType, element.getTextContent().trim());
+        fields.put(field, value);
+        return dataType.getSize();
+    }
+
+    /**
+     * Returns field value
+     *
+     * @param dataType field data type
+     * @param value value to convert
+     * @return field value
+     * @throws SAXException
+     */
+    private Object getFieldValue(DataType dataType, String value) throws SAXException {
+        switch (dataType) {
+            case BYTE:
+                return getByteValue(value);
+            case ADDRESS:
+                return getAddressValue(value);
+            default:
+                throw new SAXException("invalid field data type");
+        }
+    }
+
+    /**
+     * Returns field value as a byte
+     *
+     * @param value value to convert
+     * @return byte
+     * @throws SAXException
+     */
+    private byte getByteValue(String value) throws SAXException {
+        try {
+            return value.isEmpty() ? 0x00 : (byte) HexUtils.toInteger(value);
+        } catch (NumberFormatException e) {
+            throw new SAXException("invalid field byte value: " + value);
+        }
+    }
+
+    /**
+     * Returns field value as an insteon address
+     *
+     * @param value value to convert
+     * @return insteon address
+     * @throws SAXException
+     */
+    private InsteonAddress getAddressValue(String value) throws SAXException {
+        try {
+            return value.isEmpty() ? InsteonAddress.UNKNOWN : new InsteonAddress(value);
+        } catch (IllegalArgumentException e) {
+            throw new SAXException("invalid field address value: " + value);
+        }
+    }
+
+    /**
+     * Returns new message template
+     *
+     * @param fields msg fields
+     * @param length msg length
+     * @param headerLength header length
+     * @param direction msg direction
+     * @return new msg template
+     * @throws FieldException
+     */
+    private Msg makeMsgTemplate(Map<Field, Object> fields, int headerLength, int length, Direction direction)
+            throws FieldException {
+        Msg msg = new Msg(headerLength, length, direction);
+        for (Entry<Field, Object> entry : fields.entrySet()) {
+            Field field = entry.getKey();
+            byte[] data = msg.getData();
+            field.set(data, entry.getValue());
+            if (!field.getName().isEmpty()) {
+                msg.addField(field);
+            }
+        }
+        return msg;
+    }
+
+    /**
+     * Singleton instance function
+     *
+     * @return MsgDefinitionRegistry singleton reference
+     */
+    public static synchronized MsgDefinitionRegistry getInstance() {
+        if (MSG_DEFINITION_REGISTRY.getDefinitions().isEmpty()) {
+            MSG_DEFINITION_REGISTRY.initialize();
+        }
+        return MSG_DEFINITION_REGISTRY;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgFactory.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgFactory.java
new file mode 100644 (file)
index 0000000..5394ec3
--- /dev/null
@@ -0,0 +1,180 @@
+/**
+ * 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.transport.message;
+
+import java.io.IOException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.insteon.internal.utils.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class takes data coming from the serial port and turns it
+ * into a message. For that, it has to figure out the length of the
+ * message from the header, and read enough bytes until it hits the
+ * message boundary. The code is tricky, partly because the Insteon protocol is.
+ * Most of the time the command code (second byte) is enough to determine the length
+ * of the incoming message, but sometimes one has to look deeper into the message
+ * to determine if it is a standard or extended message (their lengths differ).
+ *
+ * @author Bernd Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ */
+@NonNullByDefault
+public class MsgFactory {
+    private final Logger logger = LoggerFactory.getLogger(MsgFactory.class);
+    // no idea what the max msg length could be, but
+    // I doubt it'll ever be larger than 4k
+    private static final int MAX_MSG_LEN = 4096;
+    private byte[] buf = new byte[MAX_MSG_LEN];
+    private int end = 0; // offset of end of buffer
+    private boolean done = true; // done fully processing buffer flag
+
+    /**
+     * Constructor
+     */
+    public MsgFactory() {
+    }
+
+    /**
+     * Indicates if no more complete message available in the buffer to be processed
+     *
+     * @return buffer data fully processed flag
+     */
+    public boolean isDone() {
+        return done;
+    }
+
+    /**
+     * Adds incoming data to the data buffer. First call addData(), then call processData()
+     *
+     * @param data data to be added
+     * @param len length of data to be added
+     */
+    public void addData(byte[] data, int len) {
+        int l = len;
+        if (l + end > MAX_MSG_LEN) {
+            logger.warn("truncating excessively long message!");
+            l = MAX_MSG_LEN - end;
+        }
+        // indicate new data can be processed if length > 0
+        if (l > 0) {
+            done = false;
+        }
+        // append the new data to the one we already have
+        System.arraycopy(data, 0, buf, end, l);
+        end += l;
+        // copy the incoming data to the end of the buffer
+        if (logger.isTraceEnabled()) {
+            logger.trace("read buffer: len {} data: {}", end, HexUtils.getHexString(buf, end, false));
+        }
+    }
+
+    /**
+     * After data has been added, this method processes it.
+     * processData() needs to be called until it returns null, indicating that no
+     * more messages can be formed from the data buffer.
+     *
+     * @return a valid message, or null if the message is not complete
+     * @throws IOException if data was received with unknown command codes
+     */
+    public @Nullable Msg processData() throws IOException {
+        Msg msg = null;
+        // handle the case where we get a pure nack
+        if (end > 0 && buf[0] == 0x15) {
+            logger.trace("got pure nack!");
+            removeFromBuffer(1);
+            try {
+                msg = Msg.makeMessage("PureNACK");
+                return msg;
+            } catch (InvalidMessageTypeException e) {
+                return null;
+            }
+        }
+        // drain the buffer until the first byte is 0x02
+        if (end > 0 && buf[0] != 0x02) {
+            logger.debug("incoming message does not start with 0x02");
+            bail();
+        }
+        // Now see if we have enough data for a complete message.
+        // If not, we return null, and expect this method to be called again
+        // when more data has come in.
+        if (end > 1) {
+            // we have some data, but do we have enough to read the entire header?
+            int headerLength = Msg.getHeaderLength(buf[1]);
+            boolean isExtended = Msg.isExtended(buf, end, headerLength);
+            logger.trace("header length expected: {} extended: {}", headerLength, isExtended);
+            if (headerLength < 0) {
+                if (logger.isDebugEnabled()) {
+                    logger.debug("got unknown command code: {}", HexUtils.getHexString(buf[1]));
+                }
+                removeFromBuffer(1); // get rid of the leading 0x02 so draining works
+                bail();
+            } else if (headerLength >= 2) {
+                if (end >= headerLength) {
+                    // only when the header is complete do we know that isExtended is correct!
+                    int msgLen = Msg.getMessageLength(buf[1], isExtended);
+                    logger.trace("msgLen expected: {}", msgLen);
+                    if (msgLen < 0) {
+                        // Cannot make sense out of the combined command code & isExtended flag.
+                        if (logger.isDebugEnabled()) {
+                            logger.debug("got unknown command code/ext flag: {}", HexUtils.getHexString(buf[1]));
+                        }
+                        removeFromBuffer(1);
+                        bail();
+                    } else if (msgLen > 0) {
+                        if (end >= msgLen) {
+                            msg = Msg.createMessage(buf, msgLen, isExtended);
+                            removeFromBuffer(msgLen);
+                        }
+                    } else { // should never happen
+                        logger.warn("invalid message length, internal error!");
+                    }
+                }
+            } else { // should never happen
+                logger.warn("invalid header length, internal error!");
+            }
+        }
+        // indicate no more messages available in buffer if empty or undefined message
+        if (end == 0 || msg == null) {
+            logger.trace("done processing current buffer data");
+            done = true;
+        }
+        if (logger.isTraceEnabled()) {
+            logger.trace("keeping buffer len {} data: {}", end, HexUtils.getHexString(buf, end, false));
+        }
+        return msg;
+    }
+
+    private void bail() throws IOException {
+        drainBuffer(); // this will drain until end or it finds the next message start
+        throw new IOException("bad data received");
+    }
+
+    private void drainBuffer() {
+        while (end > 0 && buf[0] != 0x02) {
+            removeFromBuffer(1);
+        }
+    }
+
+    private void removeFromBuffer(int len) {
+        int l = len;
+        if (l > end) {
+            l = end;
+        }
+        System.arraycopy(buf, l, buf, 0, end + 1 - l);
+        end -= l;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgType.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/transport/message/MsgType.java
new file mode 100644 (file)
index 0000000..4f37c2b
--- /dev/null
@@ -0,0 +1,71 @@
+/**
+ * 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.transport.message;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Represents insteon message type flags
+ *
+ * @author Daniel Pfrommer - Initial contribution
+ * @author Rob Nielsen - Port to openHAB 2 insteon binding
+ * @author Jeremy Setton - Rewrite insteon binding
+ */
+@NonNullByDefault
+public enum MsgType {
+    /*
+     * From the official Insteon docs: the message flags are as follows:
+     *
+     * Bit 0 max hops low bit
+     * Bit 1 max hops high bit
+     * Bit 2 hops left low bit
+     * Bit 3 hops left high bit
+     * Bit 4 0: is standard message, 1: is extended message
+     * Bit 5 ACK
+     * Bit 6 0: not link related, 1: is ALL-Link message
+     * Bit 7 Broadcast/NAK
+     */
+    BROADCAST(0x80),
+    DIRECT(0x00),
+    ACK_OF_DIRECT(0x20),
+    NACK_OF_DIRECT(0xA0),
+    ALL_LINK_BROADCAST(0xC0),
+    ALL_LINK_CLEANUP(0x40),
+    ALL_LINK_CLEANUP_ACK(0x60),
+    ALL_LINK_CLEANUP_NACK(0xE0),
+    INVALID(0xFF);
+
+    private static final int FLAGS_MASK = 0xE0;
+
+    private static final Map<Integer, MsgType> FLAGS_MAP = Arrays.stream(values())
+            .collect(Collectors.toUnmodifiableMap(type -> type.flags, Function.identity()));
+
+    private final int flags;
+
+    private MsgType(int flags) {
+        this.flags = flags;
+    }
+
+    public static MsgType valueOf(int flags) throws IllegalArgumentException {
+        MsgType type = FLAGS_MAP.get(flags & FLAGS_MASK);
+        if (type == null) {
+            throw new IllegalArgumentException("unexpected msg flags value");
+        }
+        return type;
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/BinaryUtils.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/BinaryUtils.java
new file mode 100644 (file)
index 0000000..b3d51c6
--- /dev/null
@@ -0,0 +1,100 @@
+/**
+ * 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.utils;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link BinaryUtils} represents binary utility functions
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class BinaryUtils {
+    /**
+     * Returns a binary string for a given byte
+     *
+     * @param b the byte
+     * @return the formatted binary string
+     */
+    public static String getBinaryString(byte b) {
+        return getBinaryString(b & 0xFF);
+    }
+
+    /**
+     * Returns a binary string for a given integer
+     *
+     * @param i the integer
+     * @return the formatted binary string
+     */
+    public static String getBinaryString(int i) {
+        String binary = Integer.toBinaryString(i);
+        return String.format("%8s", binary).replace(" ", "0");
+    }
+
+    /**
+     * Returns a bit value
+     *
+     * @param bitmask the bitmask
+     * @param bit the bit to extract
+     * @return the bit value
+     */
+    public static int getBit(int bitmask, int bit) {
+        return (bitmask >> bit) & 0x1;
+    }
+
+    /**
+     * Returns if a bit is set
+     *
+     * @param bitmask the bitmask
+     * @param bit the bit to check
+     * @return true if bit is set, otherwise false
+     */
+    public static boolean isBitSet(int bitmask, int bit) {
+        return getBit(bitmask, bit) == 0x1;
+    }
+
+    /**
+     * Updates a bit in a bitmask
+     *
+     * @param bitmask the bitmask to update
+     * @param bit the bit to update
+     * @param shouldSet if bit should be set
+     * @return the updated bitmask
+     */
+    public static int updateBit(int bitmask, int bit, boolean shouldSet) {
+        return shouldSet ? bitmask | (0x1 << bit) : bitmask & ~(0x1 << bit);
+    }
+
+    /**
+     * Sets a bit in a bitmask
+     *
+     * @param bitmask the bitmask to update
+     * @param bit the bit to set
+     * @return the updated bitmask
+     */
+    public static int setBit(int bitmask, int bit) {
+        return updateBit(bitmask, bit, true);
+    }
+
+    /**
+     * Clears a bit in a bitmask
+     *
+     * @param bitmask the bitmask to update
+     * @param bit the bit to clear
+     * @return the updated bitmask
+     */
+    public static int clearBit(int bitmask, int bit) {
+        return updateBit(bitmask, bit, false);
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/HexUtils.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/HexUtils.java
new file mode 100644 (file)
index 0000000..dbb2b1f
--- /dev/null
@@ -0,0 +1,182 @@
+/**
+ * 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.utils;
+
+import java.util.Arrays;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link HexUtils} represents hex utility functions
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class HexUtils {
+    /**
+     * Returns a hex string for a given byte
+     *
+     * @param b the byte
+     * @return the formatted hex string
+     */
+    public static String getHexString(byte b) {
+        return getHexString(b & 0xFF, 2, true);
+    }
+
+    /**
+     * Returns a hex string for a given integer
+     *
+     * @param i the integer
+     * @return the formatted hex string
+     */
+    public static String getHexString(int i) {
+        return getHexString(i, 2, true);
+    }
+
+    /**
+     * Returns a hex string for a given integer and length
+     *
+     * @param i the integer
+     * @param len the string length
+     * @return the formatted hex string
+     */
+    public static String getHexString(int i, int len) {
+        return getHexString(i, len, true);
+    }
+
+    /**
+     * Returns a hex string for a given integer, length and prefix flag
+     *
+     * @param i the integer
+     * @param len the string length
+     * @param addPrefix if hex prefix should be added
+     * @return the formatted hex string
+     */
+    public static String getHexString(int i, int len, boolean addPrefix) {
+        String fmt = "%" + (len > 0 ? "0" + len : "") + "X";
+        String s = String.format(fmt, i);
+        if (!s.isEmpty() && addPrefix) {
+            s = "0x" + s;
+        }
+        return s;
+    }
+
+    /**
+     * Returns a hex string for a given byte array and length
+     *
+     * @param bytes the byte array
+     * @param len the string length
+     * @return the formatted hex string
+     */
+    public static String getHexString(byte[] bytes, int len) {
+        return getHexString(bytes, len, true);
+    }
+
+    /**
+     * Returns a hex string for a given byte array, length and prefix flag
+     *
+     * @param bytes the byte array
+     * @param len the string length
+     * @param addPrefix if hex prefix should be added
+     * @return the formatted hex string
+     */
+    public static String getHexString(byte[] bytes, int len, boolean addPrefix) {
+        String s = "";
+        for (int i = 0; i < bytes.length && i < len; i++) {
+            s += String.format("%02X", bytes[i] & 0xFF);
+        }
+        if (!s.isEmpty() && addPrefix) {
+            s = "0x" + s;
+        }
+        return s;
+    }
+
+    /**
+     * Returns if a hex string is valid
+     *
+     * @param s the string to validate
+     * @return true if valid hex string
+     */
+    public static boolean isValidHexString(String s) {
+        String hex = s.startsWith("0x") ? s.substring(2) : s;
+        return hex.matches("\\p{XDigit}{1,2}");
+    }
+
+    /**
+     * Returns if a hex string array is valid
+     *
+     * @param strings the string array to validate
+     * @param from the array start index
+     * @param to the array end index
+     * @return true if valid hex string array
+     */
+    public static boolean isValidHexStringArray(String[] strings, int from, int to) {
+        String[] array = Arrays.copyOfRange(strings, from, to);
+        for (int i = 0; i < array.length; i++) {
+            if (!isValidHexString(array[i])) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns a hex string as byte array
+     *
+     * @param s the hex string to convert
+     * @return the converted byte array
+     * @throws IllegalArgumentException if invalid hex string
+     */
+    public static byte[] toByteArray(String s) throws IllegalArgumentException {
+        int len = s.length();
+        if (len % 2 != 0) {
+            throw new IllegalArgumentException("string length not even: " + len);
+        }
+        byte[] bytes = new byte[len / 2];
+        for (int i = 0; i < len; i += 2) {
+            bytes[i / 2] = (byte) toInteger(s.substring(i, i + 2));
+        }
+        return bytes;
+    }
+
+    /**
+     * Returns a hex string array as byte array
+     *
+     * @param strings the hex string array to convert
+     * @param from the array start index
+     * @param to the array end index
+     * @return the converted byte array
+     * @throws NumberFormatException
+     */
+    public static byte[] toByteArray(String[] strings, int from, int to) throws NumberFormatException {
+        String[] array = Arrays.copyOfRange(strings, from, to);
+        int len = array.length;
+        byte[] bytes = new byte[len];
+        for (int i = 0; i < len; i++) {
+            bytes[i] = (byte) toInteger(array[i]);
+        }
+        return bytes;
+    }
+
+    /**
+     * Returns a hex string as an integer
+     *
+     * @param s the hex string to convert
+     * @return the converted integer
+     * @throws NumberFormatException
+     */
+    public static int toInteger(String s) throws NumberFormatException {
+        String hex = s.startsWith("0x") ? s.substring(2) : s;
+        return Integer.parseInt(hex, 16);
+    }
+}
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/utils/Pair.java
deleted file mode 100644 (file)
index c58b015..0000000
+++ /dev/null
@@ -1,46 +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.utils;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * Generic pair class.
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class Pair<K, V> {
-    private K key;
-    private V value;
-
-    /**
-     * Constructs a new <code>Pair</code> with a given key/value
-     *
-     * @param key the key
-     * @param value the value
-     */
-    public Pair(K key, V value) {
-        this.key = key;
-        this.value = value;
-    }
-
-    public K getKey() {
-        return key;
-    }
-
-    public V getValue() {
-        return value;
-    }
-}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/ParameterParser.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/ParameterParser.java
new file mode 100644 (file)
index 0000000..bd5d29d
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * 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.utils;
+
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link ParameterParser} represents parameter parser functions
+ *
+ * @author Jeremy Setton - Initial contribution
+ */
+@NonNullByDefault
+public class ParameterParser {
+    /**
+     * Returns a parameter value as type
+     *
+     * @param value the parameter value
+     * @param type the parameter type
+     * @return the parameter value as type if not null, otherwise null
+     * @throws NumberFormatException
+     */
+    @SuppressWarnings("unchecked")
+    public static <@NonNull T> @Nullable T getParameterAs(@Nullable String value, Class<T> type)
+            throws NumberFormatException {
+        if (value == null) {
+            return null;
+        }
+
+        Object result = value;
+        if (Integer.class.equals(type)) {
+            result = value.startsWith("0x") ? HexUtils.toInteger(value) : Integer.parseInt(value);
+        } else if (Double.class.equals(type)) {
+            result = Double.parseDouble(value);
+        } else if (Long.class.equals(type)) {
+            result = Long.parseLong(value);
+        } else if (Boolean.class.equals(type)) {
+            result = Boolean.valueOf(value);
+        }
+
+        return (T) result;
+    }
+
+    /**
+     * Returns a parameter value as type or default value
+     *
+     * @param value the parameter value
+     * @param type the parameter type
+     * @param defaultValue the default value
+     * @return the parameter value as type if not null, otherwise default value
+     */
+    public static <@NonNull T> T getParameterAsOrDefault(@Nullable String value, Class<T> type, T defaultValue) {
+        try {
+            return Objects.requireNonNullElse(getParameterAs(value, type), defaultValue);
+        } catch (NumberFormatException e) {
+            return defaultValue;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/Utils.java b/bundles/org.openhab.binding.insteon/src/main/java/org/openhab/binding/insteon/internal/utils/Utils.java
deleted file mode 100644 (file)
index effbae4..0000000
+++ /dev/null
@@ -1,138 +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.utils;
-
-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.DataType;
-
-/**
- * Various utility functions for e.g. hex string parsing
- *
- * @author Daniel Pfrommer - Initial contribution
- * @author Rob Nielsen - Port to openHAB 2 insteon binding
- */
-@NonNullByDefault
-public class Utils {
-    public static String getHexString(int b) {
-        return String.format("%02X", b & 0xFF);
-    }
-
-    public static String getHexString(byte[] b) {
-        return getHexString(b, b.length);
-    }
-
-    public static String getHexString(byte[] b, int len) {
-        String result = "";
-        for (int i = 0; i < b.length && i < len; i++) {
-            result += String.format("%02X ", b[i] & 0xFF);
-        }
-        return result;
-    }
-
-    public static int strToInt(String s) throws NumberFormatException {
-        int ret = -1;
-        if (s.startsWith("0x")) {
-            ret = Integer.parseInt(s.substring(2), 16);
-        } else {
-            ret = Integer.parseInt(s);
-        }
-        return (ret);
-    }
-
-    public static int fromHexString(String string) {
-        return Integer.parseInt(string, 16);
-    }
-
-    public static int from0xHexString(String string) {
-        String hex = string.substring(2);
-        return fromHexString(hex);
-    }
-
-    public static String getHexByte(byte b) {
-        return String.format("0x%02X", b & 0xFF);
-    }
-
-    public static String getHexByte(int b) {
-        return String.format("0x%02X", b);
-    }
-
-    public static class DataTypeParser {
-        public static Object parseDataType(DataType type, String val) {
-            switch (type) {
-                case BYTE:
-                    return parseByte(val);
-                case INT:
-                    return parseInt(val);
-                case FLOAT:
-                    return parseFloat(val);
-                case ADDRESS:
-                    return parseAddress(val);
-                default:
-                    throw new IllegalArgumentException("Data Type not implemented in Field Value Parser!");
-            }
-        }
-
-        public static byte parseByte(@Nullable String val) {
-            if (val != null && !"".equals(val.trim())) {
-                return (byte) Utils.from0xHexString(val.trim());
-            } else {
-                return 0x00;
-            }
-        }
-
-        public static int parseInt(@Nullable String val) {
-            if (val != null && !"".equals(val.trim())) {
-                return Integer.parseInt(val);
-            } else {
-                return 0x00;
-            }
-        }
-
-        public static float parseFloat(@Nullable String val) {
-            if (val != null && !"".equals(val.trim())) {
-                return Float.parseFloat(val.trim());
-            } else {
-                return 0;
-            }
-        }
-
-        public static InsteonAddress parseAddress(@Nullable String val) {
-            if (val != null && !"".equals(val.trim())) {
-                return InsteonAddress.parseAddress(val.trim());
-            } else {
-                return new InsteonAddress();
-            }
-        }
-    }
-
-    /**
-     * Exception to indicate various xml parsing errors.
-     */
-    public static class ParsingException extends Exception {
-        private static final long serialVersionUID = 3997461423241843949L;
-
-        public ParsingException(String msg) {
-            super(msg);
-        }
-
-        public ParsingException(String msg, Throwable cause) {
-            super(msg, cause);
-        }
-    }
-
-    public static String redactPassword(String port) {
-        return !port.startsWith("/hub2/") ? port : port.replaceAll(":\\w+@", ":******@");
-    }
-}
index b3966c2c54496e2ad0fe91fa415108daca2e95de..f693176a3b5b2daa24ebc346b06963534701bb4b 100644 (file)
@@ -4,60 +4,33 @@
        xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
        xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
 
-       <config-description uri="channel-type:insteon:button">
-               <parameter name="related" type="text">
-                       <label>Related Devices</label>
-                       <description>List of related Insteon devices separated by a '+' sign.</description>
-               </parameter>
-       </config-description>
-
-       <config-description uri="channel-type:insteon:contact">
-               <parameter name="related" type="text">
-                       <label>Related Devices</label>
-                       <description>List of related Insteon devices separated by a '+' sign.</description>
-               </parameter>
-       </config-description>
-
        <config-description uri="channel-type:insteon:dimmer">
-               <parameter name="dimmermax" type="integer" min="1" max="99">
-                       <label>Maximum Brightness</label>
-                       <description>Maximum brightness of the dimmer in percentage.</description>
-               </parameter>
-               <parameter name="related" type="text">
-                       <label>Related Devices</label>
-                       <description>List of related Insteon devices separated by a '+' sign.</description>
+               <parameter name="onLevel" type="integer" min="0" max="100">
+                       <label>On Level</label>
+                       <description>Override the dimmer on level local setting.</description>
+               </parameter>
+               <parameter name="rampRate" type="decimal">
+                       <label>Ramp Rate</label>
+                       <description>Override the dimmer ramp rate local setting.</description>
+                       <options>
+                               <option value="0.1">0.1 Seconds (Instant)</option>
+                               <option value="0.3">0.3 Seconds (Fast)</option>
+                               <option value="2">2 Seconds (Medium)</option>
+                               <option value="6.5">6.5 Seconds (Slow)</option>
+                               <option value="19">19 Seconds</option>
+                               <option value="23.5">23.5 Seconds</option>
+                               <option value="28">28 Seconds</option>
+                               <option value="32">32 Seconds</option>
+                               <option value="38.5">38.5 Seconds</option>
+                               <option value="47">47 Seconds</option>
+                               <option value="90">1.5 Minutes</option>
+                               <option value="150">2.5 Minutes</option>
+                               <option value="210">3.5 Minutes</option>
+                               <option value="270">4.5 Minutes</option>
+                               <option value="360">6 Minutes</option>
+                               <option value="480">8 Minutes</option>
+                       </options>
                </parameter>
        </config-description>
 
-       <config-description uri="channel-type:insteon:keypad-button">
-               <parameter name="group" type="text">
-                       <label>Group</label>
-                       <description>Insteon broadcast group configured for this button.</description>
-               </parameter>
-               <parameter name="related" type="text">
-                       <label>Related Devices</label>
-                       <description>List of related Insteon devices separated by a '+' sign.</description>
-               </parameter>
-       </config-description>
-
-       <config-description uri="channel-type:insteon:keypad-button-fastonoff">
-               <parameter name="group" type="text">
-                       <label>Group</label>
-                       <description>Insteon broadcast group configured for this button.</description>
-               </parameter>
-       </config-description>
-
-       <config-description uri="channel-type:insteon:keypad-button-manualchange">
-               <parameter name="group" type="text">
-                       <label>Group</label>
-                       <description>Insteon broadcast group configured for this button.</description>
-               </parameter>
-       </config-description>
-
-       <config-description uri="channel-type:insteon:switch">
-               <parameter name="related" type="text">
-                       <label>Related Devices</label>
-                       <description>List of related Insteon devices separated by a '+' sign.</description>
-               </parameter>
-       </config-description>
 </config-description:config-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/config/legacy-config.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/config/legacy-config.xml
new file mode 100644 (file)
index 0000000..b584930
--- /dev/null
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="channel-type:insteon:legacy-button">
+               <parameter name="related" type="text">
+                       <label>Related Devices</label>
+                       <description>List of related Insteon devices separated by a '+' sign.</description>
+               </parameter>
+       </config-description>
+
+       <config-description uri="channel-type:insteon:legacy-contact">
+               <parameter name="related" type="text">
+                       <label>Related Devices</label>
+                       <description>List of related Insteon devices separated by a '+' sign.</description>
+               </parameter>
+       </config-description>
+
+       <config-description uri="channel-type:insteon:legacy-dimmer">
+               <parameter name="dimmermax" type="integer" min="1" max="99">
+                       <label>Maximum Brightness</label>
+                       <description>Maximum brightness of the dimmer in percentage.</description>
+               </parameter>
+               <parameter name="related" type="text">
+                       <label>Related Devices</label>
+                       <description>List of related Insteon devices separated by a '+' sign.</description>
+               </parameter>
+       </config-description>
+
+       <config-description uri="channel-type:insteon:legacy-keypad-button">
+               <parameter name="group" type="text">
+                       <label>Group</label>
+                       <description>Insteon broadcast group configured for this button.</description>
+               </parameter>
+               <parameter name="related" type="text">
+                       <label>Related Devices</label>
+                       <description>List of related Insteon devices separated by a '+' sign.</description>
+               </parameter>
+       </config-description>
+
+       <config-description uri="channel-type:insteon:legacy-keypad-button-fastonoff">
+               <parameter name="group" type="text">
+                       <label>Group</label>
+                       <description>Insteon broadcast group configured for this button.</description>
+               </parameter>
+       </config-description>
+
+       <config-description uri="channel-type:insteon:legacy-keypad-button-manualchange">
+               <parameter name="group" type="text">
+                       <label>Group</label>
+                       <description>Insteon broadcast group configured for this button.</description>
+               </parameter>
+       </config-description>
+
+       <config-description uri="channel-type:insteon:legacy-switch">
+               <parameter name="related" type="text">
+                       <label>Related Devices</label>
+                       <description>List of related Insteon devices separated by a '+' sign.</description>
+               </parameter>
+       </config-description>
+
+</config-description:config-descriptions>
index 2e7dd0f6de0cb5792486887eb25ff8cf628cd6f5..79f1f188b8ce7abf13b691f2293801b9f174a725 100644 (file)
@@ -6,64 +6,119 @@ addon.insteon.description = This is the binding for Insteon.
 # thing types
 
 thing-type.insteon.device.label = Insteon Device
-thing-type.insteon.device.description = Insteon devices such as switches, dimmers, keypads, sensors, etc.
-thing-type.insteon.network.label = Insteon Network
-thing-type.insteon.network.description = An Insteon PLM or hub that is used to communicate with the Insteon devices.
+thing-type.insteon.device.description = An Insteon device such as a switch, dimmer, keypad, sensor, etc.
+
+thing-type.insteon.hub1.label = Insteon Hub
+thing-type.insteon.hub1.description = An Insteon Hub Legacy that communicates with Insteon devices.
+
+thing-type.insteon.hub2.label = Insteon Hub 2
+thing-type.insteon.hub2.description = An Insteon Hub 2 that communicates with Insteon devices.
+
+thing-type.insteon.legacy-device.label = Insteon Device (Legacy)
+thing-type.insteon.legacy-device.description = An Insteon or X10 device such as a switch, dimmer, keypad, sensor, etc.
+
+thing-type.insteon.network.label = Insteon Network (Legacy)
+thing-type.insteon.network.description = An Insteon PLM or Hub that communicates with Insteon devices.
+
+thing-type.insteon.plm.label = Insteon PLM
+thing-type.insteon.plm.description = An Insteon PLM that communicates with Insteon devices.
+
+thing-type.insteon.scene.label = Insteon Scene
+thing-type.insteon.scene.description = An Insteon scene that controls multiple devices simultaneously.
+
+thing-type.insteon.x10.label = X10 Device
+thing-type.insteon.x10.description = An X10 device such as a switch, dimmer or sensor.
 
 # thing types config
 
-thing-type.config.insteon.device.address.label = Address
+thing-type.config.insteon.device.address.label = Insteon Address
 thing-type.config.insteon.device.address.description = Insteon address of the device. Example: 12.34.56
-thing-type.config.insteon.device.deviceConfig.label = Device Configuration
-thing-type.config.insteon.device.deviceConfig.description = Optional JSON object with device specific configuration.
-thing-type.config.insteon.device.productKey.label = Product Key
-thing-type.config.insteon.device.productKey.description = Insteon binding product key that is used to identify the model of the device.
-thing-type.config.insteon.device.productKey.option.F00.00.01 = 2477D SwitchLinc Dimmer - F00.00.01
-thing-type.config.insteon.device.productKey.option.F00.00.02 = 2477S SwitchLinc Switch - F00.00.02
-thing-type.config.insteon.device.productKey.option.F00.00.03 = 2845-222 Hidden Door Sensor - F00.00.03
-thing-type.config.insteon.device.productKey.option.F00.00.04 = 2876S ICON Switch - F00.00.04
-thing-type.config.insteon.device.productKey.option.F00.00.05 = 2456D3 LampLinc V2 - F00.00.05
-thing-type.config.insteon.device.productKey.option.F00.00.06 = 2442-222 Micro Dimmer - F00.00.06
-thing-type.config.insteon.device.productKey.option.F00.00.07 = 2453-222 DIN Rail On/Off - F00.00.07
-thing-type.config.insteon.device.productKey.option.F00.00.08 = 2452-222 DIN Rail Dimmer - F00.00.08
-thing-type.config.insteon.device.productKey.option.F00.00.09 = 2458-A1 MorningLinc RF Lock Controller - F00.00.09
-thing-type.config.insteon.device.productKey.option.F00.00.0A = 2852-222 Leak Sensor - F00.00.0A
-thing-type.config.insteon.device.productKey.option.F00.00.0B = 2672-422 LED Dimmer - F00.00.0B
-thing-type.config.insteon.device.productKey.option.F00.00.0C = 2476D SwitchLinc Dimmer - F00.00.0C
-thing-type.config.insteon.device.productKey.option.F00.00.0D = 2634-222 On/Off Dual-Band Outdoor Module - F00.00.0D
-thing-type.config.insteon.device.productKey.option.F00.00.10 = 2342-2 Mini Remote - F00.00.10
-thing-type.config.insteon.device.productKey.option.F00.00.11 = 2466D ToggleLinc Dimmer - F00.00.11
-thing-type.config.insteon.device.productKey.option.F00.00.12 = 2466S ToggleLinc Switch - F00.00.12
-thing-type.config.insteon.device.productKey.option.F00.00.13 = 2672-222 LED Bulb - F00.00.13
-thing-type.config.insteon.device.productKey.option.F00.00.14 = 2487S KeypadLinc On/Off 6-Button - F00.00.14
-thing-type.config.insteon.device.productKey.option.F00.00.15 = 2334-232 KeypadLink Dimmer 6-Button - F00.00.15
-thing-type.config.insteon.device.productKey.option.F00.00.16 = 2334-232 KeypadLink Dimmer 8-Button - F00.00.16
-thing-type.config.insteon.device.productKey.option.F00.00.17 = 2423A1 iMeter Solo Power Meter - F00.00.17
-thing-type.config.insteon.device.productKey.option.F00.00.18 = 2423A1 Thermostat 2441TH - F00.00.18
-thing-type.config.insteon.device.productKey.option.F00.00.19 = 2457D2 LampLinc Dimmer - F00.00.19
-thing-type.config.insteon.device.productKey.option.F00.00.1A = 2475SDB In-LineLinc Relay - F00.00.1A
-thing-type.config.insteon.device.productKey.option.F00.00.1B = 2635-222 On/Off Module - F00.00.1B
-thing-type.config.insteon.device.productKey.option.F00.00.1C = 2475F FanLinc Module - F00.00.1C
-thing-type.config.insteon.device.productKey.option.F00.00.1D = 2456S3 ApplianceLinc - F00.00.1D
-thing-type.config.insteon.device.productKey.option.F00.00.1E = 2674-222 LED Bulb (Recessed) - F00.00.1E
-thing-type.config.insteon.device.productKey.option.F00.00.1F = 2477SA1 220V 30-amp Load Controller N/O - F00.00.1F
-thing-type.config.insteon.device.productKey.option.F00.00.20 = 2342-222 Mini Remote (8-Button) - F00.00.20
-thing-type.config.insteon.device.productKey.option.F00.00.21 = 2441V Insteon Thermostat Adaptor for Venstar - F00.00.21
-thing-type.config.insteon.device.productKey.option.F00.00.22 = 2982-222 Insteon Smoke Bridge - F00.00.22
-thing-type.config.insteon.device.productKey.option.F00.00.23 = 2487S KeypadLinc On/Off 8-Button - F00.00.23
-thing-type.config.insteon.device.productKey.option.F00.00.24 = Motion Sensor II - F00.00.24
-thing-type.config.insteon.device.productKey.option.0x00001A = 2450 IO Link - 0x00001A
-thing-type.config.insteon.device.productKey.option.0x000037 = 2486D KeypadLinc Dimmer - 0x000037
-thing-type.config.insteon.device.productKey.option.0x000039 = 2663-222 On/Off Outlet - 0x000039
-thing-type.config.insteon.device.productKey.option.0x000041 = 2484DWH8 KeypadLinc Countdown Timer - 0x000041
-thing-type.config.insteon.device.productKey.option.0x000045 = PLM or hub - 0x000045
-thing-type.config.insteon.device.productKey.option.0x000049 = 2843-222 Wireless Open/Close Sensor - 0x000049
-thing-type.config.insteon.device.productKey.option.0x00004A = 2842-222 Motion Sensor - 0x00004A
-thing-type.config.insteon.device.productKey.option.0x000051 = 2486DWH8 KeypadLinc Dimmer - 0x000051
-thing-type.config.insteon.device.productKey.option.0x000068 = 2472D OutletLinc Dimmer - 0x000068
-thing-type.config.insteon.device.productKey.option.X00.00.01 = X10 switch Generic X10 switch - X00.00.01
-thing-type.config.insteon.device.productKey.option.X00.00.02 = X10 dimmer Generic X10 dimmer - X00.00.02
-thing-type.config.insteon.device.productKey.option.X00.00.03 = X10 motion Generic X10 motion sensor - X00.00.03
+
+thing-type.config.insteon.hub1.deviceDiscoveryEnabled.label = Discover Devices
+thing-type.config.insteon.hub1.deviceDiscoveryEnabled.description = Discover Insteon devices found in the modem database.
+thing-type.config.insteon.hub1.devicePollIntervalInSeconds.label = Device Poll Interval
+thing-type.config.insteon.hub1.devicePollIntervalInSeconds.description = Device poll interval in seconds.
+thing-type.config.insteon.hub1.deviceSyncEnabled.label = Synchronize Related Devices
+thing-type.config.insteon.hub1.deviceSyncEnabled.description = Synchronize related devices based on their all-link database.
+thing-type.config.insteon.hub1.hostname.label = Network Address
+thing-type.config.insteon.hub1.hostname.description = Network address of the hub.
+thing-type.config.insteon.hub1.port.label = Network Port
+thing-type.config.insteon.hub1.port.description = Network port of the hub.
+thing-type.config.insteon.hub1.sceneDiscoveryEnabled.label = Discover Scenes
+thing-type.config.insteon.hub1.sceneDiscoveryEnabled.description = Discover Insteon scenes found in the modem database.
+
+thing-type.config.insteon.hub2.deviceDiscoveryEnabled.label = Discover Devices
+thing-type.config.insteon.hub2.deviceDiscoveryEnabled.description = Discover Insteon devices found in the modem database.
+thing-type.config.insteon.hub2.devicePollIntervalInSeconds.label = Device Poll Interval
+thing-type.config.insteon.hub2.devicePollIntervalInSeconds.description = Device poll interval in seconds.
+thing-type.config.insteon.hub2.deviceSyncEnabled.label = Synchronize Related Devices
+thing-type.config.insteon.hub2.deviceSyncEnabled.description = Synchronize related devices based on their all-link database.
+thing-type.config.insteon.hub2.hostname.label = Network Address
+thing-type.config.insteon.hub2.hostname.description = Network address of the hub.
+thing-type.config.insteon.hub2.hubPollIntervalInMilliseconds.label = Hub Poll Interval
+thing-type.config.insteon.hub2.hubPollIntervalInMilliseconds.description = Hub poll interval in milliseconds.
+thing-type.config.insteon.hub2.password.label = Hub Password
+thing-type.config.insteon.hub2.password.description = Password to access the hub.
+thing-type.config.insteon.hub2.port.label = Network Port
+thing-type.config.insteon.hub2.port.description = Network port of the hub.
+thing-type.config.insteon.hub2.sceneDiscoveryEnabled.label = Discover Scenes
+thing-type.config.insteon.hub2.sceneDiscoveryEnabled.description = Discover Insteon scenes found in the modem database.
+thing-type.config.insteon.hub2.username.label = Hub Username
+thing-type.config.insteon.hub2.username.description = Username to access the hub.
+
+thing-type.config.insteon.legacy-device.address.label = Address
+thing-type.config.insteon.legacy-device.address.description = Device address. Example: 12.34.56 (Insteon) or A.1 (X10)
+thing-type.config.insteon.legacy-device.deviceConfig.label = Device Configuration
+thing-type.config.insteon.legacy-device.deviceConfig.description = Optional JSON object with device specific configuration.
+thing-type.config.insteon.legacy-device.productKey.label = Product Key
+thing-type.config.insteon.legacy-device.productKey.description = Product key used to identify the model of the legacy-device.
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.01 = 2477D SwitchLinc Dimmer - F00.00.01
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.02 = 2477S SwitchLinc Switch - F00.00.02
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.03 = 2845-222 Hidden Door Sensor - F00.00.03
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.04 = 2876S ICON Switch - F00.00.04
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.05 = 2456D3 LampLinc V2 - F00.00.05
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.06 = 2442-222 Micro Dimmer - F00.00.06
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.07 = 2453-222 DIN Rail On/Off - F00.00.07
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.08 = 2452-222 DIN Rail Dimmer - F00.00.08
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.09 = 2458-A1 MorningLinc RF Lock Controller - F00.00.09
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.0A = 2852-222 Leak Sensor - F00.00.0A
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.0B = 2672-422 LED Dimmer - F00.00.0B
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.0C = 2476D SwitchLinc Dimmer - F00.00.0C
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.0D = 2634-222 On/Off Dual-Band Outdoor Module - F00.00.0D
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.10 = 2342-2 Mini Remote - F00.00.10
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.11 = 2466D ToggleLinc Dimmer - F00.00.11
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.12 = 2466S ToggleLinc Switch - F00.00.12
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.13 = 2672-222 LED Bulb - F00.00.13
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.14 = 2487S KeypadLinc On/Off 6-Button - F00.00.14
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.15 = 2334-232 KeypadLink Dimmer 6-Button - F00.00.15
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.16 = 2334-232 KeypadLink Dimmer 8-Button - F00.00.16
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.17 = 2423A1 iMeter Solo Power Meter - F00.00.17
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.18 = 2423A1 Thermostat 2441TH - F00.00.18
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.19 = 2457D2 LampLinc Dimmer - F00.00.19
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.1A = 2475SDB In-LineLinc Relay - F00.00.1A
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.1B = 2635-222 On/Off Module - F00.00.1B
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.1C = 2475F FanLinc Module - F00.00.1C
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.1D = 2456S3 ApplianceLinc - F00.00.1D
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.1E = 2674-222 LED Bulb (Recessed) - F00.00.1E
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.1F = 2477SA1 220V 30-amp Load Controller N/O - F00.00.1F
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.20 = 2342-222 Mini Remote (8-Button) - F00.00.20
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.21 = 2441V Insteon Thermostat Adaptor for Venstar - F00.00.21
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.22 = 2982-222 Insteon Smoke Bridge - F00.00.22
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.23 = 2487S KeypadLinc On/Off 8-Button - F00.00.23
+thing-type.config.insteon.legacy-device.productKey.option.F00.00.24 = Motion Sensor II - F00.00.24
+thing-type.config.insteon.legacy-device.productKey.option.0x00001A = 2450 IO Link - 0x00001A
+thing-type.config.insteon.legacy-device.productKey.option.0x000037 = 2486D KeypadLinc Dimmer - 0x000037
+thing-type.config.insteon.legacy-device.productKey.option.0x000039 = 2663-222 On/Off Outlet - 0x000039
+thing-type.config.insteon.legacy-device.productKey.option.0x000041 = 2484DWH8 KeypadLinc Countdown Timer - 0x000041
+thing-type.config.insteon.legacy-device.productKey.option.0x000045 = PLM or hub - 0x000045
+thing-type.config.insteon.legacy-device.productKey.option.0x000049 = 2843-222 Wireless Open/Close Sensor - 0x000049
+thing-type.config.insteon.legacy-device.productKey.option.0x00004A = 2842-222 Motion Sensor - 0x00004A
+thing-type.config.insteon.legacy-device.productKey.option.0x000051 = 2486DWH8 KeypadLinc Dimmer - 0x000051
+thing-type.config.insteon.legacy-device.productKey.option.0x000068 = 2472D OutletLinc Dimmer - 0x000068
+thing-type.config.insteon.legacy-device.productKey.option.X00.00.01 = X10 switch Generic X10 switch - X00.00.01
+thing-type.config.insteon.legacy-device.productKey.option.X00.00.02 = X10 dimmer Generic X10 dimmer - X00.00.02
+thing-type.config.insteon.legacy-device.productKey.option.X00.00.03 = X10 motion Generic X10 motion sensor - X00.00.03
+
 thing-type.config.insteon.network.additionalDevices.label = Additional Devices
 thing-type.config.insteon.network.additionalDevices.description = File with additional device types.
 thing-type.config.insteon.network.additionalFeatures.label = Additional Features
@@ -73,105 +128,356 @@ thing-type.config.insteon.network.devicePollIntervalSeconds.description = Device
 thing-type.config.insteon.network.port.label = Port
 thing-type.config.insteon.network.port.description = Configuration information that is used to connect to PLM or hub.
 
+thing-type.config.insteon.plm.baudRate.label = Baud rate
+thing-type.config.insteon.plm.baudRate.description = Baud rate of the serial port connected to the modem.
+thing-type.config.insteon.plm.deviceDiscoveryEnabled.label = Discover Devices
+thing-type.config.insteon.plm.deviceDiscoveryEnabled.description = Discover Insteon devices found in the modem database.
+thing-type.config.insteon.plm.devicePollIntervalInSeconds.label = Device Poll Interval
+thing-type.config.insteon.plm.devicePollIntervalInSeconds.description = Device poll interval in seconds.
+thing-type.config.insteon.plm.deviceSyncEnabled.label = Synchronize Related Devices
+thing-type.config.insteon.plm.deviceSyncEnabled.description = Synchronize related devices based on their all-link database.
+thing-type.config.insteon.plm.sceneDiscoveryEnabled.label = Discover Scenes
+thing-type.config.insteon.plm.sceneDiscoveryEnabled.description = Discover Insteon scenes found in the modem database.
+thing-type.config.insteon.plm.serialPort.label = Serial Port
+thing-type.config.insteon.plm.serialPort.description = Serial port connected to the modem. Example: /dev/ttyS0 or COM1
+
+thing-type.config.insteon.scene.group.label = Group
+thing-type.config.insteon.scene.group.description = Insteon scene group number
+
+thing-type.config.insteon.x10.deviceType.label = Device Type
+thing-type.config.insteon.x10.deviceType.description = X10 device type.
+thing-type.config.insteon.x10.deviceType.option.X10_Dimmer = Dimmer
+thing-type.config.insteon.x10.deviceType.option.X10_Sensor = Sensor
+thing-type.config.insteon.x10.deviceType.option.X10_Switch = Switch
+thing-type.config.insteon.x10.houseCode.label = House Code
+thing-type.config.insteon.x10.houseCode.description = X10 house code of the device. Example: A
+thing-type.config.insteon.x10.unitCode.label = Unit Code
+thing-type.config.insteon.x10.unitCode.description = X10 unit code of the device. Example: 1
+
 # channel types
 
-channel-type.insteon.acDelay.label = AC Delay
-channel-type.insteon.backlightDuration.label = Back Light Duration
-channel-type.insteon.batteryLevel.label = Battery Level
-channel-type.insteon.batteryPercent.label = Battery Percent
-channel-type.insteon.batteryWatermarkLevel.label = Battery Watermark Level
+channel-type.insteon.3-way-mode.label = 3-Way Toggle Mode
+channel-type.insteon.ac-delay.label = AC Delay
+channel-type.insteon.alert-delay.label = Alarm Delay
+channel-type.insteon.alert-duration.label = Alarm Duration
+channel-type.insteon.alert-type.label = Alarm Type
+channel-type.insteon.alert-type.state.option.CHIME = Chime
+channel-type.insteon.alert-type.state.option.LOUD_SIREN = Loud Siren
+channel-type.insteon.armed.label = Armed
+channel-type.insteon.backlight-duration.label = LED Backlight Duration
+channel-type.insteon.battery-level.label = Battery Level
+channel-type.insteon.battery-powered.label = Battery Powered
+channel-type.insteon.battery-powered.description = Battery powered or wired
 channel-type.insteon.beep.label = Beep
-channel-type.insteon.bottomOutlet.label = Bottom Outlet
-channel-type.insteon.broadcastOnOff.label = Broadcast On/Off
-channel-type.insteon.buttonA.label = Button A
-channel-type.insteon.buttonB.label = Button B
-channel-type.insteon.buttonC.label = Button C
-channel-type.insteon.buttonD.label = Button D
-channel-type.insteon.buttonE.label = Button E
-channel-type.insteon.buttonF.label = Button F
-channel-type.insteon.buttonG.label = Button G
-channel-type.insteon.buttonH.label = Button H
-channel-type.insteon.contact.label = Contact
-channel-type.insteon.coolSetPoint.label = Cool Set Point
+channel-type.insteon.beep.description = Trigger a beep (write only)
+channel-type.insteon.button-a.label = Button A
+channel-type.insteon.button-a.description = Toggle the button led or the connected scene when bridge device sync enabled.
+channel-type.insteon.button-b.label = Button B
+channel-type.insteon.button-b.description = Toggle the button led or the connected scene when bridge device sync enabled.
+channel-type.insteon.button-c.label = Button C
+channel-type.insteon.button-c.description = Toggle the button led or the connected scene when bridge device sync enabled.
+channel-type.insteon.button-d.label = Button D
+channel-type.insteon.button-d.description = Toggle the button led or the connected scene when bridge device sync enabled.
+channel-type.insteon.button-e.label = Button E
+channel-type.insteon.button-e.description = Toggle the button led or the connected scene when bridge device sync enabled.
+channel-type.insteon.button-f.label = Button F
+channel-type.insteon.button-f.description = Toggle the button led or the connected scene when bridge device sync enabled.
+channel-type.insteon.button-g.label = Button G
+channel-type.insteon.button-g.description = Toggle the button led or the connected scene when bridge device sync enabled.
+channel-type.insteon.button-h.label = Button H
+channel-type.insteon.button-h.description = Toggle the button led or the connected scene when bridge device sync enabled.
+channel-type.insteon.button-beep.label = Button Beep
+channel-type.insteon.button-beep.description = Enable beep on button press.
+channel-type.insteon.button-config.label = Button Config
+channel-type.insteon.button-config.description = Configure the button/scene mode.
+channel-type.insteon.button-config.state.option.BUTTON_6 = 6-Button
+channel-type.insteon.button-config.state.option.BUTTON_8 = 8-Button
+channel-type.insteon.button-lock.label = Button Lock
+channel-type.insteon.button-lock.description = Disable the front button press.
+channel-type.insteon.carbon-monoxide-alarm.label = Carbon Monoxide Alarm
+channel-type.insteon.contact.label = Contact Sensor
+channel-type.insteon.cool-setpoint.label = Cool Setpoint
+channel-type.insteon.cool-setpoint.description = Set the temperature setpoint to activate the cooling.
+channel-type.insteon.daytime.label = Daytime
+channel-type.insteon.daytime.description = Daytime or nightime
+channel-type.insteon.dehumidify-setpoint.label = Dehumidify Setpoint
+channel-type.insteon.dehumidify-setpoint.description = Set the humidity setpoint to activate the dehumidifier.
 channel-type.insteon.dimmer.label = Dimmer
-channel-type.insteon.fan.label = Fan
-channel-type.insteon.fanMode.label = Fan Mode
-channel-type.insteon.fastOnOff.label = Fast On/Off
-channel-type.insteon.fastOnOffButtonA.label = Fast On/Off Button A
-channel-type.insteon.fastOnOffButtonB.label = Fast On/Off Button B
-channel-type.insteon.fastOnOffButtonC.label = Fast On/Off Button C
-channel-type.insteon.fastOnOffButtonD.label = Fast On/Off Button D
-channel-type.insteon.fastOnOffButtonE.label = Fast On/Off Button E
-channel-type.insteon.fastOnOffButtonF.label = Fast On/Off Button F
-channel-type.insteon.fastOnOffButtonG.label = Fast On/Off Button G
-channel-type.insteon.fastOnOffButtonH.label = Fast On/Off Button H
-channel-type.insteon.heatSetPoint.label = Heat Set Point
-channel-type.insteon.humidity.label = Humidity
-channel-type.insteon.humidityHigh.label = Humidity High
-channel-type.insteon.humidityLow.label = Humidity Low
-channel-type.insteon.isCooling.label = Is Cooling
-channel-type.insteon.isHeating.label = Is Heating
-channel-type.insteon.kWh.label = Kilowatt Hour
-channel-type.insteon.keypadButtonA.label = Keypad Button A
-channel-type.insteon.keypadButtonB.label = Keypad Button B
-channel-type.insteon.keypadButtonC.label = Keypad Button C
-channel-type.insteon.keypadButtonD.label = Keypad Button D
-channel-type.insteon.keypadButtonE.label = Keypad Button E
-channel-type.insteon.keypadButtonF.label = Keypad Button F
-channel-type.insteon.keypadButtonG.label = Keypad Button G
-channel-type.insteon.keypadButtonH.label = Keypad Button H
-channel-type.insteon.lastHeardFrom.label = Last Heard From
-channel-type.insteon.ledBrightness.label = LED Brightness
-channel-type.insteon.ledOnOff.label = LED On/Off
-channel-type.insteon.lightDimmer.label = Light Dimmer
-channel-type.insteon.lightLevel.label = Light Level
-channel-type.insteon.lightLevelAboveThreshold.label = Light Level Above/Below Threshold
-channel-type.insteon.loadDimmer.label = Load Dimmer
-channel-type.insteon.loadSwitch.label = Load Switch
-channel-type.insteon.loadSwitchFastOnOff.label = Load Switch Fast On/Off
-channel-type.insteon.loadSwitchManualChange.label = Load Switch Manual Change
-channel-type.insteon.lowBattery.label = Low Battery
-channel-type.insteon.manualChange.label = Manual Change
-channel-type.insteon.manualChangeButtonA.label = Manual Change Button A
-channel-type.insteon.manualChangeButtonB.label = Manual Change Button B
-channel-type.insteon.manualChangeButtonC.label = Manual Change Button C
-channel-type.insteon.manualChangeButtonD.label = Manual Change Button D
-channel-type.insteon.manualChangeButtonE.label = Manual Change Button E
-channel-type.insteon.manualChangeButtonF.label = Manual Change Button F
-channel-type.insteon.manualChangeButtonG.label = Manual Change Button G
-channel-type.insteon.manualChangeButtonH.label = Manual Change Button H
-channel-type.insteon.notification.label = Notification
-channel-type.insteon.onLevel.label = On Level
-channel-type.insteon.rampDimmer.label = Ramp Dimmer
-channel-type.insteon.rampRate.label = Ramp Rate
-channel-type.insteon.reset.label = Reset
-channel-type.insteon.stage1Duration.label = Stage 1 Duration
+channel-type.insteon.energy-offset.label = Energy Temperature Offset
+channel-type.insteon.energy-offset.description = Set the energy setback temperature offset
+channel-type.insteon.energy-reset.label = Energy Usage Reset
+channel-type.insteon.energy-reset.description = Reset the accumulated energy usage (write only)
+channel-type.insteon.energy-saving.label = Energy Saving Mode
+channel-type.insteon.energy-usage.label = Energy Usage
+channel-type.insteon.fan-mode.label = Fan Mode
+channel-type.insteon.fan-mode.state.option.AUTO = Auto
+channel-type.insteon.fan-mode.state.option.ALWAYS_ON = Always On
+channel-type.insteon.fan-speed.label = Fan Speed
+channel-type.insteon.fan-speed.state.option.OFF = Off
+channel-type.insteon.fan-speed.state.option.LOW = Low
+channel-type.insteon.fan-speed.state.option.MEDIUM = Medium
+channel-type.insteon.fan-speed.state.option.HIGH = High
+channel-type.insteon.fan-state.label = Fan State
+channel-type.insteon.fast-on-off.label = Fast On/Off
+channel-type.insteon.fast-on-off.description = Send a fast on/off command to scene (write only)
+channel-type.insteon.heartbeat-interval.label = Heartbeat Interval
+channel-type.insteon.heartbeat-interval.description = Set the heartbeat interval. Higher frequency will impact battery usage.
+channel-type.insteon.heartbeat-on-off.label = Heartbeat Enabled
+channel-type.insteon.heartbeat-on-off.description = Enable heartbeat messages to be broadcast.
+channel-type.insteon.heat-setpoint.label = Heat Setpoint
+channel-type.insteon.heat-setpoint.description = Set the temperature setpoint to activate the heating.
+channel-type.insteon.humidifier-state.label = Humidifier State
+channel-type.insteon.humidifier-state.state.option.OFF = Off
+channel-type.insteon.humidifier-state.state.option.DEHUMIDIFYING = Dehumidifying
+channel-type.insteon.humidifier-state.state.option.HUMIDIFYING = Humidifying
+channel-type.insteon.humidify-setpoint.label = Humidify Setpoint
+channel-type.insteon.humidify-setpoint.description = Set the humidity setpoint to activate the humidifier.
+channel-type.insteon.humidity.label = Ambient Humidity
+channel-type.insteon.last-heard-from.label = Last Heard From
+channel-type.insteon.last-heard-from.description = The last time a message from the device was received.
+channel-type.insteon.leak.label = Leak Sensor
+channel-type.insteon.led-brightness.label = LED Brightness Level
+channel-type.insteon.led-brightness.description = Set the device led(s) brightness level.
+channel-type.insteon.led-on-off.label = LED Enabled
+channel-type.insteon.led-on-off.description = Toggle the device led(s).
+channel-type.insteon.led-traffic.label = LED Traffic Blinking
+channel-type.insteon.led-traffic.description = Set the device led to blink when sending or receiving messages.
+channel-type.insteon.light-level.label = Ambient Light Level
+channel-type.insteon.load.label = Load Sensor
+channel-type.insteon.load-sense.label = Load Sense
+channel-type.insteon.load-sense.description = Toggle appliance load sensing.
+channel-type.insteon.load-sense-bottom.label = Load Sense Bottom Outlet
+channel-type.insteon.load-sense-bottom.description = Toggle bottom outlet load sensing.
+channel-type.insteon.load-sense-top.label = Load Sense Top Outlet
+channel-type.insteon.load-sense-top.description = Toggle top outlet load sensing.
+channel-type.insteon.lock.label = Lock
+channel-type.insteon.low-battery.label = Low Battery Alert
+channel-type.insteon.malfunction.label = Malfunction Alert
+channel-type.insteon.manual-change.label = Manual Change
+channel-type.insteon.manual-change.description = Send a manual change command to scene (write only)
+channel-type.insteon.momentary-duration.label = Momentary Duration
+channel-type.insteon.momentary-duration.description = Set the output relay closure duration for momentary relay modes (A-C).
+channel-type.insteon.monitor-mode.label = Monitor Mode
+channel-type.insteon.monitor-mode.description = Process messages not specifically directed to the modem.
+channel-type.insteon.motion.label = Motion Sensor
+channel-type.insteon.on-level.label = On Level
+channel-type.insteon.operation-mode.label = Switch Operation Mode
+channel-type.insteon.operation-mode.state.option.LATCHING = Latching
+channel-type.insteon.operation-mode.state.option.SINGLE_MOMENTARY = Single Momentary
+channel-type.insteon.operation-mode.state.option.DUAL_MOMENTARY = Dual Momentary
+channel-type.insteon.outlet-bottom.label = Bottom Outlet
+channel-type.insteon.outlet-top.label = Top Outlet
+channel-type.insteon.power-usage.label = Power Usage
+channel-type.insteon.program1.label = Program 1
+channel-type.insteon.program1.description = Toggle the sprinkler program. When needed, activate its previous/next valve.
+channel-type.insteon.program2.label = Program 2
+channel-type.insteon.program2.description = Toggle the sprinkler program. When needed, activate its previous/next valve.
+channel-type.insteon.program3.label = Program 3
+channel-type.insteon.program3.description = Toggle the sprinkler program. When needed, activate its previous/next valve.
+channel-type.insteon.program4.label = Program 4
+channel-type.insteon.program4.description = Toggle the sprinkler program. When needed, activate its previous/next valve.
+channel-type.insteon.program-lock.label = Local Programming Lock
+channel-type.insteon.program-lock.description = Prevent manual device configuration.
+channel-type.insteon.pump.label = Pump Control
+channel-type.insteon.ramp-rate.label = Ramp Rate
+channel-type.insteon.ramp-rate.description = Control how fast the dimmer turns on or off.
+channel-type.insteon.relay-mode.label = Output Relay Mode
+channel-type.insteon.relay-mode.state.option.LATCHING = Latching
+channel-type.insteon.relay-mode.state.option.MOMENTARY_A = Momentary A
+channel-type.insteon.relay-mode.state.option.MOMENTARY_B = Momentary B
+channel-type.insteon.relay-mode.state.option.MOMENTARY_C = Momentary C
+channel-type.insteon.relay-sensor-follow.label = Output Relay Sensor Follow
+channel-type.insteon.relay-sensor-follow.description = Set the output relay to trigger when the sensor input changes state.
+channel-type.insteon.resume-dim.label = Resume Dim Level
+channel-type.insteon.resume-dim.description = Resume previous dim level when turned on locally.
+channel-type.insteon.reverse-direction.label = Reverse Motor Direction
+channel-type.insteon.rollershutter.label = Rollershutter
+channel-type.insteon.scene.label = Scene
+channel-type.insteon.siren.label = Siren
+channel-type.insteon.smoke-alarm.label = Smoke Alarm
+channel-type.insteon.stage1-duration.label = Stage 1 Duration
+channel-type.insteon.stage1-duration.description = Set stage 1 duration before enabling stage 2.
+channel-type.insteon.stay-awake.label = Stay Awake
+channel-type.insteon.stay-awake.description = Stay awake for extended time (write only on some products)
 channel-type.insteon.switch.label = Switch
-channel-type.insteon.systemMode.label = System Mode
-channel-type.insteon.tamperSwitch.label = Tamper Switch
-channel-type.insteon.temperature.label = Temperature
-channel-type.insteon.temperatureLevel.label = Temperature Level
-channel-type.insteon.topOutlet.label = Top Outlet
-channel-type.insteon.update.label = Update
-channel-type.insteon.watts.label = Watts
+channel-type.insteon.sync-time.label = Synchronize Time
+channel-type.insteon.sync-time.description = Synchronize the time with the openhab server (write only)
+channel-type.insteon.system-mode.label = System Mode
+channel-type.insteon.system-mode.state.option.OFF = Off
+channel-type.insteon.system-mode.state.option.HEAT = Heat
+channel-type.insteon.system-mode.state.option.COOL = Cool
+channel-type.insteon.system-mode.state.option.AUTO = Auto
+channel-type.insteon.system-mode.state.option.PROGRAM = Program
+channel-type.insteon.system-state.label = System State
+channel-type.insteon.system-state.state.option.OFF = Off
+channel-type.insteon.system-state.state.option.COOLING = Cooling
+channel-type.insteon.system-state.state.option.HEATING = Heating
+channel-type.insteon.tamper-switch.label = Tamper Switch
+channel-type.insteon.temperature.label = Ambient Temperature
+channel-type.insteon.temperature-scale.label = Temperature Scale
+channel-type.insteon.temperature-scale.state.option.CELSIUS = Celsius
+channel-type.insteon.temperature-scale.state.option.FAHRENHEIT = Fahrenheit
+channel-type.insteon.test-alarm.label = Test Alarm
+channel-type.insteon.time-format.label = Time Format
+channel-type.insteon.time-format.state.option.12H = 12-Hour
+channel-type.insteon.time-format.state.option.24H = 24-Hour
+channel-type.insteon.toggle-mode-button-a.label = Toggle Mode Button A
+channel-type.insteon.toggle-mode-button-a.state.option.ALWAYS_OFF = Always Off
+channel-type.insteon.toggle-mode-button-a.state.option.ALWAYS_ON = Always On
+channel-type.insteon.toggle-mode-button-a.state.option.TOGGLE = Toggle
+channel-type.insteon.toggle-mode-button-b.label = Toggle Mode Button B
+channel-type.insteon.toggle-mode-button-b.state.option.ALWAYS_OFF = Always Off
+channel-type.insteon.toggle-mode-button-b.state.option.ALWAYS_ON = Always On
+channel-type.insteon.toggle-mode-button-b.state.option.TOGGLE = Toggle
+channel-type.insteon.toggle-mode-button-c.label = Toggle Mode Button C
+channel-type.insteon.toggle-mode-button-c.state.option.ALWAYS_OFF = Always Off
+channel-type.insteon.toggle-mode-button-c.state.option.ALWAYS_ON = Always On
+channel-type.insteon.toggle-mode-button-c.state.option.TOGGLE = Toggle
+channel-type.insteon.toggle-mode-button-d.label = Toggle Mode Button D
+channel-type.insteon.toggle-mode-button-d.state.option.ALWAYS_OFF = Always Off
+channel-type.insteon.toggle-mode-button-d.state.option.ALWAYS_ON = Always On
+channel-type.insteon.toggle-mode-button-d.state.option.TOGGLE = Toggle
+channel-type.insteon.toggle-mode-button-e.label = Toggle Mode Button E
+channel-type.insteon.toggle-mode-button-e.state.option.ALWAYS_OFF = Always Off
+channel-type.insteon.toggle-mode-button-e.state.option.ALWAYS_ON = Always On
+channel-type.insteon.toggle-mode-button-e.state.option.TOGGLE = Toggle
+channel-type.insteon.toggle-mode-button-f.label = Toggle Mode Button F
+channel-type.insteon.toggle-mode-button-f.state.option.ALWAYS_OFF = Always Off
+channel-type.insteon.toggle-mode-button-f.state.option.ALWAYS_ON = Always On
+channel-type.insteon.toggle-mode-button-f.state.option.TOGGLE = Toggle
+channel-type.insteon.toggle-mode-button-g.label = Toggle Mode Button G
+channel-type.insteon.toggle-mode-button-g.state.option.ALWAYS_OFF = Always Off
+channel-type.insteon.toggle-mode-button-g.state.option.ALWAYS_ON = Always On
+channel-type.insteon.toggle-mode-button-g.state.option.TOGGLE = Toggle
+channel-type.insteon.toggle-mode-button-h.label = Toggle Mode Button H
+channel-type.insteon.toggle-mode-button-h.state.option.ALWAYS_OFF = Always Off
+channel-type.insteon.toggle-mode-button-h.state.option.ALWAYS_ON = Always On
+channel-type.insteon.toggle-mode-button-h.state.option.TOGGLE = Toggle
+channel-type.insteon.valve1.label = Valve 1
+channel-type.insteon.valve2.label = Valve 2
+channel-type.insteon.valve3.label = Valve 3
+channel-type.insteon.valve4.label = Valve 4
+channel-type.insteon.valve5.label = Valve 5
+channel-type.insteon.valve6.label = Valve 6
+channel-type.insteon.valve7.label = Valve 7
+channel-type.insteon.valve8.label = Valve 8
+
+channel-type.insteon.event-button.label = Event Button
+channel-type.insteon.event-button-a.label = Event Button A
+channel-type.insteon.event-button-b.label = Event Button B
+channel-type.insteon.event-button-c.label = Event Button C
+channel-type.insteon.event-button-d.label = Event Button D
+channel-type.insteon.event-button-e.label = Event Button E
+channel-type.insteon.event-button-f.label = Event Button F
+channel-type.insteon.event-button-g.label = Event Button G
+channel-type.insteon.event-button-h.label = Event Button H
+channel-type.insteon.event-button-main.label = Event Button Main
+channel-type.insteon.event-button-bottom.label = Event Button Bottom
+channel-type.insteon.event-button-top.label = Event Button Top
+channel-type.insteon.im-event-button.label = Event Button
+
+channel-type.insteon.legacyAcDelay.label = AC Delay
+channel-type.insteon.legacyBacklightDuration.label = Back Light Duration
+channel-type.insteon.legacyBatteryLevel.label = Battery Level
+channel-type.insteon.legacyBatteryPercent.label = Battery Percent
+channel-type.insteon.legacyBatteryWatermarkLevel.label = Battery Watermark Level
+channel-type.insteon.legacyBeep.label = Beep
+channel-type.insteon.legacyBottomOutlet.label = Bottom Outlet
+channel-type.insteon.legacyBroadcastOnOff.label = Broadcast On/Off
+channel-type.insteon.legacyButtonA.label = Button A
+channel-type.insteon.legacyButtonB.label = Button B
+channel-type.insteon.legacyButtonC.label = Button C
+channel-type.insteon.legacyButtonD.label = Button D
+channel-type.insteon.legacyButtonE.label = Button E
+channel-type.insteon.legacyButtonF.label = Button F
+channel-type.insteon.legacyButtonG.label = Button G
+channel-type.insteon.legacyButtonH.label = Button H
+channel-type.insteon.legacyContact.label = Contact
+channel-type.insteon.legacyCoolSetPoint.label = Cool Setpoint
+channel-type.insteon.legacyDimmer.label = Dimmer
+channel-type.insteon.legacyFan.label = Fan
+channel-type.insteon.legacyFanMode.label = Fan Mode
+channel-type.insteon.legacyFastOnOff.label = Fast On/Off
+channel-type.insteon.legacyFastOnOffButtonA.label = Fast On/Off Button A
+channel-type.insteon.legacyFastOnOffButtonB.label = Fast On/Off Button B
+channel-type.insteon.legacyFastOnOffButtonC.label = Fast On/Off Button C
+channel-type.insteon.legacyFastOnOffButtonD.label = Fast On/Off Button D
+channel-type.insteon.legacyFastOnOffButtonE.label = Fast On/Off Button E
+channel-type.insteon.legacyFastOnOffButtonF.label = Fast On/Off Button F
+channel-type.insteon.legacyFastOnOffButtonG.label = Fast On/Off Button G
+channel-type.insteon.legacyFastOnOffButtonH.label = Fast On/Off Button H
+channel-type.insteon.legacyHeatSetPoint.label = Heat Setpoint
+channel-type.insteon.legacyHumidity.label = Humidity
+channel-type.insteon.legacyHumidityHigh.label = Humidity High
+channel-type.insteon.legacyHumidityLow.label = Humidity Low
+channel-type.insteon.legacyIsCooling.label = Is Cooling
+channel-type.insteon.legacyIsHeating.label = Is Heating
+channel-type.insteon.legacyKWh.label = Kilowatt Hour
+channel-type.insteon.legacyKeypadButtonA.label = Keypad Button A
+channel-type.insteon.legacyKeypadButtonB.label = Keypad Button B
+channel-type.insteon.legacyKeypadButtonC.label = Keypad Button C
+channel-type.insteon.legacyKeypadButtonD.label = Keypad Button D
+channel-type.insteon.legacyKeypadButtonE.label = Keypad Button E
+channel-type.insteon.legacyKeypadButtonF.label = Keypad Button F
+channel-type.insteon.legacyKeypadButtonG.label = Keypad Button G
+channel-type.insteon.legacyKeypadButtonH.label = Keypad Button H
+channel-type.insteon.legacyLastHeardFrom.label = Last Heard From
+channel-type.insteon.legacyLedBrightness.label = LED Brightness
+channel-type.insteon.legacyLedOnOff.label = LED On/Off
+channel-type.insteon.legacyLightDimmer.label = Light Dimmer
+channel-type.insteon.legacyLightLevel.label = Light Level
+channel-type.insteon.legacyLightLevelAboveThreshold.label = Light Level Above/Below Threshold
+channel-type.insteon.legacyLoadDimmer.label = Load Dimmer
+channel-type.insteon.legacyLoadSwitch.label = Load Switch
+channel-type.insteon.legacyLoadSwitchFastOnOff.label = Load Switch Fast On/Off
+channel-type.insteon.legacyLoadSwitchManualChange.label = Load Switch Manual Change
+channel-type.insteon.legacyLowBattery.label = Low Battery
+channel-type.insteon.legacyManualChange.label = Manual Change
+channel-type.insteon.legacyManualChangeButtonA.label = Manual Change Button A
+channel-type.insteon.legacyManualChangeButtonB.label = Manual Change Button B
+channel-type.insteon.legacyManualChangeButtonC.label = Manual Change Button C
+channel-type.insteon.legacyManualChangeButtonD.label = Manual Change Button D
+channel-type.insteon.legacyManualChangeButtonE.label = Manual Change Button E
+channel-type.insteon.legacyManualChangeButtonF.label = Manual Change Button F
+channel-type.insteon.legacyManualChangeButtonG.label = Manual Change Button G
+channel-type.insteon.legacyManualChangeButtonH.label = Manual Change Button H
+channel-type.insteon.legacyNotification.label = Notification
+channel-type.insteon.legacyOnLevel.label = On Level
+channel-type.insteon.legacyRampDimmer.label = Ramp Dimmer
+channel-type.insteon.legacyRampRate.label = Ramp Rate
+channel-type.insteon.legacyReset.label = Reset
+channel-type.insteon.legacyStage1Duration.label = Stage 1 Duration
+channel-type.insteon.legacySwitch.label = Switch
+channel-type.insteon.legacySystemMode.label = System Mode
+channel-type.insteon.legacyTamperSwitch.label = Tamper Switch
+channel-type.insteon.legacyTemperature.label = Temperature
+channel-type.insteon.legacyTemperatureLevel.label = Temperature Level
+channel-type.insteon.legacyTopOutlet.label = Top Outlet
+channel-type.insteon.legacyUpdate.label = Update
+channel-type.insteon.legacyWatts.label = Watts
 
 # channel types config
 
-channel-type.config.insteon.button.related.label = Related Devices
-channel-type.config.insteon.button.related.description = List of related Insteon devices separated by a '+' sign.
-channel-type.config.insteon.contact.related.label = Related Devices
-channel-type.config.insteon.contact.related.description = List of related Insteon devices separated by a '+' sign.
-channel-type.config.insteon.dimmer.dimmermax.label = Maximum Brightness
-channel-type.config.insteon.dimmer.dimmermax.description = Maximum brightness of the dimmer in percentage.
-channel-type.config.insteon.dimmer.related.label = Related Devices
-channel-type.config.insteon.dimmer.related.description = List of related Insteon devices separated by a '+' sign.
-channel-type.config.insteon.keypad-button-fastonoff.group.label = Group
-channel-type.config.insteon.keypad-button-fastonoff.group.description = Insteon broadcast group configured for this button.
-channel-type.config.insteon.keypad-button-manualchange.group.label = Group
-channel-type.config.insteon.keypad-button-manualchange.group.description = Insteon broadcast group configured for this button.
-channel-type.config.insteon.keypad-button.group.label = Group
-channel-type.config.insteon.keypad-button.group.description = Insteon broadcast group configured for this button.
-channel-type.config.insteon.keypad-button.related.label = Related Devices
-channel-type.config.insteon.keypad-button.related.description = List of related Insteon devices separated by a '+' sign.
-channel-type.config.insteon.switch.related.label = Related Devices
-channel-type.config.insteon.switch.related.description = List of related Insteon devices separated by a '+' sign.
+channel-type.config.insteon.dimmer.onLevel.label = On Level
+channel-type.config.insteon.dimmer.onLevel.description = Override the dimmer on level local setting.
+channel-type.config.insteon.dimmer.rampRate.label = Ramp Rate
+channel-type.config.insteon.dimmer.rampRate.description = Override the dimmer ramp rate local setting.
+
+channel-type.config.insteon.legacy-button.related.label = Related Devices
+channel-type.config.insteon.legacy-button.related.description = List of related Insteon devices separated by a '+' sign.
+channel-type.config.insteon.legacy-contact.related.label = Related Devices
+channel-type.config.insteon.legacy-contact.related.description = List of related Insteon devices separated by a '+' sign.
+channel-type.config.insteon.legacy-dimmer.dimmermax.label = Maximum Brightness
+channel-type.config.insteon.legacy-dimmer.dimmermax.description = Maximum brightness of the dimmer in percentage.
+channel-type.config.insteon.legacy-dimmer.related.label = Related Devices
+channel-type.config.insteon.legacy-dimmer.related.description = List of related Insteon devices separated by a '+' sign.
+channel-type.config.insteon.legacy-keypad-button-fastonoff.group.label = Group
+channel-type.config.insteon.legacy-keypad-button-fastonoff.group.description = Insteon broadcast group configured for this button.
+channel-type.config.insteon.legacy-keypad-button-manualchange.group.label = Group
+channel-type.config.insteon.legacy-keypad-button-manualchange.group.description = Insteon broadcast group configured for this button.
+channel-type.config.insteon.legacy-keypad-button.group.label = Group
+channel-type.config.insteon.legacy-keypad-button.group.description = Insteon broadcast group configured for this button.
+channel-type.config.insteon.legacy-keypad-button.related.label = Related Devices
+channel-type.config.insteon.legacy-keypad-button.related.description = List of related Insteon devices separated by a '+' sign.
+channel-type.config.insteon.legacy-switch.related.label = Related Devices
+channel-type.config.insteon.legacy-switch.related.description = List of related Insteon devices separated by a '+' sign.
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/channels.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/channels.xml
new file mode 100644 (file)
index 0000000..5d70532
--- /dev/null
@@ -0,0 +1,1029 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="insteon"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <!-- state channel types -->
+
+       <channel-type id="3-way-mode" advanced="true">
+               <item-type>Switch</item-type>
+               <label>3-Way Toggle Mode</label>
+       </channel-type>
+
+       <channel-type id="ac-delay" advanced="true">
+               <item-type>Number:Time</item-type>
+               <label>AC Delay</label>
+               <state min="2" max="20" step="1" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="alert-delay" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Alert Delay</label>
+       </channel-type>
+
+       <channel-type id="alert-duration" advanced="true">
+               <item-type>Number:Time</item-type>
+               <label>Alert Duration</label>
+               <state min="0" max="127" step="1" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="alert-type" advanced="true">
+               <item-type>String</item-type>
+               <label>Alert Type</label>
+               <state>
+                       <options>
+                               <option value="CHIME">Chime</option>
+                               <option value="LOUD_SIREN">Loud Siren</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="armed">
+               <item-type>Switch</item-type>
+               <label>Armed</label>
+       </channel-type>
+
+       <channel-type id="backlight-duration" advanced="true">
+               <item-type>Number:Time</item-type>
+               <label>LED Backlight Duration</label>
+               <state min="0" max="255" step="1" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="battery-level">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Battery Level</label>
+               <category>BatteryLevel</category>
+               <state readOnly="true" pattern="%.1f %%"/>
+       </channel-type>
+
+       <channel-type id="battery-powered">
+               <item-type>Switch</item-type>
+               <label>Battery Powered</label>
+               <description>Battery powered or wired</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="beep" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Beep</label>
+               <description>Trigger a beep (write only)</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- write only -->
+       </channel-type>
+
+       <channel-type id="button-a">
+               <item-type>Switch</item-type>
+               <label>Button A</label>
+               <description>Toggle the button led or the connected scene when bridge device sync enabled.</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+       </channel-type>
+
+       <channel-type id="button-b">
+               <item-type>Switch</item-type>
+               <label>Button B</label>
+               <description>Toggle the button led or the connected scene when bridge device sync enabled.</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+       </channel-type>
+
+       <channel-type id="button-c">
+               <item-type>Switch</item-type>
+               <label>Button C</label>
+               <description>Toggle the button led or the connected scene when bridge device sync enabled.</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+       </channel-type>
+
+       <channel-type id="button-d">
+               <item-type>Switch</item-type>
+               <label>Button D</label>
+               <description>Toggle the button led or the connected scene when bridge device sync enabled.</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+       </channel-type>
+
+       <channel-type id="button-e">
+               <item-type>Switch</item-type>
+               <label>Button E</label>
+               <description>Toggle the button led or the connected scene when bridge device sync enabled.</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+       </channel-type>
+
+       <channel-type id="button-f">
+               <item-type>Switch</item-type>
+               <label>Button F</label>
+               <description>Toggle the button led or the connected scene when bridge device sync enabled.</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+       </channel-type>
+
+       <channel-type id="button-g">
+               <item-type>Switch</item-type>
+               <label>Button G</label>
+               <description>Toggle the button led or the connected scene when bridge device sync enabled.</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+       </channel-type>
+
+       <channel-type id="button-h">
+               <item-type>Switch</item-type>
+               <label>Button H</label>
+               <description>Toggle the button led or the connected scene when bridge device sync enabled.</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+       </channel-type>
+
+       <channel-type id="button-beep" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Button Beep</label>
+               <description>Enable beep on button press.</description>
+       </channel-type>
+
+       <channel-type id="button-config" advanced="true">
+               <item-type>String</item-type>
+               <label>Button Config</label>
+               <description>Configure the button/scene mode.</description>
+               <state>
+                       <options>
+                               <option value="BUTTON_6">6-Button</option>
+                               <option value="BUTTON_8">8-Button</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="button-lock" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Button Lock</label>
+               <description>Disable the front button press.</description>
+       </channel-type>
+
+       <channel-type id="carbon-monoxide-alarm">
+               <item-type>Switch</item-type>
+               <label>Carbon Monoxide Alarm</label>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="contact">
+               <item-type>Contact</item-type>
+               <label>Contact Sensor</label>
+       </channel-type>
+
+       <channel-type id="cool-setpoint">
+               <item-type>Number:Temperature</item-type>
+               <label>Cool Setpoint</label>
+               <description>Set the temperature setpoint to activate the cooling.</description>
+               <category>Temperature</category>
+               <state step="0.5" pattern="%.1f %unit%"/>
+       </channel-type>
+
+       <channel-type id="daytime">
+               <item-type>Switch</item-type>
+               <label>Daytime</label>
+               <description>Daytime or nightime</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="dehumidify-setpoint">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Dehumidify Setpoint</label>
+               <description>Set the humidity setpoint to activate the dehumidifier.</description>
+               <category>Humidity</category>
+               <state min="20" max="90" step="1" pattern="%.1f %%"/>
+       </channel-type>
+
+       <channel-type id="dimmer">
+               <item-type>Dimmer</item-type>
+               <label>Dimmer</label>
+               <category>Light</category>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+               <config-description-ref uri="channel-type:insteon:dimmer"/>
+       </channel-type>
+
+       <channel-type id="energy-offset" advanced="true">
+               <item-type>Number:Temperature</item-type>
+               <label>Energy Temperature Offset</label>
+               <description>Set the energy setback temperature offset.</description>
+               <state step="0.5" pattern="%.1f %unit%"/>
+       </channel-type>
+
+       <channel-type id="energy-reset" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Energy Usage Reset</label>
+               <description>Reset the accumulated energy usage (write only)</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- write only -->
+       </channel-type>
+
+       <channel-type id="energy-saving">
+               <item-type>Switch</item-type>
+               <label>Energy Saving Mode</label>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="energy-usage">
+               <item-type>Number:Energy</item-type>
+               <label>Energy Usage</label>
+               <category>Energy</category>
+               <state readOnly="true" pattern="%.1f %unit%"/>
+       </channel-type>
+
+       <channel-type id="fan-mode">
+               <item-type>String</item-type>
+               <label>Fan Mode</label>
+               <state>
+                       <options>
+                               <option value="AUTO">Auto</option>
+                               <option value="ALWAYS_ON">Always On</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="fan-speed">
+               <item-type>String</item-type>
+               <label>Fan Speed</label>
+               <state>
+                       <options>
+                               <option value="OFF">Off</option>
+                               <option value="LOW">Low</option>
+                               <option value="MEDIUM">Medium</option>
+                               <option value="HIGH">High</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="fan-state">
+               <item-type>Switch</item-type>
+               <label>Fan State</label>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="fast-on-off" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off</label>
+               <description>Send a fast on/off command to scene (write only)</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- write only -->
+       </channel-type>
+
+       <channel-type id="heartbeat-interval" advanced="true">
+               <item-type>Number:Time</item-type>
+               <label>Heartbeat Interval</label>
+               <description>Set the heartbeat interval. Higher frequency will impact battery usage.</description>
+               <state>
+                       <options>
+                               <option value="60 min">1 Hour</option>
+                               <option value="180 min">3 Hours</option>
+                               <option value="360 min">6 Hours</option>
+                               <option value="720 min">12 Hours</option>
+                               <option value="1440 min">24 Hours</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="heartbeat-on-off" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Heartbeat Enabled</label>
+               <description>Enable heartbeat messages to be broadcast.</description>
+       </channel-type>
+
+       <channel-type id="heat-setpoint">
+               <item-type>Number:Temperature</item-type>
+               <label>Heat Setpoint</label>
+               <description>Set the temperature setpoint to activate the heating.</description>
+               <category>Temperature</category>
+               <state step="0.5" pattern="%.1f %unit%"/>
+       </channel-type>
+
+       <channel-type id="humidifier-state">
+               <item-type>String</item-type>
+               <label>Humidifier State</label>
+               <state readOnly="true">
+                       <options>
+                               <option value="OFF">Off</option>
+                               <option value="DEHUMIDIFYING">Dehumidifying</option>
+                               <option value="HUMIDIFYING">Humidifying</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="humidify-setpoint">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Humidify Setpoint</label>
+               <description>Set the humidity setpoint to activate the humidifier.</description>
+               <category>Humidity</category>
+               <state min="0" max="79" step="1" pattern="%.1f %%"/>
+       </channel-type>
+
+       <channel-type id="humidity">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Ambient Humidity</label>
+               <category>Humidity</category>
+               <state readOnly="true" pattern="%.1f %%"/>
+       </channel-type>
+
+       <channel-type id="last-heard-from">
+               <item-type>DateTime</item-type>
+               <label>Last Heard From</label>
+               <description>The last time a message from the device was received.</description>
+               <category>Time</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="leak">
+               <item-type>Switch</item-type>
+               <label>Leak Sensor</label>
+               <category>Water</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="led-brightness" advanced="true">
+               <item-type>Dimmer</item-type>
+               <label>LED Brightness Level</label>
+               <description>Set the device led(s) brightness level.</description>
+       </channel-type>
+
+       <channel-type id="led-on-off" advanced="true">
+               <item-type>Switch</item-type>
+               <label>LED Enabled</label>
+               <description>Toggle the device led(s).</description>
+       </channel-type>
+
+       <channel-type id="led-traffic" advanced="true">
+               <item-type>Switch</item-type>
+               <label>LED Traffic Blinking</label>
+               <description>Set the device led to blink when sending or receiving messages.</description>
+       </channel-type>
+
+       <channel-type id="light-level">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Ambient Light Level</label>
+               <state readOnly="true" pattern="%.1f %%"/>
+       </channel-type>
+
+       <channel-type id="load">
+               <item-type>Switch</item-type>
+               <label>Load Sensor</label>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="load-sense" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Load Sense</label>
+               <description>Toggle appliance load sensing.</description>
+       </channel-type>
+
+       <channel-type id="load-sense-bottom" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Load Sense Bottom Outlet</label>
+               <description>Toggle bottom outlet load sensing.</description>
+       </channel-type>
+
+       <channel-type id="load-sense-top" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Load Sense Top Outlet</label>
+               <description>Toggle top outlet load sensing.</description>
+       </channel-type>
+
+       <channel-type id="lock">
+               <item-type>Switch</item-type>
+               <label>Lock</label>
+               <category>Lock</category>
+       </channel-type>
+
+       <channel-type id="low-battery">
+               <item-type>Switch</item-type>
+               <label>Low Battery Alert</label>
+               <category>LowBattery</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="malfunction">
+               <item-type>Switch</item-type>
+               <label>Malfunction Alert</label>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="manual-change" advanced="true">
+               <item-type>Rollershutter</item-type>
+               <label>Manual Change</label>
+               <description>Send a manual change command to scene (write only)</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- write only -->
+       </channel-type>
+
+       <channel-type id="momentary-duration" advanced="true">
+               <item-type>Number:Time</item-type>
+               <label>Momentary Duration</label>
+               <description>Set the output relay closure duration for momentary relay modes (A-C).</description>
+               <state min="0.2" max="25" step="0.1" pattern="%.1f %unit%"/>
+       </channel-type>
+
+       <channel-type id="monitor-mode" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Monitor Mode</label>
+               <description>Process messages not specifically directed to the modem.</description>
+       </channel-type>
+
+       <channel-type id="motion">
+               <item-type>Switch</item-type>
+               <label>Motion Sensor</label>
+               <category>Motion</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="on-level" advanced="true">
+               <item-type>Dimmer</item-type>
+               <label>On Level</label>
+       </channel-type>
+
+       <channel-type id="operation-mode" advanced="true">
+               <item-type>String</item-type>
+               <label>Switch Operation Mode</label>
+               <state>
+                       <options>
+                               <option value="LATCHING">Latching</option>
+                               <option value="SINGLE_MOMENTARY">Single Momentary</option>
+                               <option value="DUAL_MOMENTARY">Dual Momentary</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="outlet-bottom">
+               <item-type>Switch</item-type>
+               <label>Bottom Outlet</label>
+               <category>PowerOutlet</category>
+       </channel-type>
+
+       <channel-type id="outlet-top">
+               <item-type>Switch</item-type>
+               <label>Top Outlet</label>
+               <category>PowerOutlet</category>
+       </channel-type>
+
+       <channel-type id="power-usage">
+               <item-type>Number:Power</item-type>
+               <label>Power Usage</label>
+               <category>Energy</category>
+               <state readOnly="true" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="program1">
+               <item-type>Player</item-type>
+               <label>Program 1</label>
+               <description>Toggle the sprinkler program. When needed, activate the previous/next valve.</description>
+       </channel-type>
+
+       <channel-type id="program2">
+               <item-type>Player</item-type>
+               <label>Program 2</label>
+               <description>Toggle the sprinkler program. When needed, activate its previous/next valve.</description>
+       </channel-type>
+
+       <channel-type id="program3">
+               <item-type>Player</item-type>
+               <label>Program 3</label>
+               <description>Toggle the sprinkler program. When needed, activate its previous/next valve.</description>
+       </channel-type>
+
+       <channel-type id="program4">
+               <item-type>Player</item-type>
+               <label>Program 4</label>
+               <description>Toggle the sprinkler program. When needed, activate its previous/next valve.</description>
+       </channel-type>
+
+       <channel-type id="program-lock" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Local Programming Lock</label>
+               <description>Prevent manual device configuration.</description>
+       </channel-type>
+
+       <channel-type id="pump">
+               <item-type>Switch</item-type>
+               <label>Pump Control</label>
+       </channel-type>
+
+       <channel-type id="ramp-rate" advanced="true">
+               <item-type>Number:Time</item-type>
+               <label>Ramp Rate</label>
+               <description>Control how fast the dimmer turns on or off.</description>
+               <state>
+                       <options>
+                               <option value="0.1 s">0.1 Seconds (Instant)</option>
+                               <option value="0.2 s">0.2 Seconds</option>
+                               <option value="0.3 s">0.3 Seconds</option>
+                               <option value="0.5 s">0.5 Seconds (Fast)</option>
+                               <option value="2 s">2 Seconds (Medium)</option>
+                               <option value="4.5 s">4.5 Seconds</option>
+                               <option value="6.5 s">6.5 Seconds (Slow)</option>
+                               <option value="8.5 s">8.5 Seconds</option>
+                               <option value="19 s">19 Seconds</option>
+                               <option value="21.5 s">21.5 Seconds</option>
+                               <option value="23.5 s">23.5 Seconds</option>
+                               <option value="26 s">26 Seconds</option>
+                               <option value="28 s">28 Seconds</option>
+                               <option value="30 s">30 Seconds</option>
+                               <option value="32 s">32 Seconds</option>
+                               <option value="34 s">34 Seconds</option>
+                               <option value="38.5 s">38.5 Seconds</option>
+                               <option value="43 s">43 Seconds</option>
+                               <option value="47 s">47 Seconds</option>
+                               <option value="60 s">1 Minute</option>
+                               <option value="90 s">1.5 Minutes</option>
+                               <option value="120 s">2 Minutes</option>
+                               <option value="150 s">2.5 Minutes</option>
+                               <option value="180 s">3 Minutes</option>
+                               <option value="210 s">3.5 Minutes</option>
+                               <option value="240 s">4 Minutes</option>
+                               <option value="270 s">4.5 minutes</option>
+                               <option value="300 s">5 Minutes</option>
+                               <option value="360 s">6 Minutes</option>
+                               <option value="420 s">7 Minutes</option>
+                               <option value="480 s">8 Minutes</option>
+                               <option value="540 s">9 Minutes</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="relay-mode" advanced="true">
+               <item-type>String</item-type>
+               <label>Output Relay Mode</label>
+               <state>
+                       <options>
+                               <option value="LATCHING">Latching</option>
+                               <option value="MOMENTARY_A">Momentary A</option>
+                               <option value="MOMENTARY_B">Momentary B</option>
+                               <option value="MOMENTARY_C">Momentary C</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="relay-sensor-follow" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Output Relay Sensor Follow</label>
+               <description>Set the output relay to trigger when the sensor input changes state.</description>
+       </channel-type>
+
+       <channel-type id="resume-dim" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Resume Dim Level</label>
+               <description>Resume previous dim level when turned on locally.</description>
+       </channel-type>
+
+       <channel-type id="reverse-direction" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Reverse Motor Direction</label>
+       </channel-type>
+
+       <channel-type id="rollershutter">
+               <item-type>Rollershutter</item-type>
+               <label>Rollershutter</label>
+               <category>Rollershutter</category>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- binding controls state updates -->
+       </channel-type>
+
+       <channel-type id="scene">
+               <item-type>Switch</item-type>
+               <label>Scene</label>
+               <category>Switch</category>
+       </channel-type>
+
+       <channel-type id="siren">
+               <item-type>Switch</item-type>
+               <label>Siren</label>
+               <category>Siren</category>
+       </channel-type>
+
+       <channel-type id="smoke-alarm">
+               <item-type>Switch</item-type>
+               <label>Smoke Alarm</label>
+               <category>Smoke</category>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="stage1-duration" advanced="true">
+               <item-type>Number:Time</item-type>
+               <label>Stage 1 Duration</label>
+               <description>Set stage 1 duration before enabling stage 2.</description>
+               <state min="1" max="60" step="1" pattern="%d %unit%"/>
+       </channel-type>
+
+       <channel-type id="stay-awake" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Stay Awake</label>
+               <description>Stay awake for extended time (write only on some products)</description>
+       </channel-type>
+
+       <channel-type id="switch">
+               <item-type>Switch</item-type>
+               <label>Switch</label>
+               <category>Switch</category>
+       </channel-type>
+
+       <channel-type id="sync-time" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Synchronize Time</label>
+               <description>Synchronize the time with the openhab server (write only)</description>
+               <autoUpdatePolicy>veto</autoUpdatePolicy> <!-- write only -->
+       </channel-type>
+
+       <channel-type id="system-mode">
+               <item-type>String</item-type>
+               <label>System Mode</label>
+               <state>
+                       <options>
+                               <option value="OFF">Off</option>
+                               <option value="HEAT">Heat</option>
+                               <option value="COOL">Cool</option>
+                               <option value="AUTO">Auto</option>
+                               <option value="PROGRAM">Program</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="system-state">
+               <item-type>String</item-type>
+               <label>System State</label>
+               <state readOnly="true">
+                       <options>
+                               <option value="OFF">Off</option>
+                               <option value="COOLING">Cooling</option>
+                               <option value="HEATING">Heating</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="tamper-switch">
+               <item-type>Contact</item-type>
+               <label>Tamper Switch</label>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="temperature">
+               <item-type>Number:Temperature</item-type>
+               <label>Ambient Temperature</label>
+               <category>Temperature</category>
+               <state readOnly="true" pattern="%.1f %unit%"/>
+       </channel-type>
+
+       <channel-type id="temperature-scale" advanced="true">
+               <item-type>String</item-type>
+               <label>Temperature Scale</label>
+               <state>
+                       <options>
+                               <option value="CELSIUS">Celsius</option>
+                               <option value="FAHRENHEIT">Fahrenheit</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="test-alarm" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Test Alarm</label>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="time-format" advanced="true">
+               <item-type>String</item-type>
+               <label>Time Format</label>
+               <state>
+                       <options>
+                               <option value="12H">12-Hour</option>
+                               <option value="24H">24-Hour</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="toggle-mode-button-a" advanced="true">
+               <item-type>String</item-type>
+               <label>Toggle Mode Button A</label>
+               <state>
+                       <options>
+                               <option value="TOGGLE">Toggle</option>
+                               <option value="ALWAYS_ON">Always On</option>
+                               <option value="ALWAYS_OFF">Always Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="toggle-mode-button-b" advanced="true">
+               <item-type>String</item-type>
+               <label>Toggle Mode Button B</label>
+               <state>
+                       <options>
+                               <option value="TOGGLE">Toggle</option>
+                               <option value="ALWAYS_ON">Always On</option>
+                               <option value="ALWAYS_OFF">Always Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="toggle-mode-button-c" advanced="true">
+               <item-type>String</item-type>
+               <label>Toggle Mode Button C</label>
+               <state>
+                       <options>
+                               <option value="TOGGLE">Toggle</option>
+                               <option value="ALWAYS_ON">Always On</option>
+                               <option value="ALWAYS_OFF">Always Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="toggle-mode-button-d" advanced="true">
+               <item-type>String</item-type>
+               <label>Toggle Mode Button D</label>
+               <state>
+                       <options>
+                               <option value="TOGGLE">Toggle</option>
+                               <option value="ALWAYS_ON">Always On</option>
+                               <option value="ALWAYS_OFF">Always Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="toggle-mode-button-e" advanced="true">
+               <item-type>String</item-type>
+               <label>Toggle Mode Button E</label>
+               <state>
+                       <options>
+                               <option value="TOGGLE">Toggle</option>
+                               <option value="ALWAYS_ON">Always On</option>
+                               <option value="ALWAYS_OFF">Always Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="toggle-mode-button-f" advanced="true">
+               <item-type>String</item-type>
+               <label>Toggle Mode Button F</label>
+               <state>
+                       <options>
+                               <option value="TOGGLE">Toggle</option>
+                               <option value="ALWAYS_ON">Always On</option>
+                               <option value="ALWAYS_OFF">Always Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="toggle-mode-button-g" advanced="true">
+               <item-type>String</item-type>
+               <label>Toggle Mode Button G</label>
+               <state>
+                       <options>
+                               <option value="TOGGLE">Toggle</option>
+                               <option value="ALWAYS_ON">Always On</option>
+                               <option value="ALWAYS_OFF">Always Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="toggle-mode-button-h" advanced="true">
+               <item-type>String</item-type>
+               <label>Toggle Mode Button H</label>
+               <state>
+                       <options>
+                               <option value="TOGGLE">Toggle</option>
+                               <option value="ALWAYS_ON">Always On</option>
+                               <option value="ALWAYS_OFF">Always Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="valve1">
+               <item-type>Switch</item-type>
+               <label>Valve 1</label>
+       </channel-type>
+
+       <channel-type id="valve2">
+               <item-type>Switch</item-type>
+               <label>Valve 2</label>
+       </channel-type>
+
+       <channel-type id="valve3">
+               <item-type>Switch</item-type>
+               <label>Valve 3</label>
+       </channel-type>
+
+       <channel-type id="valve4">
+               <item-type>Switch</item-type>
+               <label>Valve 4</label>
+       </channel-type>
+
+       <channel-type id="valve5">
+               <item-type>Switch</item-type>
+               <label>Valve 5</label>
+       </channel-type>
+
+       <channel-type id="valve6">
+               <item-type>Switch</item-type>
+               <label>Valve 6</label>
+       </channel-type>
+
+       <channel-type id="valve7">
+               <item-type>Switch</item-type>
+               <label>Valve 7</label>
+       </channel-type>
+
+       <channel-type id="valve8">
+               <item-type>Switch</item-type>
+               <label>Valve 8</label>
+       </channel-type>
+
+       <!-- trigger channel types -->
+
+       <channel-type id="event-button" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-a" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button A</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-b" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button B</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-c" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button C</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-d" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button D</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-e" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button E</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-f" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button F</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-g" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button G</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-h" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button H</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-main" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button Main</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-bottom" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button Bottom</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="event-button-top" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button Top</label>
+               <event>
+                       <options>
+                               <option value="PRESSED_ON"/>
+                               <option value="PRESSED_OFF"/>
+                               <option value="DOUBLE_PRESSED_ON"/>
+                               <option value="DOUBLE_PRESSED_OFF"/>
+                               <option value="HELD_UP"/>
+                               <option value="HELD_DOWN"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+       <channel-type id="im-event-button" advanced="true">
+               <kind>trigger</kind>
+               <label>Event Button</label>
+               <event>
+                       <options>
+                               <option value="PRESSED"/>
+                               <option value="HELD"/>
+                               <option value="RELEASED"/>
+                       </options>
+               </event>
+       </channel-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/device.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/device.xml
new file mode 100644 (file)
index 0000000..46b9d74
--- /dev/null
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="insteon"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="device">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="hub1"/>
+                       <bridge-type-ref id="hub2"/>
+                       <bridge-type-ref id="plm"/>
+               </supported-bridge-type-refs>
+
+               <label>Insteon Device</label>
+               <description>An Insteon device such as a switch, dimmer, keypad, sensor, etc.</description>
+
+               <representation-property>address</representation-property>
+
+               <config-description>
+                       <parameter name="address" type="text" required="true" pattern="[0-9A-F]{2}\.[0-9A-F]{2}\.[0-9A-F]{2}">
+                               <label>Insteon Address</label>
+                               <description>Insteon address of the device. Example: 12.34.56</description>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/hub1.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/hub1.xml
new file mode 100644 (file)
index 0000000..61e5fcb
--- /dev/null
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="insteon"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="hub1">
+               <label>Insteon Hub</label>
+               <description>An Insteon Hub Legacy that communicates with Insteon devices.</description>
+
+               <config-description>
+                       <parameter name="hostname" type="text" required="true">
+                               <context>network-address</context>
+                               <label>Network Address</label>
+                               <description>Network address of the hub.</description>
+                       </parameter>
+
+                       <parameter name="port" type="integer" min="0" max="65535">
+                               <label>Network Port</label>
+                               <description>Network port of the hub.</description>
+                               <default>9761</default>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="devicePollIntervalInSeconds" type="integer" min="5" max="3600">
+                               <label>Device Poll Interval</label>
+                               <description>Device poll interval in seconds.</description>
+                               <default>300</default>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="deviceDiscoveryEnabled" type="boolean">
+                               <label>Discover Devices</label>
+                               <description>Discover Insteon devices found in the modem database.</description>
+                               <default>true</default>
+                       </parameter>
+
+                       <parameter name="sceneDiscoveryEnabled" type="boolean">
+                               <label>Discover Scenes</label>
+                               <description>Discover Insteon scenes found in the modem database.</description>
+                               <default>false</default>
+                       </parameter>
+
+                       <parameter name="deviceSyncEnabled" type="boolean">
+                               <label>Synchronize Related Devices</label>
+                               <description>Synchronize related devices based on their all-link database.</description>
+                               <default>false</default>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/hub2.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/hub2.xml
new file mode 100644 (file)
index 0000000..d0f91e0
--- /dev/null
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="insteon"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="hub2">
+               <label>Insteon Hub 2</label>
+               <description>An Insteon Hub 2 that communicates with Insteon devices.</description>
+
+               <config-description>
+                       <parameter name="hostname" type="text" required="true">
+                               <context>network-address</context>
+                               <label>Network Address</label>
+                               <description>Network address of the hub.</description>
+                       </parameter>
+
+                       <parameter name="port" type="integer" min="0" max="65535">
+                               <label>Network Port</label>
+                               <description>Network port of the hub.</description>
+                               <default>25105</default>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="username" type="text" required="true">
+                               <label>Hub Username</label>
+                               <description>Username to access the hub.</description>
+                       </parameter>
+
+                       <parameter name="password" type="text" required="true">
+                               <context>password</context>
+                               <label>Hub Password</label>
+                               <description>Password to access the hub.</description>
+                       </parameter>
+
+                       <parameter name="hubPollIntervalInMilliseconds" type="integer" min="500" max="5000">
+                               <label>Hub Poll Interval</label>
+                               <description>Hub poll interval in milliseconds.</description>
+                               <default>1000</default>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="devicePollIntervalInSeconds" type="integer" min="5" max="3600">
+                               <label>Device Poll Interval</label>
+                               <description>Device poll interval in seconds.</description>
+                               <default>300</default>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="deviceDiscoveryEnabled" type="boolean">
+                               <label>Discover Devices</label>
+                               <description>Discover Insteon devices found in the modem database.</description>
+                               <default>true</default>
+                       </parameter>
+
+                       <parameter name="sceneDiscoveryEnabled" type="boolean">
+                               <label>Discover Scenes</label>
+                               <description>Discover Insteon scenes found in the modem database.</description>
+                               <default>false</default>
+                       </parameter>
+
+                       <parameter name="deviceSyncEnabled" type="boolean">
+                               <label>Synchronize Related Devices</label>
+                               <description>Synchronize related devices based on their all-link database.</description>
+                               <default>false</default>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/legacy-thing-types.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/legacy-thing-types.xml
new file mode 100644 (file)
index 0000000..a122a1e
--- /dev/null
@@ -0,0 +1,553 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="insteon"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="network">
+               <label>Insteon Network (Legacy)</label>
+               <description>An Insteon PLM or Hub that communicates with Insteon devices.</description>
+
+               <config-description>
+                       <parameter name="port" type="text" required="true">
+                               <label>Port</label>
+                               <description>Configuration information that is used to connect to PLM or hub.</description>
+                       </parameter>
+
+                       <parameter name="devicePollIntervalSeconds" type="integer" min="5" max="3600">
+                               <label>Device Poll Interval</label>
+                               <description>Device poll interval in seconds.</description>
+                               <default>300</default>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="additionalDevices" type="text">
+                               <label>Additional Devices</label>
+                               <description>File with additional device types.</description>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="additionalFeatures" type="text">
+                               <label>Additional Features</label>
+                               <description>File with additional feature templates.</description>
+                               <advanced>true</advanced>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+
+       <thing-type id="legacy-device">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="network"/>
+               </supported-bridge-type-refs>
+
+               <label>Insteon Device (Legacy)</label>
+               <description>An Insteon or X10 device such as a switch, dimmer, keypad, sensor, etc.</description>
+
+               <representation-property>address</representation-property>
+
+               <config-description>
+                       <parameter name="address" type="text" required="true">
+                               <label>Device Address</label>
+                               <description>Device address. Example: 12.34.56 (Insteon) or A.1 (X10)</description>
+                       </parameter>
+
+                       <parameter name="productKey" type="text" required="true">
+                               <label>Product Key</label>
+                               <description>Product key used to identify the model of the device.</description>
+                               <options>
+                                       <option value="F00.00.01">2477D SwitchLinc Dimmer - F00.00.01</option>
+                                       <option value="F00.00.02">2477S SwitchLinc Switch - F00.00.02</option>
+                                       <option value="F00.00.03">2845-222 Hidden Door Sensor - F00.00.03</option>
+                                       <option value="F00.00.04">2876S ICON Switch - F00.00.04</option>
+                                       <option value="F00.00.05">2456D3 LampLinc V2 - F00.00.05</option>
+                                       <option value="F00.00.06">2442-222 Micro Dimmer - F00.00.06</option>
+                                       <option value="F00.00.07">2453-222 DIN Rail On/Off - F00.00.07</option>
+                                       <option value="F00.00.08">2452-222 DIN Rail Dimmer - F00.00.08</option>
+                                       <option value="F00.00.09">2458-A1 MorningLinc RF Lock Controller - F00.00.09</option>
+                                       <option value="F00.00.0A">2852-222 Leak Sensor - F00.00.0A</option>
+                                       <option value="F00.00.0B">2672-422 LED Dimmer - F00.00.0B</option>
+                                       <option value="F00.00.0C">2476D SwitchLinc Dimmer - F00.00.0C</option>
+                                       <option value="F00.00.0D">2634-222 On/Off Dual-Band Outdoor Module - F00.00.0D</option>
+                                       <option value="F00.00.10">2342-2 Mini Remote - F00.00.10</option>
+                                       <option value="F00.00.11">2466D ToggleLinc Dimmer - F00.00.11</option>
+                                       <option value="F00.00.12">2466S ToggleLinc Switch - F00.00.12</option>
+                                       <option value="F00.00.13">2672-222 LED Bulb - F00.00.13</option>
+                                       <option value="F00.00.14">2487S KeypadLinc On/Off 6-Button - F00.00.14</option>
+                                       <option value="F00.00.15">2334-232 KeypadLink Dimmer 6-Button - F00.00.15</option>
+                                       <option value="F00.00.16">2334-232 KeypadLink Dimmer 8-Button - F00.00.16</option>
+                                       <option value="F00.00.17">2423A1 iMeter Solo Power Meter - F00.00.17</option>
+                                       <option value="F00.00.18">2423A1 Thermostat 2441TH - F00.00.18</option>
+                                       <option value="F00.00.19">2457D2 LampLinc Dimmer - F00.00.19</option>
+                                       <option value="F00.00.1A">2475SDB In-LineLinc Relay - F00.00.1A</option>
+                                       <option value="F00.00.1B">2635-222 On/Off Module - F00.00.1B</option>
+                                       <option value="F00.00.1C">2475F FanLinc Module - F00.00.1C</option>
+                                       <option value="F00.00.1D">2456S3 ApplianceLinc - F00.00.1D</option>
+                                       <option value="F00.00.1E">2674-222 LED Bulb (Recessed) - F00.00.1E</option>
+                                       <option value="F00.00.1F">2477SA1 220V 30-amp Load Controller N/O - F00.00.1F</option>
+                                       <option value="F00.00.20">2342-222 Mini Remote (8-Button) - F00.00.20</option>
+                                       <option value="F00.00.21">2441V Insteon Thermostat Adaptor for Venstar - F00.00.21</option>
+                                       <option value="F00.00.22">2982-222 Insteon Smoke Bridge - F00.00.22</option>
+                                       <option value="F00.00.23">2487S KeypadLinc On/Off 8-Button - F00.00.23</option>
+                                       <option value="F00.00.24">Motion Sensor II - F00.00.24</option>
+                                       <option value="0x00001A">2450 IO Link - 0x00001A</option>
+                                       <option value="0x000037">2486D KeypadLinc Dimmer - 0x000037</option>
+                                       <option value="0x000039">2663-222 On/Off Outlet - 0x000039</option>
+                                       <option value="0x000041">2484DWH8 KeypadLinc Countdown Timer - 0x000041</option>
+                                       <option value="0x000045">PLM or Hub - 0x000045</option>
+                                       <option value="0x000049">2843-222 Wireless Open/Close Sensor - 0x000049</option>
+                                       <option value="0x00004A">2842-222 Motion Sensor - 0x00004A</option>
+                                       <option value="0x000051">2486DWH8 KeypadLinc Dimmer - 0x000051</option>
+                                       <option value="0x000068">2472D OutletLinc Dimmer - 0x000068</option>
+                                       <option value="X00.00.01">X10 switch Generic X10 switch - X00.00.01</option>
+                                       <option value="X00.00.02">X10 dimmer Generic X10 dimmer - X00.00.02</option>
+                                       <option value="X00.00.03">X10 motion Generic X10 motion sensor - X00.00.03</option>
+                               </options>
+                               <limitToOptions>false</limitToOptions>
+                       </parameter>
+
+                       <parameter name="deviceConfig" type="text">
+                               <label>Device Configuration</label>
+                               <description>Optional JSON object with device specific configuration.</description>
+                               <advanced>true</advanced>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <channel-type id="legacyAcDelay">
+               <item-type>Number</item-type>
+               <label>AC Delay</label>
+       </channel-type>
+
+       <channel-type id="legacyBacklightDuration">
+               <item-type>Number</item-type>
+               <label>Back Light Duration</label>
+       </channel-type>
+
+       <channel-type id="legacyBatteryLevel">
+               <item-type>Number</item-type>
+               <label>Battery Level</label>
+       </channel-type>
+
+       <channel-type id="legacyBatteryPercent">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Battery Percent</label>
+       </channel-type>
+
+       <channel-type id="legacyBatteryWatermarkLevel">
+               <item-type>Number</item-type>
+               <label>Battery Watermark Level</label>
+       </channel-type>
+
+       <channel-type id="legacyBeep">
+               <item-type>Switch</item-type>
+               <label>Beep</label>
+       </channel-type>
+
+       <channel-type id="legacyBottomOutlet">
+               <item-type>Switch</item-type>
+               <label>Bottom Outlet</label>
+               <config-description-ref uri="channel-type:insteon:legacy-switch"/>
+       </channel-type>
+
+       <channel-type id="legacyButtonA">
+               <item-type>Switch</item-type>
+               <label>Button A</label>
+               <config-description-ref uri="channel-type:insteon:legacy-button"/>
+       </channel-type>
+
+       <channel-type id="legacyButtonB">
+               <item-type>Switch</item-type>
+               <label>Button B</label>
+               <config-description-ref uri="channel-type:insteon:legacy-button"/>
+       </channel-type>
+
+       <channel-type id="legacyButtonC">
+               <item-type>Switch</item-type>
+               <label>Button C</label>
+               <config-description-ref uri="channel-type:insteon:legacy-button"/>
+       </channel-type>
+
+       <channel-type id="legacyButtonD">
+               <item-type>Switch</item-type>
+               <label>Button D</label>
+               <config-description-ref uri="channel-type:insteon:legacy-button"/>
+       </channel-type>
+
+       <channel-type id="legacyButtonE">
+               <item-type>Switch</item-type>
+               <label>Button E</label>
+               <config-description-ref uri="channel-type:insteon:legacy-button"/>
+       </channel-type>
+
+       <channel-type id="legacyButtonF">
+               <item-type>Switch</item-type>
+               <label>Button F</label>
+               <config-description-ref uri="channel-type:insteon:legacy-button"/>
+       </channel-type>
+
+       <channel-type id="legacyButtonG">
+               <item-type>Switch</item-type>
+               <label>Button G</label>
+               <config-description-ref uri="channel-type:insteon:legacy-button"/>
+       </channel-type>
+
+       <channel-type id="legacyButtonH">
+               <item-type>Switch</item-type>
+               <label>Button H</label>
+               <config-description-ref uri="channel-type:insteon:legacy-button"/>
+       </channel-type>
+
+       <channel-type id="legacyBroadcastOnOff">
+               <item-type>Switch</item-type>
+               <label>Broadcast On/Off</label>
+               <config-description-ref uri="channel-type:insteon:legacy-switch"/>
+       </channel-type>
+
+       <channel-type id="legacyContact">
+               <item-type>Contact</item-type>
+               <label>Contact</label>
+               <config-description-ref uri="channel-type:insteon:legacy-contact"/>
+       </channel-type>
+
+       <channel-type id="legacyCoolSetPoint">
+               <item-type>Number</item-type>
+               <label>Cool Setpoint</label>
+       </channel-type>
+
+       <channel-type id="legacyDimmer">
+               <item-type>Dimmer</item-type>
+               <label>Dimmer</label>
+               <config-description-ref uri="channel-type:insteon:legacy-dimmer"/>
+       </channel-type>
+
+       <channel-type id="legacyFan">
+               <item-type>Number</item-type>
+               <label>Fan</label>
+       </channel-type>
+
+       <channel-type id="legacyFanMode">
+               <item-type>Number</item-type>
+               <label>Fan Mode</label>
+       </channel-type>
+
+       <channel-type id="legacyFastOnOff">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off</label>
+       </channel-type>
+
+       <channel-type id="legacyFastOnOffButtonA">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off Button A</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-fastonoff"/>
+       </channel-type>
+
+       <channel-type id="legacyFastOnOffButtonB">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off Button B</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-fastonoff"/>
+       </channel-type>
+
+       <channel-type id="legacyFastOnOffButtonC">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off Button C</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-fastonoff"/>
+       </channel-type>
+
+       <channel-type id="legacyFastOnOffButtonD">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off Button D</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-fastonoff"/>
+       </channel-type>
+
+       <channel-type id="legacyFastOnOffButtonE">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off Button E</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-fastonoff"/>
+       </channel-type>
+
+       <channel-type id="legacyFastOnOffButtonF">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off Button F</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-fastonoff"/>
+       </channel-type>
+
+       <channel-type id="legacyFastOnOffButtonG">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off Button G</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-fastonoff"/>
+       </channel-type>
+
+       <channel-type id="legacyFastOnOffButtonH">
+               <item-type>Switch</item-type>
+               <label>Fast On/Off Button H</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-fastonoff"/>
+       </channel-type>
+
+       <channel-type id="legacyHeatSetPoint">
+               <item-type>Number</item-type>
+               <label>Heat Setpoint</label>
+       </channel-type>
+
+       <channel-type id="legacyHumidity">
+               <item-type>Number</item-type>
+               <label>Humidity</label>
+       </channel-type>
+
+       <channel-type id="legacyHumidityHigh">
+               <item-type>Number</item-type>
+               <label>Humidity High</label>
+       </channel-type>
+
+       <channel-type id="legacyHumidityLow">
+               <item-type>Number</item-type>
+               <label>Humidity Low</label>
+       </channel-type>
+
+       <channel-type id="legacyIsCooling">
+               <item-type>Number</item-type>
+               <label>Is Cooling</label>
+       </channel-type>
+
+       <channel-type id="legacyIsHeating">
+               <item-type>Number</item-type>
+               <label>Is Heating</label>
+       </channel-type>
+
+       <channel-type id="legacyKeypadButtonA">
+               <item-type>Switch</item-type>
+               <label>Keypad Button A</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button"/>
+       </channel-type>
+
+       <channel-type id="legacyKeypadButtonB">
+               <item-type>Switch</item-type>
+               <label>Keypad Button B</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button"/>
+       </channel-type>
+
+       <channel-type id="legacyKeypadButtonC">
+               <item-type>Switch</item-type>
+               <label>Keypad Button C</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button"/>
+       </channel-type>
+
+       <channel-type id="legacyKeypadButtonD">
+               <item-type>Switch</item-type>
+               <label>Keypad Button D</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button"/>
+       </channel-type>
+
+       <channel-type id="legacyKeypadButtonE">
+               <item-type>Switch</item-type>
+               <label>Keypad Button E</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button"/>
+       </channel-type>
+
+       <channel-type id="legacyKeypadButtonF">
+               <item-type>Switch</item-type>
+               <label>Keypad Button F</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button"/>
+       </channel-type>
+
+       <channel-type id="legacyKeypadButtonG">
+               <item-type>Switch</item-type>
+               <label>Keypad Button G</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button"/>
+       </channel-type>
+
+       <channel-type id="legacyKeypadButtonH">
+               <item-type>Switch</item-type>
+               <label>Keypad Button H</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button"/>
+       </channel-type>
+
+       <channel-type id="legacyKWh">
+               <item-type>Number:Energy</item-type>
+               <label>Kilowatt Hour</label>
+       </channel-type>
+
+       <channel-type id="legacyLastHeardFrom">
+               <item-type>DateTime</item-type>
+               <label>Last Heard From</label>
+       </channel-type>
+
+       <channel-type id="legacyLedBrightness">
+               <item-type>Number</item-type>
+               <label>LED Brightness</label>
+       </channel-type>
+
+       <channel-type id="legacyLedOnOff">
+               <item-type>Switch</item-type>
+               <label>LED On/Off</label>
+       </channel-type>
+
+       <channel-type id="legacyLightDimmer">
+               <item-type>Dimmer</item-type>
+               <label>Light Dimmer</label>
+               <config-description-ref uri="channel-type:insteon:legacy-dimmer"/>
+       </channel-type>
+
+       <channel-type id="legacyLightLevel">
+               <item-type>Number</item-type>
+               <label>Light Level</label>
+       </channel-type>
+
+       <channel-type id="legacyLightLevelAboveThreshold">
+               <item-type>Contact</item-type>
+               <label>Light Level Above/Below Threshold</label>
+       </channel-type>
+
+       <channel-type id="legacyLoadDimmer">
+               <item-type>Dimmer</item-type>
+               <label>Load Dimmer</label>
+               <config-description-ref uri="channel-type:insteon:legacy-dimmer"/>
+       </channel-type>
+
+       <channel-type id="legacyLoadSwitch">
+               <item-type>Switch</item-type>
+               <label>Load Switch</label>
+               <config-description-ref uri="channel-type:insteon:legacy-switch"/>
+       </channel-type>
+
+       <channel-type id="legacyLoadSwitchFastOnOff">
+               <item-type>Switch</item-type>
+               <label>Load Switch Fast On/Off</label>
+               <config-description-ref uri="channel-type:insteon:legacy-switch"/>
+       </channel-type>
+
+       <channel-type id="legacyLoadSwitchManualChange">
+               <item-type>Number</item-type>
+               <label>Load Switch Manual Change</label>
+       </channel-type>
+
+       <channel-type id="legacyLowBattery">
+               <item-type>Contact</item-type>
+               <label>Low Battery</label>
+       </channel-type>
+
+       <channel-type id="legacyManualChange">
+               <item-type>Number</item-type>
+               <label>Manual Change</label>
+       </channel-type>
+
+       <channel-type id="legacyManualChangeButtonA">
+               <item-type>Number</item-type>
+               <label>Manual Change Button A</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-manualchange"/>
+       </channel-type>
+
+       <channel-type id="legacyManualChangeButtonB">
+               <item-type>Number</item-type>
+               <label>Manual Change Button B</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-manualchange"/>
+       </channel-type>
+
+       <channel-type id="legacyManualChangeButtonC">
+               <item-type>Number</item-type>
+               <label>Manual Change Button C</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-manualchange"/>
+       </channel-type>
+
+       <channel-type id="legacyManualChangeButtonD">
+               <item-type>Number</item-type>
+               <label>Manual Change Button D</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-manualchange"/>
+       </channel-type>
+
+       <channel-type id="legacyManualChangeButtonE">
+               <item-type>Number</item-type>
+               <label>Manual Change Button E</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-manualchange"/>
+       </channel-type>
+
+       <channel-type id="legacyManualChangeButtonF">
+               <item-type>Number</item-type>
+               <label>Manual Change Button F</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-manualchange"/>
+       </channel-type>
+
+       <channel-type id="legacyManualChangeButtonG">
+               <item-type>Number</item-type>
+               <label>Manual Change Button G</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-manualchange"/>
+       </channel-type>
+
+       <channel-type id="legacyManualChangeButtonH">
+               <item-type>Number</item-type>
+               <label>Manual Change Button H</label>
+               <config-description-ref uri="channel-type:insteon:legacy-keypad-button-manualchange"/>
+       </channel-type>
+
+       <channel-type id="legacyNotification">
+               <item-type>Number</item-type>
+               <label>Notification</label>
+       </channel-type>
+
+       <channel-type id="legacyOnLevel">
+               <item-type>Number</item-type>
+               <label>On Level</label>
+       </channel-type>
+
+       <channel-type id="legacyRampDimmer">
+               <item-type>Dimmer</item-type>
+               <label>Ramp Dimmer</label>
+       </channel-type>
+
+       <channel-type id="legacyRampRate">
+               <item-type>Number</item-type>
+               <label>Ramp Rate</label>
+       </channel-type>
+
+       <channel-type id="legacyReset">
+               <item-type>Switch</item-type>
+               <label>Reset</label>
+       </channel-type>
+
+       <channel-type id="legacyStage1Duration">
+               <item-type>Number</item-type>
+               <label>Stage 1 Duration</label>
+       </channel-type>
+
+       <channel-type id="legacySwitch">
+               <item-type>Switch</item-type>
+               <label>Switch</label>
+               <config-description-ref uri="channel-type:insteon:legacy-switch"/>
+       </channel-type>
+
+       <channel-type id="legacySystemMode">
+               <item-type>Number</item-type>
+               <label>System Mode</label>
+       </channel-type>
+
+       <channel-type id="legacyTamperSwitch">
+               <item-type>Contact</item-type>
+               <label>Tamper Switch</label>
+       </channel-type>
+
+       <channel-type id="legacyTemperature">
+               <item-type>Number:Temperature</item-type>
+               <label>Temperature</label>
+       </channel-type>
+
+       <channel-type id="legacyTemperatureLevel">
+               <item-type>Number</item-type>
+               <label>Temperature Level</label>
+       </channel-type>
+
+       <channel-type id="legacyTopOutlet">
+               <item-type>Switch</item-type>
+               <label>Top Outlet</label>
+               <config-description-ref uri="channel-type:insteon:legacy-switch"/>
+       </channel-type>
+
+       <channel-type id="legacyUpdate">
+               <item-type>Switch</item-type>
+               <label>Update</label>
+       </channel-type>
+
+       <channel-type id="legacyWatts">
+               <item-type>Number:Power</item-type>
+               <label>Watts</label>
+       </channel-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/plm.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/plm.xml
new file mode 100644 (file)
index 0000000..a03e4da
--- /dev/null
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="insteon"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="plm">
+               <label>Insteon PLM</label>
+               <description>An Insteon PLM that communicates with Insteon devices.</description>
+
+               <config-description>
+                       <parameter name="serialPort" type="text" required="true">
+                               <context>serial-port</context>
+                               <label>Serial Port</label>
+                               <description>Serial port connected to the modem. Example: /dev/ttyS0 or COM1</description>
+                       </parameter>
+
+                       <parameter name="baudRate" type="integer" required="true">
+                               <label>Baud Rate</label>
+                               <description>Baud rate of the serial port connected to the modem.</description>
+                               <default>19200</default>
+                               <advanced>true</advanced>
+                               <options>
+                                       <option value="1200">1200</option>
+                                       <option value="2400">2400</option>
+                                       <option value="4800">4800</option>
+                                       <option value="9600">9600</option>
+                                       <option value="19200">19200</option>
+                                       <option value="38400">38400</option>
+                                       <option value="57600">57600</option>
+                                       <option value="115200">115200</option>
+                               </options>
+                       </parameter>
+
+                       <parameter name="devicePollIntervalInSeconds" type="integer" min="5" max="3600">
+                               <label>Device Poll Interval</label>
+                               <description>Device poll interval in seconds.</description>
+                               <default>300</default>
+                               <advanced>true</advanced>
+                       </parameter>
+
+                       <parameter name="deviceDiscoveryEnabled" type="boolean">
+                               <label>Discover Devices</label>
+                               <description>Discover Insteon devices found in the modem database.</description>
+                               <default>true</default>
+                       </parameter>
+
+                       <parameter name="sceneDiscoveryEnabled" type="boolean">
+                               <label>Discover Scenes</label>
+                               <description>Discover Insteon scenes found in the modem database.</description>
+                               <default>false</default>
+                       </parameter>
+
+                       <parameter name="deviceSyncEnabled" type="boolean">
+                               <label>Synchronize Related Devices</label>
+                               <description>Synchronize related devices based on their all-link database.</description>
+                               <default>false</default>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/scene.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/scene.xml
new file mode 100644 (file)
index 0000000..642f5a0
--- /dev/null
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="insteon"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="scene">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="hub1"/>
+                       <bridge-type-ref id="hub2"/>
+                       <bridge-type-ref id="plm"/>
+               </supported-bridge-type-refs>
+
+               <label>Insteon Scene</label>
+               <description>An Insteon scene that controls multiple devices simultaneously.</description>
+
+               <channels>
+                       <channel id="scene" typeId="scene"/>
+                       <channel id="fast-on-off" typeId="fast-on-off"/>
+                       <channel id="manual-change" typeId="manual-change"/>
+               </channels>
+
+               <representation-property>group</representation-property>
+
+               <config-description>
+                       <parameter name="group" type="integer" min="2" max="254" required="true">
+                               <label>Group</label>
+                               <description>Insteon scene group number.</description>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/thing-types.xml
deleted file mode 100644 (file)
index 95e5c30..0000000
+++ /dev/null
@@ -1,547 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<thing:thing-descriptions bindingId="insteon"
-       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
-       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
-
-       <bridge-type id="network">
-               <label>Insteon Network</label>
-               <description>An Insteon PLM or hub that is used to communicate with the Insteon devices.</description>
-
-               <config-description>
-                       <parameter name="port" type="text" required="true">
-                               <label>Port</label>
-                               <description>Configuration information that is used to connect to PLM or hub.</description>
-                       </parameter>
-
-                       <parameter name="devicePollIntervalSeconds" type="integer" min="5" max="3600">
-                               <label>Device Poll Interval</label>
-                               <description>Device poll interval in seconds.</description>
-                       </parameter>
-
-                       <parameter name="additionalDevices" type="text">
-                               <label>Additional Devices</label>
-                               <description>File with additional device types.</description>
-                       </parameter>
-
-                       <parameter name="additionalFeatures" type="text">
-                               <label>Additional Features</label>
-                               <description>File with additional feature templates.</description>
-                       </parameter>
-               </config-description>
-       </bridge-type>
-
-       <thing-type id="device">
-               <supported-bridge-type-refs>
-                       <bridge-type-ref id="network"/>
-               </supported-bridge-type-refs>
-
-               <label>Insteon Device</label>
-               <description>Insteon devices such as switches, dimmers, keypads, sensors, etc.</description>
-
-               <representation-property>address</representation-property>
-
-               <config-description>
-                       <parameter name="address" type="text" required="true">
-                               <label>Address</label>
-                               <description>Insteon address of the device. Example: 12.34.56</description>
-                       </parameter>
-                       <parameter name="productKey" type="text" required="true">
-                               <label>Product Key</label>
-                               <description>Insteon binding product key that is used to identify the model of the device.</description>
-                               <options>
-                                       <option value="F00.00.01">2477D SwitchLinc Dimmer - F00.00.01</option>
-                                       <option value="F00.00.02">2477S SwitchLinc Switch - F00.00.02</option>
-                                       <option value="F00.00.03">2845-222 Hidden Door Sensor - F00.00.03</option>
-                                       <option value="F00.00.04">2876S ICON Switch - F00.00.04</option>
-                                       <option value="F00.00.05">2456D3 LampLinc V2 - F00.00.05</option>
-                                       <option value="F00.00.06">2442-222 Micro Dimmer - F00.00.06</option>
-                                       <option value="F00.00.07">2453-222 DIN Rail On/Off - F00.00.07</option>
-                                       <option value="F00.00.08">2452-222 DIN Rail Dimmer - F00.00.08</option>
-                                       <option value="F00.00.09">2458-A1 MorningLinc RF Lock Controller - F00.00.09</option>
-                                       <option value="F00.00.0A">2852-222 Leak Sensor - F00.00.0A</option>
-                                       <option value="F00.00.0B">2672-422 LED Dimmer - F00.00.0B</option>
-                                       <option value="F00.00.0C">2476D SwitchLinc Dimmer - F00.00.0C</option>
-                                       <option value="F00.00.0D">2634-222 On/Off Dual-Band Outdoor Module - F00.00.0D</option>
-                                       <option value="F00.00.10">2342-2 Mini Remote - F00.00.10</option>
-                                       <option value="F00.00.11">2466D ToggleLinc Dimmer - F00.00.11</option>
-                                       <option value="F00.00.12">2466S ToggleLinc Switch - F00.00.12</option>
-                                       <option value="F00.00.13">2672-222 LED Bulb - F00.00.13</option>
-                                       <option value="F00.00.14">2487S KeypadLinc On/Off 6-Button - F00.00.14</option>
-                                       <option value="F00.00.15">2334-232 KeypadLink Dimmer 6-Button - F00.00.15</option>
-                                       <option value="F00.00.16">2334-232 KeypadLink Dimmer 8-Button - F00.00.16</option>
-                                       <option value="F00.00.17">2423A1 iMeter Solo Power Meter - F00.00.17</option>
-                                       <option value="F00.00.18">2423A1 Thermostat 2441TH - F00.00.18</option>
-                                       <option value="F00.00.19">2457D2 LampLinc Dimmer - F00.00.19</option>
-                                       <option value="F00.00.1A">2475SDB In-LineLinc Relay - F00.00.1A</option>
-                                       <option value="F00.00.1B">2635-222 On/Off Module - F00.00.1B</option>
-                                       <option value="F00.00.1C">2475F FanLinc Module - F00.00.1C</option>
-                                       <option value="F00.00.1D">2456S3 ApplianceLinc - F00.00.1D</option>
-                                       <option value="F00.00.1E">2674-222 LED Bulb (Recessed) - F00.00.1E</option>
-                                       <option value="F00.00.1F">2477SA1 220V 30-amp Load Controller N/O - F00.00.1F</option>
-                                       <option value="F00.00.20">2342-222 Mini Remote (8-Button) - F00.00.20</option>
-                                       <option value="F00.00.21">2441V Insteon Thermostat Adaptor for Venstar - F00.00.21</option>
-                                       <option value="F00.00.22">2982-222 Insteon Smoke Bridge - F00.00.22</option>
-                                       <option value="F00.00.23">2487S KeypadLinc On/Off 8-Button - F00.00.23</option>
-                                       <option value="F00.00.24">Motion Sensor II - F00.00.24</option>
-                                       <option value="0x00001A">2450 IO Link - 0x00001A</option>
-                                       <option value="0x000037">2486D KeypadLinc Dimmer - 0x000037</option>
-                                       <option value="0x000039">2663-222 On/Off Outlet - 0x000039</option>
-                                       <option value="0x000041">2484DWH8 KeypadLinc Countdown Timer - 0x000041</option>
-                                       <option value="0x000045">PLM or hub - 0x000045</option>
-                                       <option value="0x000049">2843-222 Wireless Open/Close Sensor - 0x000049</option>
-                                       <option value="0x00004A">2842-222 Motion Sensor - 0x00004A</option>
-                                       <option value="0x000051">2486DWH8 KeypadLinc Dimmer - 0x000051</option>
-                                       <option value="0x000068">2472D OutletLinc Dimmer - 0x000068</option>
-                                       <option value="X00.00.01">X10 switch Generic X10 switch - X00.00.01</option>
-                                       <option value="X00.00.02">X10 dimmer Generic X10 dimmer - X00.00.02</option>
-                                       <option value="X00.00.03">X10 motion Generic X10 motion sensor - X00.00.03</option>
-                               </options>
-                               <limitToOptions>false</limitToOptions>
-                       </parameter>
-
-                       <parameter name="deviceConfig" type="text">
-                               <label>Device Configuration</label>
-                               <description>Optional JSON object with device specific configuration.</description>
-                       </parameter>
-               </config-description>
-       </thing-type>
-
-       <channel-type id="acDelay">
-               <item-type>Number</item-type>
-               <label>AC Delay</label>
-       </channel-type>
-
-       <channel-type id="backlightDuration">
-               <item-type>Number</item-type>
-               <label>Back Light Duration</label>
-       </channel-type>
-
-       <channel-type id="batteryLevel">
-               <item-type>Number</item-type>
-               <label>Battery Level</label>
-       </channel-type>
-
-       <channel-type id="batteryPercent">
-               <item-type>Number:Dimensionless</item-type>
-               <label>Battery Percent</label>
-       </channel-type>
-
-       <channel-type id="batteryWatermarkLevel">
-               <item-type>Number</item-type>
-               <label>Battery Watermark Level</label>
-       </channel-type>
-
-       <channel-type id="beep">
-               <item-type>Switch</item-type>
-               <label>Beep</label>
-       </channel-type>
-
-       <channel-type id="bottomOutlet">
-               <item-type>Switch</item-type>
-               <label>Bottom Outlet</label>
-               <config-description-ref uri="channel-type:insteon:switch"/>
-       </channel-type>
-
-       <channel-type id="buttonA">
-               <item-type>Switch</item-type>
-               <label>Button A</label>
-               <config-description-ref uri="channel-type:insteon:button"/>
-       </channel-type>
-
-       <channel-type id="buttonB">
-               <item-type>Switch</item-type>
-               <label>Button B</label>
-               <config-description-ref uri="channel-type:insteon:button"/>
-       </channel-type>
-
-       <channel-type id="buttonC">
-               <item-type>Switch</item-type>
-               <label>Button C</label>
-               <config-description-ref uri="channel-type:insteon:button"/>
-       </channel-type>
-
-       <channel-type id="buttonD">
-               <item-type>Switch</item-type>
-               <label>Button D</label>
-               <config-description-ref uri="channel-type:insteon:button"/>
-       </channel-type>
-
-       <channel-type id="buttonE">
-               <item-type>Switch</item-type>
-               <label>Button E</label>
-               <config-description-ref uri="channel-type:insteon:button"/>
-       </channel-type>
-
-       <channel-type id="buttonF">
-               <item-type>Switch</item-type>
-               <label>Button F</label>
-               <config-description-ref uri="channel-type:insteon:button"/>
-       </channel-type>
-
-       <channel-type id="buttonG">
-               <item-type>Switch</item-type>
-               <label>Button G</label>
-               <config-description-ref uri="channel-type:insteon:button"/>
-       </channel-type>
-
-       <channel-type id="buttonH">
-               <item-type>Switch</item-type>
-               <label>Button H</label>
-               <config-description-ref uri="channel-type:insteon:button"/>
-       </channel-type>
-
-       <channel-type id="broadcastOnOff">
-               <item-type>Switch</item-type>
-               <label>Broadcast On/Off</label>
-               <config-description-ref uri="channel-type:insteon:switch"/>
-       </channel-type>
-
-       <channel-type id="contact">
-               <item-type>Contact</item-type>
-               <label>Contact</label>
-               <config-description-ref uri="channel-type:insteon:contact"/>
-       </channel-type>
-
-       <channel-type id="coolSetPoint">
-               <item-type>Number</item-type>
-               <label>Cool Set Point</label>
-       </channel-type>
-
-       <channel-type id="dimmer">
-               <item-type>Dimmer</item-type>
-               <label>Dimmer</label>
-               <config-description-ref uri="channel-type:insteon:dimmer"/>
-       </channel-type>
-
-       <channel-type id="fan">
-               <item-type>Number</item-type>
-               <label>Fan</label>
-       </channel-type>
-
-       <channel-type id="fanMode">
-               <item-type>Number</item-type>
-               <label>Fan Mode</label>
-       </channel-type>
-
-       <channel-type id="fastOnOff">
-               <item-type>Switch</item-type>
-               <label>Fast On/Off</label>
-       </channel-type>
-
-       <channel-type id="fastOnOffButtonA">
-               <item-type>Switch</item-type>
-               <label>Fast On/Off Button A</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-fastonoff"/>
-       </channel-type>
-
-       <channel-type id="fastOnOffButtonB">
-               <item-type>Switch</item-type>
-               <label>Fast On/Off Button B</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-fastonoff"/>
-       </channel-type>
-
-       <channel-type id="fastOnOffButtonC">
-               <item-type>Switch</item-type>
-               <label>Fast On/Off Button C</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-fastonoff"/>
-       </channel-type>
-
-       <channel-type id="fastOnOffButtonD">
-               <item-type>Switch</item-type>
-               <label>Fast On/Off Button D</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-fastonoff"/>
-       </channel-type>
-
-       <channel-type id="fastOnOffButtonE">
-               <item-type>Switch</item-type>
-               <label>Fast On/Off Button E</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-fastonoff"/>
-       </channel-type>
-
-       <channel-type id="fastOnOffButtonF">
-               <item-type>Switch</item-type>
-               <label>Fast On/Off Button F</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-fastonoff"/>
-       </channel-type>
-
-       <channel-type id="fastOnOffButtonG">
-               <item-type>Switch</item-type>
-               <label>Fast On/Off Button G</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-fastonoff"/>
-       </channel-type>
-
-       <channel-type id="fastOnOffButtonH">
-               <item-type>Switch</item-type>
-               <label>Fast On/Off Button H</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-fastonoff"/>
-       </channel-type>
-
-       <channel-type id="heatSetPoint">
-               <item-type>Number</item-type>
-               <label>Heat Set Point</label>
-       </channel-type>
-
-       <channel-type id="humidity">
-               <item-type>Number</item-type>
-               <label>Humidity</label>
-       </channel-type>
-
-       <channel-type id="humidityHigh">
-               <item-type>Number</item-type>
-               <label>Humidity High</label>
-       </channel-type>
-
-       <channel-type id="humidityLow">
-               <item-type>Number</item-type>
-               <label>Humidity Low</label>
-       </channel-type>
-
-       <channel-type id="isCooling">
-               <item-type>Number</item-type>
-               <label>Is Cooling</label>
-       </channel-type>
-
-       <channel-type id="isHeating">
-               <item-type>Number</item-type>
-               <label>Is Heating</label>
-       </channel-type>
-
-       <channel-type id="keypadButtonA">
-               <item-type>Switch</item-type>
-               <label>Keypad Button A</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button"/>
-       </channel-type>
-
-       <channel-type id="keypadButtonB">
-               <item-type>Switch</item-type>
-               <label>Keypad Button B</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button"/>
-       </channel-type>
-
-       <channel-type id="keypadButtonC">
-               <item-type>Switch</item-type>
-               <label>Keypad Button C</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button"/>
-       </channel-type>
-
-       <channel-type id="keypadButtonD">
-               <item-type>Switch</item-type>
-               <label>Keypad Button D</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button"/>
-       </channel-type>
-
-       <channel-type id="keypadButtonE">
-               <item-type>Switch</item-type>
-               <label>Keypad Button E</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button"/>
-       </channel-type>
-
-       <channel-type id="keypadButtonF">
-               <item-type>Switch</item-type>
-               <label>Keypad Button F</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button"/>
-       </channel-type>
-
-       <channel-type id="keypadButtonG">
-               <item-type>Switch</item-type>
-               <label>Keypad Button G</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button"/>
-       </channel-type>
-
-       <channel-type id="keypadButtonH">
-               <item-type>Switch</item-type>
-               <label>Keypad Button H</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button"/>
-       </channel-type>
-
-       <channel-type id="kWh">
-               <item-type>Number:Energy</item-type>
-               <label>Kilowatt Hour</label>
-       </channel-type>
-
-       <channel-type id="lastHeardFrom">
-               <item-type>DateTime</item-type>
-               <label>Last Heard From</label>
-       </channel-type>
-
-       <channel-type id="ledBrightness">
-               <item-type>Number</item-type>
-               <label>LED Brightness</label>
-       </channel-type>
-
-       <channel-type id="ledOnOff">
-               <item-type>Switch</item-type>
-               <label>LED On/Off</label>
-       </channel-type>
-
-       <channel-type id="lightDimmer">
-               <item-type>Dimmer</item-type>
-               <label>Light Dimmer</label>
-               <config-description-ref uri="channel-type:insteon:dimmer"/>
-       </channel-type>
-
-       <channel-type id="lightLevel">
-               <item-type>Number</item-type>
-               <label>Light Level</label>
-       </channel-type>
-
-       <channel-type id="lightLevelAboveThreshold">
-               <item-type>Contact</item-type>
-               <label>Light Level Above/Below Threshold</label>
-       </channel-type>
-
-       <channel-type id="loadDimmer">
-               <item-type>Dimmer</item-type>
-               <label>Load Dimmer</label>
-               <config-description-ref uri="channel-type:insteon:dimmer"/>
-       </channel-type>
-
-       <channel-type id="loadSwitch">
-               <item-type>Switch</item-type>
-               <label>Load Switch</label>
-               <config-description-ref uri="channel-type:insteon:switch"/>
-       </channel-type>
-
-       <channel-type id="loadSwitchFastOnOff">
-               <item-type>Switch</item-type>
-               <label>Load Switch Fast On/Off</label>
-               <config-description-ref uri="channel-type:insteon:switch"/>
-       </channel-type>
-
-       <channel-type id="loadSwitchManualChange">
-               <item-type>Number</item-type>
-               <label>Load Switch Manual Change</label>
-       </channel-type>
-
-       <channel-type id="lowBattery">
-               <item-type>Contact</item-type>
-               <label>Low Battery</label>
-       </channel-type>
-
-       <channel-type id="manualChange">
-               <item-type>Number</item-type>
-               <label>Manual Change</label>
-       </channel-type>
-
-       <channel-type id="manualChangeButtonA">
-               <item-type>Number</item-type>
-               <label>Manual Change Button A</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-manualchange"/>
-       </channel-type>
-
-       <channel-type id="manualChangeButtonB">
-               <item-type>Number</item-type>
-               <label>Manual Change Button B</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-manualchange"/>
-       </channel-type>
-
-       <channel-type id="manualChangeButtonC">
-               <item-type>Number</item-type>
-               <label>Manual Change Button C</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-manualchange"/>
-       </channel-type>
-
-       <channel-type id="manualChangeButtonD">
-               <item-type>Number</item-type>
-               <label>Manual Change Button D</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-manualchange"/>
-       </channel-type>
-
-       <channel-type id="manualChangeButtonE">
-               <item-type>Number</item-type>
-               <label>Manual Change Button E</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-manualchange"/>
-       </channel-type>
-
-       <channel-type id="manualChangeButtonF">
-               <item-type>Number</item-type>
-               <label>Manual Change Button F</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-manualchange"/>
-       </channel-type>
-
-       <channel-type id="manualChangeButtonG">
-               <item-type>Number</item-type>
-               <label>Manual Change Button G</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-manualchange"/>
-       </channel-type>
-
-       <channel-type id="manualChangeButtonH">
-               <item-type>Number</item-type>
-               <label>Manual Change Button H</label>
-               <config-description-ref uri="channel-type:insteon:keypad-button-manualchange"/>
-       </channel-type>
-
-       <channel-type id="notification">
-               <item-type>Number</item-type>
-               <label>Notification</label>
-       </channel-type>
-
-       <channel-type id="onLevel">
-               <item-type>Number</item-type>
-               <label>On Level</label>
-       </channel-type>
-
-       <channel-type id="rampDimmer">
-               <item-type>Dimmer</item-type>
-               <label>Ramp Dimmer</label>
-       </channel-type>
-
-       <channel-type id="rampRate">
-               <item-type>Number</item-type>
-               <label>Ramp Rate</label>
-       </channel-type>
-
-       <channel-type id="reset">
-               <item-type>Switch</item-type>
-               <label>Reset</label>
-       </channel-type>
-
-       <channel-type id="stage1Duration">
-               <item-type>Number</item-type>
-               <label>Stage 1 Duration</label>
-       </channel-type>
-
-       <channel-type id="switch">
-               <item-type>Switch</item-type>
-               <label>Switch</label>
-               <config-description-ref uri="channel-type:insteon:switch"/>
-       </channel-type>
-
-       <channel-type id="systemMode">
-               <item-type>Number</item-type>
-               <label>System Mode</label>
-       </channel-type>
-
-       <channel-type id="tamperSwitch">
-               <item-type>Contact</item-type>
-               <label>Tamper Switch</label>
-       </channel-type>
-
-       <channel-type id="temperature">
-               <item-type>Number:Temperature</item-type>
-               <label>Temperature</label>
-       </channel-type>
-
-       <channel-type id="temperatureLevel">
-               <item-type>Number</item-type>
-               <label>Temperature Level</label>
-       </channel-type>
-
-       <channel-type id="topOutlet">
-               <item-type>Switch</item-type>
-               <label>Top Outlet</label>
-               <config-description-ref uri="channel-type:insteon:switch"/>
-       </channel-type>
-
-       <channel-type id="update">
-               <item-type>Switch</item-type>
-               <label>Update</label>
-       </channel-type>
-
-       <channel-type id="watts">
-               <item-type>Number:Power</item-type>
-               <label>Watts</label>
-       </channel-type>
-
-</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/x10.xml b/bundles/org.openhab.binding.insteon/src/main/resources/OH-INF/thing/x10.xml
new file mode 100644 (file)
index 0000000..56c6048
--- /dev/null
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="insteon"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="x10">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="hub1"/>
+                       <bridge-type-ref id="hub2"/>
+                       <bridge-type-ref id="plm"/>
+               </supported-bridge-type-refs>
+
+               <label>X10 Device</label>
+               <description>An X10 device such as a switch, dimmer or sensor.</description>
+
+               <config-description>
+                       <parameter name="houseCode" type="text" pattern="[A-P]" required="true">
+                               <label>House Code</label>
+                               <description>X10 house code of the device. Example: A</description>
+                       </parameter>
+
+                       <parameter name="unitCode" type="integer" min="1" max="16" required="true">
+                               <label>Unit Code</label>
+                               <description>X10 unit code of the device. Example: 1</description>
+                       </parameter>
+
+                       <parameter name="deviceType" type="text" required="true">
+                               <label>Device Type</label>
+                               <description>X10 device type.</description>
+                               <options>
+                                       <option value="X10_Switch">Switch</option>
+                                       <option value="X10_Dimmer">Dimmer</option>
+                                       <option value="X10_Sensor">Sensor</option>
+                               </options>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/device-features.xml b/bundles/org.openhab.binding.insteon/src/main/resources/device-features.xml
new file mode 100644 (file)
index 0000000..20b7fc6
--- /dev/null
@@ -0,0 +1,1064 @@
+<xml>
+       <feature-type name="GenericDimmer" link="both">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x11" group="1">DimmerOnMsgHandler</message-handler>
+               <message-handler command="0x12" group="1" mode="FAST">DimmerOnMsgHandler</message-handler>
+               <message-handler command="0x13" group="1">DimmerOffMsgHandler</message-handler>
+               <message-handler command="0x14" group="1" mode="FAST">DimmerOffMsgHandler</message-handler>
+               <message-handler command="0x17" group="1">NoOpMsgHandler</message-handler>
+               <message-handler command="0x18" group="1">TriggerPollMsgHandler</message-handler>
+               <message-handler command="0x19">DimmerRequestReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">DimmerOnOffCommandHandler</command-handler>
+               <command-handler command="PercentType">DimmerPercentCommandHandler</command-handler>
+               <command-handler command="IncreaseDecreaseType">DimmerIncreaseDecreaseCommandHandler</command-handler>
+               <command-handler command="UpDownType">RollershutterUpDownCommandHandler</command-handler>
+               <command-handler command="StopMoveType">RollershutterStopCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="GenericSwitch" link="both">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x11" group="1">SwitchOnMsgHandler</message-handler>
+               <message-handler command="0x12" group="1" mode="FAST">SwitchOnMsgHandler</message-handler>
+               <message-handler command="0x13" group="1">SwitchOffMsgHandler</message-handler>
+               <message-handler command="0x14" group="1" mode="FAST">SwitchOffMsgHandler</message-handler>
+               <message-handler command="0x17" group="1">NoOpMsgHandler</message-handler>
+               <message-handler command="0x18" group="1">TriggerPollMsgHandler</message-handler>
+               <message-handler command="0x19">SwitchRequestReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">SwitchOnOffCommandHandler</command-handler>
+               <command-handler command="PercentType">SwitchPercentCommandHandler</command-handler>
+               <command-handler command="IncreaseDecreaseType">SwitchIncrementCommandHandler</command-handler>
+               <command-handler command="UpDownType">SwitchIncrementCommandHandler</command-handler>
+               <command-handler command="StopMoveType">NoOpCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="GenericLastTime" status="true">
+               <message-dispatcher>PassThroughDispatcher</message-dispatcher>
+               <message-handler default="true">LastTimeMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="GenericButtonEvent" event="true">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x11" group="1">ButtonEventMsgHandler</message-handler>
+               <message-handler command="0x12" group="1">ButtonEventMsgHandler</message-handler>
+               <message-handler command="0x13" group="1">ButtonEventMsgHandler</message-handler>
+               <message-handler command="0x14" group="1">ButtonEventMsgHandler</message-handler>
+               <message-handler command="0x17" group="1" duplicate="false">ButtonEventMsgHandler</message-handler>
+               <message-handler command="0x18" group="1" duplicate="false">ButtonEventMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="KeypadButtonStatusGroup"> <!-- does the polling for keypad button status -->
+               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
+               <message-handler command="0x19">StatusRequestReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="KeypadButton" link="both">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x11" group="1">KeypadButtonOnMsgHandler</message-handler>
+               <message-handler command="0x12" group="1" mode="FAST">KeypadButtonOnMsgHandler</message-handler>
+               <message-handler command="0x13" group="1">KeypadButtonOffMsgHandler</message-handler>
+               <message-handler command="0x14" group="1" mode="FAST">KeypadButtonOffMsgHandler</message-handler>
+               <message-handler command="0x17" group="1">NoOpMsgHandler</message-handler>
+               <message-handler command="0x18" group="1">TriggerPollMsgHandler</message-handler>
+               <message-handler command="0x19">KeypadButtonReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" ext="1" cmd1="0x2E" d1="0x01" d2="0x09" field="userData3">KeypadButtonOnOffCommandHandler</command-handler>
+               <command-handler command="PercentType" ext="1" cmd1="0x2E" d1="0x01" d2="0x09" field="userData3">KeypadButtonPercentCommandHandler</command-handler>
+               <command-handler command="IncreaseDecreaseType" ext="1" cmd1="0x2E" d1="0x01" d2="0x09"
+                       field="userData3">KeypadButtonIncrementCommandHandler</command-handler>
+               <command-handler command="UpDownType" ext="1" cmd1="0x2E" d1="0x01" d2="0x09" field="userData3">KeypadButtonIncrementCommandHandler</command-handler>
+               <command-handler command="StopMoveType">NoOpCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by KeypadButtonStatusGroup -->
+       </feature-type>
+
+       <feature-type name="GroupBroadcastOnOff" link="controller" hidden="true">
+               <message-dispatcher>NoOpDispatcher</message-dispatcher>
+               <command-handler command="OnOffType">BroadcastOnOffCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <command-handler command="RefreshType">BroadcastRefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="GroupBroadcastFastOnOff" link="controller" hidden="true">
+               <message-dispatcher>NoOpDispatcher</message-dispatcher>
+               <command-handler command="OnOffType">BroadcastFastOnOffCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="GroupBroadcastManualChange" link="controller" hidden="true">
+               <message-dispatcher>NoOpDispatcher</message-dispatcher>
+               <command-handler command="UpDownType">BroadcastManualChangeUpDownCommandHandler</command-handler>
+               <command-handler command="StopMoveType">BroadcastManualChangeStopCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="InsteonEngine" hidden="true">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">InsteonEngineReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <poll-handler ext="0" cmd1="0x0D" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="Ping" hidden="true">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">PingReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <poll-handler ext="0" cmd1="0x0F" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="DatabaseDelta" hidden="true">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">DatabaseDeltaReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <poll-handler ext="0" cmd1="0x1F" cmd2="0x01">FlexPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="OpFlagsGroup"> <!-- does the polling for operating flags -->
+               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <poll-handler ext="0" cmd1="0x1F" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="OpFlags">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">OpFlagsReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">OpFlagsCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by OpFlagsGroup -->
+       </feature-type>
+       <feature-type name="LinkOpFlags">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">LinkOpFlagsReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">OpFlagsCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by OpFlagsGroup -->
+       </feature-type>
+       <feature-type name="HeartbeatOnOff">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">HeartbeatOnOffReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">OpFlagsCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by OpFlagsGroup -->
+       </feature-type>
+       <feature-type name="KeypadButtonConfig">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">KeypadButtonConfigReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="StringType" ext="1" cmd1="0x20" field="command2">KeypadButtonConfigCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by OpFlagsGroup -->
+       </feature-type>
+
+       <feature-type name="ExtDataGroup" group="1"> <!-- does the polling for extended data -->
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="1" cmd1="0x2E" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="RampRate">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler> <!-- poll after cmd request reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData7"
+                       mask="0x1F">RampRateMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType">RampRateCommandHandler</command-handler>
+               <command-handler command="QuantityType">RampRateCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="OnLevel">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler> <!-- poll after cmd request reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData8">CustomPercentMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" ext="1" cmd1="0x2E" d2="0x06" field="userData3">CustomOnOffCommandHandler</command-handler>
+               <command-handler command="PercentType" ext="1" cmd1="0x2E" d2="0x06" field="userData3">CustomPercentCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="LEDBrightness">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler> <!-- poll after cmd request reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData9"
+                       mask="0x7F">LEDBrightnessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" d2="0x07">LEDBrightnessCommandHandler</command-handler>
+               <command-handler command="PercentType" d2="0x07">LEDBrightnessCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="KeypadButtonOnMask" hidden="true">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19" delay="2000">TriggerPollMsgHandler</message-handler> <!-- poll after cmd request reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData3">CustomCacheMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d2="0x02" field="userData3">CustomDecimalCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="KeypadButtonOffMask" hidden="true">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19" delay="2000">TriggerPollMsgHandler</message-handler> <!-- poll after cmd request reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData4">CustomCacheMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d2="0x03" field="userData3">CustomDecimalCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="KeypadButtonToggleMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19" delay="2000">TriggerPollMsgHandler</message-handler> <!-- poll after cmd request reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01">KeypadButtonToggleModeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType">KeypadButtonToggleModeCommandHandler</command-handler>
+               <command-handler command="StringType">KeypadButtonToggleModeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+
+       <feature-type name="Beep">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" cmd1="0x30">MomentaryOnCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="IOLincRelayMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">IOLincRelayModeReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">IOLincRelayModeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by OpFlagsGroup -->
+       </feature-type>
+       <feature-type name="IOLincMomentaryDuration">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19" delay="3000">TriggerPollMsgHandler</message-handler> <!-- poll after cmd requests reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData3"
+                       num_bytes="2">IOLincMomentaryDurationMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType">IOLincMomentaryDurationCommandHandler</command-handler>
+               <command-handler command="QuantityType">IOLincMomentaryDurationCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+
+       <!-- sensor notifications -->
+       <feature-type name="GenericSensorContact" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x11" group="1">ContactOpenMsgHandler</message-handler>
+               <message-handler command="0x13" group="1">ContactClosedMsgHandler</message-handler>
+               <message-handler command="0x19">ContactRequestReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="GenericSensorState" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x11" group="1">SwitchOnMsgHandler</message-handler>
+               <message-handler command="0x13" group="1">SwitchOffMsgHandler</message-handler>
+               <message-handler command="0x19">SwitchRequestReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="WirelessSensorContact" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- all-link broadcast standard open event -->
+               <message-handler command="0x11" group="1">WirelessSensorContactOpenMsgHandler</message-handler>
+               <!-- all-link broadcast standard closed event -->
+               <message-handler command="0x13" group="1">WirelessSensorContactClosedMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="WirelessSensorState" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- all-link broadcast standard on event -->
+               <message-handler command="0x11" group="1">WirelessSensorOnMsgHandler</message-handler>
+               <!-- all-link broadcast standard off event -->
+               <message-handler command="0x13" group="1">WirelessSensorOffMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="OpenClosedSensorContact" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- all-link broadcast standard open event -->
+               <message-handler command="0x11" group="1">WirelessSensorOpenMsgHandler</message-handler>
+               <!-- all-link broadcast standard closed event (2-groups: off) -->
+               <message-handler command="0x13" group="1">WirelessSensorClosedMsgHandler</message-handler>
+               <!-- all-link broadcast standard closed event (2-groups: on) -->
+               <message-handler command="0x11" group="2">WirelessSensorClosedMsgHandler</message-handler>
+               <!-- all-link broadcast heartbeat open event -->
+               <message-handler command="0x11" group="4">WirelessSensorOpenMsgHandler</message-handler>
+               <!-- all-link broadcast heartbeat closed event -->
+               <message-handler command="0x13" group="4">WirelessSensorClosedMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="LeakSensorState" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- all-link broadcast standard dry event -->
+               <message-handler command="0x11" group="1">WirelessSensorOffMsgHandler</message-handler>
+               <!-- all-link broadcast standard wet event (2-groups: off) -->
+               <message-handler command="0x13" group="1">WirelessSensorOnMsgHandler</message-handler>
+               <!-- all-link broadcast standard wet event (2-groups: on) -->
+               <message-handler command="0x11" group="2">WirelessSensorOnMsgHandler</message-handler>
+               <!-- all-link broadcast heartbeat dry event -->
+               <message-handler command="0x11" group="4">WirelessSensorOffMsgHandler</message-handler>
+               <!-- all-link broadcast heartbeat wet event -->
+               <message-handler command="0x13" group="4">WirelessSensorOnMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="SmokeSensorState" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- all-link broadcast set event -->
+               <message-handler command="0x11" group="1">WirelessSensorOnMsgHandler</message-handler>
+               <!-- all-link broadcast clear event -->
+               <message-handler command="0x11" group="5">WirelessSensorOffMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="HeartbeatMonitor" hidden="true" status="true">
+               <message-dispatcher>PassThroughDispatcher</message-dispatcher>
+               <message-handler default="true">HeartbeatMonitorMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+
+       <!-- sensor data -->
+       <feature-type name="WirelessSensorLEDBrightness">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData3">CustomPercentMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" d2="0x02">LEDBrightnessCommandHandler</command-handler>
+               <command-handler command="PercentType" d2="0x02">LEDBrightnessCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="DoorSensorBatteryLevel">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- battery level range 0x3A => 0x5A (typical); 0x27 => 0x61 (documented) -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData4" min="0x3A"
+                       max="0x5A">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="DoorSensorHeartbeatInterval">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- heartbeat interval increment 5 min; preset 24 hr -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData6"
+                       increment="5" preset="1440">HeartbeatIntervalMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d2="0x02" field="userData3" increment="5"
+                       preset="1440">HeartbeatIntervalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x2E" d2="0x02" field="userData3" increment="5"
+                       preset="1440">HeartbeatIntervalCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="MotionSensorBatteryLevel">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- battery level range 0x3A => 0x5A (typical); 0x27 => 0x61 (documented) -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData12"
+                       min="0x3A" max="0x5A">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="MotionSensorLightLevel">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- light level range 0x00 => 0xFF -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData11"
+                       min="0x00" max="0xFF">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <!-- motion sensor 2 data -->
+       <feature-type name="MotionSensor2Data1Group"> <!-- does the polling for motion sensor 2 data 1 -->
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="1" cmd1="0x2E" cmd2="0x01">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="MotionSensor2Data3Group"> <!-- does the polling for motion sensor 2 data 3 -->
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="1" cmd1="0x2E" cmd2="0x03">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="MotionSensor2BatteryLevel">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- battery level range 0x70 => 0xD2 -->
+               <!-- all-link broadcast low battery alert event -->
+               <message-handler command="0x11" group="3" field="addressHighByte" min="0x70" max="0xD2">CustomDimensionlessMsgHandler</message-handler>
+               <!-- all-link broadcast low battery clear event -->
+               <message-handler command="0x13" group="3" field="addressHighByte" min="0x70" max="0xD2">CustomDimensionlessMsgHandler</message-handler>
+               <!-- all-link broadcast standard heartbeat event -->
+               <message-handler command="0x11" group="4" field="addressHighByte" min="0x70" max="0xD2">CustomDimensionlessMsgHandler</message-handler>
+               <!-- all-link broadcast alternate heartbeat event -->
+               <message-handler command="0x11" group="11" field="addressHighByte" min="0x70" max="0xD2">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x03" d2="0x01" field="userData6" min="0x70"
+                       max="0xD2">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by MotionSensor2Data3Group -->
+       </feature-type>
+       <feature-type name="MotionSensor2LightLevel">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- light level range 0x00 => 0xFF -->
+               <!-- all-link broadcast alternate heartbeat event -->
+               <message-handler command="0x11" group="11" field="addressMiddleByte" min="0x00" max="0xFF">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x03" d2="0x01" field="userData7" min="0x00"
+                       max="0xFF">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by MotionSensor2Data3Group -->
+       </feature-type>
+       <feature-type name="MotionSensor2Temperature">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- temperature (°F) = 0.73 * value - 20.53 (battery powered); 0.72 * value - 24.61 (usb powered) -->
+               <!-- all-link broadcast alternate heartbeat event -->
+               <message-handler command="0x11" group="11" field="command2">MotionSensor2TemperatureMsgHandler</message-handler>
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x03" d2="0x01" field="userData8">MotionSensor2TemperatureMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by MotionSensor2Data3Group -->
+       </feature-type>
+       <feature-type name="MotionSensor2LowBatteryThreshold" hidden="true">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData12">CustomCacheMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by MotionSensor2Data1Group -->
+       </feature-type>
+       <feature-type name="MotionSensor2HeartbeatInterval">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- heartbeat interval increment 23 min -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData13"
+                       increment="23">HeartbeatIntervalMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" increment="23">MotionSensor2HeartbeatIntervalCommandHandler</command-handler>
+               <command-handler command="QuantityType" increment="23">MotionSensor2HeartbeatIntervalCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by MotionSensor2Data1Group -->
+       </feature-type>
+       <!-- motion sensor 2 status -->
+       <feature-type name="MotionSensor2StatusGroup"> <!-- does the polling for motion sensor 2 status -->
+               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
+               <message-handler command="0x19">StatusRequestReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="MotionSensor2BatteryPowered">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">MotionSensor2BatteryPoweredReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by MotionSensor2StatusGroup -->
+       </feature-type>
+
+       <feature-type name="FanLincFan" link="responder" group="2">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">FanLincFanReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">FanLincFanOnOffCommandHandler</command-handler>
+               <command-handler command="PercentType">FanLincFanPercentCommandHandler</command-handler>
+               <command-handler command="StringType">FanLincFanSpeedCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x03">FlexPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="MicroModuleOpFlagsGroup"> <!-- does the polling for micro module operating flags -->
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="0" cmd1="0x1F" cmd2="0x06">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="MicroModuleOpMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">MicroModuleOpModeReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">MicroModuleOpModeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by MicroModuleOpFlagsGroup -->
+       </feature-type>
+
+       <feature-type name="OutletStatusGroup">
+               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
+               <message-handler command="0x19">StatusRequestReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="OutletSwitch" link="both">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x11" group="1">SwitchOnMsgHandler</message-handler>
+               <message-handler command="0x13" group="1">SwitchOffMsgHandler</message-handler>
+               <message-handler command="0x19">OutletSwitchReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">SwitchOnOffCommandHandler</command-handler>
+               <command-handler command="PercentType">SwitchPercentCommandHandler</command-handler>
+               <command-handler command="IncreaseDecreaseType">SwitchIncrementCommandHandler</command-handler>
+               <command-handler command="UpDownType">SwitchIncrementCommandHandler</command-handler>
+               <command-handler command="StopMoveType">NoOpCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by OutletStatusGroup -->
+       </feature-type>
+
+       <feature-type name="PowerMeterDataGroup">
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="0" cmd1="0x82" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="PowerMeterEnergy">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x82" ext="2" cmd1="0x82" cmd2="0x00" field="userData9" num_bytes="4">PowerMeterEnergyMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by PowerMeterDataGroup -->
+       </feature-type>
+       <feature-type name="PowerMeterPower">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x82" ext="2" cmd1="0x82" cmd2="0x00" field="userData7" num_bytes="2">PowerMeterPowerMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by PowerMeterDataGroup -->
+       </feature-type>
+       <feature-type name="PowerMeterReset">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" cmd1="0x80">MomentaryOnCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="SirenOnOff" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x12" group="1">SwitchOnMsgHandler</message-handler>
+               <message-handler command="0x14" group="1">SwitchOffMsgHandler</message-handler>
+               <message-handler command="0x19">SirenRequesteplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">SirenOnOffCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="SirenArmed">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">SirenArmedReplyHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" on="0x05" off="0x04">SirenArmedCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x03">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="SirenAlertType">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler> <!-- poll after cmd request reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData7">SirenAlertTypeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="StringType" ext="1" cmd1="0x2E" d1="0x01" d2="0x05" field="userData3">SirenAlertTypeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="SirenAlertDelay">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler> <!-- poll after cmd request reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData8" bit="7">CustomBitmaskMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" ext="1" cmd1="0x2E" d1="0x01" d2="0x06" field="userData3" bit="7">CustomBitmaskCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+       <feature-type name="SirenAlertDuration">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler> <!-- poll after cmd request reply ack -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData8"
+                       mask="0x7F" scale="second">CustomTimeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d1="0x01" d2="0x06" field="userData3">SirenAlertDurationCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x2E" d1="0x01" d2="0x06" field="userData3">SirenAlertDurationCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtDataGroup -->
+       </feature-type>
+
+       <feature-type name="SprinklerStatusGroup">
+               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
+               <poll-handler ext="0" cmd1="0x44" cmd2="0x02">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="SprinklerValve">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19" ext="0" field="command2">SprinklerValveMsgHandler</message-handler>
+               <message-handler command="0x27" ext="0" field="command2">SprinklerValveMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">SprinklerValveOnOffCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by SprinklerStatusGroup -->
+       </feature-type>
+       <feature-type name="SprinklerProgram">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19" ext="0" field="command2">SprinklerProgramMsgHandler</message-handler>
+               <message-handler command="0x27" ext="0" field="command2">SprinklerProgramMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="PlayPauseType">SprinklerProgramOnOffCommandHandler</command-handler>
+               <command-handler command="NextPreviousType">SprinklerProgramNextPreviousCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by SprinklerStatusGroup -->
+       </feature-type>
+       <feature-type name="SprinklerPump">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19" ext="0" field="command2" bit="6">CustomBitmaskMsgHandler</message-handler>
+               <message-handler command="0x27" ext="0" field="command2" bit="6">CustomBitmaskMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" cmd1="0x44" on="0x07" off="0x08">CustomOnOffCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by SprinklerStatusGroup -->
+       </feature-type>
+
+       <feature-type name="ThermostatData1Group">
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="1" cmd1="0x2E" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="ThermostatData1bGroup">
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="1" cmd1="0x2E" cmd2="0x00" d3="0x01">FlexPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="ThermostatData2Group">
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="2" cmd1="0x2E" cmd2="0x02">FlexPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="ThermostatCoolSetpoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after set point has been changed -->
+               <message-handler command="0x19" ext="0" cmd1="0x6C" field="command2" factor="0.5" scale="fahrenheit">CustomTemperatureMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="2" cmd1="0x2E" cmd2="0x02" d1="0x01" field="userData7"
+                       scale="fahrenheit">CustomTemperatureMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x71" ext="0" cmd1="0x71" field="command2" scale="fahrenheit">CustomTemperatureMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6C" factor="2" field="command2">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x6C" factor="2" field="command2"
+                       scale="fahrenheit">CustomTemperatureCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature-type>
+       <feature-type name="ThermostatHeatSetpoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after set point has been changed -->
+               <message-handler command="0x19" ext="0" cmd1="0x6D" field="command2" factor="0.5" scale="fahrenheit">CustomTemperatureMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="2" cmd1="0x2E" cmd2="0x02" d1="0x01" field="userData12"
+                       scale="fahrenheit">CustomTemperatureMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x72" ext="0" cmd1="0x72" field="command2" scale="fahrenheit">CustomTemperatureMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6D" factor="2" field="command2">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x6D" factor="2" field="command2"
+                       scale="fahrenheit">CustomTemperatureCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature-type>
+       <feature-type name="ThermostatSystemMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after system mode has been changed -->
+               <message-handler command="0x19" ext="0" cmd1="0x6B" field="command2">ThermostatSystemModeReplyHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="2" cmd1="0x2E" cmd2="0x02" d1="0x01" field="userData6"
+                       mask="0xF0" rshift="4">ThermostatSystemModeMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x70" ext="0" cmd1="0x70" field="command2" mask="0x0F">ThermostatSystemModeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="StringType" ext="1" cmd1="0x6B" field="command2">ThermostatSystemModeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature-type>
+       <feature-type name="ThermostatEnergySaving">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="2" cmd1="0x2E" cmd2="0x02" d1="0x01" field="userData11" bit="2">CustomBitmaskMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature-type>
+       <feature-type name="ThermostatFanMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after fan mode has been changed -->
+               <message-handler command="0x19" ext="0" cmd1="0x6B" field="command2">ThermostatFanModeReplyHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="2" cmd1="0x2E" cmd2="0x02" d1="0x01" field="userData6"
+                       mask="0x0F">ThermostatFanModeMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x70" ext="0" cmd1="0x70" field="command2" mask="0xF0" rshift="4">ThermostatFanModeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="StringType" ext="1" cmd1="0x6B" field="command2">ThermostatFanModeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature-type>
+       <feature-type name="ThermostatTemperature">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="2" cmd1="0x2E" cmd2="0x02" d1="0x01" field="userData9"
+                       num_bytes="2" factor="0.1" scale="celsius">CustomTemperatureMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x6E" ext="0" cmd1="0x6E" field="command2" factor="0.5" scale="fahrenheit">CustomTemperatureMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature-type>
+       <feature-type name="ThermostatHumidity">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="2" cmd1="0x2E" cmd2="0x02" d1="0x01" field="userData8">CustomDimensionlessMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x6F" ext="0" cmd1="0x6F" field="command2">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature-type>
+       <feature-type name="ThermostatBackLightDuration">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after backlight duration has been changed -->
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData10"
+                       scale="second">CustomTimeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d1="0x01" d2="0x05" field="userData3">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x2E" d1="0x01" d2="0x05" field="userData3"
+                       scale="second">CustomTimeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="ThermostatACDelay">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after backlight duration has been changed -->
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData11"
+                       scale="minute">CustomTimeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d1="0x01" d2="0x06" field="userData3">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x2E" d1="0x01" d2="0x06" field="userData3"
+                       scale="minute">CustomTimeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="ThermostatEnergyOffset">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after energy offset has been changed -->
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d1="0x01" field="userData12"
+                       scale="fahrenheit">CustomTemperatureMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d1="0x01" d2="0x07" field="userData3">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x2E" d1="0x01" d2="0x07" field="userData3"
+                       scale="fahrenheit">CustomTemperatureMsgHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="ThermostatOpFlags">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after op flags has been changed -->
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData13">CustomBitmaskMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType" ext="1" cmd1="0x2E" d2="0x04" field="userData4">CustomBitmaskCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="ThermostatTemperatureScale">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after temperature scale has been changed -->
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData13" bit="3">ThermostatTemperatureScaleMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="StringType" ext="1" cmd1="0x2E" d2="0x04" field="userData4" bit="3">ThermostatTemperatureScaleCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="ThermostatTimeFormat">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after time format has been changed -->
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData13" bit="4">ThermostatTimeFormatMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="StringType" ext="1" cmd1="0x2E" d2="0x04" field="userData4" bit="4">ThermostatTimeFormatCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="ThermostatDehumidifySetpoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after value has been changed -->
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" d3="0x01" field="userData4">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d1="0x01" d2="0x0B" field="userData3">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x2E" d1="0x01" d2="0x0B" field="userData3">CustomDimensionlessCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature-type>
+       <feature-type name="ThermostatHumidifySetpoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after value has been changed -->
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" d3="0x01" field="userData5">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d1="0x01" d2="0x0C" field="userData3">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x2E" d1="0x01" d2="0x0C" field="userData3">CustomDimensionlessCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature-type>
+       <feature-type name="ThermostatStage1Duration">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after value has been changed -->
+               <message-handler command="0x19">TriggerPollMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" d3="0x01" field="userData11"
+                       scale="minute">CustomTimeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2E" d1="0x01" d2="0x0A" field="userData3">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x2E" d1="0x01" d2="0x0A" field="userData3"
+                       scale="minute">CustomTimeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature-type>
+       <feature-type name="ThermostatHumidifierState" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles all-link broadcast message DEHUMIDIFYING ON -->
+               <message-handler command="0x11" group="3">ThermostatHumidifierDehumidifyingMsgHandler</message-handler>
+               <!-- handles all-link broadcast message DEHUMIDIFYING OFF -->
+               <message-handler command="0x13" group="3">ThermostatHumidifierOffMsgHandler</message-handler>
+               <!-- handles all-link broadcast message HUMIDIFYING ON -->
+               <message-handler command="0x11" group="4">ThermostatHumidifierHumidifyingMsgHandler</message-handler>
+               <!-- handles all-link broadcast message HUMIDIFYING OFF -->
+               <message-handler command="0x13" group="4">ThermostatHumidifierOffMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="ThermostatSystemState" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles all-link broadcast message COOLING ON -->
+               <message-handler command="0x11" group="1">ThermostatSystemCoolingMsgHandler</message-handler>
+               <!-- handles all-link broadcast message COOLING OFF -->
+               <message-handler command="0x13" group="1">ThermostatSystemOffMsgHandler</message-handler>
+               <!-- handles all-link broadcast message HEATING ON -->
+               <message-handler command="0x11" group="2">ThermostatSystemHeatingMsgHandler</message-handler>
+               <!-- handles all-link broadcast message HEATING OFF -->
+               <message-handler command="0x13" group="2">ThermostatSystemOffMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="ThermostatSyncTime">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">ThermostatSyncTimeCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="VenstarCoolSetpoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after set point has been changed -->
+               <message-handler command="0x19" ext="0" cmd1="0x6C" field="command2" factor="0.5">VenstarTemperatureMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData6">VenstarTemperatureMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x71" ext="0" cmd1="0x71" field="command2">VenstarTemperatureMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6C" field="command2" factor="2">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x6C" field="command2" factor="2"
+                       scale="fahrenheit">CustomTemperatureCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="VenstarHeatSetpoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after set point has been changed -->
+               <message-handler command="0x19" ext="0" cmd1="0x6D" field="command2" factor="0.5">VenstarTemperatureMsgHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData7">VenstarTemperatureMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x72" ext="0" cmd1="0x72" field="command2">VenstarTemperatureMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6D" field="command2" factor="2">CustomDecimalCommandHandler</command-handler>
+               <command-handler command="QuantityType" ext="1" cmd1="0x6D" field="command2" factor="2"
+                       scale="fahrenheit">CustomTemperatureCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="VenstarSystemMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after system mode has been changed -->
+               <message-handler command="0x19" ext="0" cmd1="0x6B" field="command2">VenstarSystemModeReplyHandler</message-handler>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData3">VenstarSystemModeMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x70" ext="0" cmd1="0x70" field="command2" mask="0x0F">VenstarSystemModeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="StringType" ext="1" cmd1="0x6B" field="command2">VenstarSystemModeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="VenstarFanMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct ack after fan mode has been changed -->
+               <message-handler command="0x19" ext="0" cmd1="0x6B" field="command2">ThermostatFanModeReplyHandler</message-handler>
+               <!-- handles direct extended message after query mask for second bit -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData9"
+                       mask="0x10" rshift="4">ThermostatFanModeMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x70" ext="0" cmd1="0x70" field="command2" mask="0x10" rshift="4">ThermostatFanModeMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="StringType" ext="1" cmd1="0x6B" field="command2">ThermostatFanModeCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="VenstarTemperature">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData5">VenstarTemperatureMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x6E" ext="0" cmd1="0x6E" field="command2" factor="0.5">VenstarTemperatureMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="VenstarHumidity">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler command="0x2E" ext="1" cmd1="0x2E" cmd2="0x00" d2="0x01" field="userData4">CustomDimensionlessMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler command="0x6F" ext="0" cmd1="0x6F" field="command2">CustomDimensionlessMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature-type>
+       <feature-type name="VenstarFanState" link="controller">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles all-link broadcast message FAN ON -->
+               <message-handler command="0x11" group="3">SwitchOnMsgHandler</message-handler>
+               <!-- handles all-link broadcast message FAN OFF -->
+               <message-handler command="0x13" group="3">SwitchOffMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="VenstarTemperatureScale">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler command="0x19" ext="0" cmd1="0xF0" field="command2" bit="0">ThermostatTemperatureScaleMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="StringType" ext="1" cmd1="0xF1" field="command2" bit="0">ThermostatTemperatureScaleCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0xF0" cmd2="0x49">FlexPollHandler</poll-handler>
+       </feature-type>
+
+       <feature-type name="IMButtonEvent" event="true">
+               <message-dispatcher>IMDispatcher</message-dispatcher>
+               <message-handler command="0x54">IMButtonEventMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="IMLEDOnOff">
+               <message-dispatcher>IMDispatcher</message-dispatcher>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">IMLEDOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="IMBeep">
+               <message-dispatcher>IMDispatcher</message-dispatcher>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">IMBeepCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="IMConfigGroup"> <!-- does the polling for im config -->
+               <message-dispatcher>IMGroupDispatcher</message-dispatcher>
+               <poll-handler cmd="0x73">IMPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="IMConfigFlags">
+               <message-dispatcher>IMDispatcher</message-dispatcher>
+               <message-handler command="0x6B">IMConfigMsgHandler</message-handler>
+               <message-handler command="0x73">IMConfigMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">IMConfigCommandHandler</command-handler>
+               <command-handler command="RefreshType">RefreshCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by IMConfigGroup -->
+       </feature-type>
+
+       <feature-type name="X10Dimmer">
+               <message-dispatcher>X10Dispatcher</message-dispatcher>
+               <message-handler command="0x02">X10OnHandler</message-handler>
+               <message-handler command="0x03">X10OffHandler</message-handler>
+               <message-handler command="0x05">X10BrightHandler</message-handler>
+               <message-handler command="0x04">X10DimHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">X10OnOffCommandHandler</command-handler>
+               <command-handler command="PercentType">X10PercentCommandHandler</command-handler>
+               <command-handler command="IncreaseDecreaseType">X10IncreaseDecreaseCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="X10Switch">
+               <message-dispatcher>X10Dispatcher</message-dispatcher>
+               <message-handler command="0x02">X10OnHandler</message-handler>
+               <message-handler command="0x03">X10OffHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">X10OnOffCommandHandler</command-handler>
+               <command-handler command="PercentType">NoOpCommandHandler</command-handler>
+               <command-handler command="IncreaseDecreaseType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+       <feature-type name="X10Contact">
+               <message-dispatcher>X10Dispatcher</message-dispatcher>
+               <message-handler command="0x02">X10OpenHandler</message-handler>
+               <message-handler command="0x03">X10ClosedHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature-type>
+</xml>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/device-products.xml b/bundles/org.openhab.binding.insteon/src/main/resources/device-products.xml
new file mode 100644 (file)
index 0000000..b52d13d
--- /dev/null
@@ -0,0 +1,1807 @@
+<xml>
+
+       <!-- Generalized Controllers (0x00) -->
+
+       <product devCat="0x00">
+               <description>Generalized Controller</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x00" subCat="0x04">
+               <description>ControLinc</description>
+               <model>2430</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_ControLinc</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x05" productKey="0x000034">
+               <description>RemoteLinc</description>
+               <model>2440</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_RemoteLinc</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x06">
+               <description>ICON Tabletop Controller</description>
+               <model>2830</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_ControLinc</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x08" productKey="0x00003D">
+               <description>Ethernet to X10/Insteon Controller</description>
+               <model>EZBridge</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x00" subCat="0x09">
+               <description>SignaLinc RF Signal Enhancer</description>
+               <model>2442</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x00" subCat="0x0A" productKey="0x000007">
+               <description>Poolux LCD Controller</description>
+               <vendor>Balboa</vendor>
+       </product>
+       <product devCat="0x00" subCat="0x0B" productKey="0x000022">
+               <description>Access Point</description>
+               <model>2443</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x00" subCat="0x0C" productKey="0x000028">
+               <description>Color Touchscreen</description>
+               <model>12005</model>
+               <vendor>IES</vendor>
+       </product>
+       <product devCat="0x00" subCat="0x0D" productKey="0x00004D">
+               <description>SmartLabs KeyFOB</description>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x00" subCat="0x0E">
+               <description>RemoteLinc EZ</description>
+               <model>2440EZ</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x00" subCat="0x10">
+               <description>RemoteLinc 2 Keypad 4-Scene</description>
+               <model>2444A2WH4</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteScene4</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x11">
+               <description>RemoteLinc 2 Switch</description>
+               <model>2444A3</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteSwitch</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x12">
+               <description>RemoteLinc 2 Keypad 8-Scene</description>
+               <model>2444A2WH8</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteScene8</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x13">
+               <description>Diagnostics Keypad</description>
+               <model>2993-222</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x00" subCat="0x14">
+               <description>Mini Remote Keypad 4-Scene</description>
+               <model>2342-432</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteScene4</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x15">
+               <description>Mini Remote Switch</description>
+               <model>2342-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteSwitch</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x16">
+               <description>Mini Remote Keypad 8-Scene</description>
+               <model>2342-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteScene8</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x17">
+               <description>Mini Remote Keypad 4-Scene</description>
+               <model>2342-532</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteScene4</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x18">
+               <description>Mini Remote Keypad 8-Scene</description>
+               <model>2342-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteScene8</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x19">
+               <description>Mini Remote Switch</description>
+               <model>2342-542</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteSwitch</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x1A">
+               <description>Mini Remote Keypad 8-Scene</description>
+               <model>2342-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteScene8</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x1B">
+               <description>Mini Remote Keypad 4-Scene</description>
+               <model>2342-232</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteScene4</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x1C">
+               <description>Mini Remote Switch</description>
+               <model>2342-242</model>
+               <vendor>Insteon</vendor>
+               <device-type>GeneralizedController_MiniRemoteSwitch</device-type>
+       </product>
+       <product devCat="0x00" subCat="0x1D" productKey="0x000022">
+               <description>Range Extender</description>
+               <model>2992-222</model>
+               <vendor>Insteon</vendor>
+       </product>
+
+       <!-- Dimmable Lighting Control (0x01) -->
+
+       <product devCat="0x01">
+               <description>Dimmable Lighting Control</description>
+               <vendor>Generic</vendor>
+               <device-type>DimmableLightingControl</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x00">
+               <description>LampLinc 3-Pin Dimmer</description>
+               <model>2456D3</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LampLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x01">
+               <description>SwitchLinc Dimmer</description>
+               <model>2476D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x02">
+               <description>In-LineLinc Dimmer</description>
+               <model>2475D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x03" firstRecord="0x00FF">
+               <description>ICON Dimmer Switch</description>
+               <model>2876DB</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x04">
+               <description>SwitchLinc Dimmer (High Wattage)</description>
+               <model>2476DH</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x05" productKey="0x000041">
+               <description>KeypadLinc Dimmer Countdown Timer</description>
+               <model>2484DWH8</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x06">
+               <description>LampLinc Dimmer</description>
+               <model>2456D2</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LampLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x07" firstRecord="0x00FF">
+               <description>ICON LampLinc</description>
+               <model>2856D2B</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LampLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x08" productKey="0x000040">
+               <description>SwitchLinc Dimmer Countdown Timer</description>
+               <model>2476DT</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x09" productKey="0x000037">
+               <description>KeypadLinc Dimmer 6-Button</description>
+               <model>2486DWH6</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_KeypadButton6</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x0A" firstRecord="0x00FF">
+               <description>ICON In-Wall Controller</description>
+               <model>2886D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x0B" productKey="0x00001C">
+               <description>Dimmer Module</description>
+               <model>2632-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x0C" productKey="0x00001D">
+               <description>KeypadLinc Dimmer 8-Button</description>
+               <model>2486DWH8</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_KeypadButton8</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x0D" productKey="0x00001E">
+               <description>SocketLinc</description>
+               <model>2454D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LampLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x0E" productKey="0x00004B">
+               <description>LampLinc Dimmer</description>
+               <model>2457D2</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LampLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x0F">
+               <description>Dimmer Module</description>
+               <model>2632-432</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x11">
+               <description>Dimmer Module</description>
+               <model>2632-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x12">
+               <description>Dimmer Module</description>
+               <model>2632-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x13" productKey="0x000032">
+               <description>ICON SwitchLinc Dimmer (Lixar)</description>
+               <model>2676D-B</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x17">
+               <description>ToggleLinc Dimmer</description>
+               <model>2466D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_ToggleLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x18" productKey="0x00003F" firstRecord="0x00FF">
+               <description>ICON SwitchLinc Dimmer In-Line Companion</description>
+               <model>2474D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x19" productKey="0x00004E">
+               <description>SwitchLinc Dimmer</description>
+               <model>2476D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x1A" productKey="0x00004F">
+               <description>In-LineLinc Dimmer</description>
+               <model>2475D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x1B" productKey="0x000050">
+               <description>KeypadLinc Dimmer 6-Button</description>
+               <model>2486DWH6</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_KeypadButton6</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x1C" productKey="0x000051">
+               <description>KeypadLinc Dimmer 8-Button</description>
+               <model>2486DWH8</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_KeypadButton8</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x1D" productKey="0x000052">
+               <description>SwitchLinc Dimmer (High Wattage)</description>
+               <model>2476DH</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x1E" firstRecord="0x00FF">
+               <description>ICON Dimmer Switch</description>
+               <model>2876DB</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x1F">
+               <description>ToggleLinc Dimmer</description>
+               <model>2466DW</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_ToggleLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x20" productKey="0x00006B">
+               <description>SwitchLinc Dimmer</description>
+               <model>2477D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x21">
+               <description>OutletLinc Dimmer</description>
+               <model>2472DWH</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_OutletLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x22">
+               <description>LampLinc Dimmer</description>
+               <model>2457D2X</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LampLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x23">
+               <description>LampLinc EZ</description>
+               <model>2457D2</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LampLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x24">
+               <description>SwitchLinc 2-Wire Dimmer</description>
+               <model>2474DWH</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x25">
+               <description>Ballast Dimmer</description>
+               <model>2475DA2</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x27" productKey="0x000087">
+               <description>Wall Dimmer</description>
+               <model>4701</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x29" productKey="0x000089">
+               <description>Wall Keypad Dimmer</description>
+               <model>4703</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x2A" productKey="0x00008B">
+               <description>Plug-in Dimmer</description>
+               <model>4705</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x2B" productKey="0x000091">
+               <description>Wall Dimmer (High Wattage)</description>
+               <model>4711</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x2C" productKey="0x000092">
+               <description>In-Line Dimmer</description>
+               <model>4712</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x2D" productKey="0x00009E">
+               <description>SwitchLinc Dimmer (High Wattage)</description>
+               <model>2477DH</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x2E">
+               <description>FanLinc</description>
+               <model>2475F</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_FanLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x2F">
+               <description>KeypadLinc Dimmer Schedule Timer</description>
+               <model>2484DST6</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x30">
+               <description>SwitchLinc Dimmer</description>
+               <model>2476D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x31">
+               <description>SwitchLinc Dimmer</description>
+               <model>2478D</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x32">
+               <description>In-LineLinc Dimmer</description>
+               <model>2475DA1</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x34">
+               <description>DIN Rail Dimmer</description>
+               <model>2452-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x35">
+               <description>Micro Dimmer</description>
+               <model>2442-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x36">
+               <description>DIN Rail Dimmer</description>
+               <model>2452-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x37">
+               <description>DIN Rail Dimmer</description>
+               <model>2452-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x38">
+               <description>Micro Dimmer</description>
+               <model>2442-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x39">
+               <description>Micro Dimmer</description>
+               <model>2442-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x3A">
+               <description>LED Bulb (A19)</description>
+               <model>2672-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x3B">
+               <description>LED Bulb (E27)</description>
+               <model>2672-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x3C">
+               <description>LED Bulb (E27)</description>
+               <model>2672-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x3D">
+               <description>Ballast Dimmer</description>
+               <model>2446-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x3E">
+               <description>Ballast Dimmer</description>
+               <model>2446-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x3F">
+               <description>Fixture Dimmer</description>
+               <model>2447-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x40">
+               <description>Fixture Dimmer</description>
+               <model>2447-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x41">
+               <description>Keypad Dimmer 8-Button</description>
+               <model>2334-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_KeypadButton8</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x42">
+               <description>Keypad Dimmer 6-Button</description>
+               <model>2334-232</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_KeypadButton6</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x49">
+               <description>LED Bulb (PAR38)</description>
+               <model>2674-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x4A">
+               <description>LED Bulb (PAR38)</description>
+               <model>2674-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x4B">
+               <description>LED Bulb (PAR38)</description>
+               <model>2674-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x4C">
+               <description>LED Bulb (E27)</description>
+               <model>2672-432</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x4D">
+               <description>LED Bulb (E27)</description>
+               <model>2672-532</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x4E">
+               <description>LED Bulb (PAR38)</description>
+               <model>2674-432</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x4F">
+               <description>LED Bulb (PAR38)</description>
+               <model>2674-532</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x50">
+               <description>Dimmer Module</description>
+               <model>2632-452</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x51">
+               <description>LED Bulb (E27)</description>
+               <model>2672-452</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_LEDBulb</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x57">
+               <description>i3 Paddle</description>
+               <model>PS01</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x58">
+               <description>i3 Dial</description>
+               <model>DS01</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x01" subCat="0x59">
+               <description>i3 Keypad</description>
+               <model>KP014</model>
+               <vendor>Insteon</vendor>
+               <device-type>DimmableLightingControl_SwitchLinc</device-type>
+       </product>
+
+       <!-- Switched Lighting Control (0x02) -->
+
+       <product devCat="0x02">
+               <description>Switched Lighting Control</description>
+               <vendor>Generic</vendor>
+               <device-type>SwitchedLightingControl</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x05" productKey="0x000042">
+               <description>KeypadLinc On/Off Switch 8-Button</description>
+               <model>2486SWH8</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_KeypadButton8</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x06" productKey="0x000048">
+               <description>Outdoor ApplianceLinc</description>
+               <model>2456S3E</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_ApplianceLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x07" productKey="0x000029">
+               <description>TimerLinc</description>
+               <model>2456S3T</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x08" productKey="0x000023">
+               <description>OutletLinc</description>
+               <model>2473S</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_OutletLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x09">
+               <description>ApplianceLinc</description>
+               <model>2456S3</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_ApplianceLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x0A">
+               <description>SwitchLinc Relay</description>
+               <model>2476S</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x0B" firstRecord="0x00FF">
+               <description>ICON On/Off Switch</description>
+               <model>2876SB</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x0C" firstRecord="0x00FF">
+               <description>ICON Appliance Module</description>
+               <model>2856S3B</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_ApplianceLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x0D">
+               <description>ToggleLinc Relay</description>
+               <model>2466S</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_ToggleLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x0E">
+               <description>SwitchLinc Relay Countdown Timer</description>
+               <model>2476ST</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x0F" productKey="0x000036">
+               <description>KeypadLinc On/Off Switch 6-Button</description>
+               <model>2486SWH6</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_KeypadButton6</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x10" productKey="0x00001B">
+               <description>In-LineLinc Relay</description>
+               <model>2475S</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x11" productKey="0x00003C">
+               <description>240V 30A Switch</description>
+               <model>EZSwitch30</model>
+               <vendor>Compacta</vendor>
+               <device-type>SwitchedLightingControl</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x12" productKey="0x00003E">
+               <description>ICON In-LineLinc Relay</description>
+               <model>2474S/D</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x13" productKey="0x000033">
+               <description>Icon SwitchLinc Relay (Lixar)</description>
+               <model>2676R-B</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x14" productKey="0x000045">
+               <description>In-LineLinc Relay with Sense</description>
+               <model>2475S2</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x15" productKey="0x000047">
+               <description>SwitchLinc Relay with Sense</description>
+               <model>2476SS</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x16" firstRecord="0x00FF">
+               <description>ICON On/Off Switch</description>
+               <model>2876SB</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x17" firstRecord="0x00FF">
+               <description>ICON Appliance Module</description>
+               <model>2856S3B</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_ApplianceLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x18" productKey="0x000060">
+               <description>SwitchLinc 220V Relay</description>
+               <model>2494S220</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x19">
+               <description>SwitchLinc 220V Relay</description>
+               <model>2494S220</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x1A">
+               <description>ToggleLinc Relay</description>
+               <model>2466S</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_ToggleLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x1C">
+               <description>SwitchLinc Relay</description>
+               <model>2476S</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x1E">
+               <description>KeypadLinc On/Off Switch 6-Button</description>
+               <model>2487S</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_KeypadButton6</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x1F">
+               <description>In-LineLinc Relay</description>
+               <model>2475SDB</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x20" productKey="0x00008A">
+               <description>Wall Keypad Switch</description>
+               <model>4704</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x21" productKey="0x00008C">
+               <description>Outlet Switch</description>
+               <model>4707</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x22" productKey="0x000093">
+               <description>In-Line Switch</description>
+               <model>4713</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x23" productKey="0x000088">
+               <description>Wall Switch</description>
+               <model>4702</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x24" productKey="0x0000A1">
+               <description>Wall Keypad Switch 277V</description>
+               <model>4102</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x25">
+               <description>KeypadLinc On/Off Switch Countdown Timer</description>
+               <model>2484SWH8</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x26">
+               <description>KeypadLinc On/Off Switch Schedule Timer</description>
+               <model>2485SWH6</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x29">
+               <description>SwitchLinc Relay Countdown Timer</description>
+               <model>2476ST</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x2A">
+               <description>SwitchLinc Relay</description>
+               <model>2477S</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x2B">
+               <description>In-LineLinc Relay</description>
+               <model>2475SDB-50</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_InLineLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x2C">
+               <description>KeypadLinc On/Off Switch 6-Button</description>
+               <model>2487S</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_KeypadButton6</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x2D">
+               <description>On/Off Module</description>
+               <model>2633-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x2E">
+               <description>DIN Rail On/Off</description>
+               <model>2453-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x2F">
+               <description>Micro On/Off</description>
+               <model>2443-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x30">
+               <description>On/Off Module</description>
+               <model>2633-432</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x31">
+               <description>Micro On/Off</description>
+               <model>2443-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x32">
+               <description>Micro On/Off</description>
+               <model>2443-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x33">
+               <description>DIN Rail On/Off</description>
+               <model>2453-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x34">
+               <description>DIN Rail On/Off</description>
+               <model>2453-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_MicroModule</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x35">
+               <description>On/Off Module</description>
+               <model>2633-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x36">
+               <description>On/Off Module</description>
+               <model>2633-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x37">
+               <description>On/Off Module</description>
+               <model>2635-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x38">
+               <description>On/Off Outdoor Module</description>
+               <model>2634-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_ApplianceLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x39">
+               <description>On/Off Outlet</description>
+               <model>2663-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_OnOffOutlet</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x3A">
+               <description>On/Off Module</description>
+               <model>2633-452</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_SwitchLinc</device-type>
+       </product>
+       <product devCat="0x02" subCat="0x3F">
+               <description>i3 Outlet</description>
+               <model>WR01</model>
+               <vendor>Insteon</vendor>
+               <device-type>SwitchedLightingControl_OnOffOutlet</device-type>
+       </product>
+
+       <!-- Network Bridges (0x03) -->
+
+       <product devCat="0x03">
+               <description>Network Bridge</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x01">
+               <description>PowerLinc Serial Controller</description>
+               <model>2414S</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x02">
+               <description>PowerLinc USB Controller</description>
+               <model>2414U</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x03">
+               <description>ICON PowerLinc Serial</description>
+               <model>2814S</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x04">
+               <description>ICON PowerLinc USB</description>
+               <model>2814U</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x05" productKey="0x00000C">
+               <description>PowerLinc Serial Modem</description>
+               <model>2412S</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x06" productKey="0x000016">
+               <description>IRLinc Receiver</description>
+               <model>2411R</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x07" productKey="0x000017">
+               <description>IRLinc Transmitter</description>
+               <model>2411T</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x08" productKey="0x000018">
+               <description>Bi-Directional IR</description>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x09" productKey="0x000019">
+               <description>RF Developer Board</description>
+               <model>2600RF</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x0A" productKey="0x00000D">
+               <description>SeriaLinc</description>
+               <model>2410S</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x0B" productKey="0x000030">
+               <description>PowerLinc USB Modem</description>
+               <model>2412U</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x0C" productKey="0x000031">
+               <description>PLM Alert Serial</description>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x0D" productKey="0x000035">
+               <description>X10 RF Transceiver</description>
+               <model>EZX10RF</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x0E" productKey="0x00002C">
+               <description>X10 Translator</description>
+               <model>TW-523/PSC05</model>
+               <vendor>X10</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x0F" productKey="0x00003B">
+               <description>X10 IR Receiver</description>
+               <model>EZX10IR</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x10" productKey="0x000044">
+               <description>SmartLinc</description>
+               <model>2412N</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x11" productKey="0x000045">
+               <description>PowerLinc Serial Modem</description>
+               <model>2413S</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x12" productKey="0x00004C">
+               <description>RF Modem Card</description>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x13" productKey="0x000053">
+               <description>PowerLinc USB Modem (HouseLinc)</description>
+               <model>2412UH</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x14" productKey="0x000054">
+               <description>PowerLinc Serial Modem (HouseLinc)</description>
+               <model>2412SH</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x15">
+               <description>PowerLinc USB Modem</description>
+               <model>2413U</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x18">
+               <description>Central Controller</description>
+               <model>2243-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x19">
+               <description>PowerLinc Serial Modem (HouseLinc)</description>
+               <model>2413SH</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x1A">
+               <description>PowerLinc USB Modem (HouseLinc)</description>
+               <model>2413UH</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x1B">
+               <description>iGateway</description>
+               <model>2423A4</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x1C">
+               <description>iGateway 2.0</description>
+               <model>2423A7</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x1E">
+               <description>PowerLinc Serial Modem</description>
+               <model>2412S</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_PLM</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x1F" productKey="0x00007E">
+               <description>USB Adapter</description>
+               <model>2448A7</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x20" productKey="0x00007E">
+               <description>USB Adapter</description>
+               <model>2448A7</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x21" productKey="0x00008E">
+               <description>USB Adapter (HouseLinc)</description>
+               <model>2448A7H</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x22" productKey="0x00008F">
+               <description>Central Controller Interface</description>
+               <model>4706A</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x23" productKey="0x00008E">
+               <description>USB Adapter (HouseLinc)</description>
+               <model>2448A7H</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x24" productKey="0x0000A2">
+               <description>TouchLinc</description>
+               <model>2448A7T</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x27" productKey="0x0000A2">
+               <description>TouchLinc</description>
+               <model>2448A7T</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x03" subCat="0x2B">
+               <description>Hub</description>
+               <model>2242-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x2C">
+               <description>Central Controller</description>
+               <model>2243-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x2D">
+               <description>Central Controller</description>
+               <model>2243-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x2E">
+               <description>Hub</description>
+               <model>2242-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x2F">
+               <description>Hub</description>
+               <model>2242-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x30">
+               <description>Hub</description>
+               <model>2242-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x31">
+               <description>Hub</description>
+               <model>2242-232</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x32">
+               <description>Hub</description>
+               <model>2242-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x33">
+               <description>Hub</description>
+               <model>2245-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x34">
+               <description>Hub</description>
+               <model>2245-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x35">
+               <description>Hub</description>
+               <model>2245-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x36">
+               <description>Hub</description>
+               <model>2245-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+       <product devCat="0x03" subCat="0x37">
+               <description>Hub</description>
+               <model>2242-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>NetworkBridge_Hub</device-type>
+       </product>
+
+       <!-- Irrigation Control (0x04) -->
+
+       <product devCat="0x04">
+               <description>Irrigation Control</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x04" subCat="0x00" productKey="0x000001" firstRecord="0x00FF">
+               <description>Sprinkler Controller</description>
+               <model>EZRain</model>
+               <vendor>Compacta</vendor>
+               <device-type>IrrigationControl_Sprinkler</device-type>
+       </product>
+
+       <!-- Climate Control (0x05) -->
+
+       <product devCat="0x05">
+               <description>Climate Control</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x00">
+               <description>SMSC080 Exhaust Fan</description>
+               <model>2670IAQ-80</model>
+               <vendor>Broan</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x01" productKey="0x000002">
+               <description>Thermostat</description>
+               <model>EZTherm</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x02">
+               <description>SMSC110 Exhaust Fan</description>
+               <model>2670IAQ-110</model>
+               <vendor>Broan</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x03" productKey="0x00001F">
+               <description>Venstar Thermostat Adapter</description>
+               <model>2441V</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_VenstarThermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x04" productKey="0x000024">
+               <description>Thermostat</description>
+               <model>EZThermx</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x05" productKey="0x000038">
+               <description>Venmar BEST Rangehoods</description>
+               <vendor>Broan</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x06" productKey="0x000043">
+               <description>SmartSense Make-up Damper</description>
+               <vendor>Broan</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x07" firstRecord="0x1FFF">
+               <description>Wireless Thermostat</description>
+               <model>2441ZTH</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_WirelessThermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x08" firstRecord="0x1FFF">
+               <description>Thermostat</description>
+               <model>2441TH</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x09" productKey="0x000094">
+               <description>7 Day Thermostat</description>
+               <model>4715</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x0A" firstRecord="0x1FFF">
+               <description>Wireless Thermostat</description>
+               <model>2441ZTH</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_WirelessThermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x0B" firstRecord="0x1FFF">
+               <description>Thermostat</description>
+               <model>2441TH</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x0C">
+               <description>SMSC080 Exhaust Fan</description>
+               <model>2670IAQ-80</model>
+               <vendor>Broan</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x0D">
+               <description>SMSC110 Exhaust Fan</description>
+               <model>2670IAQ-110</model>
+               <vendor>Broan</vendor>
+       </product>
+       <product devCat="0x05" subCat="0x0E">
+               <description>Venstar Integrated Thermostat</description>
+               <model>2491T1E</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_VenstarThermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x0F" firstRecord="0x1FFF">
+               <description>Thermostat</description>
+               <model>2732-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x10" firstRecord="0x1FFF">
+               <description>Thermostat</description>
+               <model>2732-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x11" firstRecord="0x1FFF">
+               <description>Wireless Thermostat</description>
+               <model>2732-432</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_WirelessThermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x12" firstRecord="0x1FFF">
+               <description>Wireless Thermostat</description>
+               <model>2732-532</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_WirelessThermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x13" firstRecord="0x1FFF">
+               <description>Thermostat (Heat Pump)</description>
+               <model>2732-242</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x14" firstRecord="0x1FFF">
+               <description>Thermostat (Heat Pump)</description>
+               <model>2732-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x15" firstRecord="0x1FFF">
+               <description>Thermostat (Heat Pump)</description>
+               <model>2732-542</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x16" firstRecord="0x1FFF">
+               <description>Thermostat</description>
+               <model>2441TH</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x17" firstRecord="0x1FFF">
+               <description>Thermostat</description>
+               <model>2732-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+       <product devCat="0x05" subCat="0x18" firstRecord="0x1FFF">
+               <description>Thermostat</description>
+               <model>2732-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>ClimateControl_Thermostat</device-type>
+       </product>
+
+       <!-- Pool and Spa Control (0x06) -->
+
+       <product devCat="0x06">
+               <description>Pool Spa Control</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x06" subCat="0x00" productKey="0x000003">
+               <description>Pool Controller</description>
+               <model>EZPool</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x06" subCat="0x01" productKey="0x000008">
+               <description>Low-End Pool Controller</description>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x06" subCat="0x02" productKey="0x000009">
+               <description>Mid-Range Pool Controller</description>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x06" subCat="0x03" productKey="0x00000A">
+               <description>Next Generation Pool Controller</description>
+               <vendor>Insteon</vendor>
+       </product>
+
+       <!-- Sensors and Actuators (0x07) -->
+
+       <product devCat="0x07">
+               <description>Sensor Actuator</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x00" productKey="0x00001A">
+               <description>I/O Linc</description>
+               <model>2450</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x01" productKey="0x000004">
+               <description>Sensor Interface Module</description>
+               <model>EZSns1W</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x02" productKey="0x000012">
+               <description>I/O Module</description>
+               <model>EZIO8T</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x03" productKey="0x000005">
+               <description>I/O Module</description>
+               <model>EZIO2X4</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x04" productKey="0x000013">
+               <description>I/O Module</description>
+               <model>EZIO8SA</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x05" productKey="0x000014">
+               <description>RF Receiver Interface Module</description>
+               <model>EZSnsRF</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x06" productKey="0x000015">
+               <description>Sensor Interface Module</description>
+               <model>EZISnsRf</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x07" productKey="0x000039">
+               <description>I/O Module (6 Inputs)</description>
+               <model>EZIO6I</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x08" productKey="0x00003A">
+               <description>I/O Module (4 Relay Outputs)</description>
+               <model>EZIO4O</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x09">
+               <description>SynchroLinc</description>
+               <model>2423A5</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x0C">
+               <description>Lumistat</description>
+               <model>2448A5</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x07" subCat="0x0D">
+               <description>I/O Linc</description>
+               <model>2450</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x0E">
+               <description>I/O Module</description>
+               <model>2248-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x0F">
+               <description>I/O Module</description>
+               <model>2248-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x10">
+               <description>I/O Module</description>
+               <model>2248-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x11">
+               <description>I/O Module</description>
+               <model>2248-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x12">
+               <description>I/O Module</description>
+               <model>2822-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x13">
+               <description>I/O Module</description>
+               <model>2822-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x14">
+               <description>I/O Module</description>
+               <model>2822-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x15">
+               <description>I/O Module</description>
+               <model>2822-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x16">
+               <description>I/O Module</description>
+               <model>2822-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x17">
+               <description>I/O Module</description>
+               <model>2822-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x18">
+               <description>I/O Module</description>
+               <model>2822-442</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x19">
+               <description>I/O Module</description>
+               <model>2822-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_IOLinc</device-type>
+       </product>
+       <product devCat="0x07" subCat="0x1E" firstRecord="0x00FF">
+               <description>Siren Module</description>
+               <model>2868-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SensorsActuators_Siren</device-type>
+       </product>
+
+       <!-- Home Entertainment (0x08) -->
+
+       <!-- Energy Management (0x09) -->
+
+       <product devCat="0x09">
+               <description>Energy Management</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x00" productKey="0x000006">
+               <description>Energy Management</description>
+               <model>EZEnergy</model>
+               <vendor>Compacta</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x01" productKey="0x000020">
+               <description>Leak Detector</description>
+               <vendor>OnSitePro</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x02" productKey="0x000021">
+               <description>Control Valve</description>
+               <vendor>OnSitePro</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x03" productKey="0x000025">
+               <description>TED 5000 Single Phase MTU</description>
+               <vendor>Energy Inc.</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x04" productKey="0x000026">
+               <description>TED 5000 Gateway USB</description>
+               <vendor>Energy Inc.</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x05" productKey="0x00002A">
+               <description>TED 5000 Gateway Ethernet</description>
+               <vendor>Energy Inc.</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x06" productKey="0x00002B">
+               <description>TED 3000 Three Phase MTU</description>
+               <vendor>Energy Inc.</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x07" productKey="0x00006C">
+               <description>iMeter Solo</description>
+               <model>2423A1</model>
+               <vendor>Insteon</vendor>
+               <device-type>EnergyManagement_PowerMeter</device-type>
+       </product>
+       <product devCat="0x09" subCat="0x08">
+               <description>iMeter Home (Breaker Panel)</description>
+               <model>2423A2</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x09">
+               <description>iMeter Home (Meter)</description>
+               <model>2423A3</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x0A">
+               <description>220V/240V 30 AMP Load Controller Normally Open</description>
+               <model>2477SA1</model>
+               <vendor>Insteon</vendor>
+               <device-type>EnergyManagement_LoadController</device-type>
+       </product>
+       <product devCat="0x09" subCat="0x0B">
+               <description>220V/240V 30 AMP Load Controller Normally Closed</description>
+               <model>2477SA2</model>
+               <vendor>Insteon</vendor>
+               <device-type>EnergyManagement_LoadController</device-type>
+       </product>
+       <product devCat="0x09" subCat="0x0C">
+               <description>GE Water Heater U-SNAP Module</description>
+               <model>2630A1</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x0D">
+               <description>Energy Display</description>
+               <model>2448A2</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x0E">
+               <description>Power Strip with iMeter and SynchroLinc</description>
+               <model>2423A6</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x10" productKey="0x000090">
+               <description>Network Hub</description>
+               <model>4700</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x09" subCat="0x11">
+               <description>Digital Meter Reader</description>
+               <model>2423A8</model>
+               <vendor>Insteon</vendor>
+       </product>
+
+       <!-- Built-In Appliance Control (0x0A) -->
+
+       <!-- Plumbing (0x0B) -->
+
+       <!-- Communication (0x0C) -->
+
+       <!-- Computer Control (0x0D) -->
+
+       <!-- Window Coverings (0x0E) -->
+
+       <product devCat="0x0E">
+               <description>Window Coverings</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x0E" subCat="0x00" productKey="0x00000B">
+               <description>Drape Controller RF Bridge</description>
+               <model>318276I</model>
+               <vendor>Somfy</vendor>
+       </product>
+       <product devCat="0x0E" subCat="0x01">
+               <description>Micro Open/Close</description>
+               <model>2444-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>WindowCovering_MicroModule</device-type>
+       </product>
+       <product devCat="0x0E" subCat="0x02">
+               <description>Micro Open/Close</description>
+               <model>2444-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>WindowCovering_MicroModule</device-type>
+       </product>
+       <product devCat="0x0E" subCat="0x03">
+               <description>Micro Open/Close</description>
+               <model>2444-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>WindowCovering_MicroModule</device-type>
+       </product>
+       <product devCat="0x0E" subCat="0x04">
+               <description>Window Shade Kit</description>
+               <model>2772-222</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x0E" subCat="0x05">
+               <description>Window Shade Kit</description>
+               <model>2772-422</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x0E" subCat="0x06">
+               <description>Window Shade Kit</description>
+               <model>2772-522</model>
+               <vendor>Insteon</vendor>
+       </product>
+
+       <!-- Access Control (0x0F) -->
+
+       <product devCat="0x0F">
+               <description>Access Control</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x00" productKey="0x00000E">
+               <description>Central Drive and Controller</description>
+               <vendor>Weiland Doors</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x01" productKey="0x00000F">
+               <description>Secondary Central Drive</description>
+               <vendor>Weiland Doors</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x02" productKey="0x000010">
+               <description>Assist Drive</description>
+               <vendor>Weiland Doors</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x03" productKey="0x000011">
+               <description>Elevation Drive</description>
+               <vendor>Weiland Doors</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x04">
+               <description>Garage Unit</description>
+               <vendor>GarageHawk</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x05">
+               <description>Remote Unit</description>
+               <vendor>GarageHawk</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x06">
+               <description>MorningLinc</description>
+               <model>2458A1</model>
+               <vendor>Insteon</vendor>
+               <device-type>AccessControl_LockController</device-type>
+       </product>
+       <product devCat="0x0F" subCat="0x07">
+               <description>Deadbolt</description>
+               <model>2863-222</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x08">
+               <description>Deadbolt</description>
+               <model>2863-422</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x09">
+               <description>Deadbolt</description>
+               <model>2863-522</model>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x0F" subCat="0x0A">
+               <description>Lock Controller</description>
+               <model>2862-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>AccessControl_LockController</device-type>
+       </product>
+
+       <!-- Security, Health, Safety (0x10) -->
+
+       <product devCat="0x10">
+               <description>Security Heath Safety Device</description>
+               <vendor>Generic</vendor>
+       </product>
+       <product devCat="0x10" subCat="0x00" productKey="0x000027">
+               <description>First Alert ONELink RF Bridge</description>
+               <vendor>Insteon</vendor>
+       </product>
+       <product devCat="0x10" subCat="0x01" productKey="0x00004A">
+               <description>Motion Sensor</description>
+               <model>2842-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_MotionSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x02" productKey="0x000049">
+               <description>Open/Close Sensor</description>
+               <model>2843-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_OpenCloseSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x03">
+               <description>Motion Sensor</description>
+               <model>4716</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_MotionSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x04">
+               <description>Motion Sensor</description>
+               <model>2842-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_MotionSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x05">
+               <description>Motion Sensor</description>
+               <model>2842-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_MotionSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x06">
+               <description>Open/Close Sensor</description>
+               <model>2843-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_OpenCloseSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x07">
+               <description>Open/Close Sensor</description>
+               <model>2843-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_OpenCloseSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x08">
+               <description>Leak Sensor</description>
+               <model>2852-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_LeakSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x09">
+               <description>Door Sensor</description>
+               <model>2843-232</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_OpenCloseSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x0A">
+               <description>Smoke Bridge</description>
+               <model>2982-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_SmokeBridge</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x11">
+               <description>Hidden Door Sensor</description>
+               <model>2845-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_DoorSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x14">
+               <description>Hidden Door Sensor</description>
+               <model>2845-422</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_DoorSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x15">
+               <description>Hidden Door Sensor</description>
+               <model>2845-522</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_DoorSensor</device-type>
+       </product>
+       <product devCat="0x10" subCat="0x16">
+               <description>Motion Sensor II</description>
+               <model>2844-222</model>
+               <vendor>Insteon</vendor>
+               <device-type>SecurityHealthSafety_MotionSensor2</device-type>
+       </product>
+
+       <!-- Surveillance (0x11) -->
+
+       <!-- Automotive (0x12) -->
+
+       <!-- Pet Care (0x13) -->
+
+       <!-- Toys (0x14) -->
+
+       <!-- Timekeeping (0x15) -->
+
+       <!-- Holiday (0x16) -->
+
+       <!-- Unassigned (0xFF) -->
+
+</xml>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/device-types.xml b/bundles/org.openhab.binding.insteon/src/main/resources/device-types.xml
new file mode 100644 (file)
index 0000000..a3b2831
--- /dev/null
@@ -0,0 +1,1023 @@
+<xml>
+
+       <!-- Base features for all device types except Network Bridge and X10 -->
+
+       <base-features>
+               <feature name="insteonEngine">InsteonEngine</feature>
+               <feature name="ping">Ping</feature>
+               <feature name="databaseDelta">DatabaseDelta</feature>
+               <feature name="lastHeardFrom">GenericLastTime</feature>
+       </base-features>
+
+       <!-- Generalized Controllers -->
+
+       <device-type name="GeneralizedController_ControLinc" batteryPowered="true">
+               <feature name="eventButtonA" group="1">GenericButtonEvent</feature>
+               <feature name="eventButtonB" group="2">GenericButtonEvent</feature>
+               <feature name="eventButtonC" group="3">GenericButtonEvent</feature>
+               <feature name="eventButtonD" group="4">GenericButtonEvent</feature>
+               <feature name="eventButtonE" group="5">GenericButtonEvent</feature>
+               <feature name="eventButtonF" group="6">GenericButtonEvent</feature>
+               <default-link name="buttonA" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonB" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonC" type="controller" group="3" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonD" type="controller" group="4" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonE" type="controller" group="5" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonF" type="controller" group="6" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="GeneralizedController_RemoteLinc" batteryPowered="true">
+               <feature name="eventButtonA" group="1">GenericButtonEvent</feature>
+               <feature name="eventButtonB" group="2">GenericButtonEvent</feature>
+               <feature name="eventButtonC" group="3">GenericButtonEvent</feature>
+               <feature name="eventButtonD" group="4">GenericButtonEvent</feature>
+               <feature name="eventButtonE" group="5">GenericButtonEvent</feature>
+               <feature name="eventButtonF" group="6">GenericButtonEvent</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="stayAwake" bit="3" on="0x06" off="0x07">OpFlags</feature>
+               </feature-group>
+               <default-link name="buttonA" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonB" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonC" type="controller" group="3" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonD" type="controller" group="4" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonE" type="controller" group="5" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonF" type="controller" group="6" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="GeneralizedController_MiniRemoteScene4" batteryPowered="true">
+               <feature name="eventButtonA" group="1">GenericButtonEvent</feature>
+               <feature name="eventButtonB" group="2">GenericButtonEvent</feature>
+               <feature name="eventButtonC" group="3">GenericButtonEvent</feature>
+               <feature name="eventButtonD" group="4">GenericButtonEvent</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledOnOff" bit="1" on="0x02" off="0x03" inverted="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="stayAwake" bit="3" on="0x06" off="0x07">OpFlags</feature>
+               </feature-group>
+               <default-link name="buttonA" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonB" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonC" type="controller" group="3" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonD" type="controller" group="4" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="GeneralizedController_MiniRemoteScene8" batteryPowered="true">
+               <feature name="eventButtonA" group="1">GenericButtonEvent</feature>
+               <feature name="eventButtonB" group="2">GenericButtonEvent</feature>
+               <feature name="eventButtonC" group="3">GenericButtonEvent</feature>
+               <feature name="eventButtonD" group="4">GenericButtonEvent</feature>
+               <feature name="eventButtonE" group="5">GenericButtonEvent</feature>
+               <feature name="eventButtonF" group="6">GenericButtonEvent</feature>
+               <feature name="eventButtonG" group="7">GenericButtonEvent</feature>
+               <feature name="eventButtonH" group="8">GenericButtonEvent</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledOnOff" bit="1" on="0x02" off="0x03" inverted="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="stayAwake" bit="3" on="0x06" off="0x07">OpFlags</feature>
+               </feature-group>
+               <default-link name="buttonA" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonB" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonC" type="controller" group="3" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonD" type="controller" group="4" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonE" type="controller" group="5" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonF" type="controller" group="6" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonG" type="controller" group="7" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="buttonH" type="controller" group="8" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="GeneralizedController_MiniRemoteSwitch" batteryPowered="true">
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledOnOff" bit="1" on="0x02" off="0x03" inverted="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="stayAwake" bit="3" on="0x06" off="0x07">OpFlags</feature>
+               </feature-group>
+               <default-link name="button" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <!-- Dimmable Lighting Control -->
+
+       <device-type name="DimmableLightingControl">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <default-link name="dimmer" type="controller" group="1" data1="0x03" data2="0x1C" data3="0x01"/>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_LampLinc">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="resumeDim" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="loadSense" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="dimmer" type="controller" group="1" data1="0x03" data2="0x1C" data3="0x01"/>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_SwitchLinc">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="resumeDim" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="dimmer" type="controller" group="1" data1="0x03" data2="0x1C" data3="0x01"/>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_ToggleLinc">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="resumeDim" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="dimmer" type="controller" group="1" data1="0x03" data2="0x1C" data3="0x01"/>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_InLineLinc">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true">OpFlags</feature>
+               </feature-group>
+               <default-link name="dimmer" type="controller" group="1" data1="0x03" data2="0x1C" data3="0x01"/>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_FanLinc">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="fanSpeed">FanLincFan</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+               </feature-group>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_OutletLinc">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="loadSense" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="dimmer" type="controller" group="1" data1="0x03" data2="0x1C" data3="0x01"/>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_KeypadButton6">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature-group name="buttonGroup" type="KeypadButtonStatusGroup">
+                       <feature name="buttonA" group="3">KeypadButton</feature>
+                       <feature name="buttonB" group="4">KeypadButton</feature>
+                       <feature name="buttonC" group="5">KeypadButton</feature>
+                       <feature name="buttonD" group="6">KeypadButton</feature>
+               </feature-group>
+               <feature name="eventButtonMain" group="1">GenericButtonEvent</feature>
+               <feature name="eventButtonA" group="3">GenericButtonEvent</feature>
+               <feature name="eventButtonB" group="4">GenericButtonEvent</feature>
+               <feature name="eventButtonC" group="5">GenericButtonEvent</feature>
+               <feature name="eventButtonD" group="6">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonA" type="ExtDataGroup" group="3">
+                       <feature name="onMaskButtonA">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonA">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonA">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonB" type="ExtDataGroup" group="4">
+                       <feature name="onMaskButtonB">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonB">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonB">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonC" type="ExtDataGroup" group="5">
+                       <feature name="onMaskButtonC">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonC">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonC">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonD" type="ExtDataGroup" group="6">
+                       <feature name="onMaskButtonD">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonD">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonD">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="resumeDim" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="buttonConfig" bit="3" on="0x06" off="0x07">KeypadButtonConfig</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="dimmer" type="controller" group="1" data1="0x03" data2="0x1C" data3="0x01"/>
+               <default-link name="buttonA" type="controller" group="3" data1="0x03" data2="0x1C" data3="0x03"/>
+               <default-link name="buttonB" type="controller" group="4" data1="0x03" data2="0x1C" data3="0x04"/>
+               <default-link name="buttonC" type="controller" group="5" data1="0x03" data2="0x1C" data3="0x05"/>
+               <default-link name="buttonD" type="controller" group="6" data1="0x03" data2="0x1C" data3="0x06"/>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_KeypadButton8">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature-group name="buttonGroup" type="KeypadButtonStatusGroup">
+                       <feature name="buttonB" group="2">KeypadButton</feature>
+                       <feature name="buttonC" group="3">KeypadButton</feature>
+                       <feature name="buttonD" group="4">KeypadButton</feature>
+                       <feature name="buttonE" group="5">KeypadButton</feature>
+                       <feature name="buttonF" group="6">KeypadButton</feature>
+                       <feature name="buttonG" group="7">KeypadButton</feature>
+                       <feature name="buttonH" group="8">KeypadButton</feature>
+               </feature-group>
+               <feature name="eventButtonMain" group="1">GenericButtonEvent</feature>
+               <feature name="eventButtonB" group="2">GenericButtonEvent</feature>
+               <feature name="eventButtonC" group="3">GenericButtonEvent</feature>
+               <feature name="eventButtonD" group="4">GenericButtonEvent</feature>
+               <feature name="eventButtonE" group="5">GenericButtonEvent</feature>
+               <feature name="eventButtonF" group="6">GenericButtonEvent</feature>
+               <feature name="eventButtonG" group="7">GenericButtonEvent</feature>
+               <feature name="eventButtonH" group="8">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonB" type="ExtDataGroup" group="2">
+                       <feature name="onMaskButtonB">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonB">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonB">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonC" type="ExtDataGroup" group="3">
+                       <feature name="onMaskButtonC">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonC">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonC">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonD" type="ExtDataGroup" group="4">
+                       <feature name="onMaskButtonD">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonD">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonD">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonE" type="ExtDataGroup" group="5">
+                       <feature name="onMaskButtonE">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonE">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonE">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonF" type="ExtDataGroup" group="6">
+                       <feature name="onMaskButtonF">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonF">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonF">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonG" type="ExtDataGroup" group="7">
+                       <feature name="onMaskButtonG">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonG">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonG">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonH" type="ExtDataGroup" group="8">
+                       <feature name="onMaskButtonH">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonH">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonH">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="resumeDim" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="buttonConfig" bit="3" on="0x06" off="0x07">KeypadButtonConfig</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="dimmer" type="controller" group="1" data1="0x03" data2="0x1C" data3="0x01"/>
+               <default-link name="buttonB" type="controller" group="2" data1="0x03" data2="0x1C" data3="0x02"/>
+               <default-link name="buttonC" type="controller" group="3" data1="0x03" data2="0x1C" data3="0x03"/>
+               <default-link name="buttonD" type="controller" group="4" data1="0x03" data2="0x1C" data3="0x04"/>
+               <default-link name="buttonE" type="controller" group="5" data1="0x03" data2="0x1C" data3="0x05"/>
+               <default-link name="buttonF" type="controller" group="6" data1="0x03" data2="0x1C" data3="0x06"/>
+               <default-link name="buttonG" type="controller" group="7" data1="0x03" data2="0x1C" data3="0x07"/>
+               <default-link name="buttonH" type="controller" group="8" data1="0x03" data2="0x1C" data3="0x08"/>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_LEDBulb">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+               </feature-group>
+       </device-type>
+
+       <device-type name="DimmableLightingControl_MicroModule">
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="resumeDim" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <feature-group name="microModuleOpFlagsGroup" type="MicroModuleOpFlagsGroup">
+                       <feature name="operationMode">MicroModuleOpMode</feature>
+                       <feature name="3WayMode" bit="2" on="0x23" off="0x22" inverted="true">OpFlags</feature>
+               </feature-group>
+               <default-link name="dimmer" type="controller" group="1" data1="0x03" data2="0x1C" data3="0x01"/>
+       </device-type>
+
+       <!-- Switched Lighting Control -->
+
+       <device-type name="SwitchedLightingControl">
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SwitchedLightingControl_SwitchLinc">
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SwitchedLightingControl_ToggleLinc">
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SwitchedLightingControl_InLineLinc">
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true">OpFlags</feature>
+               </feature-group>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SwitchedLightingControl_ApplianceLinc">
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true">OpFlags</feature>
+                       <feature name="loadSense" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SwitchedLightingControl_OutletLinc">
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true">OpFlags</feature>
+                       <feature name="loadSense" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SwitchedLightingControl_OnOffOutlet">
+               <feature-group name="outletStatusGroup" type="OutletStatusGroup">
+                       <feature name="outletTop" group="1">OutletSwitch</feature>
+                       <feature name="outletBottom" group="2">OutletSwitch</feature>
+               </feature-group>
+               <feature name="eventButtonTop" group="1">GenericButtonEvent</feature>
+               <feature name="eventButtonBottom" group="2">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="loadSenseTop" bit="3" on="0x06" off="0x07">OpFlags</feature>
+                       <feature name="loadSenseBottom" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="outletTop" type="controller" group="1" data1="0x03" data2="0x00" data3="0x01"/>
+               <default-link name="outletBottom" type="controller" group="2" data1="0x03" data2="0x00" data3="0x02"/>
+       </device-type>
+
+       <device-type name="SwitchedLightingControl_KeypadButton6">
+               <feature name="switch">GenericSwitch</feature>
+               <feature-group name="buttonGroup" type="KeypadButtonStatusGroup">
+                       <feature name="buttonA" group="3">KeypadButton</feature>
+                       <feature name="buttonB" group="4">KeypadButton</feature>
+                       <feature name="buttonC" group="5">KeypadButton</feature>
+                       <feature name="buttonD" group="6">KeypadButton</feature>
+               </feature-group>
+               <feature name="eventButtonMain" group="1">GenericButtonEvent</feature>
+               <feature name="eventButtonA" group="3">GenericButtonEvent</feature>
+               <feature name="eventButtonB" group="4">GenericButtonEvent</feature>
+               <feature name="eventButtonC" group="5">GenericButtonEvent</feature>
+               <feature name="eventButtonD" group="6">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonA" type="ExtDataGroup" group="3">
+                       <feature name="onMaskButtonA">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonA">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonA">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonB" type="ExtDataGroup" group="4">
+                       <feature name="onMaskButtonB">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonB">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonB">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonC" type="ExtDataGroup" group="5">
+                       <feature name="onMaskButtonC">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonC">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonC">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonD" type="ExtDataGroup" group="6">
+                       <feature name="onMaskButtonD">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonD">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonD">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="buttonConfig" bit="3" on="0x06" off="0x07">KeypadButtonConfig</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x01"/>
+               <default-link name="buttonA" type="controller" group="3" data1="0x03" data2="0x00" data3="0x03"/>
+               <default-link name="buttonB" type="controller" group="4" data1="0x03" data2="0x00" data3="0x04"/>
+               <default-link name="buttonC" type="controller" group="5" data1="0x03" data2="0x00" data3="0x05"/>
+               <default-link name="buttonD" type="controller" group="6" data1="0x03" data2="0x00" data3="0x06"/>
+       </device-type>
+
+       <device-type name="SwitchedLightingControl_KeypadButton8">
+               <feature name="switch">GenericSwitch</feature>
+               <feature-group name="buttonGroup" type="KeypadButtonStatusGroup">
+                       <feature name="buttonB" group="2">KeypadButton</feature>
+                       <feature name="buttonC" group="3">KeypadButton</feature>
+                       <feature name="buttonD" group="4">KeypadButton</feature>
+                       <feature name="buttonE" group="5">KeypadButton</feature>
+                       <feature name="buttonF" group="6">KeypadButton</feature>
+                       <feature name="buttonG" group="7">KeypadButton</feature>
+                       <feature name="buttonH" group="8">KeypadButton</feature>
+               </feature-group>
+               <feature name="eventButtonMain" group="1">GenericButtonEvent</feature>
+               <feature name="eventButtonB" group="2">GenericButtonEvent</feature>
+               <feature name="eventButtonC" group="3">GenericButtonEvent</feature>
+               <feature name="eventButtonD" group="4">GenericButtonEvent</feature>
+               <feature name="eventButtonE" group="5">GenericButtonEvent</feature>
+               <feature name="eventButtonF" group="6">GenericButtonEvent</feature>
+               <feature name="eventButtonG" group="7">GenericButtonEvent</feature>
+               <feature name="eventButtonH" group="8">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonB" type="ExtDataGroup" group="2">
+                       <feature name="onMaskButtonB">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonB">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonB">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonC" type="ExtDataGroup" group="3">
+                       <feature name="onMaskButtonC">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonC">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonC">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonD" type="ExtDataGroup" group="4">
+                       <feature name="onMaskButtonD">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonD">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonD">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonE" type="ExtDataGroup" group="5">
+                       <feature name="onMaskButtonE">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonE">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonE">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonF" type="ExtDataGroup" group="6">
+                       <feature name="onMaskButtonF">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonF">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonF">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonG" type="ExtDataGroup" group="7">
+                       <feature name="onMaskButtonG">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonG">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonG">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="extDataGroupButtonH" type="ExtDataGroup" group="8">
+                       <feature name="onMaskButtonH">KeypadButtonOnMask</feature>
+                       <feature name="offMaskButtonH">KeypadButtonOffMask</feature>
+                       <feature name="toggleModeButtonH">KeypadButtonToggleMode</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="buttonConfig" bit="3" on="0x06" off="0x07">KeypadButtonConfig</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x01"/>
+               <default-link name="buttonB" type="controller" group="2" data1="0x03" data2="0x00" data3="0x02"/>
+               <default-link name="buttonC" type="controller" group="3" data1="0x03" data2="0x00" data3="0x03"/>
+               <default-link name="buttonD" type="controller" group="4" data1="0x03" data2="0x00" data3="0x04"/>
+               <default-link name="buttonE" type="controller" group="5" data1="0x03" data2="0x00" data3="0x05"/>
+               <default-link name="buttonF" type="controller" group="6" data1="0x03" data2="0x00" data3="0x06"/>
+               <default-link name="buttonG" type="controller" group="7" data1="0x03" data2="0x00" data3="0x07"/>
+               <default-link name="buttonH" type="controller" group="8" data1="0x03" data2="0x00" data3="0x08"/>
+       </device-type>
+
+       <device-type name="SwitchedLightingControl_MicroModule">
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <feature-group name="microModuleOpFlagsGroup" type="MicroModuleOpFlagsGroup">
+                       <feature name="operationMode">MicroModuleOpMode</feature>
+                       <feature name="3WayMode" bit="2" on="0x23" off="0x22" inverted="true">OpFlags</feature>
+               </feature-group>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <!-- Network Bridges -->
+
+       <device-type name="NetworkBridge_Hub">
+               <feature name="scene">GroupBroadcastOnOff</feature>
+               <feature name="fastOnOff">GroupBroadcastFastOnOff</feature>
+               <feature name="manualChange">GroupBroadcastManualChange</feature>
+               <feature name="imEventButton">IMButtonEvent</feature>
+               <feature name="ledOnOff">IMLEDOnOff</feature>
+               <feature-group name="imConfigGroup" type="IMConfigGroup">
+                       <feature name="ledControl" bit="5" hidden="true">IMConfigFlags</feature>
+                       <feature name="monitorMode" bit="6">IMConfigFlags</feature>
+                       <feature name="programLock" bit="7">IMConfigFlags</feature>
+               </feature-group>
+       </device-type>
+
+       <device-type name="NetworkBridge_PLM">
+               <feature name="scene">GroupBroadcastOnOff</feature>
+               <feature name="fastOnOff">GroupBroadcastFastOnOff</feature>
+               <feature name="manualChange">GroupBroadcastManualChange</feature>
+               <feature name="imEventButton">IMButtonEvent</feature>
+               <feature name="ledOnOff">IMLEDOnOff</feature>
+               <feature name="beep">IMBeep</feature>
+               <feature-group name="imConfigGroup" type="IMConfigGroup">
+                       <feature name="ledControl" bit="5" hidden="true">IMConfigFlags</feature>
+                       <feature name="monitorMode" bit="6">IMConfigFlags</feature>
+                       <feature name="programLock" bit="7">IMConfigFlags</feature>
+               </feature-group>
+       </device-type>
+
+       <!-- Irrigation Control -->
+
+       <device-type name="IrrigationControl_Sprinkler">
+               <feature-group name="sprinklerStatusGroup" type="SprinklerStatusGroup">
+                       <feature name="valve1" valve="0">SprinklerValve</feature>
+                       <feature name="valve2" valve="1">SprinklerValve</feature>
+                       <feature name="valve3" valve="2">SprinklerValve</feature>
+                       <feature name="valve4" valve="3">SprinklerValve</feature>
+                       <feature name="valve5" valve="4">SprinklerValve</feature>
+                       <feature name="valve6" valve="5">SprinklerValve</feature>
+                       <feature name="valve7" valve="6">SprinklerValve</feature>
+                       <feature name="valve8" valve="7">SprinklerValve</feature>
+                       <feature name="program1" program="0">SprinklerProgram</feature>
+                       <feature name="program2" program="1">SprinklerProgram</feature>
+                       <feature name="program3" program="2">SprinklerProgram</feature>
+                       <feature name="program4" program="3">SprinklerProgram</feature>
+                       <feature name="pump">SprinklerPump</feature>
+               </feature-group>
+               <feature name="eventButton">GenericButtonEvent</feature>
+       </device-type>
+
+       <!-- Climate Control -->
+
+       <device-type name="ClimateControl_Thermostat">
+               <feature-group name="thermostatData2Group" type="ThermostatData2Group">
+                       <feature name="coolSetpoint">ThermostatCoolSetpoint</feature>
+                       <feature name="heatSetpoint">ThermostatHeatSetpoint</feature>
+                       <feature name="systemMode">ThermostatSystemMode</feature>
+                       <feature name="systemState">ThermostatSystemState</feature>
+                       <feature name="energySaving">ThermostatEnergySaving</feature>
+                       <feature name="fanMode">ThermostatFanMode</feature>
+                       <feature name="temperature">ThermostatTemperature</feature>
+                       <feature name="humidity">ThermostatHumidity</feature>
+               </feature-group>
+               <feature-group name="thermostatData1bGroup" type="ThermostatData1bGroup">
+                       <feature name="dehumidifySetpoint">ThermostatDehumidifySetpoint</feature>
+                       <feature name="humidifySetpoint">ThermostatHumidifySetpoint</feature>
+                       <feature name="humidifierState">ThermostatHumidifierState</feature>
+                       <feature name="stage1Duration">ThermostatStage1Duration</feature>
+               </feature-group>
+               <feature name="syncTime">ThermostatSyncTime</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="thermostatData1Group" type="ThermostatData1Group">
+                       <feature name="backlightDuration">ThermostatBackLightDuration</feature>
+                       <feature name="acDelay">ThermostatACDelay</feature>
+                       <feature name="energyOffset">ThermostatEnergyOffset</feature>
+                       <feature name="programLock" bit="0">ThermostatOpFlags</feature>
+                       <feature name="buttonBeep" bit="1">ThermostatOpFlags</feature>
+                       <feature name="buttonLock" bit="2">ThermostatOpFlags</feature>
+                       <feature name="temperatureScale">ThermostatTemperatureScale</feature>
+                       <feature name="timeFormat">ThermostatTimeFormat</feature>
+                       <feature name="ledOnOff" bit="6">ThermostatOpFlags</feature>
+               </feature-group>
+               <default-link name="cooling" type="controller" group="1" data1="0x03" data2="0x00" data3="0x01"/>
+               <default-link name="heating" type="controller" group="2" data1="0x03" data2="0x00" data3="0x02"/>
+               <default-link name="dehumidifying" type="controller" group="3" data1="0x03" data2="0x00" data3="0x03"/>
+               <default-link name="humidifying" type="controller" group="4" data1="0x03" data2="0x00" data3="0x04"/>
+               <default-link name="broadcast" type="controller" group="239" data1="0x03" data2="0x00" data3="0xEF">
+                       <command name="enableStatusReporting" ext="1" cmd1="0x2E" cmd2="0x00" data2="0x08"/>
+               </default-link>
+       </device-type>
+
+       <device-type name="ClimateControl_WirelessThermostat" batteryPowered="true">
+               <feature-group name="thermostatData2Group" type="ThermostatData2Group">
+                       <feature name="coolSetpoint">ThermostatCoolSetpoint</feature>
+                       <feature name="heatSetpoint">ThermostatHeatSetpoint</feature>
+                       <feature name="systemMode">ThermostatSystemMode</feature>
+                       <feature name="systemState">ThermostatSystemState</feature>
+                       <feature name="energySaving">ThermostatEnergySaving</feature>
+                       <feature name="fanMode">ThermostatFanMode</feature>
+                       <feature name="temperature">ThermostatTemperature</feature>
+                       <feature name="humidity">ThermostatHumidity</feature>
+               </feature-group>
+               <feature-group name="thermostatData1bGroup" type="ThermostatData1bGroup">
+                       <feature name="dehumidifySetpoint">ThermostatDehumidifySetpoint</feature>
+                       <feature name="humidifySetpoint">ThermostatHumidifySetpoint</feature>
+                       <feature name="humidifierState">ThermostatHumidifierState</feature>
+                       <feature name="stage1Duration">ThermostatStage1Duration</feature>
+               </feature-group>
+               <feature name="syncTime">ThermostatSyncTime</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="thermostatData1Group" type="ThermostatData1Group">
+                       <feature name="backlightDuration">ThermostatBackLightDuration</feature>
+                       <feature name="acDelay">ThermostatACDelay</feature>
+                       <feature name="energyOffset">ThermostatEnergyOffset</feature>
+                       <feature name="programLock" bit="0">ThermostatOpFlags</feature>
+                       <feature name="buttonBeep" bit="1">ThermostatOpFlags</feature>
+                       <feature name="buttonLock" bit="2">ThermostatOpFlags</feature>
+                       <feature name="temperatureScale">ThermostatTemperatureScale</feature>
+                       <feature name="timeFormat">ThermostatTimeFormat</feature>
+                       <feature name="ledOnOff" bit="6">ThermostatOpFlags</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="stayAwake" on="0x06" off="0x07">OpFlags</feature>
+               </feature-group>
+               <default-link name="cooling" type="controller" group="1" data1="0x03" data2="0x00" data3="0x01"/>
+               <default-link name="heating" type="controller" group="2" data1="0x03" data2="0x00" data3="0x02"/>
+               <default-link name="dehumidifying" type="controller" group="3" data1="0x03" data2="0x00" data3="0x03"/>
+               <default-link name="humidifying" type="controller" group="4" data1="0x03" data2="0x00" data3="0x04"/>
+               <default-link name="broadcast" type="controller" group="239" data1="0x03" data2="0x00" data3="0xEF">
+                       <command name="enableStatusReporting" ext="1" cmd1="0x2E" cmd2="0x00" data2="0x08"/>
+               </default-link>
+       </device-type>
+
+       <device-type name="ClimateControl_VenstarThermostat">
+               <feature-group name="thermostatDataGroup" type="ThermostatData1Group">
+                       <feature name="coolSetpoint">VenstarCoolSetpoint</feature>
+                       <feature name="heatSetpoint">VenstarHeatSetpoint</feature>
+                       <feature name="systemMode">VenstarSystemMode</feature>
+                       <feature name="systemState">ThermostatSystemState</feature>
+                       <feature name="fanMode">VenstarFanMode</feature>
+                       <feature name="fanState">VenstarFanState</feature>
+                       <feature name="temperature">VenstarTemperature</feature>
+                       <feature name="humidity">VenstarHumidity</feature>
+               </feature-group>
+               <feature name="temperatureScale">VenstarTemperatureScale</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="7" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="ledOnOff" bit="5" on="0x06" off="0x07" inverted="true">OpFlags</feature>
+               </feature-group>
+               <default-link name="cooling" type="controller" group="1" data1="0x03" data2="0x00" data3="0x01"/>
+               <default-link name="heating" type="controller" group="2" data1="0x03" data2="0x00" data3="0x02"/>
+               <default-link name="fan" type="controller" group="3" data1="0x03" data2="0x00" data3="0x03"/>
+               <default-link name="broadcast" type="controller" group="239" data1="0x03" data2="0x00" data3="0xEF">
+                       <command name="enableStatusReporting" ext="1" cmd1="0x2E" cmd2="0x00" data2="0x08"/>
+               </default-link>
+       </device-type>
+
+       <!-- Sensors and Actuators -->
+
+       <device-type name="SensorsActuators_IOLinc">
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="contact" group="2">GenericSensorContact</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="momentaryDuration">IOLincMomentaryDuration</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="relaySensorFollow" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="relayMode">IOLincRelayMode</feature>
+               </feature-group>
+               <default-link name="relay" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SensorsActuators_Siren">
+               <feature name="siren">SirenOnOff</feature>
+               <feature name="armed">SirenArmed</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="alertType">SirenAlertType</feature>
+                       <feature name="alertDelay">SirenAlertDelay</feature>
+                       <feature name="alertDuration">SirenAlertDuration</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true">OpFlags</feature>
+               </feature-group>
+               <default-link name="siren" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <!-- Energy Management -->
+
+       <device-type name="EnergyManagement_PowerMeter">
+               <feature-group name="powerMeterDataGroup" type="PowerMeterDataGroup">
+                       <feature name="energyUsage">PowerMeterEnergy</feature>
+                       <feature name="powerUsage">PowerMeterPower</feature>
+                       <feature name="energyReset">PowerMeterReset</feature>
+               </feature-group>
+       </device-type>
+
+       <device-type name="EnergyManagement_LoadController">
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="load" group="2">GenericSensorState</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="loadSense" bit="5" on="0x06" off="0x07">OpFlags</feature>
+               </feature-group>
+               <default-link name="switch" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <!-- Window Coverings -->
+
+       <device-type name="WindowCovering_MicroModule">
+               <feature name="rollershutter">GenericDimmer</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">LEDBrightness</feature>
+                       <feature name="rampRate">RampRate</feature>
+                       <feature name="onLevel">OnLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="resumeDim" bit="2" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="5" on="0x0A" off="0x0B">OpFlags</feature>
+               </feature-group>
+               <feature-group name="microModuleOpFlagsGroup" type="MicroModuleOpFlagsGroup">
+                       <feature name="operationMode">MicroModuleOpMode</feature>
+                       <feature name="3WayMode" bit="2" on="0x23" off="0x22" inverted="true">OpFlags</feature>
+                       <feature name="reverseDirection" bit="3" on="0x25" off="0x24" inverted="true">OpFlags</feature>
+               </feature-group>
+               <default-link name="rollershutter" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <!-- Access Control -->
+
+       <device-type name="AccessControl_LockController">
+               <feature name="lock">GenericSwitch</feature>
+               <feature name="eventButton">GenericButtonEvent</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true">OpFlags</feature>
+               </feature-group>
+               <default-link name="lock" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <!-- Security, Health, Safety -->
+
+       <device-type name="SecurityHealthSafety_MotionSensor" batteryPowered="true">
+               <feature name="motion">WirelessSensorState</feature>
+               <feature name="daytime" group="2">WirelessSensorState</feature>
+               <feature name="lowBattery" group="3">WirelessSensorState</feature>
+               <feature name="heartbeat" group="4">HeartbeatMonitor</feature>
+               <feature-group name="extDataGroup" type="extDataGroup">
+                       <feature name="batteryLevel">MotionSensorBatteryLevel</feature>
+                       <feature name="lightLevel">MotionSensorLightLevel</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="7" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledOnOff" bit="5" on="0x03" off="0x02" inverted="true">OpFlags</feature>
+                       <feature name="stayAwake" on="0x18" off="0x19">OpFlags</feature>
+               </feature-group>
+               <default-link name="motion" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="daytime" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="lowBattery" type="controller" group="3" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="heartbeat" type="controller" group="4" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SecurityHealthSafety_MotionSensor2" batteryPowered="true">
+               <feature name="motion">WirelessSensorState</feature>
+               <feature name="daytime" group="2">WirelessSensorState</feature>
+               <feature name="lowBattery" group="3">WirelessSensorState</feature>
+               <feature name="heartbeat" group="4">HeartbeatMonitor</feature>
+               <feature name="tamperSwitch" group="16">WirelessSensorContact</feature>
+               <feature-group name="motionSensor2Data3Group" type="MotionSensor2Data3Group">
+                       <feature name="batteryLevel">MotionSensor2BatteryLevel</feature>
+                       <feature name="lightLevel">MotionSensor2LightLevel</feature>
+                       <feature name="temperature">MotionSensor2Temperature</feature>
+               </feature-group>
+               <feature-group name="motionSensor2Data1Group" type="MotionSensor2Data1Group">
+                       <feature name="lowBatteryThreshold">MotionSensor2LowBatteryThreshold</feature>
+                       <feature name="heartbeatInterval">MotionSensor2HeartbeatInterval</feature>
+               </feature-group>
+               <feature-group name="motionSensor2StatusGroup" type="MotionSensor2StatusGroup">
+                       <feature name="batteryPowered">MotionSensor2BatteryPowered</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="7" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledOnOff" bit="5" on="0x03" off="0x02" inverted="true">OpFlags</feature>
+                       <feature name="buttonBeep" bit="3" on="0x04" off="0x05">OpFlags</feature>
+                       <feature name="heartbeatOnOff" bit="6" on="0x08" off="0x09">HeartbeatOnOff</feature>
+                       <feature name="stayAwake" on="0x18" off="0x19">OpFlags</feature>
+               </feature-group>
+               <default-link name="motion" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="daytime" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="lowBattery" type="controller" group="3" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="heartbeat" type="controller" group="4" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="tamperSwitch" type="controller" group="16" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SecurityHealthSafety_OpenCloseSensor" batteryPowered="true">
+               <feature name="contact">OpenClosedSensorContact</feature>
+               <feature name="heartbeat" group="4">HeartbeatMonitor</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">WirelessSensorLEDBrightness</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="7" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledOnOff" bit="5" on="0x03" off="0x02" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="2Groups" bit="2" on="0x04" off="0x05" hidden="true">LinkOpFlags</feature>
+                       <feature name="linkFFGroup" bit="6" on="0x06" off="0x07" hidden="true">LinkOpFlags</feature>
+                       <feature name="stayAwake" on="0x18" off="0x19">OpFlags</feature>
+               </feature-group>
+               <default-link name="open" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="closed" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="heartbeat" type="controller" group="4" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SecurityHealthSafety_DoorSensor" batteryPowered="true">
+               <feature name="contact">OpenClosedSensorContact</feature>
+               <feature name="lowBattery" group="3">WirelessSensorState</feature>
+               <feature name="heartbeat" group="4">HeartbeatMonitor</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="batteryLevel">DoorSensorBatteryLevel</feature>
+                       <feature name="heartbeatInterval">DoorSensorHeartbeatInterval</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="7" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledOnOff" bit="5" on="0x03" off="0x02" inverted="true">OpFlags</feature>
+                       <feature name="2Groups" bit="1" on="0x04" off="0x05" hidden="true">LinkOpFlags</feature>
+                       <feature name="linkFFGroup" bit="4" on="0x06" off="0x07" hidden="true">LinkOpFlags</feature>
+                       <feature name="stayAwake" on="0x18" off="0x19">OpFlags</feature>
+               </feature-group>
+               <default-link name="open" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="closed" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="lowBattery" type="controller" group="3" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="heartbeat" type="controller" group="4" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SecurityHealthSafety_LeakSensor" batteryPowered="true">
+               <feature name="leak">LeakSensorState</feature>
+               <feature name="heartbeat" group="4">HeartbeatMonitor</feature>
+               <feature-group name="extDataGroup" type="ExtDataGroup">
+                       <feature name="ledBrightness">WirelessSensorLEDBrightness</feature>
+               </feature-group>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="7" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledOnOff" bit="5" on="0x03" off="0x02" inverted="true" hidden="true">OpFlags</feature>
+                       <feature name="2Groups" bit="2" on="0x04" off="0x05" hidden="true">LinkOpFlags</feature>
+                       <feature name="linkFFGroup" bit="6" on="0x06" off="0x07" hidden="true">LinkOpFlags</feature>
+                       <feature name="stayAwake" on="0x18" off="0x19">OpFlags</feature>
+               </feature-group>
+               <default-link name="dry" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="wet" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="heartbeat" type="controller" group="4" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <device-type name="SecurityHealthSafety_SmokeBridge">
+               <feature name="smokeAlarm" group="1">SmokeSensorState</feature>
+               <feature name="carbonMonoxideAlarm" group="2">SmokeSensorState</feature>
+               <feature name="testAlarm" group="3">SmokeSensorState</feature>
+               <feature name="lowBattery" group="6">SmokeSensorState</feature>
+               <feature name="malfunction" group="7">SmokeSensorState</feature>
+               <feature name="heartbeat" group="10">HeartbeatMonitor</feature>
+               <feature name="beep">Beep</feature>
+               <feature-group name="opFlagsGroup" type="OpFlagsGroup">
+                       <feature name="programLock" bit="0" on="0x00" off="0x01">OpFlags</feature>
+                       <feature name="ledTraffic" bit="1" on="0x02" off="0x03">OpFlags</feature>
+                       <feature name="ledOnOff" bit="4" on="0x09" off="0x08" inverted="true">OpFlags</feature>
+                       <feature name="heartbeatOnOff" bit="5" on="0x06" off="0x07">HeartbeatOnOff</feature>
+               </feature-group>
+               <default-link name="smoke" type="controller" group="1" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="carbonMonoxide" type="controller" group="2" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="test" type="controller" group="3" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="clear" type="controller" group="5" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="lowBattery" type="controller" group="6" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="malfunction" type="controller" group="7" data1="0x03" data2="0x00" data3="0x00"/>
+               <default-link name="heartbeat" type="controller" group="10" data1="0x03" data2="0x00" data3="0x00"/>
+       </device-type>
+
+       <!-- X10 -->
+
+       <device-type name="X10_Switch">
+               <feature name="switch">X10Switch</feature>
+       </device-type>
+
+       <device-type name="X10_Dimmer">
+               <feature name="dimmer">X10Dimmer</feature>
+       </device-type>
+
+       <device-type name="X10_Sensor">
+               <feature name="contact">X10Contact</feature>
+       </device-type>
+
+</xml>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/device_features.xml b/bundles/org.openhab.binding.insteon/src/main/resources/device_features.xml
deleted file mode 100644 (file)
index d634712..0000000
+++ /dev/null
@@ -1,897 +0,0 @@
-<xml>
-       <feature name="GenericSwitch" timeout="5000">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="1">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x12" group="1" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" group="1">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x14" group="1" mode="FAST">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x19">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="FastOnOff">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x14" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-       <feature name="RampDimmer">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- cmd1 defaults to 0x2E, 0x2F -->
-               <!-- default dispatcher uses 0x19 for lookup key instead of cmd1 -->
-               <message-handler cmd="0x19">RampDimmerHandler</message-handler>
-               <command-handler command="PercentType">RampPercentHandler</command-handler>
-               <command-handler command="OnOffType">RampOnOffCommandHandler</command-handler>
-       </feature>
-
-       <feature name="RampDimmer_3435">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- default dispatcher uses 0x19 for lookup key instead of cmd1 -->
-               <message-handler cmd="0x19" on="0x34" off="0x35">RampDimmerHandler</message-handler>
-               <command-handler command="PercentType" on="0x34" off="0x35">RampPercentHandler</command-handler>
-               <command-handler command="OnOffType" on="0x34" off="0x35">RampOnOffCommandHandler</command-handler>
-       </feature>
-
-       <feature name="ManualChange">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-       <feature name="RemoteButton1">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="1" group="1">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="1" group="1">LightOffSwitchHandler</message-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="RemoteButton2">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="2">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="2" group="2">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="2" group="2">LightOffSwitchHandler</message-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="RemoteButton3">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="3">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="3" group="3">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="3" group="3">LightOffSwitchHandler</message-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="RemoteButton4">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="4">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="4" group="4">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="4" group="4">LightOffSwitchHandler</message-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="RemoteButton5">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="5">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="5" group="5">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="5" group="5">LightOffSwitchHandler</message-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="RemoteButton6">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="6">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="6" group="6">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="6" group="6">LightOffSwitchHandler</message-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="RemoteButton7">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="7">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="7" group="7">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="7" group="7">LightOffSwitchHandler</message-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="RemoteButton8">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="8">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="8" group="8">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="8" group="8">LightOffSwitchHandler</message-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="LoadSwitchButton">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="1" group="1">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x12" button="1" group="1" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="1" group="1">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x14" button="1" group="1" mode="FAST">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x19" button="1" group="1">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="LoadSwitchManualChange">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17" group="1">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18" group="1">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-       <feature name="LoadSwitchFastOnOff">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" group="1" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x19" group="1">SwitchRequestReplyHandler</message-handler>
-               <message-handler cmd="0x14" group="1" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="LoadDimmerButton">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="1" group="1">LightOnDimmerHandler</message-handler>
-               <message-handler cmd="0x12" button="1" group="1" mode="FAST">LightOnDimmerHandler</message-handler>
-               <message-handler cmd="0x13" button="1" group="1">LightOffDimmerHandler</message-handler>
-               <message-handler cmd="0x14" button="1" group="1" mode="FAST">LightOffDimmerHandler</message-handler>
-               <message-handler cmd="0x17" button="1" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x18" button="1" group="1">DimmerStopManualChangeHandler</message-handler>
-               <message-handler cmd="0x19" button="1" group="1">DimmerRequestReplyHandler</message-handler>
-               <command-handler command="PercentType">PercentHandler</command-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="LoadDimmerFastOnOff">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" group="1" mode="FAST">LightOnDimmerHandler</message-handler>
-               <message-handler cmd="0x14" group="1" mode="FAST">LightOffDimmerHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="LoadDimmerManualChange">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17" group="1">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18" group="1">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-       <feature name="LoadDimmerRamp">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- cmd1 defaults to 0x2E, 0x2F -->
-               <!-- default dispatcher uses 0x19 for lookup key instead of cmd1 -->
-               <message-handler cmd="0x19" group="1">RampDimmerHandler</message-handler>
-               <command-handler command="PercentType">RampPercentHandler</command-handler>
-               <command-handler command="OnOffType">RampOnOffCommandHandler</command-handler>
-       </feature>
-
-       <feature name="KeyPadButtonGroup">
-               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
-               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="FastOnOffButtonGroup">
-               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="ManualChangeButtonGroup">
-               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="KeyPadButton2">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="2" group="2">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x12" button="2" group="2" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="2" group="2">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x14" button="2" group="2" mode="FAST">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x19" button="2" group="2">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-       </feature>
-       <feature name="FastOnOffButton2">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" group="2" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x14" group="2" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="ManualChangeButton2">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17" group="2">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18" group="2">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-
-       <feature name="KeyPadButton3">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="3" group="3">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x12" button="3" group="3" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="3" group="3">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x14" button="3" group="3" mode="FAST">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x19" button="3" group="3">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-       </feature>
-       <feature name="FastOnOffButton3">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" group="3" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x14" group="3" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="ManualChangeButton3">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17" group="3">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18" group="3">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-       <feature name="KeyPadButton4">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="4" group="4">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x12" button="4" group="4" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="4" group="4">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x14" button="4" group="4" mode="FAST">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x19" button="4" group="4">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-       </feature>
-       <feature name="FastOnOffButton4">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" group="4" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x14" group="4" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="ManualChangeButton4">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17" group="4">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18" group="4">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-
-       <feature name="KeyPadButton5">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="5" group="5">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x12" button="5" group="5" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="5" group="5">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x14" button="5" group="5" mode="FAST">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x19" button="5" group="5">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-       </feature>
-       <feature name="FastOnOffButton5">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" group="5" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x14" group="5" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="ManualChangeButton5">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17" group="5">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18" group="5">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-
-       <feature name="KeyPadButton6">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="6" group="6">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x12" button="6" group="6" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="6" group="6">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x14" button="6" group="6" mode="FAST">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x19" button="6" group="6">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-       </feature>
-       <feature name="FastOnOffButton6">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" group="6" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x14" group="6" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="ManualChangeButton6">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17" group="6">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18" group="6">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-       <feature name="KeyPadButton7">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="7" group="7">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x12" button="7" group="7" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="7" group="7">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x14" button="7" group="7" mode="FAST">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x19" button="7" group="7">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-       </feature>
-       <feature name="FastOnOffButton7">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" group="7" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x14" group="7" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="ManualChangeButton7">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17" group="7">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18" group="7">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-       <feature name="KeyPadButton8">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" button="8" group="8">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x12" button="8" group="8" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x13" button="8" group="8">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x14" button="8" group="8" mode="FAST">LightOffSwitchHandler</message-handler>
-               <message-handler cmd="0x19" button="8" group="8">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-       </feature>
-       <feature name="FastOnOffButton8">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x12" group="8" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x14" group="8" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="ManualChangeButton8">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x17" group="8">StartManualChangeHandler</message-handler>
-               <message-handler cmd="0x18" group="8">StopManualChangeHandler</message-handler>
-               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-
-
-       <feature name="GenericLastTime" statusFeature="true">
-               <message-dispatcher>PassThroughDispatcher</message-dispatcher>
-               <message-handler default="true">LastTimeHandler</message-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-       </feature>
-       <feature name="GenericDimmer">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="1">LightOnDimmerHandler</message-handler>
-               <message-handler cmd="0x12" group="1" mode="FAST">LightOnDimmerHandler</message-handler>
-               <message-handler cmd="0x13" group="1">LightOffDimmerHandler</message-handler>
-               <message-handler cmd="0x14" group="1" mode="FAST">LightOffDimmerHandler</message-handler>
-               <message-handler cmd="0x17" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x18" group="1">DimmerStopManualChangeHandler</message-handler>
-               <message-handler cmd="0x19">DimmerRequestReplyHandler</message-handler>
-               <command-handler command="PercentType">PercentHandler</command-handler>
-               <command-handler command="IncreaseDecreaseType">IncreaseDecreaseCommandHandler</command-handler>
-               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
-               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="IOLincContact">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="1">OpenedContactHandler</message-handler>
-               <message-handler cmd="0x13" group="1">ClosedContactHandler</message-handler>
-               <message-handler cmd="0x19">ContactRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="IOLincSwitch">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x13" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x19">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType">IOLincOnOffCommandHandler</command-handler>
-               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="WirelessMotionSensorContact">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="1">OpenedSleepingContactHandler</message-handler>
-               <message-handler cmd="0x13" group="1">ClosedSleepingContactHandler</message-handler>
-               <message-handler cmd="0x19">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x2e">NoOpMsgHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="WirelessMotionSensorLightLevelAboveThreshold">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="2">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="2">OpenedSleepingContactHandler</message-handler>
-               <message-handler cmd="0x13" group="2">ClosedSleepingContactHandler</message-handler>
-               <message-handler cmd="0x19">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x2e">NoOpMsgHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="WirelessMotionSensorLowBattery">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="3">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="3">OpenedSleepingContactHandler</message-handler>
-               <message-handler cmd="0x13" group="3">ClosedSleepingContactHandler</message-handler>
-               <message-handler cmd="0x19">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x2e">NoOpMsgHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="WirelessMotionSensor2TamperSwitch">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="16">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="16">OpenedSleepingContactHandler</message-handler>
-               <message-handler cmd="0x13" group="16">ClosedSleepingContactHandler</message-handler>
-               <message-handler cmd="0x19">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x2e">NoOpMsgHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="MotionSensorData">
-               <message-dispatcher>SimpleDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x13" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x2e">MotionSensorDataReplyHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="MotionSensor2Data">
-               <message-dispatcher>SimpleDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x0C" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x13" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x03" group="11">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x0C" group="11">MotionSensor2AlternateHeartbeatHandler</message-handler>
-               <message-handler cmd="0x11" group="11">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x13" group="11">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x2e">MotionSensorDataReplyHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="HiddenDoorSensorData">
-               <message-dispatcher>SimpleDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x13" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x2e">HiddenDoorSensorDataReplyHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="GenericContact">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11" group="1">OpenedContactHandler</message-handler>
-               <message-handler cmd="0x13" group="1">ClosedContactHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="LeakSensorContact">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x03">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x11">OpenedOrClosedContactHandler</message-handler>
-               <message-handler cmd="0x13">OpenedOrClosedContactHandler</message-handler>
-               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="GroupBroadcastOnOff">
-               <message-dispatcher>NoOpDispatcher</message-dispatcher>
-               <command-handler command="OnOffType">GroupBroadcastCommandHandler</command-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-       </feature>
-       <feature name="PowerMeter">
-               <message-dispatcher>SimpleDispatcher</message-dispatcher>
-               <message-handler cmd="0x03">NoOpMsgHandler</message-handler>
-               <message-handler cmd="0x80">PowerMeterResetHandler</message-handler>
-               <message-handler cmd="0x82">PowerMeterUpdateHandler</message-handler>
-               <command-handler command="OnOffType">PowerMeterCommandHandler</command-handler>
-               <poll-handler ext="0" cmd1="0x82" cmd2="0x00">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="X10Dimmer" timeout="0">
-               <message-dispatcher>X10Dispatcher</message-dispatcher>
-               <message-handler cmd="0x02">X10OnHandler</message-handler>
-               <message-handler cmd="0x03">X10OffHandler</message-handler>
-               <message-handler cmd="0x05">X10BrightHandler</message-handler>
-               <message-handler cmd="0x04">X10DimHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="OnOffType">X10OnOffCommandHandler</command-handler>
-               <command-handler command="PercentType">X10PercentCommandHandler</command-handler>
-               <command-handler command="IncreaseDecreaseType">X10IncreaseDecreaseCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="X10Switch" timeout="0">
-               <message-dispatcher>X10Dispatcher</message-dispatcher>
-               <message-handler cmd="0x02">X10OnHandler</message-handler>
-               <message-handler cmd="0x03">X10OffHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="OnOffType">X10OnOffCommandHandler</command-handler>
-               <command-handler command="PercentType">NoOpCommandHandler</command-handler>
-               <command-handler command="IncreaseDecreaseType">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="X10Contact">
-               <message-dispatcher>X10Dispatcher</message-dispatcher>
-               <message-handler cmd="0x02">X10OpenHandler</message-handler>
-               <message-handler cmd="0x03">X10ClosedHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="ThermostatData1Group"> <!-- just does the polling for various quantities -->
-               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
-               <poll-handler ext="1" cmd1="0x2e" cmd2="0x00">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="ThermostatData1bGroup"> <!-- just does the polling for various quantities -->
-               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
-               <poll-handler ext="2" cmd1="0x2e" cmd2="0x00" d3="0x01">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="ThermostatData2Group"> <!-- just does the polling for various quantities -->
-               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
-               <poll-handler ext="2" cmd1="0x2e" cmd2="0x02">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="ThermostatCoolSetPoint">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
-                       low_byte="userData7">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after set point has been changed -->
-               <message-handler cmd="0x19" ext="0" match_cmd1="0x6c" low_byte="command2" factor="0.5">NumberMsgHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x71" ext="0" match_cmd1="0x71" low_byte="command2">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x6c" factor="2" value="command2">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
-       </feature>
-       <feature name="ThermostatHeatSetPoint">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
-                       low_byte="userData12">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after set point has been changed -->
-               <message-handler cmd="0x19" ext="0" match_cmd1="0x6d" low_byte="command2" factor="0.5">NumberMsgHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x72" ext="0" match_cmd1="0x72" low_byte="command2">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x6d" factor="2" value="command2">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
-       </feature>
-       <feature name="ThermostatSystemMode">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
-                       low_byte="userData6" mask="0xf0" rshift="4">ThermostatSystemModeMsgHandler</message-handler>
-               <!-- handles direct ack after system mode has been changed -->
-               <message-handler cmd="0x19" ext="0" match_cmd1="0x6b" low_byte="command2">ThermostatSystemModeReplyHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x70" ext="0" match_cmd1="0x70" low_byte="command2" mask="0x0f">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x6b" value="command2">ThermostatSystemModeCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
-       </feature>
-       <feature name="ThermostatFanMode">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
-                       low_byte="userData6" mask="0x0f">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after fan mode has been changed -->
-               <message-handler cmd="0x19" ext="0" match_cmd1="0x6b" low_byte="command2">ThermostatFanModeReplyHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x70" ext="0" match_cmd1="0x70" low_byte="command2" mask="0xf0" rshift="4">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x6b" value="command2">ThermostatFanModeCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
-       </feature>
-       <feature name="ThermostatIsHeating">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
-                       low_byte="userData11" mask="0x02" rshift="1">NumberMsgHandler</message-handler>
-               <!-- handles all-link broadcast message OFF -->
-               <message-handler cmd="0x13" ext="0" group="2" mask="0x00" low_byte="command1">NumberMsgHandler</message-handler>
-               <!-- handles all-link broadcast message ON -->
-               <message-handler cmd="0x11" ext="0" group="2" mask="0x01" low_byte="command1">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
-       </feature>
-       <feature name="ThermostatIsCooling">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
-                       low_byte="userData11" mask="0x01">NumberMsgHandler</message-handler>
-               <!-- handles all-link broadcast message OFF -->
-               <message-handler cmd="0x13" ext="0" group="1" mask="0x00" low_byte="command1">NumberMsgHandler</message-handler>
-               <!-- handles all-link broadcast message ON -->
-               <message-handler cmd="0x11" ext="0" group="1" mask="0x01" low_byte="command1">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
-       </feature>
-       <feature name="ThermostatTemperatureCelsius">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
-                       low_byte="userData10" high_byte="userData9" factor="0.1" scale="celsius">NumberMsgHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x6e" ext="0" match_cmd1="0x6e" low_byte="command2" offset="-17.7777778"
-                       factor="0.2777778" scale="celsius">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
-       </feature>
-       <feature name="ThermostatTemperatureFahrenheit">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
-                       low_byte="userData10" high_byte="userData9" offset="32" factor="0.18" scale="fahrenheit">NumberMsgHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x6e" ext="0" match_cmd1="0x6e" low_byte="command2" offset="0" factor="0.5"
-                       scale="fahrenheit">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
-       </feature>
-       <feature name="ThermostatHumidity">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
-                       low_byte="userData8">NumberMsgHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x6f" ext="0" match_cmd1="0x6f" low_byte="command2">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
-       </feature>
-       <feature name="ThermostatBackLightDuration">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       match_d3="0x00" low_byte="userData10">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after backlight duration has been changed -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x05" value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
-       </feature>
-       <feature name="ThermostatACDelay">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       match_d3="0x00" low_byte="userData11">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after backlight duration has been changed -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x06" value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
-       </feature>
-       <feature name="ThermostatHumidityHigh">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       match_d3="0x01" low_byte="userData4">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after value has been changed -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0b" value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="ThermostatHumidityLow">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       match_d3="0x01" low_byte="userData5">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after value has been changed -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0c" value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="ThermostatStage1Duration">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       match_d3="0x01" low_byte="userData11">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after value has been changed -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0a" value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="FanLincFan">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x19" ext="0" low_byte="command2">FanLincFanReplyHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x11" d1="0x02" value="command2">FanLincFanCommandHandler</command-handler>
-               <poll-handler ext="0" cmd1="0x19" cmd2="0x03">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="BottomOutlet">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x19" button="2" group="1">SwitchRequestReplyHandler</message-handler>
-               <command-handler d1="0x02" ext="1" command="OnOffType">LightOnOffCommandHandler</command-handler>
-               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="VenstarCoolSetPoint">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData6">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after set point has been changed -->
-               <message-handler cmd="0x19" ext="0" match_cmd1="0x6c" low_byte="command2" factor="0.5">NumberMsgHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x71" ext="0" match_cmd1="0x71" low_byte="command2">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x6c" factor="2" value="command2">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="VenstarHeatSetPoint">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData7">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after set point has been changed -->
-               <message-handler cmd="0x19" ext="0" match_cmd1="0x6d" low_byte="command2" factor="0.5">NumberMsgHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x72" ext="0" match_cmd1="0x72" low_byte="command2">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x6d" factor="2" value="command2">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="VenstarSystemMode">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query - use NumberMsgHandler because this adapator directly reports the correct
-                       number -->
-               <!-- 0=OFF, 1=HEAT, 2=COOL, 3= Auto, 4=Program 5=Program Heat 6=Program Cool -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       low_byte="userData3">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after system mode has been changed -->
-               <message-handler cmd="0x19" ext="0" match_cmd1="0x6b" low_byte="command2">ThermostatSystemModeReplyHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x70" ext="0" match_cmd1="0x70" low_byte="command2" mask="0x0f">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x6b" value="command2">ThermostatSystemModeCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="VenstarFanMode">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query mask for second bit -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData9"
-                       mask="0x10" rshift="4">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after fan mode has been changed -->
-               <message-handler cmd="0x19" ext="0" match_cmd1="0x6b" low_byte="command2">ThermostatFanModeReplyHandler</message-handler>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x70" ext="0" match_cmd1="0x70" low_byte="command2" mask="0xf0" rshift="4">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x6b" value="command2">ThermostatFanModeCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="VenstarTemperatureFahrenheit"> <!-- All temperatures reported in units currently set on thermostat -->
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       low_byte="userData5">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after value has been changed -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0b" value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="VenstarHumidity">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       low_byte="userData4">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after value has been changed -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0c" value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="VenstarIsHeating">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData8"
-                       mask="0x02" rshift="1">NumberMsgHandler</message-handler>
-               <!-- handles all-link broadcast message OFF -->
-               <message-handler cmd="0x13" ext="0" group="2" mask="0x00" low_byte="command1">NumberMsgHandler</message-handler>
-               <!-- handles all-link broadcast message ON -->
-               <message-handler cmd="0x11" ext="0" group="2" mask="0x01" low_byte="command1">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="VenstarIsCooling">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData8"
-                       mask="0x01">NumberMsgHandler</message-handler>
-               <!-- handles all-link broadcast message OFF -->
-               <message-handler cmd="0x13" ext="0" group="1" mask="0x00" low_byte="command1">NumberMsgHandler</message-handler>
-               <!-- handles all-link broadcast message ON -->
-               <message-handler cmd="0x11" ext="0" group="1" mask="0x01" low_byte="command1">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
-       </feature>
-       <feature name="ReceiveBroadcast">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles out-of band status messages -->
-               <message-handler cmd="0x11" ext="0" match_cmd1="0x11" low_byte="group">NumberMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- broadcast messages only, no polling! -->
-       </feature>
-       <feature name="ExtStatusGroup"> <!-- does the polling for various quantities -->
-               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
-               <poll-handler ext="2" cmd1="0x2e" cmd2="0x00" d1="0x01" d3="0x00">FlexPollHandler</poll-handler>
-       </feature>
-       <feature name="LEDBrightness">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       low_byte="userData9">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after poll -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x07" factor="1"
-                       value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtStatusGroup -->
-       </feature>
-       <feature name="LEDOnOff">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x2F" mode="FAST">LightOnSwitchHandler</message-handler>
-               <message-handler cmd="0x2F" mode="FAST">LightOffSwitchHandler</message-handler>
-               <command-handler command="OnOffType">LEDOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="Beep">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <message-handler cmd="0x19">SwitchRequestReplyHandler</message-handler>
-               <command-handler command="OnOffType" off="0x30" on="0x30">RampOnOffCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler>
-       </feature>
-       <feature name="RampRate">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       low_byte="userData7">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after poll -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x05" factor="1"
-                       value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtStatusGroup -->
-       </feature>
-       <feature name="OnLevel">
-               <message-dispatcher>DefaultDispatcher</message-dispatcher>
-               <!-- handles direct extended message after query -->
-               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
-                       low_byte="userData8">NumberMsgHandler</message-handler>
-               <!-- handles direct ack after poll -->
-               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
-               <message-handler default="true">NoOpMsgHandler</message-handler>
-               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x06" factor="1"
-                       value="userData3">NumberCommandHandler</command-handler>
-               <command-handler default="true">NoOpCommandHandler</command-handler>
-               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtStatusGroup -->
-       </feature>
-</xml>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/device_types.xml b/bundles/org.openhab.binding.insteon/src/main/resources/device_types.xml
deleted file mode 100644 (file)
index df23c5c..0000000
+++ /dev/null
@@ -1,598 +0,0 @@
-<xml>
-       <!-- device types
-
-               #
-               # PLEASE KEEP PRODUCT KEYS IN INCREASING ORDER:
-               #
-               # - first the devices with insteon assigned product keys
-               # - then X10 devices (key starting with X)
-               # - then Insteon devices with fake keys (starting with F)
-               #
-
-               #
-               Example entry:
-
-               <device productKey="F00.00.05">
-               <model>2456-D3</model>
-               <description>LampLinc V2</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-               </device>
-
-       -->
-
-       <!-- #################################################
-               devices with regular insteon product keys
-       -->
-
-       <device productKey="0x00001A">
-               <model>2450</model>
-               <description>IO Link</description>
-               <feature name="contact">IOLincContact</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-               <feature name="switch">IOLincSwitch</feature>
-       </device>
-
-       <device productKey="0x000037">
-               <model>2486D</model>
-               <description>KeypadLinc Dimmer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="0x000039">
-               <model>2663-222</model>
-               <description>On/Off Outlet</description>
-               <feature name="topoutlet">GenericSwitch</feature>
-               <feature name="bottomoutlet">BottomOutlet</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="0x000041">
-               <model>2484DWH8</model>
-               <description>KeypadLinc Countdown Timer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="0x000045">
-               <model>2413U</model>
-               <description>PowerLinc 2413U USB modem</description>
-               <feature name="broadcastonoff">GroupBroadcastOnOff</feature>
-       </device>
-
-       <device productKey="0x000049">
-               <model>2843-222</model>
-               <description>Wireless Open/Close Sensor</description>
-               <feature name="contact">GenericContact</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="0x00004A">
-               <model>2842-222</model>
-               <description>Motion Sensor</description>
-               <feature name="contact">WirelessMotionSensorContact</feature>
-               <feature name="lightlevelabovethreshold">WirelessMotionSensorLightLevelAboveThreshold</feature>
-               <feature name="lowbattery">WirelessMotionSensorLowBattery</feature>
-               <feature name="data">MotionSensorData</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="0x000050">
-               <model>2486DWH6</model>
-               <description>KeypadLinc Dimmer - 6 Button</description>
-               <feature name="loaddimmer">LoadDimmerButton</feature>
-               <feature name="rampdimmer">LoadDimmerRamp</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">LoadDimmerFastOnOff</feature>
-               <feature_group name="button_group" type="KeyPadButtonGroup">
-                       <feature name="keypadbuttona">KeyPadButton3</feature>
-                       <feature name="keypadbuttonb">KeyPadButton4</feature>
-                       <feature name="keypadbuttonc">KeyPadButton5</feature>
-                       <feature name="keypadbuttond">KeyPadButton6</feature>
-               </feature_group>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="0x000051">
-               <model>2486DWH8</model>
-               <description>KeypadLinc Dimmer - 8 Button</description>
-               <feature name="loaddimmer">LoadDimmerButton</feature>
-               <feature name="rampdimmer">LoadDimmerRamp</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">LoadDimmerFastOnOff</feature>
-               <feature_group name="button_group" type="KeyPadButtonGroup">
-                       <feature name="keypadbuttonb">KeyPadButton2</feature>
-                       <feature name="keypadbuttonc">KeyPadButton3</feature>
-                       <feature name="keypadbuttond">KeyPadButton4</feature>
-                       <feature name="keypadbuttone">KeyPadButton5</feature>
-                       <feature name="keypadbuttonf">KeyPadButton6</feature>
-                       <feature name="keypadbuttong">KeyPadButton7</feature>
-                       <feature name="keypadbuttonh">KeyPadButton8</feature>
-               </feature_group>
-
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="0x000068">
-               <model>2472D</model>
-               <description>OutletLinc Dimmer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-               <feature name="ledonoff">LEDOnOff</feature>
-               <feature name="beep">Beep</feature>
-               <feature_group name="ext_group" type="ExtStatusGroup">
-                       <feature name="ledbrightness">LEDBrightness</feature>
-                       <feature name="ramprate">RampRate</feature>
-               </feature_group>
-       </device>
-
-       <!-- #################################################
-               X10 devices with made-up product keys Xaa.bb.cc
-       -->
-
-       <device productKey="X00.00.01">
-               <model>X10 switch</model>
-               <description>any simple X10 switch</description>
-               <feature name="switch">X10Switch</feature>
-       </device>
-       <device productKey="X00.00.02">
-               <model>X10 dimmer</model>
-               <description>Generic X10 Dimmer without preset</description>
-               <feature name="switch">X10Switch</feature>
-               <feature name="dimmer">X10Dimmer</feature>
-       </device>
-       <device productKey="X00.00.03">
-               <model>X10 motion sensor</model>
-               <description>Generic X10 motion sensor</description>
-               <feature name="contact">X10Contact</feature>
-       </device>
-
-       <!-- ###################################################
-               Insteon devices with made-up product keys Faa.bb.cc
-       -->
-
-       <device productKey="F00.00.01">
-               <model>2477D</model>
-               <description>SwitchLinc Dimmer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-               <feature name="ledonoff">LEDOnOff</feature>
-               <feature name="beep">Beep</feature>
-               <feature_group name="ext_group" type="ExtStatusGroup">
-                       <feature name="ledbrightness">LEDBrightness</feature>
-                       <feature name="ramprate">RampRate</feature>
-                       <feature name="onlevel">OnLevel</feature>
-               </feature_group>
-       </device>
-
-       <device productKey="F00.00.02">
-               <model>2477S</model>
-               <description>SwitchLinc Switch</description>
-               <feature name="switch">GenericSwitch</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-               <feature name="ledonoff">LEDOnOff</feature>
-               <feature name="beep">Beep</feature>
-               <feature_group name="ext_group" type="ExtStatusGroup">
-                       <feature name="ledbrightness">LEDBrightness</feature>
-               </feature_group>
-       </device>
-
-       <device productKey="F00.00.03">
-               <model>2845-222</model>
-               <description>Hidden Door Sensor</description>
-               <feature name="contact">WirelessMotionSensorContact</feature>
-               <feature name="data">HiddenDoorSensorData</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.04">
-               <model>2876S</model>
-               <description>ICON Switch</description>
-               <feature name="switch">GenericSwitch</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.05">
-               <model>2456D3</model>
-               <description>LampLinc V2</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.06">
-               <model>2442-222</model>
-               <description>Micro Dimmer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.07">
-               <model>2453-222</model>
-               <description>DIN Rail On/Off</description>
-               <feature name="switch">GenericSwitch</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.08">
-               <model>2452-222</model>
-               <description>DIN Rail Dimmer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.09">
-               <model>2458-A1</model>
-               <description>MorningLinc RF Lock Controller</description>
-               <feature name="switch">GenericSwitch</feature>
-       </device>
-
-       <device productKey="F00.00.0A">
-               <model>2852-222</model>
-               <description>Leak Sensor</description>
-               <feature name="contact">LeakSensorContact</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.0B">
-               <model>2672-422</model>
-               <description>LED Dimmer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.0C">
-               <model>2476D</model>
-               <description>SwitchLinc Dimmer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.0D">
-               <model>2634-222</model>
-               <description>On/Off Dual-Band Outdoor Module</description>
-               <feature name="switch">GenericSwitch</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-               <feature name="ledonoff">LEDOnOff</feature>
-               <feature name="beep">Beep</feature>
-       </device>
-
-       <device productKey="F00.00.10">
-               <model>2342-2</model>
-               <description>Mini Remote</description>
-               <feature name="buttona">RemoteButton1</feature>
-               <feature name="buttonb">RemoteButton2</feature>
-               <feature name="buttonc">RemoteButton3</feature>
-               <feature name="buttond">RemoteButton4</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.11">
-               <model>2466D</model>
-               <description>ToggleLinc Dimmer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="rampdimmer">RampDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.12">
-               <model>2466S</model>
-               <description>ToggleLinc Switch</description>
-               <feature name="switch">GenericSwitch</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.13">
-               <model>2672-222</model>
-               <description>LED Bulb</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="rampdimmer">RampDimmer_3435</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.14">
-               <model>2487S</model>
-               <description>KeypadLinc On/Off 6-Button Scene Control </description>
-               <feature name="loadswitch">LoadSwitchButton</feature>
-               <feature name="loadswitchmanualchange">LoadSwitchManualChange</feature>
-               <feature name="loadswitchfastonoff">LoadSwitchFastOnOff</feature>
-               <feature_group name="button_group" type="KeyPadButtonGroup">
-                       <feature name="keypadbuttona">KeyPadButton3</feature>
-                       <feature name="keypadbuttonb">KeyPadButton4</feature>
-                       <feature name="keypadbuttonc">KeyPadButton5</feature>
-                       <feature name="keypadbuttond">KeyPadButton6</feature>
-               </feature_group>
-               <feature_group name="fastonoff_button_group" type="FastOnOffButtonGroup">
-                       <feature name="fastonoffbuttona">FastOnOffButton3</feature>
-                       <feature name="fastonoffbuttonb">FastOnOffButton4</feature>
-                       <feature name="fastonoffbuttonc">FastOnOffButton5</feature>
-                       <feature name="fastonoffbuttond">FastOnOffButton6</feature>
-               </feature_group>
-               <feature_group name="manualchange_button_group" type="ManualChangeButtonGroup">
-                       <feature name="manualchangebuttona">ManualChangeButton3</feature>
-                       <feature name="manualchangebuttonb">ManualChangeButton4</feature>
-                       <feature name="manualchangebuttonc">ManualChangeButton5</feature>
-                       <feature name="manualchangebuttond">ManualChangeButton6</feature>
-               </feature_group>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.15">
-               <model>2334-232</model>
-               <description>Keypad Dimmer Switch, 6-Button </description>
-               <feature name="loaddimmer">LoadDimmerButton</feature>
-               <feature name="rampdimmer">LoadDimmerRamp</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">LoadDimmerFastOnOff</feature>
-               <feature_group name="button_group" type="KeyPadButtonGroup">
-                       <feature name="keypadbuttona">KeyPadButton3</feature>
-                       <feature name="keypadbuttonb">KeyPadButton4</feature>
-                       <feature name="keypadbuttonc">KeyPadButton5</feature>
-                       <feature name="keypadbuttond">KeyPadButton6</feature>
-               </feature_group>
-               <feature_group name="fastonoff_button_group" type="FastOnOffButtonGroup">
-                       <feature name="fastonoffbuttona">FastOnOffButton3</feature>
-                       <feature name="fastonoffbuttonb">FastOnOffButton4</feature>
-                       <feature name="fastonoffbuttonc">FastOnOffButton5</feature>
-                       <feature name="fastonoffbuttond">FastOnOffButton6</feature>
-               </feature_group>
-               <feature_group name="manualchange_button_group" type="ManualChangeButtonGroup">
-                       <feature name="manualchangebuttona">ManualChangeButton3</feature>
-                       <feature name="manualchangebuttonb">ManualChangeButton4</feature>
-                       <feature name="manualchangebuttonc">ManualChangeButton5</feature>
-                       <feature name="manualchangebuttond">ManualChangeButton6</feature>
-               </feature_group>
-               <feature_group name="ext_group" type="ExtStatusGroup">
-                       <feature name="ledbrightness">LEDBrightness</feature>
-                       <feature name="ramprate">RampRate</feature>
-                       <feature name="onlevel">OnLevel</feature>
-               </feature_group>
-               <feature name="ledonoff">LEDOnOff</feature>
-               <feature name="beep">Beep</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.16">
-               <model>2334-232</model>
-               <description>Keypad Dimmer Switch, 8-Button </description>
-               <feature name="loaddimmer">LoadDimmerButton</feature>
-               <feature name="rampdimmer">LoadDimmerRamp</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">LoadDimmerFastOnOff</feature>
-               <feature_group name="button_group" type="KeyPadButtonGroup">
-                       <feature name="keypadbuttonb">KeyPadButton2</feature>
-                       <feature name="keypadbuttonc">KeyPadButton3</feature>
-                       <feature name="keypadbuttond">KeyPadButton4</feature>
-                       <feature name="keypadbuttone">KeyPadButton5</feature>
-                       <feature name="keypadbuttonf">KeyPadButton6</feature>
-                       <feature name="keypadbuttong">KeyPadButton7</feature>
-                       <feature name="keypadbuttonh">KeyPadButton8</feature>
-               </feature_group>
-               <feature_group name="fastonoff_button_group" type="FastOnOffButtonGroup">
-                       <feature name="fastonoffbuttonb">FastOnOffButton2</feature>
-                       <feature name="fastonoffbuttonc">FastOnOffButton3</feature>
-                       <feature name="fastonoffbuttond">FastOnOffButton4</feature>
-                       <feature name="fastonoffbuttone">FastOnOffButton5</feature>
-                       <feature name="fastonoffbuttonf">FastOnOffButton6</feature>
-                       <feature name="fastonoffbuttong">FastOnOffButton7</feature>
-                       <feature name="fastonoffbuttonh">FastOnOffButton8</feature>
-               </feature_group>
-               <feature_group name="manualchange_button_group" type="ManualChangeButtonGroup">
-                       <feature name="manualchangebuttonb">ManualChangeButton2</feature>
-                       <feature name="manualchangebuttonc">ManualChangeButton3</feature>
-                       <feature name="manualchangebuttond">ManualChangeButton4</feature>
-                       <feature name="manualchangebuttone">ManualChangeButton5</feature>
-                       <feature name="manualchangebuttonf">ManualChangeButton6</feature>
-                       <feature name="manualchangebuttong">ManualChangeButton7</feature>
-                       <feature name="manualchangebuttonh">ManualChangeButton8</feature>
-               </feature_group>
-               <feature_group name="ext_group" type="ExtStatusGroup">
-                       <feature name="ledbrightness">LEDBrightness</feature>
-                       <feature name="ramprate">RampRate</feature>
-                       <feature name="onlevel">OnLevel</feature>
-               </feature_group>
-               <feature name="ledonoff">LEDOnOff</feature>
-               <feature name="beep">Beep</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.17">
-               <model>2423A1</model>
-               <description>iMeter Solo Power Meter</description>
-               <feature name="meter">PowerMeter</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.18">
-               <model>2441TH</model>
-               <description>Insteon Thermostat</description>
-               <feature_group name="data1_group" type="ThermostatData1Group">
-                       <feature name="backlightduration">ThermostatBackLightDuration</feature>
-                       <feature name="acdelay">ThermostatACDelay</feature>
-               </feature_group>
-               <feature_group name="data1b_group" type="ThermostatData1bGroup">
-                       <feature name="humidityhigh">ThermostatHumidityHigh</feature>
-                       <feature name="humiditylow">ThermostatHumidityLow</feature>
-                       <feature name="stage1duration">ThermostatStage1Duration</feature>
-               </feature_group>
-               <feature_group name="data2_group" type="ThermostatData2Group">
-                       <feature name="coolsetpoint">ThermostatCoolSetPoint</feature>
-                       <feature name="heatsetpoint">ThermostatHeatSetPoint</feature>
-                       <feature name="systemmode">ThermostatSystemMode</feature>
-                       <feature name="fanmode">ThermostatFanMode</feature>
-                       <feature name="isheating">ThermostatIsHeating</feature>
-                       <feature name="iscooling">ThermostatIsCooling</feature>
-                       <feature name="temperature">ThermostatTemperatureFahrenheit</feature>
-                       <!--
-                               <feature name="tempcelsius">ThermostatTemperatureCelsius</feature>
-                               <feature name="tempfahrenheit">ThermostatTemperatureFahrenheit</feature>
-                       -->
-                       <feature name="humidity">ThermostatHumidity</feature>
-               </feature_group>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.19">
-               <model>2457D2</model>
-               <description>LampLinc Dimmer</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-               <feature name="ledonoff">LEDOnOff</feature>
-               <feature name="beep">Beep</feature>
-       </device>
-
-       <device productKey="F00.00.1A">
-               <model>2475SDB</model>
-               <description>In-LineLinc Relay</description>
-               <feature name="switch">GenericSwitch</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.1B">
-               <model>2635-222</model>
-               <description>On/Off Module</description>
-               <feature name="switch">GenericSwitch</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-               <feature name="ledonoff">LEDOnOff</feature>
-               <feature name="beep">Beep</feature>
-       </device>
-
-       <device productKey="F00.00.1C">
-               <model>2475F</model>
-               <description>FanLinc Module</description>
-               <feature name="lightdimmer">GenericDimmer</feature>
-               <feature name="fan">FanLincFan</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.1D">
-               <model>2456S3</model>
-               <description>ApplianceLinc</description>
-               <feature name="switch">GenericSwitch</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.1E">
-               <model>2674-222</model>
-               <description>LED Bulb (recessed)</description>
-               <feature name="dimmer">GenericDimmer</feature>
-               <feature name="rampdimmer">RampDimmer</feature>
-               <feature name="manualchange">ManualChange</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.1F">
-               <model>2477SA1</model>
-               <description>220V 30-amp Load Controller N/O</description>
-               <feature name="switch">GenericSwitch</feature>
-               <feature name="fastonoff">FastOnOff</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-       <device productKey="F00.00.20">
-               <model>2342-222</model>
-               <description>Mini Remote (8 Button)</description>
-               <feature name="buttona">RemoteButton2</feature>
-               <feature name="buttonb">RemoteButton1</feature>
-               <feature name="buttonc">RemoteButton4</feature>
-               <feature name="buttond">RemoteButton3</feature>
-               <feature name="buttone">RemoteButton6</feature>
-               <feature name="buttonf">RemoteButton5</feature>
-               <feature name="buttong">RemoteButton8</feature>
-               <feature name="buttonh">RemoteButton7</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-       <device productKey="F00.00.21">
-               <model>2441V</model>
-               <description>Insteon Thermostat Adaptor for Venstar</description>
-               <feature_group name="data1b_group" type="ThermostatData1bGroup">
-                       <feature name="coolsetpoint">VenstarCoolSetPoint</feature>
-                       <feature name="heatsetpoint">VenstarHeatSetPoint</feature>
-                       <feature name="systemmode">VenstarSystemMode</feature>
-                       <feature name="fanmode">VenstarFanMode</feature>
-                       <feature name="tempfahrenheit">VenstarTemperatureFahrenheit</feature>
-                       <feature name="humidity">VenstarHumidity</feature>
-                       <feature name="isheating">VenstarIsHeating</feature>
-                       <feature name="iscooling">VenstarIsCooling</feature>
-               </feature_group>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-       <device productKey="F00.00.22">
-               <model>2982-222</model>
-               <description>Insteon Smoke Bridge</description>
-               <feature name="notification">ReceiveBroadcast</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.23">
-               <model>2487S</model>
-               <description>KeypadLinc On/Off 8-Button Scene Control </description>
-               <feature name="loadswitch">LoadSwitchButton</feature>
-               <feature name="loadswitchmanualchange">LoadSwitchManualChange</feature>
-               <feature name="loadswitchfastonoff">LoadSwitchFastOnOff</feature>
-               <feature_group name="button_group" type="KeyPadButtonGroup">
-                       <feature name="keypadbuttonb">KeyPadButton2</feature>
-                       <feature name="keypadbuttonc">KeyPadButton3</feature>
-                       <feature name="keypadbuttond">KeyPadButton4</feature>
-                       <feature name="keypadbuttone">KeyPadButton5</feature>
-                       <feature name="keypadbuttonf">KeyPadButton6</feature>
-                       <feature name="keypadbuttong">KeyPadButton7</feature>
-                       <feature name="keypadbuttonh">KeyPadButton8</feature>
-               </feature_group>
-               <feature_group name="fastonoff_button_group" type="FastOnOffButtonGroup">
-                       <feature name="fastonoffbuttonb">FastOnOffButton2</feature>
-                       <feature name="fastonoffbuttonc">FastOnOffButton3</feature>
-                       <feature name="fastonoffbuttond">FastOnOffButton4</feature>
-                       <feature name="fastonoffbuttone">FastOnOffButton5</feature>
-                       <feature name="fastonoffbuttonf">FastOnOffButton6</feature>
-                       <feature name="fastonoffbuttong">FastOnOffButton7</feature>
-                       <feature name="fastonoffbuttonh">FastOnOffButton8</feature>
-               </feature_group>
-               <feature_group name="manualchange_button_group" type="ManualChangeButtonGroup">
-                       <feature name="manualchangebuttonb">ManualChangeButton2</feature>
-                       <feature name="manualchangebuttonc">ManualChangeButton3</feature>
-                       <feature name="manualchangebuttond">ManualChangeButton4</feature>
-                       <feature name="manualchangebuttone">ManualChangeButton5</feature>
-                       <feature name="manualchangebuttonf">ManualChangeButton6</feature>
-                       <feature name="manualchangebuttong">ManualChangeButton7</feature>
-                       <feature name="manualchangebuttonh">ManualChangeButton8</feature>
-               </feature_group>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-
-       <device productKey="F00.00.24">
-               <model>2844-222</model>
-               <description>Motion Sensor II</description>
-               <feature name="contact">WirelessMotionSensorContact</feature>
-               <feature name="lightlevelabovethreshold">WirelessMotionSensorLightLevelAboveThreshold</feature>
-               <feature name="lowbattery">WirelessMotionSensorLowBattery</feature>
-               <feature name="data">MotionSensor2Data</feature>
-               <feature name="tamperswitch">WirelessMotionSensor2TamperSwitch</feature>
-               <feature name="lastheardfrom">GenericLastTime</feature>
-       </device>
-</xml>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/legacy-device-features.xml b/bundles/org.openhab.binding.insteon/src/main/resources/legacy-device-features.xml
new file mode 100644 (file)
index 0000000..d634712
--- /dev/null
@@ -0,0 +1,897 @@
+<xml>
+       <feature name="GenericSwitch" timeout="5000">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="1">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x12" group="1" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" group="1">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x14" group="1" mode="FAST">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x19">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="FastOnOff">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x14" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+       <feature name="RampDimmer">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- cmd1 defaults to 0x2E, 0x2F -->
+               <!-- default dispatcher uses 0x19 for lookup key instead of cmd1 -->
+               <message-handler cmd="0x19">RampDimmerHandler</message-handler>
+               <command-handler command="PercentType">RampPercentHandler</command-handler>
+               <command-handler command="OnOffType">RampOnOffCommandHandler</command-handler>
+       </feature>
+
+       <feature name="RampDimmer_3435">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- default dispatcher uses 0x19 for lookup key instead of cmd1 -->
+               <message-handler cmd="0x19" on="0x34" off="0x35">RampDimmerHandler</message-handler>
+               <command-handler command="PercentType" on="0x34" off="0x35">RampPercentHandler</command-handler>
+               <command-handler command="OnOffType" on="0x34" off="0x35">RampOnOffCommandHandler</command-handler>
+       </feature>
+
+       <feature name="ManualChange">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+       <feature name="RemoteButton1">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="1" group="1">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="1" group="1">LightOffSwitchHandler</message-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="RemoteButton2">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="2">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="2" group="2">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="2" group="2">LightOffSwitchHandler</message-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="RemoteButton3">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="3">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="3" group="3">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="3" group="3">LightOffSwitchHandler</message-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="RemoteButton4">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="4">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="4" group="4">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="4" group="4">LightOffSwitchHandler</message-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="RemoteButton5">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="5">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="5" group="5">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="5" group="5">LightOffSwitchHandler</message-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="RemoteButton6">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="6">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="6" group="6">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="6" group="6">LightOffSwitchHandler</message-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="RemoteButton7">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="7">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="7" group="7">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="7" group="7">LightOffSwitchHandler</message-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="RemoteButton8">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="8">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="8" group="8">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="8" group="8">LightOffSwitchHandler</message-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="LoadSwitchButton">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="1" group="1">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x12" button="1" group="1" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="1" group="1">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x14" button="1" group="1" mode="FAST">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x19" button="1" group="1">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="LoadSwitchManualChange">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17" group="1">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18" group="1">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+       <feature name="LoadSwitchFastOnOff">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" group="1" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x19" group="1">SwitchRequestReplyHandler</message-handler>
+               <message-handler cmd="0x14" group="1" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="LoadDimmerButton">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="1" group="1">LightOnDimmerHandler</message-handler>
+               <message-handler cmd="0x12" button="1" group="1" mode="FAST">LightOnDimmerHandler</message-handler>
+               <message-handler cmd="0x13" button="1" group="1">LightOffDimmerHandler</message-handler>
+               <message-handler cmd="0x14" button="1" group="1" mode="FAST">LightOffDimmerHandler</message-handler>
+               <message-handler cmd="0x17" button="1" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x18" button="1" group="1">DimmerStopManualChangeHandler</message-handler>
+               <message-handler cmd="0x19" button="1" group="1">DimmerRequestReplyHandler</message-handler>
+               <command-handler command="PercentType">PercentHandler</command-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="LoadDimmerFastOnOff">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" group="1" mode="FAST">LightOnDimmerHandler</message-handler>
+               <message-handler cmd="0x14" group="1" mode="FAST">LightOffDimmerHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="LoadDimmerManualChange">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17" group="1">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18" group="1">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+       <feature name="LoadDimmerRamp">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- cmd1 defaults to 0x2E, 0x2F -->
+               <!-- default dispatcher uses 0x19 for lookup key instead of cmd1 -->
+               <message-handler cmd="0x19" group="1">RampDimmerHandler</message-handler>
+               <command-handler command="PercentType">RampPercentHandler</command-handler>
+               <command-handler command="OnOffType">RampOnOffCommandHandler</command-handler>
+       </feature>
+
+       <feature name="KeyPadButtonGroup">
+               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="FastOnOffButtonGroup">
+               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="ManualChangeButtonGroup">
+               <message-dispatcher>DefaultGroupDispatcher</message-dispatcher>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="KeyPadButton2">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="2" group="2">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x12" button="2" group="2" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="2" group="2">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x14" button="2" group="2" mode="FAST">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x19" button="2" group="2">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+       </feature>
+       <feature name="FastOnOffButton2">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" group="2" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x14" group="2" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="ManualChangeButton2">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17" group="2">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18" group="2">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+
+       <feature name="KeyPadButton3">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="3" group="3">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x12" button="3" group="3" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="3" group="3">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x14" button="3" group="3" mode="FAST">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x19" button="3" group="3">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+       </feature>
+       <feature name="FastOnOffButton3">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" group="3" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x14" group="3" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="ManualChangeButton3">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17" group="3">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18" group="3">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+       <feature name="KeyPadButton4">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="4" group="4">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x12" button="4" group="4" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="4" group="4">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x14" button="4" group="4" mode="FAST">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x19" button="4" group="4">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+       </feature>
+       <feature name="FastOnOffButton4">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" group="4" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x14" group="4" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="ManualChangeButton4">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17" group="4">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18" group="4">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+
+       <feature name="KeyPadButton5">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="5" group="5">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x12" button="5" group="5" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="5" group="5">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x14" button="5" group="5" mode="FAST">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x19" button="5" group="5">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+       </feature>
+       <feature name="FastOnOffButton5">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" group="5" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x14" group="5" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="ManualChangeButton5">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17" group="5">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18" group="5">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+
+       <feature name="KeyPadButton6">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="6" group="6">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x12" button="6" group="6" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="6" group="6">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x14" button="6" group="6" mode="FAST">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x19" button="6" group="6">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+       </feature>
+       <feature name="FastOnOffButton6">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" group="6" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x14" group="6" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="ManualChangeButton6">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17" group="6">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18" group="6">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+       <feature name="KeyPadButton7">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="7" group="7">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x12" button="7" group="7" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="7" group="7">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x14" button="7" group="7" mode="FAST">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x19" button="7" group="7">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+       </feature>
+       <feature name="FastOnOffButton7">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" group="7" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x14" group="7" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="ManualChangeButton7">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17" group="7">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18" group="7">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+       <feature name="KeyPadButton8">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" button="8" group="8">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x12" button="8" group="8" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x13" button="8" group="8">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x14" button="8" group="8" mode="FAST">LightOffSwitchHandler</message-handler>
+               <message-handler cmd="0x19" button="8" group="8">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+       </feature>
+       <feature name="FastOnOffButton8">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x12" group="8" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x14" group="8" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">FastOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="ManualChangeButton8">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x17" group="8">StartManualChangeHandler</message-handler>
+               <message-handler cmd="0x18" group="8">StopManualChangeHandler</message-handler>
+               <command-handler command="DecimalType">ManualChangeCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+
+
+       <feature name="GenericLastTime" statusFeature="true">
+               <message-dispatcher>PassThroughDispatcher</message-dispatcher>
+               <message-handler default="true">LastTimeHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+       </feature>
+       <feature name="GenericDimmer">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="1">LightOnDimmerHandler</message-handler>
+               <message-handler cmd="0x12" group="1" mode="FAST">LightOnDimmerHandler</message-handler>
+               <message-handler cmd="0x13" group="1">LightOffDimmerHandler</message-handler>
+               <message-handler cmd="0x14" group="1" mode="FAST">LightOffDimmerHandler</message-handler>
+               <message-handler cmd="0x17" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x18" group="1">DimmerStopManualChangeHandler</message-handler>
+               <message-handler cmd="0x19">DimmerRequestReplyHandler</message-handler>
+               <command-handler command="PercentType">PercentHandler</command-handler>
+               <command-handler command="IncreaseDecreaseType">IncreaseDecreaseCommandHandler</command-handler>
+               <command-handler command="OnOffType">LightOnOffCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="IOLincContact">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="1">OpenedContactHandler</message-handler>
+               <message-handler cmd="0x13" group="1">ClosedContactHandler</message-handler>
+               <message-handler cmd="0x19">ContactRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="IOLincSwitch">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x13" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x19">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType">IOLincOnOffCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="WirelessMotionSensorContact">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="1">OpenedSleepingContactHandler</message-handler>
+               <message-handler cmd="0x13" group="1">ClosedSleepingContactHandler</message-handler>
+               <message-handler cmd="0x19">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x2e">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="WirelessMotionSensorLightLevelAboveThreshold">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="2">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="2">OpenedSleepingContactHandler</message-handler>
+               <message-handler cmd="0x13" group="2">ClosedSleepingContactHandler</message-handler>
+               <message-handler cmd="0x19">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x2e">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="WirelessMotionSensorLowBattery">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="3">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="3">OpenedSleepingContactHandler</message-handler>
+               <message-handler cmd="0x13" group="3">ClosedSleepingContactHandler</message-handler>
+               <message-handler cmd="0x19">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x2e">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="WirelessMotionSensor2TamperSwitch">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="16">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="16">OpenedSleepingContactHandler</message-handler>
+               <message-handler cmd="0x13" group="16">ClosedSleepingContactHandler</message-handler>
+               <message-handler cmd="0x19">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x2e">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="MotionSensorData">
+               <message-dispatcher>SimpleDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x13" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x2e">MotionSensorDataReplyHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="MotionSensor2Data">
+               <message-dispatcher>SimpleDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x0C" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x13" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x03" group="11">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x0C" group="11">MotionSensor2AlternateHeartbeatHandler</message-handler>
+               <message-handler cmd="0x11" group="11">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x13" group="11">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x2e">MotionSensorDataReplyHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="HiddenDoorSensorData">
+               <message-dispatcher>SimpleDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x13" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x2e">HiddenDoorSensorDataReplyHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="GenericContact">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03" group="1">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11" group="1">OpenedContactHandler</message-handler>
+               <message-handler cmd="0x13" group="1">ClosedContactHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="LeakSensorContact">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x03">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x11">OpenedOrClosedContactHandler</message-handler>
+               <message-handler cmd="0x13">OpenedOrClosedContactHandler</message-handler>
+               <command-handler command="OnOffType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="GroupBroadcastOnOff">
+               <message-dispatcher>NoOpDispatcher</message-dispatcher>
+               <command-handler command="OnOffType">GroupBroadcastCommandHandler</command-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+       </feature>
+       <feature name="PowerMeter">
+               <message-dispatcher>SimpleDispatcher</message-dispatcher>
+               <message-handler cmd="0x03">NoOpMsgHandler</message-handler>
+               <message-handler cmd="0x80">PowerMeterResetHandler</message-handler>
+               <message-handler cmd="0x82">PowerMeterUpdateHandler</message-handler>
+               <command-handler command="OnOffType">PowerMeterCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x82" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="X10Dimmer" timeout="0">
+               <message-dispatcher>X10Dispatcher</message-dispatcher>
+               <message-handler cmd="0x02">X10OnHandler</message-handler>
+               <message-handler cmd="0x03">X10OffHandler</message-handler>
+               <message-handler cmd="0x05">X10BrightHandler</message-handler>
+               <message-handler cmd="0x04">X10DimHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">X10OnOffCommandHandler</command-handler>
+               <command-handler command="PercentType">X10PercentCommandHandler</command-handler>
+               <command-handler command="IncreaseDecreaseType">X10IncreaseDecreaseCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="X10Switch" timeout="0">
+               <message-dispatcher>X10Dispatcher</message-dispatcher>
+               <message-handler cmd="0x02">X10OnHandler</message-handler>
+               <message-handler cmd="0x03">X10OffHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="OnOffType">X10OnOffCommandHandler</command-handler>
+               <command-handler command="PercentType">NoOpCommandHandler</command-handler>
+               <command-handler command="IncreaseDecreaseType">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="X10Contact">
+               <message-dispatcher>X10Dispatcher</message-dispatcher>
+               <message-handler cmd="0x02">X10OpenHandler</message-handler>
+               <message-handler cmd="0x03">X10ClosedHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="ThermostatData1Group"> <!-- just does the polling for various quantities -->
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="1" cmd1="0x2e" cmd2="0x00">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="ThermostatData1bGroup"> <!-- just does the polling for various quantities -->
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="2" cmd1="0x2e" cmd2="0x00" d3="0x01">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="ThermostatData2Group"> <!-- just does the polling for various quantities -->
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="2" cmd1="0x2e" cmd2="0x02">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="ThermostatCoolSetPoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
+                       low_byte="userData7">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after set point has been changed -->
+               <message-handler cmd="0x19" ext="0" match_cmd1="0x6c" low_byte="command2" factor="0.5">NumberMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x71" ext="0" match_cmd1="0x71" low_byte="command2">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6c" factor="2" value="command2">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature>
+       <feature name="ThermostatHeatSetPoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
+                       low_byte="userData12">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after set point has been changed -->
+               <message-handler cmd="0x19" ext="0" match_cmd1="0x6d" low_byte="command2" factor="0.5">NumberMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x72" ext="0" match_cmd1="0x72" low_byte="command2">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6d" factor="2" value="command2">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature>
+       <feature name="ThermostatSystemMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
+                       low_byte="userData6" mask="0xf0" rshift="4">ThermostatSystemModeMsgHandler</message-handler>
+               <!-- handles direct ack after system mode has been changed -->
+               <message-handler cmd="0x19" ext="0" match_cmd1="0x6b" low_byte="command2">ThermostatSystemModeReplyHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x70" ext="0" match_cmd1="0x70" low_byte="command2" mask="0x0f">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6b" value="command2">ThermostatSystemModeCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature>
+       <feature name="ThermostatFanMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
+                       low_byte="userData6" mask="0x0f">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after fan mode has been changed -->
+               <message-handler cmd="0x19" ext="0" match_cmd1="0x6b" low_byte="command2">ThermostatFanModeReplyHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x70" ext="0" match_cmd1="0x70" low_byte="command2" mask="0xf0" rshift="4">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6b" value="command2">ThermostatFanModeCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature>
+       <feature name="ThermostatIsHeating">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
+                       low_byte="userData11" mask="0x02" rshift="1">NumberMsgHandler</message-handler>
+               <!-- handles all-link broadcast message OFF -->
+               <message-handler cmd="0x13" ext="0" group="2" mask="0x00" low_byte="command1">NumberMsgHandler</message-handler>
+               <!-- handles all-link broadcast message ON -->
+               <message-handler cmd="0x11" ext="0" group="2" mask="0x01" low_byte="command1">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature>
+       <feature name="ThermostatIsCooling">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
+                       low_byte="userData11" mask="0x01">NumberMsgHandler</message-handler>
+               <!-- handles all-link broadcast message OFF -->
+               <message-handler cmd="0x13" ext="0" group="1" mask="0x00" low_byte="command1">NumberMsgHandler</message-handler>
+               <!-- handles all-link broadcast message ON -->
+               <message-handler cmd="0x11" ext="0" group="1" mask="0x01" low_byte="command1">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature>
+       <feature name="ThermostatTemperatureCelsius">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
+                       low_byte="userData10" high_byte="userData9" factor="0.1" scale="celsius">NumberMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x6e" ext="0" match_cmd1="0x6e" low_byte="command2" offset="-17.7777778"
+                       factor="0.2777778" scale="celsius">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature>
+       <feature name="ThermostatTemperatureFahrenheit">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
+                       low_byte="userData10" high_byte="userData9" offset="32" factor="0.18" scale="fahrenheit">NumberMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x6e" ext="0" match_cmd1="0x6e" low_byte="command2" offset="0" factor="0.5"
+                       scale="fahrenheit">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature>
+       <feature name="ThermostatHumidity">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x02" match_d1="0x01"
+                       low_byte="userData8">NumberMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x6f" ext="0" match_cmd1="0x6f" low_byte="command2">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData2Group -->
+       </feature>
+       <feature name="ThermostatBackLightDuration">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       match_d3="0x00" low_byte="userData10">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after backlight duration has been changed -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x05" value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature>
+       <feature name="ThermostatACDelay">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       match_d3="0x00" low_byte="userData11">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after backlight duration has been changed -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x06" value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1Group -->
+       </feature>
+       <feature name="ThermostatHumidityHigh">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       match_d3="0x01" low_byte="userData4">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after value has been changed -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0b" value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="ThermostatHumidityLow">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       match_d3="0x01" low_byte="userData5">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after value has been changed -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0c" value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="ThermostatStage1Duration">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       match_d3="0x01" low_byte="userData11">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after value has been changed -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0a" value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="FanLincFan">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x19" ext="0" low_byte="command2">FanLincFanReplyHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x11" d1="0x02" value="command2">FanLincFanCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x03">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="BottomOutlet">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x19" button="2" group="1">SwitchRequestReplyHandler</message-handler>
+               <command-handler d1="0x02" ext="1" command="OnOffType">LightOnOffCommandHandler</command-handler>
+               <poll-handler ext="0" cmd1="0x19" cmd2="0x01">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="VenstarCoolSetPoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData6">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after set point has been changed -->
+               <message-handler cmd="0x19" ext="0" match_cmd1="0x6c" low_byte="command2" factor="0.5">NumberMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x71" ext="0" match_cmd1="0x71" low_byte="command2">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6c" factor="2" value="command2">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="VenstarHeatSetPoint">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData7">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after set point has been changed -->
+               <message-handler cmd="0x19" ext="0" match_cmd1="0x6d" low_byte="command2" factor="0.5">NumberMsgHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x72" ext="0" match_cmd1="0x72" low_byte="command2">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6d" factor="2" value="command2">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="VenstarSystemMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query - use NumberMsgHandler because this adapator directly reports the correct
+                       number -->
+               <!-- 0=OFF, 1=HEAT, 2=COOL, 3= Auto, 4=Program 5=Program Heat 6=Program Cool -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       low_byte="userData3">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after system mode has been changed -->
+               <message-handler cmd="0x19" ext="0" match_cmd1="0x6b" low_byte="command2">ThermostatSystemModeReplyHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x70" ext="0" match_cmd1="0x70" low_byte="command2" mask="0x0f">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6b" value="command2">ThermostatSystemModeCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="VenstarFanMode">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query mask for second bit -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData9"
+                       mask="0x10" rshift="4">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after fan mode has been changed -->
+               <message-handler cmd="0x19" ext="0" match_cmd1="0x6b" low_byte="command2">ThermostatFanModeReplyHandler</message-handler>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x70" ext="0" match_cmd1="0x70" low_byte="command2" mask="0xf0" rshift="4">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x6b" value="command2">ThermostatFanModeCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="VenstarTemperatureFahrenheit"> <!-- All temperatures reported in units currently set on thermostat -->
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       low_byte="userData5">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after value has been changed -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0b" value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="VenstarHumidity">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       low_byte="userData4">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after value has been changed -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x0c" value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="VenstarIsHeating">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData8"
+                       mask="0x02" rshift="1">NumberMsgHandler</message-handler>
+               <!-- handles all-link broadcast message OFF -->
+               <message-handler cmd="0x13" ext="0" group="2" mask="0x00" low_byte="command1">NumberMsgHandler</message-handler>
+               <!-- handles all-link broadcast message ON -->
+               <message-handler cmd="0x11" ext="0" group="2" mask="0x01" low_byte="command1">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="VenstarIsCooling">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" low_byte="userData8"
+                       mask="0x01">NumberMsgHandler</message-handler>
+               <!-- handles all-link broadcast message OFF -->
+               <message-handler cmd="0x13" ext="0" group="1" mask="0x00" low_byte="command1">NumberMsgHandler</message-handler>
+               <!-- handles all-link broadcast message ON -->
+               <message-handler cmd="0x11" ext="0" group="1" mask="0x01" low_byte="command1">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ThermostatData1bGroup -->
+       </feature>
+       <feature name="ReceiveBroadcast">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles out-of band status messages -->
+               <message-handler cmd="0x11" ext="0" match_cmd1="0x11" low_byte="group">NumberMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- broadcast messages only, no polling! -->
+       </feature>
+       <feature name="ExtStatusGroup"> <!-- does the polling for various quantities -->
+               <message-dispatcher>PollGroupDispatcher</message-dispatcher>
+               <poll-handler ext="2" cmd1="0x2e" cmd2="0x00" d1="0x01" d3="0x00">FlexPollHandler</poll-handler>
+       </feature>
+       <feature name="LEDBrightness">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       low_byte="userData9">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after poll -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x07" factor="1"
+                       value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtStatusGroup -->
+       </feature>
+       <feature name="LEDOnOff">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x2F" mode="FAST">LightOnSwitchHandler</message-handler>
+               <message-handler cmd="0x2F" mode="FAST">LightOffSwitchHandler</message-handler>
+               <command-handler command="OnOffType">LEDOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="Beep">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <message-handler cmd="0x19">SwitchRequestReplyHandler</message-handler>
+               <command-handler command="OnOffType" off="0x30" on="0x30">RampOnOffCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler>
+       </feature>
+       <feature name="RampRate">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       low_byte="userData7">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after poll -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x05" factor="1"
+                       value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtStatusGroup -->
+       </feature>
+       <feature name="OnLevel">
+               <message-dispatcher>DefaultDispatcher</message-dispatcher>
+               <!-- handles direct extended message after query -->
+               <message-handler cmd="0x2e" ext="1" match_cmd1="0x2e" match_cmd2="0x00" match_d2="0x01"
+                       low_byte="userData8">NumberMsgHandler</message-handler>
+               <!-- handles direct ack after poll -->
+               <message-handler cmd="0x19">TriggerPollMsgHandler</message-handler>
+               <message-handler default="true">NoOpMsgHandler</message-handler>
+               <command-handler command="DecimalType" ext="1" cmd1="0x2e" d1="0x01" d2="0x06" factor="1"
+                       value="userData3">NumberCommandHandler</command-handler>
+               <command-handler default="true">NoOpCommandHandler</command-handler>
+               <poll-handler>NoPollHandler</poll-handler> <!-- polled by ExtStatusGroup -->
+       </feature>
+</xml>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/legacy-device-types.xml b/bundles/org.openhab.binding.insteon/src/main/resources/legacy-device-types.xml
new file mode 100644 (file)
index 0000000..df23c5c
--- /dev/null
@@ -0,0 +1,598 @@
+<xml>
+       <!-- device types
+
+               #
+               # PLEASE KEEP PRODUCT KEYS IN INCREASING ORDER:
+               #
+               # - first the devices with insteon assigned product keys
+               # - then X10 devices (key starting with X)
+               # - then Insteon devices with fake keys (starting with F)
+               #
+
+               #
+               Example entry:
+
+               <device productKey="F00.00.05">
+               <model>2456-D3</model>
+               <description>LampLinc V2</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+               </device>
+
+       -->
+
+       <!-- #################################################
+               devices with regular insteon product keys
+       -->
+
+       <device productKey="0x00001A">
+               <model>2450</model>
+               <description>IO Link</description>
+               <feature name="contact">IOLincContact</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+               <feature name="switch">IOLincSwitch</feature>
+       </device>
+
+       <device productKey="0x000037">
+               <model>2486D</model>
+               <description>KeypadLinc Dimmer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="0x000039">
+               <model>2663-222</model>
+               <description>On/Off Outlet</description>
+               <feature name="topoutlet">GenericSwitch</feature>
+               <feature name="bottomoutlet">BottomOutlet</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="0x000041">
+               <model>2484DWH8</model>
+               <description>KeypadLinc Countdown Timer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="0x000045">
+               <model>2413U</model>
+               <description>PowerLinc 2413U USB modem</description>
+               <feature name="broadcastonoff">GroupBroadcastOnOff</feature>
+       </device>
+
+       <device productKey="0x000049">
+               <model>2843-222</model>
+               <description>Wireless Open/Close Sensor</description>
+               <feature name="contact">GenericContact</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="0x00004A">
+               <model>2842-222</model>
+               <description>Motion Sensor</description>
+               <feature name="contact">WirelessMotionSensorContact</feature>
+               <feature name="lightlevelabovethreshold">WirelessMotionSensorLightLevelAboveThreshold</feature>
+               <feature name="lowbattery">WirelessMotionSensorLowBattery</feature>
+               <feature name="data">MotionSensorData</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="0x000050">
+               <model>2486DWH6</model>
+               <description>KeypadLinc Dimmer - 6 Button</description>
+               <feature name="loaddimmer">LoadDimmerButton</feature>
+               <feature name="rampdimmer">LoadDimmerRamp</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">LoadDimmerFastOnOff</feature>
+               <feature_group name="button_group" type="KeyPadButtonGroup">
+                       <feature name="keypadbuttona">KeyPadButton3</feature>
+                       <feature name="keypadbuttonb">KeyPadButton4</feature>
+                       <feature name="keypadbuttonc">KeyPadButton5</feature>
+                       <feature name="keypadbuttond">KeyPadButton6</feature>
+               </feature_group>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="0x000051">
+               <model>2486DWH8</model>
+               <description>KeypadLinc Dimmer - 8 Button</description>
+               <feature name="loaddimmer">LoadDimmerButton</feature>
+               <feature name="rampdimmer">LoadDimmerRamp</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">LoadDimmerFastOnOff</feature>
+               <feature_group name="button_group" type="KeyPadButtonGroup">
+                       <feature name="keypadbuttonb">KeyPadButton2</feature>
+                       <feature name="keypadbuttonc">KeyPadButton3</feature>
+                       <feature name="keypadbuttond">KeyPadButton4</feature>
+                       <feature name="keypadbuttone">KeyPadButton5</feature>
+                       <feature name="keypadbuttonf">KeyPadButton6</feature>
+                       <feature name="keypadbuttong">KeyPadButton7</feature>
+                       <feature name="keypadbuttonh">KeyPadButton8</feature>
+               </feature_group>
+
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="0x000068">
+               <model>2472D</model>
+               <description>OutletLinc Dimmer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+               <feature name="ledonoff">LEDOnOff</feature>
+               <feature name="beep">Beep</feature>
+               <feature_group name="ext_group" type="ExtStatusGroup">
+                       <feature name="ledbrightness">LEDBrightness</feature>
+                       <feature name="ramprate">RampRate</feature>
+               </feature_group>
+       </device>
+
+       <!-- #################################################
+               X10 devices with made-up product keys Xaa.bb.cc
+       -->
+
+       <device productKey="X00.00.01">
+               <model>X10 switch</model>
+               <description>any simple X10 switch</description>
+               <feature name="switch">X10Switch</feature>
+       </device>
+       <device productKey="X00.00.02">
+               <model>X10 dimmer</model>
+               <description>Generic X10 Dimmer without preset</description>
+               <feature name="switch">X10Switch</feature>
+               <feature name="dimmer">X10Dimmer</feature>
+       </device>
+       <device productKey="X00.00.03">
+               <model>X10 motion sensor</model>
+               <description>Generic X10 motion sensor</description>
+               <feature name="contact">X10Contact</feature>
+       </device>
+
+       <!-- ###################################################
+               Insteon devices with made-up product keys Faa.bb.cc
+       -->
+
+       <device productKey="F00.00.01">
+               <model>2477D</model>
+               <description>SwitchLinc Dimmer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+               <feature name="ledonoff">LEDOnOff</feature>
+               <feature name="beep">Beep</feature>
+               <feature_group name="ext_group" type="ExtStatusGroup">
+                       <feature name="ledbrightness">LEDBrightness</feature>
+                       <feature name="ramprate">RampRate</feature>
+                       <feature name="onlevel">OnLevel</feature>
+               </feature_group>
+       </device>
+
+       <device productKey="F00.00.02">
+               <model>2477S</model>
+               <description>SwitchLinc Switch</description>
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+               <feature name="ledonoff">LEDOnOff</feature>
+               <feature name="beep">Beep</feature>
+               <feature_group name="ext_group" type="ExtStatusGroup">
+                       <feature name="ledbrightness">LEDBrightness</feature>
+               </feature_group>
+       </device>
+
+       <device productKey="F00.00.03">
+               <model>2845-222</model>
+               <description>Hidden Door Sensor</description>
+               <feature name="contact">WirelessMotionSensorContact</feature>
+               <feature name="data">HiddenDoorSensorData</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.04">
+               <model>2876S</model>
+               <description>ICON Switch</description>
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.05">
+               <model>2456D3</model>
+               <description>LampLinc V2</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.06">
+               <model>2442-222</model>
+               <description>Micro Dimmer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.07">
+               <model>2453-222</model>
+               <description>DIN Rail On/Off</description>
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.08">
+               <model>2452-222</model>
+               <description>DIN Rail Dimmer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.09">
+               <model>2458-A1</model>
+               <description>MorningLinc RF Lock Controller</description>
+               <feature name="switch">GenericSwitch</feature>
+       </device>
+
+       <device productKey="F00.00.0A">
+               <model>2852-222</model>
+               <description>Leak Sensor</description>
+               <feature name="contact">LeakSensorContact</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.0B">
+               <model>2672-422</model>
+               <description>LED Dimmer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.0C">
+               <model>2476D</model>
+               <description>SwitchLinc Dimmer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.0D">
+               <model>2634-222</model>
+               <description>On/Off Dual-Band Outdoor Module</description>
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+               <feature name="ledonoff">LEDOnOff</feature>
+               <feature name="beep">Beep</feature>
+       </device>
+
+       <device productKey="F00.00.10">
+               <model>2342-2</model>
+               <description>Mini Remote</description>
+               <feature name="buttona">RemoteButton1</feature>
+               <feature name="buttonb">RemoteButton2</feature>
+               <feature name="buttonc">RemoteButton3</feature>
+               <feature name="buttond">RemoteButton4</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.11">
+               <model>2466D</model>
+               <description>ToggleLinc Dimmer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="rampdimmer">RampDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.12">
+               <model>2466S</model>
+               <description>ToggleLinc Switch</description>
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.13">
+               <model>2672-222</model>
+               <description>LED Bulb</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="rampdimmer">RampDimmer_3435</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.14">
+               <model>2487S</model>
+               <description>KeypadLinc On/Off 6-Button Scene Control </description>
+               <feature name="loadswitch">LoadSwitchButton</feature>
+               <feature name="loadswitchmanualchange">LoadSwitchManualChange</feature>
+               <feature name="loadswitchfastonoff">LoadSwitchFastOnOff</feature>
+               <feature_group name="button_group" type="KeyPadButtonGroup">
+                       <feature name="keypadbuttona">KeyPadButton3</feature>
+                       <feature name="keypadbuttonb">KeyPadButton4</feature>
+                       <feature name="keypadbuttonc">KeyPadButton5</feature>
+                       <feature name="keypadbuttond">KeyPadButton6</feature>
+               </feature_group>
+               <feature_group name="fastonoff_button_group" type="FastOnOffButtonGroup">
+                       <feature name="fastonoffbuttona">FastOnOffButton3</feature>
+                       <feature name="fastonoffbuttonb">FastOnOffButton4</feature>
+                       <feature name="fastonoffbuttonc">FastOnOffButton5</feature>
+                       <feature name="fastonoffbuttond">FastOnOffButton6</feature>
+               </feature_group>
+               <feature_group name="manualchange_button_group" type="ManualChangeButtonGroup">
+                       <feature name="manualchangebuttona">ManualChangeButton3</feature>
+                       <feature name="manualchangebuttonb">ManualChangeButton4</feature>
+                       <feature name="manualchangebuttonc">ManualChangeButton5</feature>
+                       <feature name="manualchangebuttond">ManualChangeButton6</feature>
+               </feature_group>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.15">
+               <model>2334-232</model>
+               <description>Keypad Dimmer Switch, 6-Button </description>
+               <feature name="loaddimmer">LoadDimmerButton</feature>
+               <feature name="rampdimmer">LoadDimmerRamp</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">LoadDimmerFastOnOff</feature>
+               <feature_group name="button_group" type="KeyPadButtonGroup">
+                       <feature name="keypadbuttona">KeyPadButton3</feature>
+                       <feature name="keypadbuttonb">KeyPadButton4</feature>
+                       <feature name="keypadbuttonc">KeyPadButton5</feature>
+                       <feature name="keypadbuttond">KeyPadButton6</feature>
+               </feature_group>
+               <feature_group name="fastonoff_button_group" type="FastOnOffButtonGroup">
+                       <feature name="fastonoffbuttona">FastOnOffButton3</feature>
+                       <feature name="fastonoffbuttonb">FastOnOffButton4</feature>
+                       <feature name="fastonoffbuttonc">FastOnOffButton5</feature>
+                       <feature name="fastonoffbuttond">FastOnOffButton6</feature>
+               </feature_group>
+               <feature_group name="manualchange_button_group" type="ManualChangeButtonGroup">
+                       <feature name="manualchangebuttona">ManualChangeButton3</feature>
+                       <feature name="manualchangebuttonb">ManualChangeButton4</feature>
+                       <feature name="manualchangebuttonc">ManualChangeButton5</feature>
+                       <feature name="manualchangebuttond">ManualChangeButton6</feature>
+               </feature_group>
+               <feature_group name="ext_group" type="ExtStatusGroup">
+                       <feature name="ledbrightness">LEDBrightness</feature>
+                       <feature name="ramprate">RampRate</feature>
+                       <feature name="onlevel">OnLevel</feature>
+               </feature_group>
+               <feature name="ledonoff">LEDOnOff</feature>
+               <feature name="beep">Beep</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.16">
+               <model>2334-232</model>
+               <description>Keypad Dimmer Switch, 8-Button </description>
+               <feature name="loaddimmer">LoadDimmerButton</feature>
+               <feature name="rampdimmer">LoadDimmerRamp</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">LoadDimmerFastOnOff</feature>
+               <feature_group name="button_group" type="KeyPadButtonGroup">
+                       <feature name="keypadbuttonb">KeyPadButton2</feature>
+                       <feature name="keypadbuttonc">KeyPadButton3</feature>
+                       <feature name="keypadbuttond">KeyPadButton4</feature>
+                       <feature name="keypadbuttone">KeyPadButton5</feature>
+                       <feature name="keypadbuttonf">KeyPadButton6</feature>
+                       <feature name="keypadbuttong">KeyPadButton7</feature>
+                       <feature name="keypadbuttonh">KeyPadButton8</feature>
+               </feature_group>
+               <feature_group name="fastonoff_button_group" type="FastOnOffButtonGroup">
+                       <feature name="fastonoffbuttonb">FastOnOffButton2</feature>
+                       <feature name="fastonoffbuttonc">FastOnOffButton3</feature>
+                       <feature name="fastonoffbuttond">FastOnOffButton4</feature>
+                       <feature name="fastonoffbuttone">FastOnOffButton5</feature>
+                       <feature name="fastonoffbuttonf">FastOnOffButton6</feature>
+                       <feature name="fastonoffbuttong">FastOnOffButton7</feature>
+                       <feature name="fastonoffbuttonh">FastOnOffButton8</feature>
+               </feature_group>
+               <feature_group name="manualchange_button_group" type="ManualChangeButtonGroup">
+                       <feature name="manualchangebuttonb">ManualChangeButton2</feature>
+                       <feature name="manualchangebuttonc">ManualChangeButton3</feature>
+                       <feature name="manualchangebuttond">ManualChangeButton4</feature>
+                       <feature name="manualchangebuttone">ManualChangeButton5</feature>
+                       <feature name="manualchangebuttonf">ManualChangeButton6</feature>
+                       <feature name="manualchangebuttong">ManualChangeButton7</feature>
+                       <feature name="manualchangebuttonh">ManualChangeButton8</feature>
+               </feature_group>
+               <feature_group name="ext_group" type="ExtStatusGroup">
+                       <feature name="ledbrightness">LEDBrightness</feature>
+                       <feature name="ramprate">RampRate</feature>
+                       <feature name="onlevel">OnLevel</feature>
+               </feature_group>
+               <feature name="ledonoff">LEDOnOff</feature>
+               <feature name="beep">Beep</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.17">
+               <model>2423A1</model>
+               <description>iMeter Solo Power Meter</description>
+               <feature name="meter">PowerMeter</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.18">
+               <model>2441TH</model>
+               <description>Insteon Thermostat</description>
+               <feature_group name="data1_group" type="ThermostatData1Group">
+                       <feature name="backlightduration">ThermostatBackLightDuration</feature>
+                       <feature name="acdelay">ThermostatACDelay</feature>
+               </feature_group>
+               <feature_group name="data1b_group" type="ThermostatData1bGroup">
+                       <feature name="humidityhigh">ThermostatHumidityHigh</feature>
+                       <feature name="humiditylow">ThermostatHumidityLow</feature>
+                       <feature name="stage1duration">ThermostatStage1Duration</feature>
+               </feature_group>
+               <feature_group name="data2_group" type="ThermostatData2Group">
+                       <feature name="coolsetpoint">ThermostatCoolSetPoint</feature>
+                       <feature name="heatsetpoint">ThermostatHeatSetPoint</feature>
+                       <feature name="systemmode">ThermostatSystemMode</feature>
+                       <feature name="fanmode">ThermostatFanMode</feature>
+                       <feature name="isheating">ThermostatIsHeating</feature>
+                       <feature name="iscooling">ThermostatIsCooling</feature>
+                       <feature name="temperature">ThermostatTemperatureFahrenheit</feature>
+                       <!--
+                               <feature name="tempcelsius">ThermostatTemperatureCelsius</feature>
+                               <feature name="tempfahrenheit">ThermostatTemperatureFahrenheit</feature>
+                       -->
+                       <feature name="humidity">ThermostatHumidity</feature>
+               </feature_group>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.19">
+               <model>2457D2</model>
+               <description>LampLinc Dimmer</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+               <feature name="ledonoff">LEDOnOff</feature>
+               <feature name="beep">Beep</feature>
+       </device>
+
+       <device productKey="F00.00.1A">
+               <model>2475SDB</model>
+               <description>In-LineLinc Relay</description>
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.1B">
+               <model>2635-222</model>
+               <description>On/Off Module</description>
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+               <feature name="ledonoff">LEDOnOff</feature>
+               <feature name="beep">Beep</feature>
+       </device>
+
+       <device productKey="F00.00.1C">
+               <model>2475F</model>
+               <description>FanLinc Module</description>
+               <feature name="lightdimmer">GenericDimmer</feature>
+               <feature name="fan">FanLincFan</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.1D">
+               <model>2456S3</model>
+               <description>ApplianceLinc</description>
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.1E">
+               <model>2674-222</model>
+               <description>LED Bulb (recessed)</description>
+               <feature name="dimmer">GenericDimmer</feature>
+               <feature name="rampdimmer">RampDimmer</feature>
+               <feature name="manualchange">ManualChange</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.1F">
+               <model>2477SA1</model>
+               <description>220V 30-amp Load Controller N/O</description>
+               <feature name="switch">GenericSwitch</feature>
+               <feature name="fastonoff">FastOnOff</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+       <device productKey="F00.00.20">
+               <model>2342-222</model>
+               <description>Mini Remote (8 Button)</description>
+               <feature name="buttona">RemoteButton2</feature>
+               <feature name="buttonb">RemoteButton1</feature>
+               <feature name="buttonc">RemoteButton4</feature>
+               <feature name="buttond">RemoteButton3</feature>
+               <feature name="buttone">RemoteButton6</feature>
+               <feature name="buttonf">RemoteButton5</feature>
+               <feature name="buttong">RemoteButton8</feature>
+               <feature name="buttonh">RemoteButton7</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+       <device productKey="F00.00.21">
+               <model>2441V</model>
+               <description>Insteon Thermostat Adaptor for Venstar</description>
+               <feature_group name="data1b_group" type="ThermostatData1bGroup">
+                       <feature name="coolsetpoint">VenstarCoolSetPoint</feature>
+                       <feature name="heatsetpoint">VenstarHeatSetPoint</feature>
+                       <feature name="systemmode">VenstarSystemMode</feature>
+                       <feature name="fanmode">VenstarFanMode</feature>
+                       <feature name="tempfahrenheit">VenstarTemperatureFahrenheit</feature>
+                       <feature name="humidity">VenstarHumidity</feature>
+                       <feature name="isheating">VenstarIsHeating</feature>
+                       <feature name="iscooling">VenstarIsCooling</feature>
+               </feature_group>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+       <device productKey="F00.00.22">
+               <model>2982-222</model>
+               <description>Insteon Smoke Bridge</description>
+               <feature name="notification">ReceiveBroadcast</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.23">
+               <model>2487S</model>
+               <description>KeypadLinc On/Off 8-Button Scene Control </description>
+               <feature name="loadswitch">LoadSwitchButton</feature>
+               <feature name="loadswitchmanualchange">LoadSwitchManualChange</feature>
+               <feature name="loadswitchfastonoff">LoadSwitchFastOnOff</feature>
+               <feature_group name="button_group" type="KeyPadButtonGroup">
+                       <feature name="keypadbuttonb">KeyPadButton2</feature>
+                       <feature name="keypadbuttonc">KeyPadButton3</feature>
+                       <feature name="keypadbuttond">KeyPadButton4</feature>
+                       <feature name="keypadbuttone">KeyPadButton5</feature>
+                       <feature name="keypadbuttonf">KeyPadButton6</feature>
+                       <feature name="keypadbuttong">KeyPadButton7</feature>
+                       <feature name="keypadbuttonh">KeyPadButton8</feature>
+               </feature_group>
+               <feature_group name="fastonoff_button_group" type="FastOnOffButtonGroup">
+                       <feature name="fastonoffbuttonb">FastOnOffButton2</feature>
+                       <feature name="fastonoffbuttonc">FastOnOffButton3</feature>
+                       <feature name="fastonoffbuttond">FastOnOffButton4</feature>
+                       <feature name="fastonoffbuttone">FastOnOffButton5</feature>
+                       <feature name="fastonoffbuttonf">FastOnOffButton6</feature>
+                       <feature name="fastonoffbuttong">FastOnOffButton7</feature>
+                       <feature name="fastonoffbuttonh">FastOnOffButton8</feature>
+               </feature_group>
+               <feature_group name="manualchange_button_group" type="ManualChangeButtonGroup">
+                       <feature name="manualchangebuttonb">ManualChangeButton2</feature>
+                       <feature name="manualchangebuttonc">ManualChangeButton3</feature>
+                       <feature name="manualchangebuttond">ManualChangeButton4</feature>
+                       <feature name="manualchangebuttone">ManualChangeButton5</feature>
+                       <feature name="manualchangebuttonf">ManualChangeButton6</feature>
+                       <feature name="manualchangebuttong">ManualChangeButton7</feature>
+                       <feature name="manualchangebuttonh">ManualChangeButton8</feature>
+               </feature_group>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+
+       <device productKey="F00.00.24">
+               <model>2844-222</model>
+               <description>Motion Sensor II</description>
+               <feature name="contact">WirelessMotionSensorContact</feature>
+               <feature name="lightlevelabovethreshold">WirelessMotionSensorLightLevelAboveThreshold</feature>
+               <feature name="lowbattery">WirelessMotionSensorLowBattery</feature>
+               <feature name="data">MotionSensor2Data</feature>
+               <feature name="tamperswitch">WirelessMotionSensor2TamperSwitch</feature>
+               <feature name="lastheardfrom">GenericLastTime</feature>
+       </device>
+</xml>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/msg-definitions.xml b/bundles/org.openhab.binding.insteon/src/main/resources/msg-definitions.xml
new file mode 100644 (file)
index 0000000..dad8f26
--- /dev/null
@@ -0,0 +1,676 @@
+<xml>
+       <!--
+               // The PureNACK message is a fake message that was introduced
+               // to make the driver code more regular. It indicates
+               // that the modem was not ready, and that the host has to
+               // write the message to the serial port again.
+       -->
+
+       <msg name="PureNACK" length="2" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte>0x15</byte>
+               </header>
+       </msg>
+
+       <!-- Commands Sent from an IM to the Host -->
+
+       <msg name="StandardMessageReceived" length="11" direction="FROM_MODEM">
+               <header length="9">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x50</byte>
+                       <address name="fromAddress"/>
+                       <address name="toAddress"/>
+                       <byte name="messageFlags"/>
+               </header>
+               <byte name="command1"/>
+               <byte name="command2"/>
+       </msg>
+       <msg name="ExtendedMessageReceived" length="25" direction="FROM_MODEM">
+               <header length="9">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x51</byte>
+                       <address name="fromAddress"/>
+                       <address name="toAddress"/>
+                       <byte name="messageFlags">0x10</byte>
+               </header>
+               <byte name="command1"/>
+               <byte name="command2"/>
+               <byte name="userData1"/>
+               <byte name="userData2"/>
+               <byte name="userData3"/>
+               <byte name="userData4"/>
+               <byte name="userData5"/>
+               <byte name="userData6"/>
+               <byte name="userData7"/>
+               <byte name="userData8"/>
+               <byte name="userData9"/>
+               <byte name="userData10"/>
+               <byte name="userData11"/>
+               <byte name="userData12"/>
+               <byte name="userData13"/>
+               <byte name="userData14"/>
+       </msg>
+       <msg name="X10Received" length="4" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x52</byte>
+               </header>
+               <byte name="rawX10"/>
+               <byte name="X10Flag"/>
+       </msg>
+       <msg name="ALLLinkingCompleted" length="10" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x53</byte>
+               </header>
+               <byte name="LinkCode"/>
+               <byte name="ALLLinkGroup"/>
+               <address name="LinkAddr"/>
+               <byte name="DeviceCategory"/>
+               <byte name="DeviceSubcategory"/>
+               <byte name="FirmwareVersion"/>
+       </msg>
+       <msg name="ButtonEventReport" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x54</byte>
+               </header>
+               <byte name="buttonEvent"/>
+       </msg>
+       <msg name="UserResetDetected" length="2" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x55</byte>
+               </header>
+       </msg>
+       <msg name="ALLLinkCleanupFailureReport" length="6" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x56</byte>
+               </header>
+               <byte name="ALLLinkGroup"/>
+               <address name="LinkAddr"/>
+       </msg>
+       <msg name="ALLLinkRecordResponse" length="10" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x57</byte>
+               </header>
+               <byte name="RecordFlags"/>
+               <byte name="ALLLinkGroup"/>
+               <address name="LinkAddr"/>
+               <byte name="LinkData1"/>
+               <byte name="LinkData2"/>
+               <byte name="LinkData3"/>
+       </msg>
+       <msg name="ALLLinkCleanupStatusReport" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x58</byte>
+               </header>
+               <byte name="statusByte"/>
+       </msg>
+       <msg name="DatabaseRecordFound" length="12" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x59</byte>
+               </header>
+               <byte name="DatabaseAddrHighByte"/>
+               <byte name="DatabaseAddrLowByte"/>
+               <byte name="RecordFlags"/>
+               <byte name="ALLLinkGroup"/>
+               <address name="LinkAddr"/>
+               <byte name="LinkData1"/>
+               <byte name="LinkData2"/>
+               <byte name="LinkData3"/>
+       </msg>
+       <msg name="MessageFailureReport" length="11" direction="FROM_MODEM">
+               <header length="9">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x5C</byte>
+                       <address name="fromAddress"/>
+                       <address name="toAddress"/>
+                       <byte name="messageFlags"/>
+               </header>
+               <byte name="command1"/>
+               <byte name="command2"/>
+       </msg>
+
+       <!-- Commands Sent from the Host to an IM -->
+
+       <msg name="GetIMInfo" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x60</byte>
+               </header>
+       </msg>
+       <msg name="GetIMInfoReply" length="9" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x60</byte>
+               </header>
+               <address name="IMAddress"/>
+               <byte name="DeviceCategory"/>
+               <byte name="DeviceSubCategory"/>
+               <byte name="FirmwareVersion"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SendALLLinkCommand" length="5" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x61</byte>
+               </header>
+               <byte name="ALLLinkGroup"/>
+               <byte name="ALLLinkCommand"/>
+               <byte name="ALLLinkCommand2">0x00</byte>
+       </msg>
+       <msg name="SendALLLinkCommandReply" length="6" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x61</byte>
+               </header>
+               <byte name="ALLLinkGroup"/>
+               <byte name="ALLLinkCommand"/>
+               <byte name="ALLLinkCommand2"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SendStandardMessage" length="8" direction="TO_MODEM">
+               <header length="6">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x62</byte>
+                       <address name="toAddress"/>
+                       <byte name="messageFlags"/>
+               </header>
+               <byte name="command1"/>
+               <byte name="command2"/>
+       </msg>
+       <msg name="SendStandardMessageReply" length="9" direction="FROM_MODEM">
+               <header length="6">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x62</byte>
+                       <address name="toAddress"/>
+                       <byte name="messageFlags"/>
+               </header>
+               <byte name="command1"/>
+               <byte name="command2"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SendExtendedMessage" length="22" direction="TO_MODEM">
+               <header length="6">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x62</byte>
+                       <address name="toAddress"/>
+                       <byte name="messageFlags">0x10</byte>
+               </header>
+               <byte name="command1"/>
+               <byte name="command2"/>
+               <byte name="userData1">0x00</byte>
+               <byte name="userData2">0x00</byte>
+               <byte name="userData3">0x00</byte>
+               <byte name="userData4">0x00</byte>
+               <byte name="userData5">0x00</byte>
+               <byte name="userData6">0x00</byte>
+               <byte name="userData7">0x00</byte>
+               <byte name="userData8">0x00</byte>
+               <byte name="userData9">0x00</byte>
+               <byte name="userData10">0x00</byte>
+               <byte name="userData11">0x00</byte>
+               <byte name="userData12">0x00</byte>
+               <byte name="userData13">0x00</byte>
+               <byte name="userData14">0x00</byte>
+       </msg>
+       <msg name="SendExtendedMessageReply" length="23" direction="FROM_MODEM">
+               <header length="6">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x62</byte>
+                       <address name="toAddress"/>
+                       <byte name="messageFlags">0x10</byte>
+               </header>
+               <byte name="command1"/>
+               <byte name="command2"/>
+               <byte name="userData1"/>
+               <byte name="userData2"/>
+               <byte name="userData3"/>
+               <byte name="userData4"/>
+               <byte name="userData5"/>
+               <byte name="userData6"/>
+               <byte name="userData7"/>
+               <byte name="userData8"/>
+               <byte name="userData9"/>
+               <byte name="userData10"/>
+               <byte name="userData11"/>
+               <byte name="userData12"/>
+               <byte name="userData13"/>
+               <byte name="userData14"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SendX10Message" length="4" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x63</byte>
+               </header>
+               <byte name="rawX10"></byte>
+               <byte name="X10Flag">0x00</byte>
+       </msg>
+       <msg name="SendX10MessageReply" length="5" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x63</byte>
+               </header>
+               <byte name="rawX10"></byte>
+               <byte name="X10Flag">0x00</byte>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="StartALLLinking" length="4" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x64</byte>
+               </header>
+               <byte name="LinkCode"></byte>
+               <byte name="ALLLinkGroup">0x00</byte>
+       </msg>
+       <msg name="StartALLLinkingReply" length="5" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x64</byte>
+               </header>
+               <byte name="LinkCode"></byte>
+               <byte name="ALLLinkGroup">0x00</byte>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="CancelALLLinking" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x65</byte>
+               </header>
+       </msg>
+       <msg name="CancelALLLinkingReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x65</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SetHostDeviceCategory" length="5" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x66</byte>
+               </header>
+               <byte name="DeviceCategory"/>
+               <byte name="DeviceSubcategory"/>
+               <byte name="FirmwareVersion"/>
+       </msg>
+       <msg name="SetHostDeviceCategoryReply" length="6" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x66</byte>
+               </header>
+               <byte name="DeviceCategory"/>
+               <byte name="DeviceSubcategory"/>
+               <byte name="FirmwareVersion"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="ResetIM" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x67</byte>
+               </header>
+       </msg>
+       <msg name="ResetIMReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x67</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SetACKMessageByte" length="3" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x68</byte>
+               </header>
+               <byte name="Command2"/>
+       </msg>
+       <msg name="SetACKMessageByteReply" length="4" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x68</byte>
+               </header>
+               <byte name="Command2"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="GetFirstALLLinkRecord" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x69</byte>
+               </header>
+       </msg>
+       <msg name="GetFirstALLLinkRecordReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x69</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="GetNextALLLinkRecord" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6A</byte>
+               </header>
+       </msg>
+       <msg name="GetNextALLLinkRecordReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6A</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SetIMConfig" length="3" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6B</byte>
+               </header>
+               <byte name="IMConfigurationFlags"/>
+       </msg>
+       <msg name="SetIMConfigReply" length="4" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6B</byte>
+               </header>
+               <byte name="IMConfigurationFlags"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="GetALLLinkRecordForSender" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6C</byte>
+               </header>
+       </msg>
+       <msg name="GetALLLinkRecordForSenderReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6C</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="LEDOn" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6D</byte>
+               </header>
+       </msg>
+       <msg name="LEDOnReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6D</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="LEDOff" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6E</byte>
+               </header>
+       </msg>
+       <msg name="LEDOffReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6E</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="ManageALLLinkRecord" length="11" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6F</byte>
+               </header>
+               <byte name="ControlCode"/>
+               <byte name="RecordFlags"/>
+               <byte name="ALLLinkGroup"/>
+               <address name="LinkAddr"/>
+               <byte name="LinkData1"/>
+               <byte name="LinkData2"/>
+               <byte name="LinkData3"/>
+       </msg>
+       <msg name="ManageALLLinkRecordReply" length="12" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x6F</byte>
+               </header>
+               <byte name="ControlCode"/>
+               <byte name="RecordFlags"/>
+               <byte name="ALLLinkGroup"/>
+               <address name="LinkAddr"/>
+               <byte name="LinkData1"/>
+               <byte name="LinkData2"/>
+               <byte name="LinkData3"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SetNAKMessageByte" length="3" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x70</byte>
+               </header>
+               <byte name="Command2"/>
+       </msg>
+       <msg name="SetNAKMessageByteReply" length="4" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x70</byte>
+               </header>
+               <byte name="Command2"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SetACKMessageTwoBytes" length="4" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x71</byte>
+               </header>
+               <byte name="Command1"/>
+               <byte name="Command2"/>
+       </msg>
+       <msg name="SetACKMessageTwoBytesReply" length="5" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x71</byte>
+               </header>
+               <byte name="Command1"/>
+               <byte name="Command2"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="RFSleep" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x72</byte>
+               </header>
+       </msg>
+       <msg name="RFSleepReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x72</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="GetIMConfig" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x73</byte>
+               </header>
+       </msg>
+       <msg name="GetIMConfigReply" length="6" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x73</byte>
+               </header>
+               <byte name="IMConfigurationFlags"/>
+               <byte name="Spare1"/>
+               <byte name="Spare2"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+
+       <!-- Commands Sent from the Host to an IM -->
+
+       <msg name="CancelCleanup" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x74</byte>
+               </header>
+       </msg>
+       <msg name="CancelCleanupReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x74</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="ReadDatabaseRecord" length="4" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x75</byte>
+               </header>
+               <byte name="DatabaseAddrHighByte"/>
+               <byte name="DatabaseAddrLowByte"/>
+       </msg>
+       <msg name="ReadDatabaseRecordReply" length="5" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x75</byte>
+               </header>
+               <byte name="DatabaseAddrHighByte"/>
+               <byte name="DatabaseAddrLowByte"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="WriteDatabaseRecord" length="12" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x76</byte>
+               </header>
+               <byte name="DatabaseAddrHighByte"/>
+               <byte name="DatabaseAddrLowByte"/>
+               <byte name="RecordFlags"/>
+               <byte name="ALLLinkGroup"/>
+               <address name="LinkAddr"/>
+               <byte name="LinkData1">0x00</byte>
+               <byte name="LinkData2">0x00</byte>
+               <byte name="LinkData3">0x00</byte>
+       </msg>
+       <msg name="WriteDatabaseRecordReply" length="13" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x76</byte>
+               </header>
+               <byte name="DatabaseAddrHighByte"/>
+               <byte name="DatabaseAddrLowByte"/>
+               <byte name="RecordFlags"/>
+               <byte name="ALLLinkGroup"/>
+               <address name="LinkAddr"/>
+               <byte name="LinkData1"/>
+               <byte name="LinkData2"/>
+               <byte name="LinkData3"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="Beep" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x77</byte>
+               </header>
+       </msg>
+       <msg name="BeepReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x77</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SetStatus" length="2" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x78</byte>
+               </header>
+       </msg>
+       <msg name="SetStatusReply" length="3" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x78</byte>
+               </header>
+               <byte name="ACK/NACK"/>
+       </msg>
+
+       <!-- RF Modem only commands -->
+
+       <msg name="SetNextLinkData" length="5" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x79</byte>
+               </header>
+               <byte name="LinkData1">0x00</byte>
+               <byte name="LinkData2">0x00</byte>
+               <byte name="LinkData3">0x00</byte>
+       </msg>
+       <msg name="SetNextLinkDataReply" length="6" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x79</byte>
+               </header>
+               <byte name="LinkData1"/>
+               <byte name="LinkData2"/>
+               <byte name="LinkData3"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SetNewLinksAppRetries" length="3" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x7A</byte>
+               </header>
+               <byte name="NumRetries"/>
+       </msg>
+       <msg name="SetNewLinksAppRetriesReply" length="4" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x7A</byte>
+               </header>
+               <byte name="NumRetries"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SetRFOffset" length="3" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x7B</byte>
+               </header>
+               <byte name="FrequencyOffset"/>
+       </msg>
+       <msg name="SetRFOffsetReply" length="4" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x7B</byte>
+               </header>
+               <byte name="FrequencyOffset"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="SetACKTempLincCommand" length="4" direction="TO_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x7C</byte>
+               </header>
+               <byte name="Command1"/>
+               <byte name="Command2"/>
+       </msg>
+       <msg name="SetACKTempLincCommandReply" length="5" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x7C</byte>
+               </header>
+               <byte name="Command1"/>
+               <byte name="Command2"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+       <msg name="UnknownMessage7F" length="4" direction="FROM_MODEM">
+               <header length="2">
+                       <byte>0x02</byte>
+                       <byte name="Cmd">0x7F</byte>
+               </header>
+               <byte name="Data"/>
+               <byte name="ACK/NACK"/>
+       </msg>
+
+</xml>
diff --git a/bundles/org.openhab.binding.insteon/src/main/resources/msg_definitions.xml b/bundles/org.openhab.binding.insteon/src/main/resources/msg_definitions.xml
deleted file mode 100644 (file)
index 26a8db7..0000000
+++ /dev/null
@@ -1,410 +0,0 @@
-<xml>
-       <!--
-               // Please keep messages ordered by increasing command number!
-               //
-               // The header is not an official Insteon concept. The boundary
-               // between header and message is the point where enough information
-               // is available to determine the full length of the incoming message.
-               // Sometimes that's just two bytes, sometimes 6 or 9.
-               //
-               ->
-
-               <!-
-               // The PureNACK message is a fake message that was introduced
-               // to make the driver code more regular. It indicates
-               // that the modem was not ready, and that the host has to
-               // write the message to the serial port again.
-       -->
-
-       <msg name="PureNACK" length="2" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte>0x15</byte>
-               </header>
-       </msg>
-
-       <msg name="StandardMessageReceived" length="11" direction="FROM_MODEM">
-               <header length="9">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x50</byte>
-                       <address name="fromAddress"/>
-                       <address name="toAddress"/>
-                       <byte name="messageFlags"/>
-               </header>
-               <byte name="command1"/>
-               <byte name="command2"/>
-       </msg>
-
-       <msg name="ExtendedMessageReceived" length="25" direction="FROM_MODEM">
-               <header length="9">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x51</byte>
-                       <address name="fromAddress"/>
-                       <address name="toAddress"/>
-                       <byte name="messageFlags">0x10</byte>
-               </header>
-               <byte name="command1"/>
-               <byte name="command2"/>
-               <byte name="userData1"/>
-               <byte name="userData2"/>
-               <byte name="userData3"/>
-               <byte name="userData4"/>
-               <byte name="userData5"/>
-               <byte name="userData6"/>
-               <byte name="userData7"/>
-               <byte name="userData8"/>
-               <byte name="userData9"/>
-               <byte name="userData10"/>
-               <byte name="userData11"/>
-               <byte name="userData12"/>
-               <byte name="userData13"/>
-               <byte name="userData14"/>
-       </msg>
-       <msg name="X10Received" length="4" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x52</byte>
-               </header>
-               <byte name="rawX10"/>
-               <byte name="X10Flag"/>
-       </msg>
-       <msg name="ALLLinkingCompleted" length="10" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x53</byte>
-               </header>
-               <byte name="linkCode"/>
-               <byte name="ALLLinkGroup"/>
-               <address name="address"/>
-               <byte name="deviceCategory"/>
-               <byte name="subCategory"/>
-               <byte name="firmwareVersion"/>
-       </msg>
-       <msg name="ButtonEventReport" length="3" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x54</byte>
-               </header>
-               <byte name="buttonEvent"/>
-       </msg>
-       <msg name="UserResetDetected" length="2" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x55</byte>
-               </header>
-       </msg>
-       <msg name="ALLLinkCleanupFailureReport" length="6" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x56</byte>
-               </header>
-               <byte name="ALLLinkGroup"/>
-               <address name="address"/>
-       </msg>
-       <msg name="ALLLinkRecordResponse" length="10" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x57</byte>
-               </header>
-               <byte name="RecordFlags"/>
-               <byte name="ALLLinkGroup"/>
-               <address name="LinkAddr"/>
-               <byte name="LinkData1"/>
-               <byte name="LinkData2"/>
-               <byte name="LinkData3"/>
-       </msg>
-       <msg name="ALLLinkCleanupStatusReport" length="3" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x58</byte>
-               </header>
-               <byte name="statusByte"/>
-       </msg>
-       <msg name="UnknownMessage5C" length="11" direction="FROM_MODEM">
-               <header length="9">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x5c</byte>
-                       <address name="fromAddress"/>
-                       <address name="toAddress"/>
-                       <byte name="messageFlags"/>
-               </header>
-               <byte name="command1"/>
-               <byte name="command2"/>
-       </msg>
-
-       <msg name="GetIMInfo" length="2" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x60</byte>
-               </header>
-       </msg>
-       <msg name="GetIMInfoReply" length="9" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x60</byte>
-               </header>
-               <address name="IMAddress"/>
-               <byte name="DeviceCategory"/>
-               <byte name="DeviceSubCategory"/>
-               <byte name="FirmwareVersion"/>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="SendALLLinkCommand" length="5" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x61</byte>
-               </header>
-               <byte name="ALLLinkGroup">0x00</byte>
-               <byte name="ALLLinkCommand">0x00</byte>
-               <byte name="BroadcastCommand2">0x00</byte>
-       </msg>
-       <msg name="SendALLLinkCommandReply" length="6" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x61</byte>
-               </header>
-               <byte name="ALLLinkGroup">0x00</byte>
-               <byte name="ALLLinkCommand">0x00</byte>
-               <byte name="BroadcastCommand2">0x00</byte>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="SendStandardMessage" length="8" direction="TO_MODEM">
-               <header length="6">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x62</byte>
-                       <address name="toAddress"/>
-                       <byte name="messageFlags"/>
-               </header>
-               <byte name="command1"/>
-               <byte name="command2"/>
-       </msg>
-       <msg name="SendStandardMessageReply" length="9" direction="FROM_MODEM">
-               <header length="6">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x62</byte>
-                       <address name="toAddress"/>
-                       <byte name="messageFlags"/>
-               </header>
-               <byte name="command1"/>
-               <byte name="command2"/>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="SendExtendedMessage" length="22" direction="TO_MODEM">
-               <header length="6">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x62</byte>
-                       <address name="toAddress"/>
-                       <byte name="messageFlags">0x10</byte>
-               </header>
-               <byte name="command1">0x00</byte>
-               <byte name="command2">0x00</byte>
-               <byte name="userData1">0x00</byte>
-               <byte name="userData2">0x00</byte>
-               <byte name="userData3">0x00</byte>
-               <byte name="userData4">0x00</byte>
-               <byte name="userData5">0x00</byte>
-               <byte name="userData6">0x00</byte>
-               <byte name="userData7">0x00</byte>
-               <byte name="userData8">0x00</byte>
-               <byte name="userData9">0x00</byte>
-               <byte name="userData10">0x00</byte>
-               <byte name="userData11">0x00</byte>
-               <byte name="userData12">0x00</byte>
-               <byte name="userData13">0x00</byte>
-               <byte name="userData14">0x00</byte>
-       </msg>
-       <msg name="SendExtendedMessageReply" length="23" direction="FROM_MODEM">
-               <header length="6">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x62</byte>
-                       <address name="toAddress"/>
-                       <byte name="messageFlags">0x10</byte>
-               </header>
-               <byte name="command1"/>
-               <byte name="command2"/>
-               <byte name="userData1"/>
-               <byte name="userData2"/>
-               <byte name="userData3"/>
-               <byte name="userData4"/>
-               <byte name="userData5"/>
-               <byte name="userData6"/>
-               <byte name="userData7"/>
-               <byte name="userData8"/>
-               <byte name="userData9"/>
-               <byte name="userData10"/>
-               <byte name="userData11"/>
-               <byte name="userData12"/>
-               <byte name="userData13"/>
-               <byte name="userData14"/>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="SendX10Message" length="4" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x63</byte>
-               </header>
-               <byte name="rawX10"></byte>
-               <byte name="X10Flag">0x00</byte>
-       </msg>
-       <msg name="SendX10MessageReply" length="5" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x63</byte>
-               </header>
-               <byte name="rawX10"></byte>
-               <byte name="X10Flag">0x00</byte>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="StartALLLinking" length="4" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x64</byte>
-               </header>
-               <byte name="LinkCode"></byte>
-               <byte name="ALLLinkGroup">0x00</byte>
-       </msg>
-       <msg name="StartALLLinkingReply" length="5" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x64</byte>
-               </header>
-               <byte name="LinkCode"></byte>
-               <byte name="ALLLinkGroup">0x00</byte>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="CancelALLLinking" length="2" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x65</byte>
-               </header>
-       </msg>
-       <msg name="CancelALLLinkingReply" length="3" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x65</byte>
-               </header>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="SetHostDeviceCategory" length="5" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x66</byte>
-               </header>
-               <byte name="DeviceCategory"/>
-               <byte name="DeviceSubcategory"/>
-               <byte name="FirmwareVersion"/>
-       </msg>
-       <msg name="SetHostDeviceCategoryReply" length="6" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x66</byte>
-               </header>
-               <byte name="DeviceCategory"/>
-               <byte name="DeviceSubcategory"/>
-               <byte name="FirmwareVersion"/>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="ResetIM" length="2" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x67</byte>
-               </header>
-       </msg>
-       <msg name="ResetIMReply" length="3" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x67</byte>
-               </header>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="SetAckMessageByte" length="3" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x68</byte>
-               </header>
-               <byte name="Command2"/>
-       </msg>
-       <msg name="SetAckMessageByteReply" length="4" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x68</byte>
-               </header>
-               <byte name="Command2"/>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="GetFirstALLLinkRecord" length="2" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x69</byte>
-               </header>
-       </msg>
-       <msg name="GetFirstALLLinkRecordReply" length="3" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x69</byte>
-               </header>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="GetNextALLLinkRecord" length="2" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x6a</byte>
-               </header>
-       </msg>
-       <msg name="GetNextALLLinkRecordReply" length="3" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x6a</byte>
-               </header>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="ManageALLLinkRecord" length="11" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x6f</byte>
-               </header>
-               <byte name="controlCode"/>
-               <byte name="recordFlags"/>
-               <byte name="ALLLinkGroup"/>
-               <address name="linkAddress"/>
-               <byte name="linkData1"/>
-               <byte name="linkData2"/>
-               <byte name="linkData3"/>
-       </msg>
-       <msg name="ManageALLLinkRecordReply" length="12" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x6f</byte>
-               </header>
-               <byte name="controlCode"/>
-               <byte name="recordFlags"/>
-               <byte name="ALLLinkGroup"/>
-               <address name="linkAddress"/>
-               <byte name="linkData1"/>
-               <byte name="linkData2"/>
-               <byte name="linkData3"/>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="Beep" length="2" direction="TO_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x77</byte>
-               </header>
-       </msg>
-       <msg name="BeepReply" length="3" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x77</byte>
-               </header>
-               <byte name="ACK/NACK"/>
-       </msg>
-       <msg name="UnknownMessage7F" length="4" direction="FROM_MODEM">
-               <header length="2">
-                       <byte>0x02</byte>
-                       <byte name="Cmd">0x7F</byte>
-               </header>
-               <byte name="Command2"/>
-               <byte name="ACK/NACK"/>
-       </msg>
-
-</xml>