From 0d112b59f2c9e6d72dbd376ae56347db7055957b Mon Sep 17 00:00:00 2001 From: bruestel Date: Fri, 19 Jun 2026 10:19:20 +0200 Subject: [PATCH] [homeconnectdirect] Home Connect Direct binding initial contribution (#20653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [homeconnectdirect] Home Connect Direct Binding initial contribution Signed-off-by: Jonas Brüstel --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../NOTICE | 35 + .../README.md | 324 +++ .../doc/home-appliances-view.png | Bin 0 -> 108412 bytes .../doc/monitor-and-control-view.png | Bin 0 -> 80752 bytes .../openhab-configuration-generator-view.png | Bin 0 -> 19342 bytes .../doc/update-profiles.png | Bin 0 -> 53636 bytes .../pom.xml | 43 + .../src/main/feature/feature.xml | 9 + .../HomeConnectDirectBindingConstants.java | 406 ++++ .../internal/common/DoubleKeyMap.java | 121 ++ .../internal/common/LimitedSizeList.java | 61 + .../json/adapter/DoubleKeyMapAdapter.java | 55 + .../json/adapter/OffsetDateTimeAdapter.java | 61 + .../common/utils/ConfigurationUtils.java | 142 ++ .../internal/common/utils/OSUtils.java | 57 + .../internal/common/utils/StringUtils.java | 264 +++ .../xml/converter/AbstractConverter.java | 137 ++ .../common/xml/exception/ParseException.java | 37 + ...meConnectDirectApplianceConfiguration.java | 29 + .../HomeConnectDirectConfiguration.java | 28 + ...ConnectDirectMDNSDiscoveryParticipant.java | 190 ++ .../HomeConnectDirectHandlerFactory.java | 112 + .../handler/BaseHomeConnectDirectHandler.java | 1444 +++++++++++++ .../HomeConnectDirectCoffeeMakerHandler.java | 187 ++ .../HomeConnectDirectCooktopHandler.java | 86 + .../HomeConnectDirectDishwasherHandler.java | 136 ++ ...HomeConnectDirectFridgeFreezerHandler.java | 359 ++++ .../handler/HomeConnectDirectHoodHandler.java | 192 ++ .../handler/HomeConnectDirectOvenHandler.java | 537 +++++ .../HomeConnectDirectWasherDryerHandler.java | 271 +++ .../handler/model/ApplianceMessage.java | 50 + .../handler/model/DynamicChannel.java | 25 + .../internal/handler/model/MessageType.java | 26 + .../handler/model/SendMessageRequest.java | 27 + .../internal/handler/model/Value.java | 59 + .../HomeConnectDirectTranslationProvider.java | 51 + ...rectDynamicCommandDescriptionProvider.java | 43 + ...DirectDynamicStateDescriptionProvider.java | 87 + .../description/DeviceDescriptionService.java | 1712 +++++++++++++++ .../description/DeviceDescriptionUtils.java | 140 ++ .../service/description/model/Access.java | 29 + .../description/model/ActiveProgram.java | 29 + .../service/description/model/Command.java | 45 + .../description/model/CommandList.java | 30 + .../description/model/ContentType.java | 159 ++ .../service/description/model/DataType.java | 88 + .../description/model/DeviceDescription.java | 29 + .../model/DeviceDescriptionType.java | 42 + .../description/model/Enumeration.java | 24 + .../description/model/EnumerationType.java | 27 + .../model/EnumerationTypeList.java | 25 + .../service/description/model/Event.java | 35 + .../service/description/model/EventList.java | 27 + .../service/description/model/Execution.java | 28 + .../service/description/model/Handling.java | 27 + .../service/description/model/Level.java | 29 + .../service/description/model/Option.java | 46 + .../service/description/model/OptionList.java | 30 + .../service/description/model/Program.java | 29 + .../description/model/ProgramGroup.java | 29 + .../description/model/ProgramOption.java | 36 + .../description/model/ProtectionPort.java | 28 + .../description/model/ProtocolType.java | 39 + .../description/model/SelectedProgram.java | 29 + .../service/description/model/Setting.java | 45 + .../description/model/SettingList.java | 30 + .../service/description/model/Status.java | 46 + .../service/description/model/StatusList.java | 30 + .../description/model/change/Change.java | 25 + .../model/change/DeviceDescriptionChange.java | 30 + .../model/provider/AccessProvider.java | 32 + .../model/provider/AvailableProvider.java | 31 + .../model/provider/ContentTypeProvider.java | 32 + .../model/provider/DataTypeProvider.java | 33 + .../provider/EnumerationTypeProvider.java | 40 + .../model/provider/KeyProvider.java | 31 + .../model/provider/RangeProvider.java | 48 + .../converter/DeviceDescriptionConverter.java | 543 +++++ .../feature/FeatureMappingService.java | 76 + .../feature/model/EnumDescription.java | 26 + .../service/feature/model/FeatureMapping.java | 60 + .../converter/FeatureMappingConverter.java | 89 + .../profile/ApplianceProfileService.java | 222 ++ .../json/adapter/ApplianceProfileAdapter.java | 117 ++ .../service/profile/model/AesCredentials.java | 24 + .../profile/model/ApplianceProfile.java | 28 + .../service/profile/model/Credentials.java | 24 + .../service/profile/model/TlsCredentials.java | 24 + .../AbstractWebSocketClientService.java | 230 ++ .../websocket/ConscryptPskKeyManager.java | 73 + .../websocket/WebSocketAesClientService.java | 256 +++ .../websocket/WebSocketClientService.java | 43 + .../service/websocket/WebSocketHandler.java | 48 + .../WebSocketTlsConscryptClientService.java | 107 + .../WebSocketClientServiceException.java | 38 + .../service/websocket/model/Access.java | 34 + .../service/websocket/model/Action.java | 28 + .../model/DescriptionChangeData.java | 28 + .../service/websocket/model/Message.java | 48 + .../service/websocket/model/Resource.java | 68 + .../model/data/ApplianceInfoData.java | 29 + .../model/data/DescriptionChangeData.java | 31 + .../websocket/model/data/DeviceData.java | 26 + .../model/data/FirstMessageIdData.java | 26 + .../websocket/model/data/ProgramData.java | 27 + .../websocket/model/data/ServiceData.java | 24 + .../websocket/model/data/ValueData.java | 24 + .../websocket/serializer/ResourceAdapter.java | 52 + .../servlet/HomeConnectDirectServlet.java | 326 +++ .../HomeConnectDirectWebSocketServlet.java | 197 ++ .../internal/servlet/ServletConstants.java | 63 + .../servlet/ServletSecurityContext.java | 127 ++ .../internal/servlet/ServletUtils.java | 145 ++ .../handler/api/ApplianceRequestHandler.java | 260 +++ .../handler/api/LogRequestHandler.java | 855 ++++++++ .../handler/api/MessageRequestHandler.java | 250 +++ .../handler/api/ProfileRequestHandler.java | 157 ++ .../handler/page/AuthRequestHandler.java | 122 ++ .../handler/page/IndexRequestHandler.java | 63 + .../servlet/model/ApiCollectionResponse.java | 26 + .../internal/servlet/model/Appliance.java | 24 + .../internal/servlet/model/Error.java | 24 + .../internal/servlet/model/LogFile.java | 26 + .../internal/servlet/model/LoginForm.java | 27 + .../internal/servlet/model/MessageFilter.java | 33 + .../internal/servlet/model/Profile.java | 27 + .../internal/servlet/model/Program.java | 24 + .../servlet/routing/RequestHandler.java | 32 + .../routing/RequestHandlerContext.java | 161 ++ .../routing/RequestHandlerException.java | 37 + .../internal/servlet/routing/Route.java | 29 + .../src/main/resources/OH-INF/addon/addon.xml | 45 + .../main/resources/OH-INF/config/config.xml | 73 + .../OH-INF/i18n/homeconnectdirect.properties | 278 +++ .../resources/OH-INF/thing/thing-types.xml | 1195 +++++++++++ .../assets/css/homeconnectdirect.css | 346 ++++ .../resources/assets/css/pico.blue.min.css | 4 + .../main/resources/assets/favicon/128x128.png | Bin 0 -> 3837 bytes .../main/resources/assets/favicon/favicon.svg | 6 + .../assets/img/clipboard-document-list.svg | 3 + .../src/main/resources/assets/img/eye.svg | 4 + .../src/main/resources/assets/img/funnel.svg | 3 + .../resources/assets/img/minus-circle.svg | 3 + .../main/resources/assets/js/alpinejs.min.js | 5 + .../resources/assets/js/homeconnectdirect.js | 1842 +++++++++++++++++ .../src/main/resources/templates/index.html | 1157 +++++++++++ .../src/main/resources/templates/login.html | 30 + .../common/utils/StringUtilsTest.java | 41 + .../DeviceDescriptionServiceTest.java | 965 +++++++++ .../handler/api/LogRequestHandlerTest.java | 112 + ...9555DWC-000000000000_DeviceDescription.xml | 1 + ...MCC9555DWC-000000000000_FeatureMapping.xml | 306 +++ ...58X06TE-000000000000_DeviceDescription.xml | 1 + ...SN658X06TE-000000000000_FeatureMapping.xml | 302 +++ ...555X1DE-000000000000_DeviceDescription.xml | 1 + ...TI9555X1DE-000000000000_FeatureMapping.xml | 405 ++++ ...M16XE91-000000000000_DeviceDescription.xml | 1 + ...S-WM16XE91-000000000000_FeatureMapping.xml | 498 +++++ bundles/pom.xml | 1 + 161 files changed, 22077 insertions(+) create mode 100644 bundles/org.openhab.binding.homeconnectdirect/NOTICE create mode 100644 bundles/org.openhab.binding.homeconnectdirect/README.md create mode 100644 bundles/org.openhab.binding.homeconnectdirect/doc/home-appliances-view.png create mode 100644 bundles/org.openhab.binding.homeconnectdirect/doc/monitor-and-control-view.png create mode 100644 bundles/org.openhab.binding.homeconnectdirect/doc/openhab-configuration-generator-view.png create mode 100644 bundles/org.openhab.binding.homeconnectdirect/doc/update-profiles.png create mode 100644 bundles/org.openhab.binding.homeconnectdirect/pom.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/HomeConnectDirectBindingConstants.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/DoubleKeyMap.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/LimitedSizeList.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/json/adapter/DoubleKeyMapAdapter.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/json/adapter/OffsetDateTimeAdapter.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/ConfigurationUtils.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/OSUtils.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/StringUtils.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/xml/converter/AbstractConverter.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/xml/exception/ParseException.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/configuration/HomeConnectDirectApplianceConfiguration.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/configuration/HomeConnectDirectConfiguration.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/discovery/HomeConnectDirectMDNSDiscoveryParticipant.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/factory/HomeConnectDirectHandlerFactory.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/BaseHomeConnectDirectHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectCoffeeMakerHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectCooktopHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectDishwasherHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectFridgeFreezerHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectHoodHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectOvenHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectWasherDryerHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/ApplianceMessage.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/DynamicChannel.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/MessageType.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/SendMessageRequest.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/Value.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/i18n/HomeConnectDirectTranslationProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/provider/HomeConnectDirectDynamicCommandDescriptionProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/provider/HomeConnectDirectDynamicStateDescriptionProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionService.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionUtils.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Access.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ActiveProgram.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Command.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/CommandList.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ContentType.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DataType.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DeviceDescription.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DeviceDescriptionType.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Enumeration.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EnumerationType.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EnumerationTypeList.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Event.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EventList.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Execution.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Handling.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Level.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Option.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/OptionList.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Program.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProgramGroup.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProgramOption.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProtectionPort.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProtocolType.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/SelectedProgram.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Setting.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/SettingList.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Status.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/StatusList.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/change/Change.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/change/DeviceDescriptionChange.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/AccessProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/AvailableProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/ContentTypeProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/DataTypeProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/EnumerationTypeProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/KeyProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/RangeProvider.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/xml/converter/DeviceDescriptionConverter.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/FeatureMappingService.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/model/EnumDescription.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/model/FeatureMapping.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/xml/converter/FeatureMappingConverter.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/ApplianceProfileService.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/json/adapter/ApplianceProfileAdapter.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/AesCredentials.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/ApplianceProfile.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/Credentials.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/TlsCredentials.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/AbstractWebSocketClientService.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/ConscryptPskKeyManager.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketAesClientService.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketClientService.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketTlsConscryptClientService.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/exception/WebSocketClientServiceException.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Access.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Action.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/DescriptionChangeData.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Message.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Resource.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ApplianceInfoData.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/DescriptionChangeData.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/DeviceData.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/FirstMessageIdData.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ProgramData.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ServiceData.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ValueData.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/serializer/ResourceAdapter.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/HomeConnectDirectServlet.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/HomeConnectDirectWebSocketServlet.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletConstants.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletSecurityContext.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletUtils.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/ApplianceRequestHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/LogRequestHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/MessageRequestHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/ProfileRequestHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/page/AuthRequestHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/page/IndexRequestHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/ApiCollectionResponse.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Appliance.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Error.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/LogFile.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/LoginForm.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/MessageFilter.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Profile.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Program.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandler.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandlerContext.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandlerException.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/Route.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/addon/addon.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/config/config.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/i18n/homeconnectdirect.properties create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/thing/thing-types.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/css/homeconnectdirect.css create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/css/pico.blue.min.css create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/favicon/128x128.png create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/favicon/favicon.svg create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/clipboard-document-list.svg create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/eye.svg create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/funnel.svg create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/minus-circle.svg create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/js/alpinejs.min.js create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/js/homeconnectdirect.js create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/templates/index.html create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/main/resources/templates/login.html create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/common/utils/StringUtilsTest.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionServiceTest.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/LogRequestHandlerTest.java create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/BOSCH-MCC9555DWC-000000000000_DeviceDescription.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/BOSCH-MCC9555DWC-000000000000_FeatureMapping.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-SN658X06TE-000000000000_DeviceDescription.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-SN658X06TE-000000000000_FeatureMapping.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-TI9555X1DE-000000000000_DeviceDescription.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-TI9555X1DE-000000000000_FeatureMapping.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-WM16XE91-000000000000_DeviceDescription.xml create mode 100644 bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-WM16XE91-000000000000_FeatureMapping.xml diff --git a/CODEOWNERS b/CODEOWNERS index d66dabd9dc..2755e0ac9e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -168,6 +168,7 @@ /bundles/org.openhab.binding.herzborg/ @Sonic-Amiga /bundles/org.openhab.binding.homeassistant/ @ccutrer /bundles/org.openhab.binding.homeconnect/ @bruestel +/bundles/org.openhab.binding.homeconnectdirect/ @bruestel /bundles/org.openhab.binding.homekit/ @andrewfg /bundles/org.openhab.binding.homie/ @ccutrer /bundles/org.openhab.binding.homematic/ @FStolte @gerrieg @mdicke2s diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index e11eedf890..463d04bc68 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -826,6 +826,11 @@ org.openhab.binding.homeconnect ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.homeconnectdirect + ${project.version} + org.openhab.addons.bundles org.openhab.binding.homekit diff --git a/bundles/org.openhab.binding.homeconnectdirect/NOTICE b/bundles/org.openhab.binding.homeconnectdirect/NOTICE new file mode 100644 index 0000000000..daf07bbb8e --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/NOTICE @@ -0,0 +1,35 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons + +== Third-party Content + +conscrypt +* License: Apache License 2.0 +* Project: https://www.conscrypt.org/ +* Source: https://github.com/google/conscrypt + +pico +* License: MIT License +* Project: https://picocss.com/ +* Source: https://github.com/picocss/pico + +Alpine.js +* License: MIT License +* Project: https://alpinejs.dev/ +* Source: https://github.com/alpinejs/alpine + +heroicons +* License: MIT License +* Project: https://heroicons.com/ +* Source: https://github.com/tailwindlabs/heroicons diff --git a/bundles/org.openhab.binding.homeconnectdirect/README.md b/bundles/org.openhab.binding.homeconnectdirect/README.md new file mode 100644 index 0000000000..763c13324f --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/README.md @@ -0,0 +1,324 @@ +# Home Connect Direct Binding + +The Home Connect Direct Binding integrates [Home Connect](https://www.home-connect.com/) enabled devices into openHAB via your local network. +Unlike the standard Home Connect binding, this implementation communicates directly with your appliances, ensuring reliable, low-latency operation without requiring cloud access. + +## Supported Appliances + +The following appliance types are supported. Appliance types marked with an asterisk (*) are currently in a **beta stage** and have not yet been extensively tested. + +| Appliance Type | Thing Type ID | +|---------------------------------|-----------------| +| Dishwasher | `dishwasher` | +| Cook Processor (Cookit) | `cookprocessor` | +| Washer | `washer` | +| Washer / Dryer Combination | `washerdryer` | +| Dryer | `dryer` | +| Oven | `oven` | +| Warming Drawer* | `warmingdrawer` | +| Coffee Machine | `coffeemaker` | +| Hood* | `hood` | +| Cooktop (Hob)* | `cooktop` | +| Fridge / Freezer* | `fridgefreezer` | +| Generic (other types) | `generic` | + +## Prerequisites & Initial Setup + +Before adding things to openHAB, you must load the **Appliance Profiles**. These profiles contain the technical descriptions (keys, enumeration values, etc.) required for the binding to communicate with your specific hardware. + +### Load Appliance Profiles + +The binding includes a dedicated web console for profile management. + +1. **Access the Console**: Open your browser and navigate to: + `http(s)://[YOUR_OPENHAB_IP]:[PORT]/homeconnectdirect` + _(e.g., `http://192.168.178.100:8080/homeconnectdirect`)_ +1. **Go to Appliance Profiles**: Open the **"Profiles"** menu item. +1. **Upload Profiles**: Click the **`Upload Profile`** button in the top-right corner. + +> **IMPORTANT:** +> The binding cannot fetch profiles automatically from the BSH cloud. You must use an external tool to download your profiles first. +> +> **Recommended Tool**: [HomeConnect Profile Downloader](https://github.com/bruestel/homeconnect-profile-downloader) +> Download the latest version from the [Releases page](https://github.com/bruestel/homeconnect-profile-downloader/releases). + +![Home Connect Direct Console - Profile UI](doc/update-profiles.png) + +## Discovery & Configuration + +### Creating Things + +Once profiles are uploaded, you can add your appliances: + +- **Automatic Discovery**: The binding uses **mDNS** to scan your local network. Discovered devices will appear in the openHAB Inbox automatically. +- **Manual Configuration**: You can manually create things via the UI or `.things` files using the parameters below. + +#### Thing Configuration Parameters + +| Parameter | Type | Description | Required | +|------------------------|---------|--------------------------------------------------------------------------|----------| +| `haId` | Text | The unique Home Appliance ID (found in the Home Connect Direct console). | Yes | +| `address` | Text | The local IP address or hostname of the appliance. | Yes | +| `connectionRetryDelay` | Integer | Delay in minutes before attempting a reconnect (1-60, default: 1). | No | + +## Channels + +The binding offers two types of channels: **Preconfigured** (standard functions) and **Custom** (custom attributes). + +### Preconfigured Channels + +These channels provide immediate access to common functions. Available channels vary by appliance type. Some channels are added dynamically based on the features supported by your specific appliance model (e.g., different heating modes for an oven). + +#### General Channels + +| Channel ID | Item Type | Access | Description | Supported Appliance Types | +|--------------------------------|----------------------|--------|---------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------| +| `power-state` | Switch | R/W | Controls and monitors the appliance power state. | Dishwasher, Cook Processor, Washer, Washer/Dryer, Dryer, Coffee Maker, Oven, Warming Drawer, Hood, Cooktop | +| `door` | Contact | R | Indicates if the door is Open or Closed. | Dishwasher, Washer, Washer/Dryer, Dryer | +| `operation-state` | String | R | Current state (e.g., Run, Ready, Finished). | Dishwasher, Cook Processor, Washer, Washer/Dryer, Dryer, Coffee Maker, Oven, Warming Drawer, Hood, Cooktop | +| `remote-control-start-allowed` | Switch | R | Indicates if remote operation is enabled. | Dishwasher, Washer, Washer/Dryer, Dryer, Coffee Maker, Oven, Warming Drawer, Hood | +| `child-lock` | Switch | R/W | The child lock state. | Cook Processor, Washer, Washer/Dryer, Dryer, Oven, Cooktop | +| `active-program` | String | R | The program currently running. Changes to UNDEF when no program is active (e.g., when paused or idle). | Dishwasher, Cook Processor, Washer, Washer/Dryer, Dryer, Coffee Maker, Oven, Warming Drawer, Hood, Cooktop | +| `selected-program` | String | R/W | The program currently selected on the device. | Dishwasher, Washer, Washer/Dryer, Dryer, Coffee Maker, Oven, Warming Drawer | +| `remaining-program-time` | Number:Time | R | Estimated time remaining. | Dishwasher, Washer, Washer/Dryer, Dryer, Oven, Warming Drawer | +| `program-progress` | Number:Dimensionless | R | Progress in percent (0-100%). | Dishwasher, Cook Processor, Washer, Washer/Dryer, Dryer, Coffee Maker, Oven, Warming Drawer | +| `program-command` | String | W | Send commands like `start`, `pause`, or `resume`. | Dishwasher, Cook Processor, Washer, Washer/Dryer, Dryer | +| `command` | String | W | Send specific operation commands to the appliance. | All | +| `raw-message` | String | W | Advanced: Send raw JSON payloads.
Example (Start coffee program): `{"action": "POST", "resource": "/ro/activeProgram", "data": [{"program": 8217}]}` | All | + +#### Dishwasher Channels + +| Channel ID | Item Type | Access | Description | +|-------------------------------|-----------|--------|----------------------------------------------------------| +| `salt-lack` | Switch | R | Indicates if dishwasher salt is empty. | +| `rinse-aid-lack` | Switch | R | Indicates if dishwasher rinse aid is empty. | +| `salt-nearly-empty` | Switch | R | Indicates when the dishwasher salt is almost empty. | +| `rinse-aid-nearly-empty` | Switch | R | Indicates when the dishwasher rinse aid is almost empty. | +| `machine-care-reminder` | Switch | R | Indicates whether the dishwasher needs cleaning. | +| `program-phase` | String | R | The program or process phase of the dishwasher. | +| `dishwasher-vario-speed-plus` | Switch | R/W | High-speed cleaning option. | +| `dishwasher-intensiv-zone` | Switch | R/W | Increases spray pressure in the lower basket. | +| `dishwasher-brilliance-dry` | Switch | R/W | Enhances the drying process for glassware. | + +#### Washer, Dryer & Washer/Dryer Channels + +| Channel ID | Item Type | Access | Description | +|-------------------------------|-------------|--------|-------------------------------------------------------------------------| +| `drum-clean-reminder` | Switch | R | Indicates whether the drum needs cleaning. | +| `laundry-load-information` | Number:Mass | R | The current laundry load information. | +| `laundry-load-recommendation` | Number:Mass | R | The recommended load for the current program. | +| `process-phase` | String | R | The current process phase of the appliance. | +| `washer-temperature` | String | R/W | The temperature of the washing program. | +| `washer-spin-speed` | String | R/W | The spin speed of the washing program. | +| `washer-speed-perfect` | Switch | R/W | Reduces the program duration. | +| `washer-water-plus` | Switch | R/W | Increases the water level. | +| `washer-prewash` | Switch | R/W | Adds a prewash cycle. | +| `washer-rinse-hold` | Switch | R/W | Stops the cycle before the final spin. | +| `washer-less-ironing` | Switch | R/W | Reduces creasing. | +| `washer-silent-wash` | Switch | R/W | Reduces noise during operation. | +| `washer-soak` | Switch | R/W | Adds an immersion phase for stubborn dirt. | +| `washer-rinse-plus` | String | R/W | Adds additional rinse cycles. | +| `washer-stains` | String | R/W | Optimizes the cycle for specific stain types. | +| `drying-target` | String | R/W | Specifies the desired dryness setting (Dryer, Washer/Dryer). | +| `wrinkle-guard` | String | R/W | Prevents laundry from creasing after the cycle (Dryer, Washer/Dryer). | +| `idos1-active` | Switch | R/W | Whether i-Dos 1 is enabled for the washing program (dynamically added). | +| `idos2-active` | Switch | R/W | Whether i-Dos 2 is enabled for the washing program (dynamically added). | +| `idos1-fill-level-poor` | Switch | R | Indicates whether i-Dos 1 is almost empty (dynamically added). | +| `idos2-fill-level-poor` | Switch | R | Indicates whether i-Dos 2 is almost empty (dynamically added). | + +#### Oven & Warming Drawer Channels + +| Channel ID | Item Type | Access | Description | +|------------------------------|--------------------|--------|-------------------------------------------------------------------------------------------------------| +| `oven-program-command` | String | W | Controls program execution (`start`, `pause`, `resume`, `stop`). | +| `duration` | Number:Time | R/W | The duration of the program (Oven). | +| `setpoint-temperature` | Number:Temperature | R/W | Target temperature. | +| `temperature-{n}` | Number:Temperature | R | The current cavity temperature (dynamically added, where {n} is the cavity number). | +| `cavity-light-{n}` | Switch | R/W | The cavity light state (dynamically added, where {n} is the cavity number). | +| `door-{n}` | Contact | R | Indicates if the door is Open or Closed (dynamically added, where {n} is the cavity number). | +| `meat-probe-temperature-{n}` | Number:Temperature | R | The current meat probe temperature (dynamically added, where {n} is the cavity number). | +| `meat-probe-plugged-{n}` | Switch | R | Indicates if the meat probe sensor is plugged in (dynamically added, where {n} is the cavity number). | + +#### Coffee Maker Channels + +| Channel ID | Item Type | Access | Description | +|-------------------------------|-----------|--------|------------------------------------------------| +| `cleaning` | Number | R | Countdown until cleaning is due. | +| `calc-n-clean` | Number | R | Countdown until calc 'n' clean is due. | +| `descaling` | Number | R | Countdown until descaling is due. | +| `water-filter` | Number | R | Countdown for water filter replacement. | +| `water-tank-empty` | Switch | R | Indicates when the water tank is empty. | +| `water-tank-nearly-empty` | Switch | R | Indicates when the water tank is almost empty. | +| `drip-tray-full` | Switch | R | Indicates when the drip tray is full. | +| `empty-milk-tank` | Switch | R | Indicates when the milk tank is empty. | +| `bean-container-empty` | Switch | R | Indicates when the bean container is empty. | +| `process-phase` | String | R | The current process phase of the coffee maker. | +| `coffeemaker-program-command` | String | W | Controls program execution (`start`, `stop`). | + +#### Hood Channels + +| Channel ID | Item Type | Access | Description | +|----------------------------|-----------|--------|-----------------------------------------| +| `cooking-light` | Switch | R/W | The functional light state. | +| `cooking-light-brightness` | Number | R/W | The brightness of the functional light. | +| `button-tones` | Switch | R/W | The button tones setting. | +| `venting-level` | String | R/W | Venting level of the hood. | +| `intensive-level` | String | R/W | Intensive venting level of the hood. | + +#### Cooktop Channels + +| Channel ID | Item Type | Access | Description | +|----------------|-----------|--------|---------------------------| +| `button-tones` | Switch | R/W | The button tones setting. | + +#### Fridge / Freezer Channels + +| Channel ID | Item Type | Access | Description | +|-------------------------------------|----------------------|--------|--------------------------------------------------------------------------------------------------| +| `refrigerator-door` | Contact | R | Indicates if the refrigerator door is Open or Closed (dynamically added). | +| `freezer-door` | Contact | R | Indicates if the freezer door is Open or Closed (dynamically added). | +| `door` | Contact | R | Indicates if the door is Open or Closed (dynamically added - fallback if specific doors absent). | +| `setpoint-temperature-refrigerator` | Number:Temperature | R/W | Target temperature of the refrigerator compartment (dynamically added). | +| `setpoint-temperature-freezer` | Number:Temperature | R/W | Target temperature of the freezer compartment (dynamically added). | +| `setpoint-temperature-chiller` | Number:Temperature | R/W | Target temperature of the chiller compartment (dynamically added). | +| `super-mode-refrigerator` | Switch | R/W | Enables Super Cooling mode for the refrigerator (dynamically added). | +| `super-mode-freezer` | Switch | R/W | Enables Super Freezing mode for the freezer (dynamically added). | +| `dispenser-enabled` | Switch | R/W | Enables or disables the dispenser (dynamically added). | +| `dispenser-party-mode` | Switch | R/W | Enables or disables the dispenser party mode (dynamically added). | +| `dispenser-water-filter-saturation` | Number:Dimensionless | R | Water filter saturation level of the dispenser in percent (dynamically added). | +| `chiller-preset` | String | R/W | Allows selecting a predefined cooling mode for the chiller compartment (dynamically added). | + +### Custom Channels + +Custom channels allow you to monitor **any** internal value or device description attribute available in the appliance profile. This is useful for advanced scenarios or accessing functions not covered by standard channels. + +The binding's web console includes an **openHAB Configuration Generator** to help create the necessary YAML or DSL for these channels. You can access it by monitoring an appliance and clicking the openHAB icon next to any value or description key. + +#### 1. Value Channels + +These channels reflect the **current actual values** of the home appliance. They answer the question: _"What is the current state?"_ + +Examples: "How hot is the oven?", "Is the door open?", "Which program is running?" + +| Channel Type ID | Item Type | Config Parameters | Description | +|-----------------|-----------|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `switch` | Switch | `valueKey` | For boolean values (true/false). | +| `string` | String | `valueKey` | For text or enumeration values. | +| `number` | Number | `valueKey`, `unit` | For numeric values. `unit` is optional (e.g., "°C", "%"). | +| `trigger` | — | `valueKey` | Trigger channel that fires events (e.g., for event keys). | +| `enum-switch` | Switch | `valueKey`, `onValue` | Read-only switch that maps enumeration values to ON/OFF. `onValue` is a comma-separated list of enum value keys that map to ON (case-insensitive). | + +#### 2. Device Description Channels + +These channels describe the **capabilities and constraints** of the appliance. They answer the question: _"What is currently possible?"_ + +Home Connect appliances are dynamic; allowed ranges or available options change based on the selected program or operation state. +Examples: "What is the maximum allowed temperature for the current program?", "Can I currently change the power state (Read/Write) or is it locked (Read-Only)?", "Is a specific option available right now?" + +| Channel Type ID | Item Type | Config Parameters | Description | +|-----------------------------|-----------|-------------------------------|---------------------------------------------------------| +| `device-description-switch` | Switch | `descriptionKey`, `attribute` | For boolean attributes (e.g. `available`). | +| `device-description-string` | String | `descriptionKey`, `attribute` | For text attributes (e.g. `access`, `enumerationType`). | +| `device-description-number` | Number | `descriptionKey`, `attribute` | For numeric attributes (e.g. `min`, `max`, `stepSize`). | + +**Supported Attributes:** + +- `access`: Current access rights (read/write). +- `available`: Whether the feature is currently available. +- `min`: Minimum allowed value. +- `max`: Maximum allowed value. +- `stepSize`: Step size for value adjustments. +- `enumerationType`: The UID of the enumeration type. +- `enumerationTypeKey`: The key of the enumeration type. + +## Full Example + +### `demo.things` + +```java +Thing homeconnectdirect:dishwasher:myDishwasher "Dishwasher" [ haId="BSH-DISHWASHER-XXXX", address="192.168.1.50" ] +Thing homeconnectdirect:oven:myOven "Oven" [ haId="BSH-OVEN-XXXX", address="192.168.1.51" ] { + Channels: + Type number : currentTemp "Internal Temp" [ valueKey="Cooking.Oven.Status.CurrentCavityTemperature", unit="°C" ] + Type trigger : programFinished "Program Finished" [ valueKey="BSH.Common.Event.ProgramFinished" ] + Type enum-switch : doorOpen "Door Open" [ valueKey="BSH.Common.Status.DoorState", onValue="Open" ] +} +``` + +### `demo.items` + +```java +// Dishwasher +Switch Dishwasher_Power "Power" { channel="homeconnectdirect:dishwasher:myDishwasher:power-state" } +Contact Dishwasher_Door "Door" { channel="homeconnectdirect:dishwasher:myDishwasher:door" } +String Dishwasher_Status "Status [%s]" { channel="homeconnectdirect:dishwasher:myDishwasher:operation-state" } +Number:Time Dishwasher_Time "Remaining [%d s]" { channel="homeconnectdirect:dishwasher:myDishwasher:remaining-program-time" } + +// Oven +Number:Temperature Oven_Temp "Temp [%.1f %unit%]" { channel="homeconnectdirect:oven:myOven:currentTemp" } +``` + +## Binding Console (Web UI) + +The binding includes a built-in web console (accessible at `http://[YOUR_OPENHAB_IP]:[PORT]/homeconnectdirect`) that serves as a powerful tool for setup, monitoring and troubleshooting. + +### Main Sections + +- **Home Appliances**: Lists all discovered and configured appliances. It provides a quick overview of their connection status (ONLINE/OFFLINE), hardware details (Brand, Type, VIB), and local network addresses. +- **Profiles**: Central management for **Appliance Profiles**. Here you can upload new profiles (required for initial setup), download existing ones for backup, or delete outdated profiles. +- **Logs**: Allows management of message logs stored on the openHAB server. You can import external logs (e.g., from [HomeCap](https://github.com/bruestel/HomeCap) or the [Home Connect Appliance Proxy](https://github.com/bruestel/homeconnect-appliance-proxy)), download logs for analysis or view them directly in the browser. + +![Home Appliances UI](doc/home-appliances-view.png) + +### Monitoring and Control + +By clicking **"Monitor and Control"** on an appliance, you enter a real-time diagnostic view: + +- **Live Message Stream**: View every message exchanged between openHAB and the appliance in real-time via WebSockets. +- **Advanced Filtering**: Narrow down the message stream by value keys, description keys, resource paths, actions (GET/POST/NOTIFY), or specific time ranges. +- **Deep Inspection**: Click on any message to view its raw JSON payload, formatted values, or device description changes. You can also browse the internal **Device Description** tree. +- **openHAB Configuration Generator**: Next to each value or description attribute, you'll find an openHAB icon. Clicking it opens a generator that provides ready-to-use **YAML** or **DSL** snippets for creating custom channels (see [Custom Channels](#custom-channels)). +- **Raw Messaging**: Send manual `GET`, `POST`, or `NOTIFY` commands directly to the appliance for testing and advanced control. + +![Monitor and Control UI](doc/monitor-and-control-view.png) +![Monitor and Control UI - openHAB Configuration Generator](doc/openhab-configuration-generator-view.png) + +## Binding Configuration + +The binding offers global settings to secure the web console and manage the internal message log. + +- **Console Security**: You can enable a login for the Home Connect Direct web console (`loginEnabled`). When enabled, a password (`loginPassword`) must be provided. +- **Message Log Size**: The `messageQueueSize` determines how many messages are kept in the internal log displayed in the console (default: 300). + +### Configuration Methods + +- **openHAB UI**: Navigate to `Settings` -> `Add-on Settings` -> `Home Connect Direct`. +- **OSGi Console**: + + ```shell + config:edit binding.homeconnectdirect + config:property-set loginEnabled true + config:property-set loginPassword your_secure_password + config:property-set messageQueueSize 500 + config:update + ``` + +## FAQ + +**Q: Are robot vacuums supported?** +**A:** No. Robot vacuums appear to communicate exclusively via the cloud. This binding is designed for direct local communication only. + +**Q: How do I find the `haId` of my device?** +**A:** You can find it via the openHAB Inbox scan (Discovery). Alternatively, open the binding's web console and navigate to the **Profiles** tab. After importing a profile, the specific Device ID is displayed there. + +**Q: I have a problem or found a bug.** +**A:** Please use the openHAB Community channels. It is helpful to create a log file using the **Logs** feature in the binding's web console, which you can provide for troubleshooting. + +## Credits + +This binding is built upon the outstanding research and work of the following projects: + +- [osresearch/hcpy](https://github.com/osresearch/hcpy) +- [hcpy2-0/hcpy](https://github.com/hcpy2-0/hcpy) + +A big thank you to the contributors of these projects for their excellent work! diff --git a/bundles/org.openhab.binding.homeconnectdirect/doc/home-appliances-view.png b/bundles/org.openhab.binding.homeconnectdirect/doc/home-appliances-view.png new file mode 100644 index 0000000000000000000000000000000000000000..1dddd312b8bd14ded7586aa27da402d21c3bc4d4 GIT binary patch literal 108412 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 Eg7DQFbN~PV literal 0 HcmV?d00001 diff --git a/bundles/org.openhab.binding.homeconnectdirect/doc/monitor-and-control-view.png b/bundles/org.openhab.binding.homeconnectdirect/doc/monitor-and-control-view.png new file mode 100644 index 0000000000000000000000000000000000000000..fcb767f9a2bbf47eeab0a4ef10bc7e79639dd417 GIT binary patch literal 80752 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 ZC(VeZ+C#AjR}csU($hB5s?l(Y`ahK@*IfVr literal 0 HcmV?d00001 diff --git a/bundles/org.openhab.binding.homeconnectdirect/doc/openhab-configuration-generator-view.png b/bundles/org.openhab.binding.homeconnectdirect/doc/openhab-configuration-generator-view.png new file mode 100644 index 0000000000000000000000000000000000000000..df5fb83de9107f9a04b303e9c6e3739f7a64adf6 GIT binary patch literal 19342 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 O0FNWyZWS)nr2hi-iNt&W literal 0 HcmV?d00001 diff --git a/bundles/org.openhab.binding.homeconnectdirect/doc/update-profiles.png b/bundles/org.openhab.binding.homeconnectdirect/doc/update-profiles.png new file mode 100644 index 0000000000000000000000000000000000000000..428207e0f0af649ef71cd9ac21f91d967737eab1 GIT binary patch literal 53636 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

Brra4U7vYkoV_WuhV~g+ zg$r4XU-w7&ElgSfkO(FxuB95AnV1yM>aF2aV{5HCxv0ft+&;l>Z~&52jg9Hoz}UF= z1r$zt!{DSqE@Uxpb)Q+xZ7s#2l&}~8AWnRBcHgs@28#gz5@<0^wHW`{6f6b+NQA}s zHSq!dgKIGj76Skz$zqzHzR!|*U@-tdaym+W-LhaY06;=5mhx*c0*e6v5?YICmTEBq ziva)PQ?M8SATbtGKgzS%z)6e00RZ5dIJd=ke|d(*001C9ggZ(wt?p|ysu~sp03_C8 zmSV9K8yN!tfVf+XNQ<$T{3F7#E(QQdjK#uJv7))eqy+#V0Tz?O5>*&11^`I1#Vocg zhS_}pKyobR-J4_+V*!h4uowU!2^N!QAn}gUk}V}BEdT%suvp5k#fY*l28#gz5@a#C zGN!!F`$<>~0FXF~SuU^`03ewir6x3{iBl;t^$GxpJt@Uv@-<6#l_|`N0RWO{F>iez k76Skz!eZ28^wE3qe|;Xh5_&X + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.2.0-SNAPSHOT + + + org.openhab.binding.homeconnectdirect + + openHAB Add-ons :: Bundles :: Home Connect Direct Binding + + + org.eclipse.jetty.websocket.server,android.util.*;resolution:="optional",sun.*;resolution:="optional" + + + + + + net.bruestel + conscrypt-openjdk-uber + 2.6.0-20260113 + compile + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/feature/feature.xml b/bundles/org.openhab.binding.homeconnectdirect/src/main/feature/feature.xml new file mode 100644 index 0000000000..1dffa871a8 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.homeconnectdirect/${project.version} + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/HomeConnectDirectBindingConstants.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/HomeConnectDirectBindingConstants.java new file mode 100644 index 0000000000..ddfffedfbe --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/HomeConnectDirectBindingConstants.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal; + +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.SLASH; + +import java.io.File; +import java.time.Duration; +import java.time.ZoneId; +import java.util.Locale; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.OpenHAB; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.type.ChannelTypeUID; + +/** + * The {@link HomeConnectDirectBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectBindingConstants { + + private HomeConnectDirectBindingConstants() { + // prevent instantiation + } + + public static final String BINDING_ID = "homeconnectdirect"; + public static final String HOME_APPLIANCE_ID = "haId"; + public static final String CONFIGURATION_PID = "binding.homeconnectdirect"; + public static final ZoneId ZONE_ID = ZoneId.systemDefault(); + public static final Locale LOCALE = Locale.ENGLISH; + + // Binding configuration parameters + public static final String CONFIGURATION_LOGIN_ENABLED = "loginEnabled"; + public static final String CONFIGURATION_LOGIN_PASSWORD = "loginPassword"; + public static final String CONFIGURATION_MESSAGE_QUEUE_SIZE = "messageQueueSize"; + + // List of all appliances + public static final String APPLIANCE_TYPE_GENERIC = "generic"; + public static final String APPLIANCE_TYPE_WASHER = "washer"; + public static final String APPLIANCE_TYPE_WASHER_AND_DRYER = "washerdryer"; + public static final String APPLIANCE_TYPE_DRYER = "dryer"; + public static final String APPLIANCE_TYPE_DISHWASHER = "dishwasher"; + public static final String APPLIANCE_TYPE_COOK_PROCESSOR = "cookprocessor"; + public static final String APPLIANCE_TYPE_COFFEE_MAKER = "coffeemaker"; + public static final String APPLIANCE_TYPE_OVEN = "oven"; + public static final String APPLIANCE_TYPE_WARMING_DRAWER = "warmingdrawer"; + public static final String APPLIANCE_TYPE_HOOD = "hood"; + public static final String APPLIANCE_TYPE_COOKTOP = "cooktop"; + public static final String APPLIANCE_TYPE_COOKTOP_ALTERNATIVE = "hob"; + public static final String APPLIANCE_TYPE_FRIDGE_FREEZER = "fridgefreezer"; + + // List of all thing type UIDs + public static final ThingTypeUID THING_TYPE_GENERIC = new ThingTypeUID(BINDING_ID, APPLIANCE_TYPE_GENERIC); + public static final ThingTypeUID THING_TYPE_DISHWASHER = new ThingTypeUID(BINDING_ID, APPLIANCE_TYPE_DISHWASHER); + public static final ThingTypeUID THING_TYPE_WASHER = new ThingTypeUID(BINDING_ID, APPLIANCE_TYPE_WASHER); + public static final ThingTypeUID THING_TYPE_WASHER_DRYER = new ThingTypeUID(BINDING_ID, + APPLIANCE_TYPE_WASHER_AND_DRYER); + public static final ThingTypeUID THING_TYPE_DRYER = new ThingTypeUID(BINDING_ID, APPLIANCE_TYPE_DRYER); + public static final ThingTypeUID THING_TYPE_COFFEE_MAKER = new ThingTypeUID(BINDING_ID, + APPLIANCE_TYPE_COFFEE_MAKER); + public static final ThingTypeUID THING_TYPE_COOK_PROCESSOR = new ThingTypeUID(BINDING_ID, + APPLIANCE_TYPE_COOK_PROCESSOR); + public static final ThingTypeUID THING_TYPE_OVEN = new ThingTypeUID(BINDING_ID, APPLIANCE_TYPE_OVEN); + public static final ThingTypeUID THING_TYPE_WARMING_DRAWER = new ThingTypeUID(BINDING_ID, + APPLIANCE_TYPE_WARMING_DRAWER); + public static final ThingTypeUID THING_TYPE_HOOD = new ThingTypeUID(BINDING_ID, APPLIANCE_TYPE_HOOD); + public static final ThingTypeUID THING_TYPE_COOKTOP = new ThingTypeUID(BINDING_ID, APPLIANCE_TYPE_COOKTOP); + public static final ThingTypeUID THING_TYPE_FRIDGE_FREEZER = new ThingTypeUID(BINDING_ID, + APPLIANCE_TYPE_FRIDGE_FREEZER); + + // List of all channel type UIDs + public static final ChannelTypeUID CHANNEL_TYPE_SWITCH_VALUE = new ChannelTypeUID(BINDING_ID, "switch"); + public static final ChannelTypeUID CHANNEL_TYPE_STRING_VALUE = new ChannelTypeUID(BINDING_ID, "string"); + public static final ChannelTypeUID CHANNEL_TYPE_NUMBER_VALUE = new ChannelTypeUID(BINDING_ID, "number"); + public static final ChannelTypeUID CHANNEL_TYPE_TRIGGER_VALUE = new ChannelTypeUID(BINDING_ID, "trigger"); + public static final ChannelTypeUID CHANNEL_TYPE_ENUM_SWITCH_VALUE = new ChannelTypeUID(BINDING_ID, "enum-switch"); + public static final ChannelTypeUID CHANNEL_TYPE_SWITCH_DESCRIPTION = new ChannelTypeUID(BINDING_ID, + "device-description-switch"); + public static final ChannelTypeUID CHANNEL_TYPE_STRING_DESCRIPTION = new ChannelTypeUID(BINDING_ID, + "device-description-string"); + public static final ChannelTypeUID CHANNEL_TYPE_NUMBER_DESCRIPTION = new ChannelTypeUID(BINDING_ID, + "device-description-number"); + + // Supported thing types + public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_GENERIC, THING_TYPE_DISHWASHER, + THING_TYPE_WASHER, THING_TYPE_WASHER_DRYER, THING_TYPE_DRYER, THING_TYPE_COFFEE_MAKER, + THING_TYPE_COOK_PROCESSOR, THING_TYPE_OVEN, THING_TYPE_WARMING_DRAWER, THING_TYPE_HOOD, THING_TYPE_COOKTOP, + THING_TYPE_FRIDGE_FREEZER); + + // Configuration properties + public static final String PROPERTY_HOME_APPLIANCE_ID = HOME_APPLIANCE_ID; + public static final String PROPERTY_ADDRESS = "address"; + + // Configuration property values + public static final int CONNECTION_TYPE_AES_PORT = 80; + public static final int CONNECTION_TYPE_TLS_PORT = 443; + + // Misc + public static final String USER_DATA_PATH = OpenHAB.getUserDataFolder(); + public static final String BINDING_PROFILES_PATH = USER_DATA_PATH + File.separator + BINDING_ID + File.separator + + "profiles"; + public static final String BINDING_LOGS_PATH = USER_DATA_PATH + File.separator + BINDING_ID + File.separator + + "logs"; + public static final String CONSCRYPT_PROVIDER = "Conscrypt"; + public static final String CONSCRYPT_REQUIRED_GLIBC_MIN_VERSION = "2.35"; + public static final String CONFIGURATION_VALUE_KEY = "valueKey"; + public static final String CONFIGURATION_UNIT_KEY = "unit"; + public static final String CONFIGURATION_ON_VALUE_KEY = "onValue"; + public static final String CONFIGURATION_DESCRIPTION_KEY = "descriptionKey"; + public static final String CONFIGURATION_ATTRIBUTE = "attribute"; + public static final Duration UPDATE_ALL_MANDATORY_VALUES_INTERVAL = Duration.ofMinutes(60); + + // Home Connect WebSocket Client + public static final String WS_DEVICE_TYPE_APPLICATION_V2 = "Application"; + public static final Integer WS_DEVICE_TYPE_APPLICATION_V1 = 2; + public static final String WS_DEVICE_NAME = "HC Direct"; + public static final String WS_AES_URI_TEMPLATE = "ws://%s:80/homeconnect"; + public static final String WS_TLS_URI_TEMPLATE = "wss://%s:443/homeconnect"; + public static final Duration WS_INACTIVITY_TIMEOUT = Duration.ofSeconds(60); + public static final Duration WS_INACTIVITY_CHECK_INTERVAL = Duration.ofSeconds(10); + public static final Duration WS_INACTIVITY_CHECK_INITIAL_DELAY = Duration.ofSeconds(5); + public static final Duration WS_PING_INTERVAL = Duration.ofSeconds(30); + public static final Duration WS_PING_INITIAL_DELAY = Duration.ofSeconds(5); + + // Servlet + public static final String SERVLET_BASE_PATH = SLASH + BINDING_ID; + public static final String SERVLET_ASSETS_PATH = SERVLET_BASE_PATH + "/assets"; + public static final String SERVLET_WEB_SOCKET_PATH = SERVLET_BASE_PATH + "/ws"; + public static final String SERVLET_WEB_SOCKET_PATTERN = SERVLET_WEB_SOCKET_PATH + "/*"; + + // Appliance Keys + public static final String POWER_STATE_KEY = "BSH.Common.Setting.PowerState"; + public static final String ROOT_OPTION_LIST_KEY = "BSH.Common.Root.OptionList"; + public static final String LAUNDRY_CARE_OPTION_LIST_KEY = "LaundryCare.Common.OptionList.Options"; + public static final String OPERATION_STATE_KEY = "BSH.Common.Status.OperationState"; + public static final String POWER_STATE_ENUM_KEY = "BSH.Common.EnumType.PowerState"; + public static final String ACTIVE_PROGRAM_KEY = "BSH.Common.Root.ActiveProgram"; + public static final String SELECTED_PROGRAM_KEY = "BSH.Common.Root.SelectedProgram"; + public static final String PAUSE_PROGRAM_KEY = "BSH.Common.Command.PauseProgram"; + public static final String RESUME_PROGRAM_KEY = "BSH.Common.Command.ResumeProgram"; + public static final String ABORT_PROGRAM_KEY = "BSH.Common.Command.AbortProgram"; + public static final String WASHER_I_DOS_1_ACTIVE_KEY = "LaundryCare.Washer.Option.IDos1.Active"; + public static final String WASHER_I_DOS_2_ACTIVE_KEY = "LaundryCare.Washer.Option.IDos2.Active"; + public static final String WASHER_I_DOS_1_FILL_LEVEL_POOR_KEY = "LaundryCare.Washer.Event.IDos1FillLevelPoor"; + public static final String WASHER_I_DOS_2_FILL_LEVEL_POOR_KEY = "LaundryCare.Washer.Event.IDos2FillLevelPoor"; + public static final String WASHER_TEMPERATURE_KEY = "LaundryCare.Washer.Option.Temperature"; + public static final String WASHER_SPIN_SPEED_KEY = "LaundryCare.Washer.Option.SpinSpeed"; + public static final String WASHER_SPEED_PERFECT_KEY = "LaundryCare.Washer.Option.SpeedPerfect"; + public static final String WASHER_WATER_PLUS_KEY = "LaundryCare.Washer.Option.WaterPlus"; + public static final String WASHER_PREWASH_KEY = "LaundryCare.Washer.Option.Prewash"; + public static final String WASHER_RINSE_HOLD_KEY = "LaundryCare.Washer.Option.RinseHold"; + public static final String WASHER_LESS_IRONING_KEY = "LaundryCare.Washer.Option.LessIroning"; + public static final String WASHER_SILENT_WASH_KEY = "LaundryCare.Washer.Option.SilentWash"; + public static final String WASHER_SOAK_KEY = "LaundryCare.Washer.Option.Soak"; + public static final String WASHER_RINSE_PLUS_KEY = "LaundryCare.Washer.Option.RinsePlus"; + public static final String WASHER_STAINS_KEY = "LaundryCare.Washer.Option.Stains"; + public static final String LAUNDRY_CARE_PROCESS_PHASE_KEY = "LaundryCare.Common.Option.ProcessPhase"; + public static final String LAUNDRY_LOAD_INFORMATION_KEY = "LaundryCare.Common.Status.LoadInformation"; + public static final String LAUNDRY_LOAD_KEY = "LaundryCare.Washer.Status.ProcessingData.LaundryLoad"; + public static final String LAUNDRY_LOAD_RECOMMENDATION_KEY = "LaundryCare.Common.Option.LoadRecommendation"; + public static final String LAUNDRY_DRUM_CLEAN_REMINDER_KEY = "LaundryCare.Washer.Event.DrumCleanReminder"; + public static final String DRYER_PROCESS_PHASE_KEY = "LaundryCare.Dryer.Option.ProcessPhase"; + public static final String DRYER_WRINKLE_GUARD_KEY = "LaundryCare.Dryer.Option.WrinkleGuard"; + public static final String DRYER_DRYING_TARGET_KEY = "LaundryCare.Dryer.Option.DryingTarget"; + public static final String COOKING_LIGHTING_KEY = "Cooking.Common.Setting.Lighting"; + public static final String CHILD_LOCK_KEY = "BSH.Common.Setting.ChildLock"; + public static final String COOKING_LIGHTING_BRIGHTNESS_KEY = "Cooking.Common.Setting.LightingBrightness"; + public static final String COOKING_BUTTON_TONES_KEY = "Cooking.Common.Setting.ButtonTones"; + public static final String HOOD_VENTING_LEVEL_KEY = "Cooking.Common.Option.Hood.VentingLevel"; + public static final String HOOD_VENTING_LEVEL_ENUM_KEY = "Cooking.Hood.EnumType.Stage"; + public static final String HOOD_INTENSIVE_LEVEL_KEY = "Cooking.Common.Option.Hood.IntensiveLevel"; + public static final String HOOD_INTENSIVE_LEVEL_ENUM_KEY = "Cooking.Hood.EnumType.IntensiveStage"; + public static final String PROGRAM_PROGRESS_KEY = "BSH.Common.Option.ProgramProgress"; + public static final String REMAINING_PROGRAM_TIME_KEY = "BSH.Common.Option.RemainingProgramTime"; + public static final String REMOTE_CONTROL_START_ALLOWED_KEY = "BSH.Common.Status.RemoteControlStartAllowed"; + public static final String DOOR_STATE_KEY = "BSH.Common.Status.DoorState"; + public static final String DISHWASHER_SALT_LACK_KEY = "Dishcare.Dishwasher.Event.SaltLack"; + public static final String DISHWASHER_RINSE_AID_LACK_KEY = "Dishcare.Dishwasher.Event.RinseAidLack"; + public static final String DISHWASHER_SALT_NEARLY_EMPTY_KEY = "Dishcare.Dishwasher.Event.SaltNearlyEmpty"; + public static final String DISHWASHER_RINSE_AID_NEARLY_EMPTY_KEY = "Dishcare.Dishwasher.Event.RinseAidNearlyEmpty"; + public static final String DISHWASHER_MACHINE_CARE_REMINDER_KEY = "Dishcare.Dishwasher.Event.MachineCareReminder"; + public static final String DISHWASHER_PROGRAM_PHASE_KEY = "Dishcare.Dishwasher.Status.ProgramPhase"; + public static final String DISHWASHER_VARIO_SPEED_PLUS_KEY = "Dishcare.Dishwasher.Option.VarioSpeedPlus"; + public static final String DISHWASHER_INTENSIV_ZONE_KEY = "Dishcare.Dishwasher.Option.IntensivZone"; + public static final String DISHWASHER_BRILLIANCE_DRY_KEY = "Dishcare.Dishwasher.Option.BrillianceDry"; + public static final String COFFEE_MAKER_PROCESS_PHASE_KEY = "ConsumerProducts.CoffeeMaker.Status.ProcessPhase"; + public static final String COFFEE_MAKER_COUNTDOWN_CLEANING_KEY = "ConsumerProducts.CoffeeMaker.Status.BeverageCountdownCleaning"; + public static final String COFFEE_MAKER_COUNTDOWN_CALC_AND_CLEAN_KEY = "ConsumerProducts.CoffeeMaker.Status.BeverageCountdownCalcNClean"; + public static final String COFFEE_MAKER_COUNTDOWN_DESCALING_KEY = "ConsumerProducts.CoffeeMaker.Status.BeverageCountdownDescaling"; + public static final String COFFEE_MAKER_COUNTDOWN_WATER_FILTER_KEY = "ConsumerProducts.CoffeeMaker.Status.BeverageCountdownWaterfilter"; + public static final String COFFEE_MAKER_WATER_TANK_EMPTY_KEY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty"; + public static final String COFFEE_MAKER_WATER_TANK_NEARLY_EMPTY_KEY = "ConsumerProducts.CoffeeMaker.Event.WaterTankNearlyEmpty"; + public static final String COFFEE_MAKER_DRIP_TRAY_FULL_KEY = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull"; + public static final String COFFEE_MAKER_EMPTY_MILK_TANK_KEY = "ConsumerProducts.CoffeeMaker.Event.EmptyMilkTank"; + public static final String COFFEE_MAKER_BEAN_CONTAINER_EMPTY_KEY = "ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty"; + public static final String OVEN_DURATION_KEY = "BSH.Common.Option.Duration"; + public static final String OVEN_SET_POINT_TEMPERATURE_KEY = "Cooking.Oven.Option.SetpointTemperature"; + public static final String OVEN_CURRENT_TEMPERATURE_KEY = "Cooking.Oven.Status.CurrentCavityTemperature"; + public static final String OVEN_CURRENT_MEAT_PROBE_TEMPERATURE_KEY = "Cooking.Oven.Status.CurrentMeatprobeTemperature"; + public static final String OVEN_MEAT_PROBE_PLUGGED_KEY = "Cooking.Oven.Status.MeatprobePlugged"; + public static final String OVEN_CAVITY_SELECTOR_ENUM_KEY = "Cooking.Oven.EnumType.CavitySelector"; + public static final String FRIDGE_DOOR_STATE_KEY = "Refrigeration.FridgeFreezer.Status.DoorRefrigerator"; + public static final String FRIDGE_DOOR_STATE_2_KEY = "Refrigeration.Common.Status.Door.Refrigerator"; + public static final String FRIDGE_CHILLER_DOOR_STATE_KEY = "Refrigeration.Common.Status.Door.ChillerCommon"; + public static final String FRIDGE_SET_POINT_TEMPERATURE_KEY = "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator"; + public static final String FRIDGE_SET_POINT_TEMPERATURE_2_KEY = "Refrigeration.Common.Setting.Refrigerator.SetpointTemperature"; + public static final String FRIDGE_CHILLER_SET_POINT_TEMPERATURE_KEY = "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureChiller"; + public static final String FRIDGE_CHILLER_SET_POINT_TEMPERATURE_2_KEY = "Refrigeration.Common.Setting.ChillerCommon.SetpointTemperature"; + public static final String FRIDGE_CHILLER_PRESET_KEY = "Refrigeration.Common.Setting.ChillerCommon.Preset"; + public static final String FRIDGE_SUPER_MODE_KEY = "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator"; + public static final String FRIDGE_SUPER_MODE_2_KEY = "Refrigeration.Common.Setting.Refrigerator.SuperMode"; + public static final String FRIDGE_DISPENSER_WATER_FILTER_SATURATION_KEY = "Refrigeration.Common.Status.Dispenser.WaterFilterSaturation"; + public static final String FRIDGE_DISPENSER_PARTY_MODE_KEY = "Refrigeration.Common.Setting.Dispenser.PartyMode"; + public static final String FRIDGE_DISPENSER_ENABLED_KEY = "Refrigeration.Common.Setting.Dispenser.Enabled"; + public static final String FREEZER_SET_POINT_TEMPERATURE_KEY = "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureFreezer"; + public static final String FREEZER_SET_POINT_TEMPERATURE_2_KEY = "Refrigeration.Common.Setting.Freezer.SetpointTemperature"; + public static final String FREEZER_DOOR_STATE_KEY = "Refrigeration.FridgeFreezer.Status.DoorFreezer"; + public static final String FREEZER_DOOR_STATE_2_KEY = "Refrigeration.Common.Status.Door.Freezer"; + public static final String FREEZER_SUPER_MODE_KEY = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer"; + public static final String FREEZER_SUPER_MODE_2_KEY = "Refrigeration.Common.Setting.Freezer.SuperMode"; + + // Appliance Template Keys + public static final String OVEN_DOOR_STATE_KEY_TEMPLATE = "Cooking.Oven.Status.Cavity.%03d.DoorState"; + public static final String OVEN_CURRENT_TEMPERATURE_KEY_TEMPLATE = "Cooking.Oven.Status.Cavity.%03d.CurrentTemperature"; + public static final String OVEN_CURRENT_MEAT_PROBE_TEMPERATURE_KEY_TEMPLATE = "Cooking.Oven.Status.Cavity.%03d.CurrentMeatprobeTemperature"; + public static final String OVEN_CAVITY_LIGHT_KEY_TEMPLATE = "Cooking.Oven.Setting.Light.Cavity.%03d.Power"; + public static final String OVEN_MEAT_PROBE_PLUGGED_KEY_TEMPLATE = "Cooking.Oven.Status.Cavity.%03d.MeatprobePlugged"; + public static final String PROGRAM_FAVORITE_KEY_TEMPLATE = "BSH.Common.Program.Favorite.%03d"; + + // Channels + public static final String CHANNEL_COMMAND = "command"; + public static final String CHANNEL_REMOTE_CONTROL_OR_START_ALLOWED = "remote-control-start-allowed"; + public static final String CHANNEL_POWER_STATE = "power-state"; + public static final String CHANNEL_DOOR = "door"; + public static final String CHANNEL_OPERATION_STATE = "operation-state"; + public static final String CHANNEL_ACTIVE_PROGRAM = "active-program"; + public static final String CHANNEL_SELECTED_PROGRAM = "selected-program"; + public static final String CHANNEL_REMAINING_PROGRAM_TIME = "remaining-program-time"; + public static final String CHANNEL_PROGRAM_PROGRESS = "program-progress"; + public static final String CHANNEL_PROGRAM_COMMAND = "program-command"; + public static final String CHANNEL_WASHER_I_DOS_1_FILL_LEVEL_POOR = "idos1-fill-level-poor"; + public static final String CHANNEL_WASHER_I_DOS_2_FILL_LEVEL_POOR = "idos2-fill-level-poor"; + public static final String CHANNEL_WASHER_I_DOS_1_ACTIVE = "idos1-active"; + public static final String CHANNEL_WASHER_I_DOS_2_ACTIVE = "idos2-active"; + public static final String CHANNEL_WASHER_TEMPERATURE = "washer-temperature"; + public static final String CHANNEL_WASHER_SPIN_SPEED = "washer-spin-speed"; + public static final String CHANNEL_WASHER_SPEED_PERFECT = "washer-speed-perfect"; + public static final String CHANNEL_WASHER_WATER_PLUS = "washer-water-plus"; + public static final String CHANNEL_WASHER_PREWASH = "washer-prewash"; + public static final String CHANNEL_WASHER_RINSE_HOLD = "washer-rinse-hold"; + public static final String CHANNEL_WASHER_LESS_IRONING = "washer-less-ironing"; + public static final String CHANNEL_WASHER_SILENT_WASH = "washer-silent-wash"; + public static final String CHANNEL_WASHER_SOAK = "washer-soak"; + public static final String CHANNEL_WASHER_RINSE_PLUS = "washer-rinse-plus"; + public static final String CHANNEL_WASHER_STAINS = "washer-stains"; + public static final String CHANNEL_DRYER_DRYING_TARGET = "drying-target"; + public static final String CHANNEL_DRYER_WRINKLE_GUARD = "wrinkle-guard"; + public static final String CHANNEL_LAUNDRY_LOAD_INFORMATION = "laundry-load-information"; + public static final String CHANNEL_LAUNDRY_LOAD_RECOMMENDATION = "laundry-load-recommendation"; + public static final String CHANNEL_LAUNDRY_DRUM_CLEAN_REMINDER = "drum-clean-reminder"; + public static final String CHANNEL_LAUNDRY_CARE_PROCESS_PHASE = "process-phase"; + public static final String CHANNEL_DISHWASHER_SALT_LACK = "salt-lack"; + public static final String CHANNEL_DISHWASHER_RINSE_AID_LACK = "rinse-aid-lack"; + public static final String CHANNEL_DISHWASHER_SALT_NEARLY_EMPTY = "salt-nearly-empty"; + public static final String CHANNEL_DISHWASHER_RINSE_AID_NEARLY_EMPTY = "rinse-aid-nearly-empty"; + public static final String CHANNEL_DISHWASHER_MACHINE_CARE_REMINDER = "machine-care-reminder"; + public static final String CHANNEL_DISHWASHER_PROGRAM_PHASE = "program-phase"; + public static final String CHANNEL_DISHWASHER_VARIO_SPEED_PLUS = "dishwasher-vario-speed-plus"; + public static final String CHANNEL_DISHWASHER_INTENSIV_ZONE = "dishwasher-intensiv-zone"; + public static final String CHANNEL_DISHWASHER_BRILLIANCE_DRY = "dishwasher-brilliance-dry"; + public static final String CHANNEL_COFFEE_MAKER_COUNTDOWN_CLEANING = "cleaning"; + public static final String CHANNEL_COFFEE_MAKER_COUNTDOWN_CALC_AND_CLEAN = "calc-n-clean"; + public static final String CHANNEL_COFFEE_MAKER_COUNTDOWN_DESCALING = "descaling"; + public static final String CHANNEL_COFFEE_MAKER_COUNTDOWN_WATER_FILTER = "water-filter"; + public static final String CHANNEL_COFFEE_MAKER_WATER_TANK_EMPTY = "water-tank-empty"; + public static final String CHANNEL_COFFEE_MAKER_WATER_TANK_NEARLY_EMPTY = "water-tank-nearly-empty"; + public static final String CHANNEL_COFFEE_MAKER_DRIP_TRAY_FULL = "drip-tray-full"; + public static final String CHANNEL_COFFEE_MAKER_EMPTY_MILK_TANK = "empty-milk-tank"; + public static final String CHANNEL_COFFEE_MAKER_BEAN_CONTAINER_EMPTY = "bean-container-empty"; + public static final String CHANNEL_COFFEE_MAKER_PROCESS_PHASE = "process-phase"; + public static final String CHANNEL_COFFEE_MAKER_PROGRAM_COMMAND = "coffeemaker-program-command"; + public static final String CHANNEL_OVEN_DURATION = "duration"; + public static final String CHANNEL_OVEN_SET_POINT_TEMPERATURE = "setpoint-temperature"; + public static final String CHANNEL_OVEN_PROGRAM_COMMAND = "oven-program-command"; + public static final String CHANNEL_COOKING_LIGHT = "cooking-light"; + public static final String CHANNEL_COOKING_LIGHT_BRIGHTNESS = "cooking-light-brightness"; + public static final String CHANNEL_BUTTON_TONES = "button-tones"; + public static final String CHANNEL_HOOD_VENTING_LEVEL = "venting-level"; + public static final String CHANNEL_HOOD_INTENSIVE_LEVEL = "intensive-level"; + public static final String CHANNEL_CHILD_LOCK = "child-lock"; + public static final String CHANNEL_FRIDGE_DOOR_STATE = "refrigerator-door"; + public static final String CHANNEL_FREEZER_DOOR_STATE = "freezer-door"; + public static final String CHANNEL_FRIDGE_CHILLER_DOOR_STATE = "chiller-door"; + public static final String CHANNEL_FRIDGE_DISPENSER_WATER_FILTER_SATURATION = "dispenser-water-filter-saturation"; + public static final String CHANNEL_FRIDGE_DISPENSER_ENABLED = "dispenser-enabled"; + public static final String CHANNEL_FRIDGE_DISPENSER_PARTY_MODE = "dispenser-party-mode"; + public static final String CHANNEL_FRIDGE_SET_POINT_TEMPERATURE = "setpoint-temperature-refrigerator"; + public static final String CHANNEL_FREEZER_SET_POINT_TEMPERATURE = "setpoint-temperature-freezer"; + public static final String CHANNEL_FRIDGE_CHILLER_SET_POINT_TEMPERATURE = "setpoint-temperature-chiller"; + public static final String CHANNEL_FRIDGE_CHILLER_PRESET = "chiller-preset"; + public static final String CHANNEL_FRIDGE_SUPER_MODE = "super-mode-refrigerator"; + public static final String CHANNEL_FREEZER_SUPER_MODE = "super-mode-freezer"; + public static final String CHANNEL_RAW_MESSAGE = "raw-message"; + + // Channel Templates + public static final String CHANNEL_OVEN_DOOR_TEMPLATE = "door-%d"; + public static final String CHANNEL_OVEN_TEMPERATURE_TEMPLATE = "temperature-%d"; + public static final String CHANNEL_OVEN_CURRENT_MEAT_PROBE_TEMPERATURE_TEMPLATE = "meat-probe-temperature-%d"; + public static final String CHANNEL_OVEN_MEAT_PROBE_PLUGGED_TEMPLATE = "meat-probe-plugged-%d"; + public static final String CHANNEL_OVEN_CAVITY_LIGHT_TEMPLATE = "cavity-light-%d"; + + // Channel Types + public static final String CHANNEL_TYPE_I_DOS_FILL_LEVEL_POOR = "washer-idos-fill-level-poor-channel"; + public static final String CHANNEL_TYPE_I_DOS_ACTIVE = "washer-idos-active-channel"; + public static final String CHANNEL_TYPE_DOOR = "door-channel"; + public static final String CHANNEL_TYPE_OVEN_CURRENT_TEMPERATURE = "oven-current-cavity-temperature-channel"; + public static final String CHANNEL_TYPE_OVEN_CURRENT_MEAT_PROBE_TEMPERATURE = "oven-current-meat-probe-temperature-channel"; + public static final String CHANNEL_TYPE_OVEN_MEAT_PROBE_PLUGGED = "oven-meat-probe-plugged-channel"; + public static final String CHANNEL_TYPE_OVEN_CAVITY_LIGHT = "oven-cavity-light-channel"; + public static final String CHANNEL_TYPE_FRIDGE_FREEZER_SET_POINT_TEMPERATURE = "fridgefreezer-setpoint-temperature-channel"; + public static final String CHANNEL_TYPE_FRIDGE_FREEZER_SUPER_MODE = "fridgefreezer-super-mode-channel"; + public static final String CHANNEL_TYPE_FRIDGE_DISPENSER_WATER_FILTER_SATURATION = "fridgefreezer-dispenser-water-filter-saturation-channel"; + public static final String CHANNEL_TYPE_FRIDGE_DISPENSER_ENABLED = "fridgefreezer-dispenser-enabled-channel"; + public static final String CHANNEL_TYPE_FRIDGE_DISPENSER_PARTY_MODE = "fridgefreezer-dispenser-party-mode-channel"; + public static final String CHANNEL_TYPE_FRIDGE_CHILLER_PRESET = "fridgefreezer-chiller-preset-channel"; + + // State values + public static final String STATE_OPEN = "Open"; + public static final String STATE_AJAR = "Ajar"; + public static final String STATE_ON = "On"; + public static final String STATE_OFF = "Off"; + public static final String STATE_MAINS_OFF = "MainsOff"; + public static final String STATE_STANDBY = "Standby"; + public static final String STATE_FINISHED = "Finished"; + public static final String STATE_RUN = "Run"; + public static final String STATE_NO_PROGRAM = "0"; + public static final String STATE_HOOD_VENTING = "Cooking.Common.Program.Hood.Venting"; + public static final String STATE_PRESENT = "Present"; + public static final String STATE_CONFIRMED = "Confirmed"; + public static final String STATE_FAN_OFF = "FanOff"; + public static final String STATE_FAN_STAGE_1 = "FanStage01"; + public static final String STATE_FAN_STAGE_2 = "FanStage02"; + public static final String STATE_FAN_STAGE_3 = "FanStage03"; + public static final String STATE_FAN_STAGE_4 = "FanStage04"; + public static final String STATE_FAN_STAGE_5 = "FanStage05"; + public static final String STATE_INTENSIVE_STAGE_OFF = "IntensiveStageOff"; + public static final String STATE_INTENSIVE_STAGE_1 = "IntensiveStage1"; + public static final String STATE_INTENSIVE_STAGE_2 = "IntensiveStage2"; + public static final String STATE_POOR = "Poor"; + + // Commands + public static final String COMMAND_START = "start"; + public static final String COMMAND_PAUSE = "pause"; + public static final String COMMAND_RESUME = "resume"; + public static final String COMMAND_STOP = "stop"; + + // Dimensions + public static final String NUMBER_TEMPERATURE = CoreItemFactory.NUMBER + ":Temperature"; + public static final String NUMBER_DIMENSIONLESS = CoreItemFactory.NUMBER + ":Dimensionless"; + + // i18n + public static final String I18N_I_DOS_FILL_LEVEL_POOR = "channel.idos.fill-level-poor"; + public static final String I18N_I_DOS_ACTIVE = "channel.idos.active"; + public static final String I18N_START_PROGRAM = "command.program.start"; + public static final String I18N_PAUSE_PROGRAM = "command.program.pause"; + public static final String I18N_RESUME_PROGRAM = "command.program.resume"; + public static final String I18N_STOP_PROGRAM = "command.program.stop"; + public static final String I18N_OVEN_DOOR = "channel.oven.door"; + public static final String I18N_OVEN_CURRENT_TEMPERATURE = "channel.oven.current-temperature"; + public static final String I18N_OVEN_CURRENT_MEAT_PROBE_TEMPERATURE = "channel.oven.meat-probe-temperature"; + public static final String I18N_OVEN_MEAT_PROBE_PLUGGED = "channel.oven.meat-probe-plugged"; + public static final String I18N_OVEN_CAVITY_LIGHT = "channel.oven.cavity-light"; + public static final String I18N_FRIDGE_FREEZER_SET_POINT_TEMPERATURE = "channel.fridgefreezer.setpoint"; + public static final String I18N_FRIDGE_FREEZER_DOOR = "channel.fridgefreezer.door"; + public static final String I18N_FRIDGE_FREEZER_SUPER_MODE = "channel.fridgefreezer.super-mode"; + public static final String I18N_FRIDGE_DISPENSER_WATER_FILTER_SATURATION = "channel.fridgefreezer.water-filter-saturation"; + public static final String I18N_FRIDGE_DISPENSER_ENABLED = "channel.fridgefreezer.dispenser-enabled"; + public static final String I18N_FRIDGE_DISPENSER_PARTY_MODE = "channel.fridgefreezer.dispenser-party-mode"; + public static final String I18N_FRIDGE_CHILLER_PRESET = "channel.fridgefreezer.chiller-preset"; + public static final String I18N_DOOR = "channel.door"; + + // Device Description Attributes + public static final String ATTRIBUTE_ACCESS = "access"; + public static final String ATTRIBUTE_AVAILABLE = "available"; + public static final String ATTRIBUTE_MIN = "min"; + public static final String ATTRIBUTE_MAX = "max"; + public static final String ATTRIBUTE_STEP_SIZE = "stepSize"; + public static final String ATTRIBUTE_ENUMERATION_TYPE = "enumerationType"; + public static final String ATTRIBUTE_ENUMERATION_TYPE_KEY = "enumerationTypeKey"; +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/DoubleKeyMap.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/DoubleKeyMap.java new file mode 100644 index 0000000000..4d55cd5abc --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/DoubleKeyMap.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * A memory-optimized, thread-safe map that allows looking up values by two different unique keys. + * It stores values only once (referenced by K1) and maintains key mappings to allow lookup by K2. + * + * @param The first key type (Primary Key) + * @param The second key type (Secondary Key) + * @param The value type + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class DoubleKeyMap { + + // Primary storage: K1 -> Value + private final Map k1ToVal = new HashMap<>(); + + // Key mappings to maintain 1:1 relationship and allow bi-directional lookups/removals + private final Map k1ToK2 = new HashMap<>(); + private final Map k2ToK1 = new HashMap<>(); + + public synchronized void put(K1 k1, K2 k2, V value) { + removeByKey1(k1); + removeByKey2(k2); + + k1ToVal.put(k1, value); + k1ToK2.put(k1, k2); + k2ToK1.put(k2, k1); + } + + public synchronized @Nullable V getByKey1(@Nullable K1 k1) { + if (k1 == null) { + return null; + } + return k1ToVal.get(k1); + } + + public synchronized @Nullable V getByKey2(@Nullable K2 k2) { + @Nullable + K1 k1 = k2ToK1.get(k2); + if (k1 == null) { + return null; + } + return k1ToVal.get(k1); + } + + public synchronized @Nullable V removeByKey1(@Nullable K1 k1) { + if (k1 == null) { + return null; + } + + @Nullable + V value = k1ToVal.remove(k1); + @Nullable + K2 k2 = k1ToK2.remove(k1); + + if (k2 != null) { + k2ToK1.remove(k2); + } + return value; + } + + public synchronized @Nullable V removeByKey2(@Nullable K2 k2) { + @Nullable + K1 k1 = k2ToK1.remove(k2); + if (k1 == null) { + return null; + } + + @Nullable + V value = k1ToVal.remove(k1); + k1ToK2.remove(k1); + + return value; + } + + public synchronized void clear() { + k1ToVal.clear(); + k1ToK2.clear(); + k2ToK1.clear(); + } + + public synchronized int size() { + return k1ToVal.size(); + } + + public synchronized Set> entrySet() { + return Set.copyOf(k1ToVal.entrySet()); + } + + public synchronized Collection values() { + return List.copyOf(k1ToVal.values()); + } + + @Override + public synchronized String toString() { + return "DoubleKeyMap{size=" + k1ToVal.size() + ", data=" + k1ToVal + "}"; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/LimitedSizeList.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/LimitedSizeList.java new file mode 100644 index 0000000000..af19bf210a --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/LimitedSizeList.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * A list implementation with a fixed maximum size that discards oldest entries. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class LimitedSizeList { + private final LinkedList list = new LinkedList<>(); + private final int capacity; + + public LimitedSizeList(int capacity) { + if (capacity <= 0) { + throw new IllegalArgumentException("Capacity must be greater than 0!"); + } + this.capacity = capacity; + } + + public synchronized void add(E element) { + if (list.size() >= capacity) { + list.removeFirst(); + } + list.addLast(element); + } + + public synchronized List getAllElements() { + return Collections.unmodifiableList(new LinkedList<>(list)); + } + + public synchronized int size() { + return list.size(); + } + + public synchronized boolean isEmpty() { + return list.isEmpty(); + } + + @Override + public synchronized String toString() { + return list.toString(); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/json/adapter/DoubleKeyMapAdapter.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/json/adapter/DoubleKeyMapAdapter.java new file mode 100644 index 0000000000..42fc198f09 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/json/adapter/DoubleKeyMapAdapter.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common.json.adapter; + +import java.lang.reflect.Type; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +/** + * Gson adapter for {@link DoubleKeyMap} serialization and deserialization. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class DoubleKeyMapAdapter implements JsonSerializer> { + + @Override + @SuppressWarnings("unchecked") + public JsonElement serialize(@Nullable DoubleKeyMap src, @Nullable Type typeOfSrc, + @Nullable JsonSerializationContext context) { + if (src == null || context == null) { + return new JsonArray(); + } + + JsonArray jsonArray = new JsonArray(); + src.entrySet().stream().sorted((e1, e2) -> { + Object k1 = e1.getKey(); + Object k2 = e2.getKey(); + if (k1 instanceof Comparable && k2 instanceof Comparable) { + return ((Comparable) k1).compareTo(k2); + } + return 0; + }).map(Map.Entry::getValue).forEach(value -> jsonArray.add(context.serialize(value))); + + return jsonArray; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/json/adapter/OffsetDateTimeAdapter.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/json/adapter/OffsetDateTimeAdapter.java new file mode 100644 index 0000000000..a7febed902 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/json/adapter/OffsetDateTimeAdapter.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common.json.adapter; + +import java.lang.reflect.Type; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +/** + * Gson adapter for {@link java.time.OffsetDateTime} serialization and deserialization. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class OffsetDateTimeAdapter implements JsonSerializer, JsonDeserializer { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + @Override + public JsonElement serialize(@Nullable OffsetDateTime src, @Nullable Type typeOfSrc, + @Nullable JsonSerializationContext context) { + if (src == null) { + return JsonNull.INSTANCE; + } + return new JsonPrimitive(src.format(FORMATTER)); + } + + @Override + public @Nullable OffsetDateTime deserialize(@Nullable JsonElement json, @Nullable Type typeOfT, + @Nullable JsonDeserializationContext context) throws JsonParseException { + if (json == null || json.isJsonNull()) { + return null; + } + String value = json.getAsString(); + if (value.isEmpty()) { + return null; + } + return OffsetDateTime.parse(value, FORMATTER); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/ConfigurationUtils.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/ConfigurationUtils.java new file mode 100644 index 0000000000..dbb792e233 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/ConfigurationUtils.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common.utils; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.HexFormat; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; +import org.openhab.binding.homeconnectdirect.internal.common.json.adapter.DoubleKeyMapAdapter; +import org.openhab.binding.homeconnectdirect.internal.common.json.adapter.OffsetDateTimeAdapter; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.serializer.ResourceAdapter; +import org.openhab.core.OpenHAB; +import org.openhab.core.util.StringUtils; +import org.osgi.service.cm.ConfigurationAdmin; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; + +/** + * + * Home Connect Direct configuration utilities. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class ConfigurationUtils { + + public static final String WS_DEVICE_ID_PREFIX = "cafebabe0000"; + public static final String WS_DEVICE_ID_PATH = OpenHAB.getUserDataFolder() + File.separator + BINDING_ID + + File.separator + "device.id"; + + private static final String DEVICE_ID_HASH_ALGORITHM = "SHA-256"; + private static final int DEVICE_ID_HASH_LENGTH = 12; + private static final HexFormat HEX_FORMATTER = HexFormat.of(); + + private ConfigurationUtils() { + // Utility class + } + + /** + * Get the binding configuration. + * + * @param configurationAdmin the configuration admin service + * @return binding configuration + */ + public static HomeConnectDirectConfiguration getConfiguration(ConfigurationAdmin configurationAdmin) { + var configuration = new HomeConnectDirectConfiguration(); + try { + var config = configurationAdmin.getConfiguration(CONFIGURATION_PID); + var properties = config.getProperties(); + + if (properties != null) { + configuration.loginEnabled = Boolean + .parseBoolean(String.valueOf(properties.get(CONFIGURATION_LOGIN_ENABLED))); + var passwordObject = properties.get(CONFIGURATION_LOGIN_PASSWORD); + if (passwordObject instanceof String password && !password.isBlank()) { + configuration.loginPassword = password; + } + var queueSizeObject = properties.get(CONFIGURATION_MESSAGE_QUEUE_SIZE); + if (queueSizeObject != null) { + int queueSize = Integer.parseInt(String.valueOf(queueSizeObject)); + configuration.messageQueueSize = Math.min(86400, Math.max(10, queueSize)); + } + } + } catch (IOException | NumberFormatException ignored) { + // could not get configuration, use defaults + } + + return configuration; + } + + /** + * Create a Gson instance with custom serializers and deserializers. + * + * @return Gson instance + */ + public static Gson createGson() { + return new GsonBuilder().registerTypeAdapter(Resource.class, new ResourceAdapter()) + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeAdapter()) + .registerTypeAdapter(DoubleKeyMap.class, new DoubleKeyMapAdapter()) + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create(); + } + + public static String getDeviceId() { + var path = Path.of(WS_DEVICE_ID_PATH); + try { + if (!Files.exists(path)) { + Files.createDirectories(path.getParent()); + var deviceId = generateDeviceId(); + Files.writeString(path, deviceId, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + return deviceId; + } else { + return Files.readString(path).trim(); + } + } catch (IOException e) { + // use fallback device id + return generateDeviceId(); + } + } + + private static String generateDeviceId() { + try { + var hostname = InetAddress.getLocalHost().getHostName().trim(); + var digest = MessageDigest.getInstance(DEVICE_ID_HASH_ALGORITHM); + byte[] fullHash = digest.digest(hostname.getBytes(StandardCharsets.UTF_8)); + + byte[] truncatedHash = Arrays.copyOf(fullHash, DEVICE_ID_HASH_LENGTH); + + return HEX_FORMATTER.formatHex(truncatedHash); + } catch (SecurityException | UnknownHostException | NoSuchAlgorithmException e) { + return WS_DEVICE_ID_PREFIX + StringUtils + .getRandomHex(2 * DEVICE_ID_HASH_LENGTH - WS_DEVICE_ID_PREFIX.length()).toLowerCase(LOCALE); + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/OSUtils.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/OSUtils.java new file mode 100644 index 0000000000..cf09b15959 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/OSUtils.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common.utils; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Operating system utility methods. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class OSUtils { + private static final String NAME = getSystemProperty("os.name", "Unknown OS"); + private static final String ARCH = getSystemProperty("os.arch", "Unknown"); + private static final String LINUX = "Linux"; + + private OSUtils() { + // Utility class + } + + public static String getOSName() { + return NAME; + } + + public static String getOSArch() { + return ARCH; + } + + public static boolean isLinux() { + return LINUX.equalsIgnoreCase(NAME); + } + + private static String getSystemProperty(String key, String defaultValue) { + String value; + try { + value = System.getProperty(key, defaultValue); + } catch (SecurityException e) { + value = defaultValue; + } + if (value == null) { + value = defaultValue; + } + + return value; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/StringUtils.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/StringUtils.java new file mode 100644 index 0000000000..7a5956aa8b --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/utils/StringUtils.java @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common.utils; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; + +/** + * Home Connect Direct string utilities. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class StringUtils { + + public static final String EMPTY_STRING = ""; + public static final String SPACE = " "; + public static final String HYPHEN = "-"; + public static final String SLASH = "/"; + public static final String OPENING_BRACE = "{"; + public static final String CLOSING_BRACE = "}"; + + private static final Pattern CAMEL_CASE_SPLIT_PATTERN = Pattern + .compile("(? LABEL_REPLACEMENTS = Map.ofEntries(Map.entry("Wi Fi", "WiFi"), + Map.entry("Auto40", "Auto 40°"), Map.entry("Auto30", "Auto 30°"), Map.entry("Eco4060", "Eco 40°-60°"), + Map.entry("Power Speed59", "powerSpeed 59′"), Map.entry("Super1530", "Super 15′/30′"), + Map.entry("Plus1", "+1"), Map.entry("Plus2", "+2"), Map.entry("Plus3", "+3"), + Map.entry("Intensiv70", "Intensiv 70°"), Map.entry("Auto2", "Auto 45°-65°"), Map.entry("Eco50", "Eco 50°"), + Map.entry("Glas40", "Glas 40°"), Map.entry("Quick45", "Quick 45′"), Map.entry("Kurz60", "Short 60°"), + Map.entry("Auto1", "Auto 35°-45°"), Map.entry("Auto3", "Auto 65°-75°"), + Map.entry(STATE_INTENSIVE_STAGE_OFF, "Off"), Map.entry(STATE_INTENSIVE_STAGE_1, "1"), + Map.entry(STATE_INTENSIVE_STAGE_2, "2"), Map.entry(STATE_FAN_OFF, "Off"), Map.entry(STATE_FAN_STAGE_1, "1"), + Map.entry(STATE_FAN_STAGE_2, "2"), Map.entry(STATE_FAN_STAGE_3, "3"), Map.entry(STATE_FAN_STAGE_4, "4"), + Map.entry(STATE_FAN_STAGE_5, "5")); + + private static final Pattern GC_PATTERN = Pattern.compile("GC(\\d{2})"); + private static final Pattern RPM_PATTERN = Pattern.compile("RPM(\\d+)"); + private static final Pattern UL_PREFIX_PATTERN = Pattern.compile("\\bUl\\s+"); + + private StringUtils() { + // Utility class + } + + /** + * Check whether the given string is {@code null}, empty or contains only whitespace. + * + * @param str the string to check (may be {@code null}) + * @return {@code true} if the string is {@code null}, empty or blank + */ + public static boolean isBlank(@Nullable String str) { + return str == null || str.isBlank(); + } + + /** + * Null-safe check whether the given string ends with the given suffix. + * + * @param str the string to check (may be {@code null}) + * @param suffix the suffix to look for (may be {@code null}) + * @return {@code true} if the string ends with the suffix, {@code false} if either argument is {@code null} + */ + public static boolean endsWith(@Nullable String str, @Nullable String suffix) { + if (str == null || suffix == null) { + return false; + } + return str.endsWith(suffix); + } + + /** + * Null-safe check whether the given string starts with the given prefix. + * + * @param str the string to check (may be {@code null}) + * @param prefix the prefix to look for (may be {@code null}) + * @return {@code true} if the string starts with the prefix, {@code false} if either argument is {@code null} + */ + public static boolean startsWith(@Nullable String str, @Nullable String prefix) { + if (str == null || prefix == null) { + return false; + } + return str.startsWith(prefix); + } + + /** + * Return the substring after the first occurrence of the given separator. + * + * @param str the string to extract from (may be {@code null}) + * @param separator the separator whose first occurrence marks the start of the result (may be {@code null}) + * @return the substring after the first occurrence of the separator, or an empty string if the string is + * {@code null}/empty, the separator is {@code null} or the separator is not found + */ + public static String substringAfter(@Nullable String str, @Nullable String separator) { + if (str == null || str.isEmpty() || separator == null) { + return EMPTY_STRING; + } + + int index = str.indexOf(separator); + if (index == -1) { + return EMPTY_STRING; + } + + return str.substring(index + separator.length()); + } + + /** + * Null-safe, case-insensitive comparison of two strings. + * + * @param str1 the first string (may be {@code null}) + * @param str2 the second string (may be {@code null}) + * @return {@code true} if both are {@code null} or equal ignoring case, {@code false} otherwise + */ + public static boolean equalsIgnoreCase(@Nullable String str1, @Nullable String str2) { + if (str1 == null && str2 == null) { + return true; + } + if (str1 == null || str2 == null) { + return false; + } + return str1.equalsIgnoreCase(str2); + } + + /** + * Null-safe check whether the given string contains the given search sequence. + * + * @param str the string to search in (may be {@code null}) + * @param search the sequence to look for (may be {@code null}) + * @return {@code true} if the string contains the search sequence, {@code false} if either argument is {@code null} + */ + public static boolean contains(@Nullable String str, @Nullable String search) { + if (str == null || search == null) { + return false; + } + return str.contains(search); + } + + /** + * Convert a camelCase string to kebab-case (e.g. {@code fooBar} becomes {@code foo-bar}). + * + * @param input the string to convert (may be {@code null}) + * @return the kebab-case representation, or an empty string if the input is {@code null} or blank + */ + public static String convertToKebabCase(@Nullable String input) { + if (input == null || input.isBlank()) { + return EMPTY_STRING; + } + + Pattern pattern = Pattern.compile("([a-z])([A-Z])"); + Matcher matcher = pattern.matcher(input); + return matcher.replaceAll("$1-$2").toLowerCase(LOCALE); + } + + /** + * Convert the given string to lower case. + * + * @param input the string to convert (may be {@code null}) + * @return the lower-case representation, or an empty string if the input is {@code null} or blank + */ + public static String toLowercase(@Nullable String input) { + if (input == null || input.isBlank()) { + return EMPTY_STRING; + } + return input.toLowerCase(LOCALE); + } + + /** + * Null-safe, case-insensitive check whether the given string contains the given search sequence. + * + * @param str the string to search in (may be {@code null}) + * @param search the sequence to look for (may be {@code null}) + * @return {@code true} if the string contains the search sequence ignoring case, {@code false} if either argument + * is + * {@code null} + */ + public static boolean containsIgnoreCase(@Nullable String str, @Nullable String search) { + if (str == null || search == null) { + return false; + } + return str.toLowerCase(LOCALE).contains(search.toLowerCase(LOCALE)); + } + + /** + * Map Home Connect key and value names to label (Using the translation provider if submitted). + * e.g. Dishcare.Dishwasher.Program.Eco50 --> Eco50 or BSH.Common.EnumType.OperationState.DelayedStart --> Delayed + * Start + * + * @param key key + * @param translationProvider translation provider + * @return human readable label + */ + public static String mapKeyToLabel(String key, @Nullable HomeConnectDirectTranslationProvider translationProvider) { + if (translationProvider != null) { + var translation = translationProvider.getText(key); + if (!translation.equals(key)) { + return translation; + } + } + + String sub = key.substring(key.lastIndexOf(".") + 1); + String label = String.join(" ", CAMEL_CASE_SPLIT_PATTERN.split(sub)); + + label = LABEL_REPLACEMENTS.entrySet().stream().reduce(label, + (res, entry) -> res.replace(entry.getKey(), entry.getValue()), (s1, s2) -> s1); + + label = GC_PATTERN.matcher(label).replaceAll("$1 °C"); + label = RPM_PATTERN.matcher(label).replaceAll("$1"); + label = UL_PREFIX_PATTERN.matcher(label).replaceAll(""); + + return label; + } + + /** + * Heuristically check whether the given payload is likely a JSON object (starts with { and ends with + * }). + * + * @param payload the payload to check + * @return {@code true} if the trimmed payload looks like a JSON object + */ + public static boolean isMostLikelyAJsonObject(String payload) { + var json = payload.trim(); + return (json.startsWith("{") && json.endsWith("}")); + } + + /** + * Heuristically check whether the given payload is likely a JSON array (starts with [ and ends with + * ]). + * + * @param payload the payload to check + * @return {@code true} if the trimmed payload looks like a JSON array + */ + public static boolean isMostLikelyAJsonArray(String payload) { + var json = payload.trim(); + return (json.startsWith("[") && json.endsWith("]")); + } + + /** + * Remove all characters that are not alphanumeric or one of {@code . _ -} from the given string. + * + * @param input the string to sanitize (may be {@code null}) + * @return the sanitized string, or an empty string if the input is {@code null} or blank + */ + public static String sanitize(@Nullable String input) { + if (input == null || input.isBlank()) { + return EMPTY_STRING; + } + return input.replaceAll("[^a-zA-Z0-9._-]", EMPTY_STRING); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/xml/converter/AbstractConverter.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/xml/converter/AbstractConverter.java new file mode 100644 index 0000000000..1900beb4f1 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/xml/converter/AbstractConverter.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common.xml.converter; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.thoughtworks.xstream.converters.Converter; +import com.thoughtworks.xstream.converters.MarshallingContext; +import com.thoughtworks.xstream.converters.UnmarshallingContext; +import com.thoughtworks.xstream.io.HierarchicalStreamReader; +import com.thoughtworks.xstream.io.HierarchicalStreamWriter; + +/** + * Abstract base class for XStream XML converters. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractConverter implements Converter { + + private final Class type; + private final @Nullable Class contextClass; + + @SuppressWarnings("unchecked") + protected AbstractConverter() { + Type superClass = getClass().getGenericSuperclass(); + if (superClass instanceof ParameterizedType parameterizedType) { + Type[] typeArguments = parameterizedType.getActualTypeArguments(); + this.type = (Class) typeArguments[0]; + this.contextClass = typeArguments.length > 1 && typeArguments[1] instanceof Class clazz + && !Void.class.equals(clazz) ? (Class) clazz : null; + } else { + throw new IllegalStateException("AbstractConverter must be extended with type arguments"); + } + } + + @Override + @SuppressWarnings("rawtypes") + public boolean canConvert(@Nullable Class type) { + return this.type.equals(type); + } + + @Override + public final void marshal(@Nullable Object o, @Nullable HierarchicalStreamWriter hierarchicalStreamWriter, + @Nullable MarshallingContext marshallingContext) { + throw new UnsupportedOperationException(); + } + + @Override + public final @Nullable Object unmarshal(@Nullable HierarchicalStreamReader reader, + @Nullable UnmarshallingContext context) { + if (reader != null) { + var ctxClass = contextClass; + if (context != null && ctxClass != null) { + return process(reader, ctxClass.cast(context.get(ctxClass.getName()))); + } else { + return process(reader, null); + } + } + return null; + } + + protected abstract T process(HierarchicalStreamReader reader, @Nullable C contextObject); + + protected Integer mapHexId(String hexIdString) { + return mapHexId(hexIdString, 0); + } + + protected Integer mapHexId(String hexIdString, Integer defaultValue) { + try { + return Integer.parseInt(hexIdString, 16); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + protected @Nullable Integer mapHexIdNullable(@Nullable String hexIdString) { + if (hexIdString == null) { + return null; + } + + try { + return Integer.parseInt(hexIdString, 16); + } catch (NumberFormatException e) { + return null; + } + } + + protected @Nullable Integer mapIntegerNullable(@Nullable String integer) { + if (integer == null) { + return null; + } + + try { + return Integer.parseInt(integer); + } catch (NumberFormatException e) { + return null; + } + } + + protected boolean mapBoolean(@Nullable String bool, boolean defaultValue) { + if (bool == null) { + return defaultValue; + } + + return Boolean.parseBoolean(bool); + } + + protected @Nullable Number mapNumberNullable(@Nullable String doubleOrInteger) { + if (doubleOrInteger == null) { + return null; + } + + try { + if (!doubleOrInteger.contains(".")) { + return Integer.parseInt(doubleOrInteger); + } + return Double.parseDouble(doubleOrInteger); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/xml/exception/ParseException.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/xml/exception/ParseException.java new file mode 100644 index 0000000000..c6e6623401 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/common/xml/exception/ParseException.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common.xml.exception; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception thrown when XML parsing fails. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class ParseException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1_644_080_964_145_839_845L; + + public ParseException(final String message) { + super(message); + } + + public ParseException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/configuration/HomeConnectDirectApplianceConfiguration.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/configuration/HomeConnectDirectApplianceConfiguration.java new file mode 100644 index 0000000000..a679192001 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/configuration/HomeConnectDirectApplianceConfiguration.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.configuration; + +import static org.apache.commons.lang3.StringUtils.EMPTY; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link HomeConnectDirectApplianceConfiguration} class represents the appliance configuration options. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectApplianceConfiguration { + public String haId = EMPTY; + public String address = EMPTY; + public int connectionRetryDelay = 1; +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/configuration/HomeConnectDirectConfiguration.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/configuration/HomeConnectDirectConfiguration.java new file mode 100644 index 0000000000..cdfe1ab98e --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/configuration/HomeConnectDirectConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.configuration; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link HomeConnectDirectConfiguration} class represents the binding configuration. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectConfiguration { + public boolean loginEnabled = false; + public @Nullable String loginPassword = null; + public int messageQueueSize = 300; +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/discovery/HomeConnectDirectMDNSDiscoveryParticipant.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/discovery/HomeConnectDirectMDNSDiscoveryParticipant.java new file mode 100644 index 0000000000..c798106b3e --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/discovery/HomeConnectDirectMDNSDiscoveryParticipant.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.discovery; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.*; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import javax.jmdns.ServiceInfo; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.util.StringUtils; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomeConnectDirectMDNSDiscoveryParticipant} is responsible for discovering Home + * Connect appliances in your network environment. + * + * @author Jonas Brüstel - Initial contribution + */ +@Component(service = MDNSDiscoveryParticipant.class, configurationPid = "discovery.homeconnectdirect") +@NonNullByDefault +public class HomeConnectDirectMDNSDiscoveryParticipant implements MDNSDiscoveryParticipant { + + private static final String SERVICE_TYPE = "_homeconnect._tcp.local."; + private static final String PROPERTY_ID = "id"; + private static final String PROPERTY_BRAND = "brand"; + private static final String PROPERTY_TYPE = "type"; + private static final String PROPERTY_VIB = "vib"; + private static final String PROPERTY_MAC = "mac"; + + private final Logger logger; + private final ThingRegistry thingRegistry; + + @Activate + public HomeConnectDirectMDNSDiscoveryParticipant(@Reference ThingRegistry thingRegistry) { + this.thingRegistry = thingRegistry; + logger = LoggerFactory.getLogger(HomeConnectDirectMDNSDiscoveryParticipant.class); + } + + @Override + public Set getSupportedThingTypeUIDs() { + return SUPPORTED_THING_TYPES; + } + + @Override + public String getServiceType() { + return SERVICE_TYPE; + } + + @Override + @Nullable + public DiscoveryResult createResult(final ServiceInfo serviceInfo) { + var ipv4List = List.of(serviceInfo.getInet4Addresses()); + var haId = serviceInfo.getPropertyString(PROPERTY_ID); + var type = serviceInfo.getPropertyString(PROPERTY_TYPE); + var brand = serviceInfo.getPropertyString(PROPERTY_BRAND); + var port = serviceInfo.getPort(); + var thingUID = mapThingUID(serviceInfo); + + logger.trace("Found appliance. haId={}, addresses={}, port={}", haId, ipv4List, port); + if (!ipv4List.isEmpty() && !isBlank(haId) + && (port == CONNECTION_TYPE_AES_PORT || port == CONNECTION_TYPE_TLS_PORT) && !isBlank(type) + && !isBlank(brand) && thingUID != null && !isAlreadyRegisteredThing(haId)) { + var thingTypeUID = mapType(type); + var friendlyName = getLabel(thingTypeUID, + Objects.requireNonNull(StringUtils.capitalize(brand.toLowerCase(LOCALE)))); + + Map properties = Map.of(PROPERTY_HOME_APPLIANCE_ID, haId, PROPERTY_ADDRESS, + ipv4List.getFirst().getHostAddress()); + + return DiscoveryResultBuilder.create(thingUID).withProperties(properties).withLabel(friendlyName) + .withRepresentationProperty(PROPERTY_HOME_APPLIANCE_ID).build(); + } else { + logger.trace("Ignore device due to missing attributes or already registered (haId={}).", haId); + return null; + } + } + + @Override + @Nullable + public ThingUID getThingUID(ServiceInfo serviceInfo) { + return mapThingUID(serviceInfo); + } + + @Nullable + private ThingUID mapThingUID(ServiceInfo serviceInfo) { + var haId = serviceInfo.getPropertyString(PROPERTY_ID); + var type = serviceInfo.getPropertyString(PROPERTY_TYPE); + var mac = serviceInfo.getPropertyString(PROPERTY_MAC); + var vib = serviceInfo.getPropertyString(PROPERTY_VIB); + var brand = serviceInfo.getPropertyString(PROPERTY_BRAND); + + if (!isBlank(haId) && !isBlank(brand) && !isBlank(type) && !isBlank(mac) && !isBlank(vib)) { + var idProposal = brand.toLowerCase(LOCALE) + "-" + vib.toLowerCase(LOCALE) + "-" + mac.toLowerCase(LOCALE); + return new ThingUID(mapType(type), idProposal); + } else { + return null; + } + } + + private boolean isAlreadyRegisteredThing(String haId) { + return thingRegistry.stream().filter(thing -> SUPPORTED_THING_TYPES.contains(thing.getThingTypeUID())) + .filter(thing -> thing.getConfiguration().containsKey(PROPERTY_HOME_APPLIANCE_ID)) + .anyMatch(thing -> equalsIgnoreCase( + String.valueOf(thing.getConfiguration().get(PROPERTY_HOME_APPLIANCE_ID)), haId)); + } + + private ThingTypeUID mapType(String type) { + if (equalsIgnoreCase(type, APPLIANCE_TYPE_WASHER)) { + return THING_TYPE_WASHER; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_DRYER)) { + return THING_TYPE_DRYER; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_WASHER_AND_DRYER)) { + return THING_TYPE_WASHER_DRYER; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_DISHWASHER)) { + return THING_TYPE_DISHWASHER; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_COFFEE_MAKER)) { + return THING_TYPE_COFFEE_MAKER; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_COOK_PROCESSOR)) { + return THING_TYPE_COOK_PROCESSOR; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_OVEN)) { + return THING_TYPE_OVEN; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_WARMING_DRAWER)) { + return THING_TYPE_WARMING_DRAWER; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_HOOD)) { + return THING_TYPE_HOOD; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_COOKTOP_ALTERNATIVE) + || equalsIgnoreCase(type, APPLIANCE_TYPE_COOKTOP)) { + return THING_TYPE_COOKTOP; + } else if (equalsIgnoreCase(type, APPLIANCE_TYPE_FRIDGE_FREEZER)) { + return THING_TYPE_FRIDGE_FREEZER; + } else { + return THING_TYPE_GENERIC; + } + } + + private String getLabel(ThingTypeUID thingTypeUID, String brand) { + if (THING_TYPE_WASHER.equals(thingTypeUID)) { + return "@text/appliance.washer.label [\"" + brand + "\"]"; + } else if (THING_TYPE_DRYER.equals(thingTypeUID)) { + return "@text/appliance.dryer.label [\"" + brand + "\"]"; + } else if (THING_TYPE_WASHER_DRYER.equals(thingTypeUID)) { + return "@text/appliance.washerdryer.label [\"" + brand + "\"]"; + } else if (THING_TYPE_DISHWASHER.equals(thingTypeUID)) { + return "@text/appliance.dishwasher.label [\"" + brand + "\"]"; + } else if (THING_TYPE_COFFEE_MAKER.equals(thingTypeUID)) { + return "@text/appliance.coffeemaker.label [\"" + brand + "\"]"; + } else if (THING_TYPE_COOK_PROCESSOR.equals(thingTypeUID)) { + return "@text/appliance.cookprocessor.label [\"" + brand + "\"]"; + } else if (THING_TYPE_OVEN.equals(thingTypeUID)) { + return "@text/appliance.oven.label [\"" + brand + "\"]"; + } else if (THING_TYPE_WARMING_DRAWER.equals(thingTypeUID)) { + return "@text/appliance.warmingdrawer.label [\"" + brand + "\"]"; + } else if (THING_TYPE_HOOD.equals(thingTypeUID)) { + return "@text/appliance.hood.label [\"" + brand + "\"]"; + } else if (THING_TYPE_COOKTOP.equals(thingTypeUID)) { + return "@text/appliance.cooktop.label [\"" + brand + "\"]"; + } else if (THING_TYPE_FRIDGE_FREEZER.equals(thingTypeUID)) { + return "@text/appliance.fridgefreezer.label [\"" + brand + "\"]"; + } else { + return "@text/appliance.generic.label [\"" + brand + "\"]"; + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/factory/HomeConnectDirectHandlerFactory.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/factory/HomeConnectDirectHandlerFactory.java new file mode 100644 index 0000000000..a1a37fa3d3 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/factory/HomeConnectDirectHandlerFactory.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.factory; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.ConfigurationUtils.getDeviceId; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.utils.ConfigurationUtils; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.handler.BaseHomeConnectDirectHandler; +import org.openhab.binding.homeconnectdirect.internal.handler.HomeConnectDirectCoffeeMakerHandler; +import org.openhab.binding.homeconnectdirect.internal.handler.HomeConnectDirectCooktopHandler; +import org.openhab.binding.homeconnectdirect.internal.handler.HomeConnectDirectDishwasherHandler; +import org.openhab.binding.homeconnectdirect.internal.handler.HomeConnectDirectFridgeFreezerHandler; +import org.openhab.binding.homeconnectdirect.internal.handler.HomeConnectDirectHoodHandler; +import org.openhab.binding.homeconnectdirect.internal.handler.HomeConnectDirectOvenHandler; +import org.openhab.binding.homeconnectdirect.internal.handler.HomeConnectDirectWasherDryerHandler; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicCommandDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicStateDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.cm.ConfigurationAdmin; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link HomeConnectDirectHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = CONFIGURATION_PID, service = ThingHandlerFactory.class) +public class HomeConnectDirectHandlerFactory extends BaseThingHandlerFactory { + + private final ApplianceProfileService applianceProfileService; + private final HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider; + private final HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider; + private final HomeConnectDirectConfiguration configuration; + private final HomeConnectDirectTranslationProvider translationProvider; + + @Activate + public HomeConnectDirectHandlerFactory(@Reference ApplianceProfileService applianceProfileService, + @Reference HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider, + @Reference HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider, + @Reference ConfigurationAdmin configurationAdmin, @Reference TranslationProvider translationProvider, + @Reference LocaleProvider localeProvider) { + this.applianceProfileService = applianceProfileService; + this.stateDescriptionProvider = stateDescriptionProvider; + this.commandDescriptionProvider = commandDescriptionProvider; + this.configuration = ConfigurationUtils.getConfiguration(configurationAdmin); + this.translationProvider = new HomeConnectDirectTranslationProvider(translationProvider, localeProvider); + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + var deviceId = getDeviceId(); + + if (THING_TYPE_WASHER.equals(thingTypeUID) || THING_TYPE_WASHER_DRYER.equals(thingTypeUID) + || THING_TYPE_DRYER.equals(thingTypeUID)) { + return new HomeConnectDirectWasherDryerHandler(thing, applianceProfileService, commandDescriptionProvider, + stateDescriptionProvider, deviceId, configuration, translationProvider); + } else if (THING_TYPE_DISHWASHER.equals(thingTypeUID)) { + return new HomeConnectDirectDishwasherHandler(thing, applianceProfileService, commandDescriptionProvider, + stateDescriptionProvider, deviceId, configuration, translationProvider); + } else if (THING_TYPE_COFFEE_MAKER.equals(thingTypeUID)) { + return new HomeConnectDirectCoffeeMakerHandler(thing, applianceProfileService, commandDescriptionProvider, + stateDescriptionProvider, deviceId, configuration, translationProvider); + } else if (THING_TYPE_OVEN.equals(thingTypeUID) || THING_TYPE_WARMING_DRAWER.equals(thingTypeUID)) { + return new HomeConnectDirectOvenHandler(thing, applianceProfileService, commandDescriptionProvider, + stateDescriptionProvider, deviceId, configuration, translationProvider); + } else if (THING_TYPE_HOOD.equals(thingTypeUID)) { + return new HomeConnectDirectHoodHandler(thing, applianceProfileService, commandDescriptionProvider, + stateDescriptionProvider, deviceId, configuration, translationProvider); + } else if (THING_TYPE_COOKTOP.equals(thingTypeUID)) { + return new HomeConnectDirectCooktopHandler(thing, applianceProfileService, commandDescriptionProvider, + stateDescriptionProvider, deviceId, configuration, translationProvider); + } else if (THING_TYPE_FRIDGE_FREEZER.equals(thingTypeUID)) { + return new HomeConnectDirectFridgeFreezerHandler(thing, applianceProfileService, commandDescriptionProvider, + stateDescriptionProvider, deviceId, configuration, translationProvider); + } else { + return new BaseHomeConnectDirectHandler(thing, applianceProfileService, commandDescriptionProvider, + stateDescriptionProvider, deviceId, configuration, translationProvider); + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/BaseHomeConnectDirectHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/BaseHomeConnectDirectHandler.java new file mode 100644 index 0000000000..5dd955c9e5 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/BaseHomeConnectDirectHandler.java @@ -0,0 +1,1444 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.OSUtils.*; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.mapKeyToLabel; +import static org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action.*; +import static org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource.*; +import static org.openhab.core.library.unit.Units.*; + +import java.math.BigDecimal; +import java.net.URI; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.measure.Unit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.LimitedSizeList; +import org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils; +import org.openhab.binding.homeconnectdirect.internal.common.xml.exception.ParseException; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectApplianceConfiguration; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.handler.model.ApplianceMessage; +import org.openhab.binding.homeconnectdirect.internal.handler.model.MessageType; +import org.openhab.binding.homeconnectdirect.internal.handler.model.SendMessageRequest; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicCommandDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicStateDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.DeviceDescriptionService; +import org.openhab.binding.homeconnectdirect.internal.service.description.DeviceDescriptionUtils; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ContentType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.RangeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.feature.FeatureMappingService; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.profile.model.AesCredentials; +import org.openhab.binding.homeconnectdirect.internal.service.profile.model.TlsCredentials; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.WebSocketAesClientService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.WebSocketClientService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.WebSocketHandler; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.WebSocketTlsConscryptClientService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.exception.WebSocketClientServiceException; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Message; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ApplianceInfoData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.DescriptionChangeData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.DeviceData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.FirstMessageIdData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ProgramData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ServiceData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ValueData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.serializer.ResourceAdapter; +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.QuantityType; +import org.openhab.core.library.types.StringType; +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.builder.ChannelBuilder; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.CommandOption; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.openhab.core.types.StateDescriptionFragmentBuilder; +import org.openhab.core.types.StateOption; +import org.openhab.core.types.UnDefType; +import org.openhab.core.types.util.UnitUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonSyntaxException; +import com.google.gson.ToNumberPolicy; + +/** + * The {@link BaseHomeConnectDirectHandler} is the base handler for all Home Connect Direct appliance handlers. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class BaseHomeConnectDirectHandler extends BaseThingHandler implements WebSocketHandler { + private static final Pattern FAVORITE_NAME_PATTERN = Pattern + .compile("^BSH\\.Common\\.Setting\\.Favorite\\.(\\d{3})\\.Name$"); + + private final ApplianceProfileService applianceProfileService; + private final Logger logger; + private final Gson gson; + private final List services; + private final SecureRandom secureRandom; + private final AtomicBoolean disposeInitialized; + private final LimitedSizeList applianceMessages; + private final List> applianceMessageConsumers; + private final HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider; + private final HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider; + private final HomeConnectDirectTranslationProvider translationProvider; + private final String deviceId; + private final ConcurrentHashMap keyValueStore; + private final ConcurrentHashMap favoriteNames; + + private @Nullable ScheduledFuture reconnectFuture; + private @Nullable ScheduledFuture updateValuesFuture; + private @Nullable WebSocketClientService webSocketClientService; + private @Nullable HomeConnectDirectApplianceConfiguration configuration; + private @Nullable DeviceDescriptionService deviceDescriptionService; + private @Nullable FeatureMappingService featureMappingService; + private @Nullable String connectionError; + private long outgoingMessageId; + private long sessionId; + private long lastRefreshExecutionTime; + + public BaseHomeConnectDirectHandler(Thing thing, ApplianceProfileService applianceProfileService, + HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider, + HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider, String deviceId, + HomeConnectDirectConfiguration configuration, HomeConnectDirectTranslationProvider translationProvider) { + super(thing); + + this.applianceProfileService = applianceProfileService; + this.logger = LoggerFactory.getLogger(BaseHomeConnectDirectHandler.class); + this.gson = new GsonBuilder().registerTypeAdapter(Resource.class, new ResourceAdapter()) + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create(); + this.secureRandom = new SecureRandom(); + this.services = new ArrayList<>(); + this.disposeInitialized = new AtomicBoolean(false); + this.applianceMessages = new LimitedSizeList<>(configuration.messageQueueSize); + this.applianceMessageConsumers = new CopyOnWriteArrayList<>(); + this.stateDescriptionProvider = stateDescriptionProvider; + this.commandDescriptionProvider = commandDescriptionProvider; + this.translationProvider = translationProvider; + this.deviceId = deviceId; + this.lastRefreshExecutionTime = 0; + this.keyValueStore = new ConcurrentHashMap<>(); + this.favoriteNames = new ConcurrentHashMap<>(); + } + + @Override + public void initialize() { + connectionError = null; + disposeInitialized.set(false); + services.clear(); + var configuration = getConfigAs(HomeConnectDirectApplianceConfiguration.class); + this.configuration = configuration; + + // check thing configuration + if (StringUtils.isBlank(configuration.address) || StringUtils.isBlank(configuration.haId)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/offline.config-error"); + return; + } + + // check and get appliance profile + var profile = applianceProfileService.getProfile(configuration.haId); + if (profile == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, + "@text/offline.profile-pending"); + return; + } + + updateStatus(ThingStatus.UNKNOWN); + + scheduler.execute(() -> { + // initialize deviceDescription and featureMapping service + try { + var featureMappingService = new FeatureMappingService(Path.of(BINDING_PROFILES_PATH, profile.featureMappingFileName())); + this.deviceDescriptionService = new DeviceDescriptionService(thing.getUID().getId(), Path.of(BINDING_PROFILES_PATH, profile.deviceDescriptionFileName()), featureMappingService.getFeatureMapping()); + this.featureMappingService = featureMappingService; + } catch (ParseException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.profile-parse-error [\"" + e.getMessage() + "\"]"); + scheduleReconnect(); + return; + } + + initializeAllStates(); + initializeStarted(); + + try { + if (profile.credentials() instanceof AesCredentials(String key, String iv)) { + URI uri = URI.create(WS_AES_URI_TEMPLATE.formatted(configuration.address)); + var webSocketClientService = new WebSocketAesClientService(getThing(), uri, key, iv, this, + scheduler); + this.webSocketClientService = webSocketClientService; + webSocketClientService.connect(); + } else if (profile.credentials() instanceof TlsCredentials(String key)) { + try { + URI uri = URI.create(WS_TLS_URI_TEMPLATE.formatted(configuration.address)); + var webSocketClientService = new WebSocketTlsConscryptClientService(getThing(), uri, + key, this, scheduler); + this.webSocketClientService = webSocketClientService; + webSocketClientService.connect(); + } catch (Error e) { + if (isUnsatisfiedLinkError(e) && isLinux()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.DISABLED, + "@text/offline.tls-unsupported-glibc [\"" + getOSName() + "\", \"" + getOSArch() + + "\", \"" + CONSCRYPT_REQUIRED_GLIBC_MIN_VERSION + "\"]"); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.DISABLED, + "@text/offline.tls-unsupported [\"" + getOSName() + "\", \"" + getOSArch() + "\"]"); + } + logger.error("Could not initialize {}!", WebSocketTlsConscryptClientService.class.getName(), e); + } + } + } catch (WebSocketClientServiceException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + scheduleReconnect(); + stopUpdateAllValuesFuture(); + } + initializeFinished(); + }); + + } + + protected void initializeStarted() { + // allow child's to overwrite + } + + protected void initializeFinished() { + // allow child's to overwrite + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + // only refresh once every 2 seconds + synchronized (this) { + long currentTime = System.currentTimeMillis(); + if (currentTime - lastRefreshExecutionTime >= 2000) { + lastRefreshExecutionTime = currentTime; + sendGet(RO_ALL_MANDATORY_VALUES); + } + } + } else if (CHANNEL_COMMAND.equals(channelUID.getId()) && command instanceof StringType) { + sendBooleanCommandIfAllowed(command.toFullString()); + } else if (CHANNEL_RAW_MESSAGE.equals(channelUID.getId()) && command instanceof StringType) { + try { + var request = gson.fromJson(command.toFullString(), SendMessageRequest.class); + if (request != null) { + send(request); + } + } catch (JsonSyntaxException e) { + logger.warn("Could not send raw message. Invalid JSON syntax: {}", e.getMessage()); + } + } else if (CHANNEL_POWER_STATE.equals(channelUID.getId()) && command instanceof OnOffType) { + mapSettingKey(POWER_STATE_KEY).ifPresent(settingUid -> { + Optional value; + if (OnOffType.ON.equals(command)) { + value = mapEnumerationValueKey(POWER_STATE_ENUM_KEY, STATE_ON); + } else { + value = mapEnumerationValueKey(POWER_STATE_ENUM_KEY, STATE_OFF) + .or(() -> mapEnumerationValueKey(POWER_STATE_ENUM_KEY, STATE_MAINS_OFF)) + .or(() -> mapEnumerationValueKey(POWER_STATE_ENUM_KEY, STATE_STANDBY)); + } + + value.ifPresent( + integer -> send(Action.POST, RO_VALUES, List.of(new ValueData(settingUid, integer)), null, 1)); + }); + } else if (CHANNEL_CHILD_LOCK.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanSettingIfAllowed(command, CHILD_LOCK_KEY); + } else if (CHANNEL_PROGRAM_COMMAND.equals(channelUID.getId()) && command instanceof StringType) { + if (COMMAND_START.equalsIgnoreCase(command.toFullString())) { + var selectedProgram = keyValueStore.get(SELECTED_PROGRAM_KEY); + if (selectedProgram != null) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.getActiveProgram(true) != null) { + mapProgramKey(selectedProgram).ifPresent(programUid -> send(Action.POST, RO_ACTIVE_PROGRAM, + List.of(new ProgramData(programUid, null)), null, 1)); + } else { + logger.warn("The '{}' control is either unavailable or in read-only mode. Cannot start program.", ACTIVE_PROGRAM_KEY); + } + }); + + } + } else if (COMMAND_PAUSE.equalsIgnoreCase(command.toFullString())) { + sendBooleanCommandIfAllowed(PAUSE_PROGRAM_KEY); + } else if (COMMAND_RESUME.equalsIgnoreCase(command.toFullString())) { + sendBooleanCommandIfAllowed(RESUME_PROGRAM_KEY); + } + } else if (CHANNEL_SELECTED_PROGRAM.equals(channelUID.getId()) && command instanceof StringType) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescription -> { + if (deviceDescription.getSelectedProgram(true) != null) { + mapProgramKey(command.toFullString()).ifPresent(selectedProgramUid -> send(Action.POST, + RO_SELECTED_PROGRAM, List.of(new ProgramData(selectedProgramUid, null)), null, 1)); + } else { + logger.warn("The '{}' control is either unavailable or in read-only mode. Cannot change selected program.", SELECTED_PROGRAM_KEY); + } + }); + } else { + // custom channels + getLinkedChannel(channelUID.getId()) + .filter(channel -> CHANNEL_TYPE_SWITCH_VALUE.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_STRING_VALUE.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_NUMBER_VALUE.equals(channel.getChannelTypeUID())) + .filter(channel -> channel.getConfiguration().containsKey(CONFIGURATION_VALUE_KEY)) + .ifPresent(channel -> { + var valueKey = channel.getConfiguration().get(CONFIGURATION_VALUE_KEY).toString(); + + var deviceDescriptionService = getDeviceDescriptionService(); + if (deviceDescriptionService != null) { + boolean isWritable = deviceDescriptionService.isSettingAvailableAndWritable(valueKey) + || deviceDescriptionService.isOptionAvailableAndWritable(valueKey) + || deviceDescriptionService.isCommandAvailableAndWritable(valueKey); + + if (!isWritable) { + logger.warn("The custom channel '{}' with key '{}' is either unavailable or in read-only mode. Command '{}' cannot be processed.", + channelUID.getId(), valueKey, command.toFullString()); + return; + } + } + + mapKey(valueKey).ifPresent(uid -> { + switch (command) { + case OnOffType onOffTypeCommand when CHANNEL_TYPE_SWITCH_VALUE.equals(channel.getChannelTypeUID()) -> + send(Action.POST, RO_VALUES, List.of(new ValueData(uid, OnOffType.ON.equals(onOffTypeCommand))), + null, 1); + case StringType stringTypeCommand when CHANNEL_TYPE_STRING_VALUE.equals(channel.getChannelTypeUID()) -> + mapEnumerationValueKeyToValue(uid, stringTypeCommand.toFullString()).ifPresentOrElse( + enumerationValue -> send(Action.POST, RO_VALUES, + List.of(new ValueData(uid, enumerationValue)), null, 1), + () -> send(Action.POST, RO_VALUES, + List.of(new ValueData(uid, command.toFullString())), null, 1)); + case Number numberCommand when CHANNEL_TYPE_NUMBER_VALUE.equals(channel.getChannelTypeUID()) -> { + Unit unit = null; + if (channel.getConfiguration() + .get(CONFIGURATION_UNIT_KEY) instanceof String unitConfiguration) { + unit = UnitUtils.parseUnit(unitConfiguration); + } + if (PERCENT.equals(unit)) { + double value = numberCommand.doubleValue(); + + // percent value + if (value > 0 && value < 1) { + value *= 100; + } + send(Action.POST, RO_VALUES, List.of(new ValueData(uid, (int) value)), null, 1); + } else { + send(Action.POST, RO_VALUES, List.of(new ValueData(uid, numberCommand.intValue())), null, + 1); + } + } + default -> { + // noop + } + } + }); + }); + } + } + + @Override + public void channelLinked(ChannelUID channelUID) { + super.channelLinked(channelUID); + initializeState(channelUID.getId()); + } + + @Override + public void dispose() { + disposeInitialized.set(true); + var webSocketClientService = this.webSocketClientService; + if (webSocketClientService != null) { + webSocketClientService.dispose(); + } + stopReconnectSchedule(); + stopUpdateAllValuesFuture(); + applianceMessageConsumers.clear(); + } + + @Override + public void onWebSocketConnect() { + updateStatus(ThingStatus.ONLINE); + logger.debug("WebSocket connection opened (thingUID={}).", thing.getUID()); + } + + @Override + public void onWebSocketMessage(String rawMessage, WebSocketClientService websocketClientService) { + Message message = gson.fromJson(rawMessage, Message.class); + + if (message != null) { + switch (message.action()) { + case POST -> { + if (EI_INITIAL_VALUES.equals(message.resource())) { + var firstMessageIdData = message.getDataAsList(FirstMessageIdData.class); + if (firstMessageIdData != null && firstMessageIdData.size() == 1) { + sessionId = message.sessionId(); + outgoingMessageId = firstMessageIdData.getFirst().messageId(); + + // reply + var deviceType = message.version() == 1 ? WS_DEVICE_TYPE_APPLICATION_V1 + : WS_DEVICE_TYPE_APPLICATION_V2; + var data = new DeviceData(deviceType, WS_DEVICE_NAME, deviceId); + send(RESPONSE, message.resource(), List.of(data), message.messageId(), message.version()); + + // get services + sendGet(CI_SERVICES); + } + } else { + logger.warn("Unknown resource! message={} thingUID={}", message, thing.getUID()); + } + } + case RESPONSE, NOTIFY -> { + if (CI_SERVICES.equals(message.resource())) { + services.clear(); + + if (message.code() == null) { + var serviceData = message.getDataAsList(ServiceData.class); + if (serviceData != null) { + services.addAll(Objects.requireNonNull(serviceData)); + } + } + + // authenticate (needed by washer) + sendGet(CI_AUTHENTICATION, List.of(Map.of("nonce", generateNonce()))); + + // needed by some services + sendNotify(EI_DEVICE_READY); + + // get device info + services.forEach(s -> { + switch (s.service()) { + case CI -> { + sendGet(CI_INFO); + sendGet(CI_TZ_INFO); + } + case IZ -> sendGet(IZ_INFO); + case NI -> sendGet(NI_INFO); + } + }); + + // get appliance info + sendGet(RO_ALL_MANDATORY_VALUES); + sendGet(RO_ALL_DESCRIPTION_CHANGES); + } else if (message.code() != null) { + logger.trace("Received message: resource={} code={} thingUID={}", message.resource(), + message.code(), thing.getUID()); + } else if (IZ_INFO.equals(message.resource()) || CI_INFO.equals(message.resource())) { + if (logger.isDebugEnabled()) { + var applianceInfoData = message.getDataAsList(ApplianceInfoData.class); + if (applianceInfoData != null) { + applianceInfoData.forEach(applianceInfo -> logger.debug( + "Received appliance info: {} (thingUID={})", applianceInfo, thing.getUID())); + } + } + } else if (RO_ALL_MANDATORY_VALUES.equals(message.resource()) + || RO_VALUES.equals(message.resource())) { + if (logger.isDebugEnabled()) { + var valueData = message.getDataAsList(ValueData.class); + if (valueData != null) { + logger.debug("Received appliance value update: {} (thingUID={})", valueData, + thing.getUID()); + } + } + scheduleUpdateAllValuesFuture(); + } else if (RO_ALL_DESCRIPTION_CHANGES.equals(message.resource()) + || RO_DESCRIPTION_CHANGE.equals(message.resource())) { + if (logger.isDebugEnabled()) { + var valueData = message.getDataAsList(DescriptionChangeData.class); + if (valueData != null) { + logger.debug("Received appliance description change: {} (thingUID={})", valueData, + thing.getUID()); + } + } + scheduleUpdateAllValuesFuture(); + } + } + case GET -> logger.trace("Received message: {} ({})", message, thing.getUID()); + } + + // parse value and device description change messages + var applianceMessage = mapApplianceMessage(message, true); + applianceMessages.add(applianceMessage); + applianceMessageConsumers.forEach(consumer -> consumer.accept(applianceMessage)); + var values = applianceMessage.values(); + if (values != null) { + values.forEach(value -> onApplianceValueEvent(value, message.resource())); + } + var deviceDescriptionChanges = applianceMessage.descriptionChanges(); + if (deviceDescriptionChanges != null) { + onApplianceDescriptionChangeEvent(deviceDescriptionChanges); + } + } + } + + @Override + public void onWebSocketClose() { + logger.debug("WebSocket closed (thingUID={})!", thing.getUID()); + stopUpdateAllValuesFuture(); + + var connectionError = this.connectionError; + if (connectionError != null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, connectionError); + } else { + updateStatus(ThingStatus.OFFLINE); + } + + // dispose websocket + var webSocketClientService = this.webSocketClientService; + if (webSocketClientService != null) { + scheduler.schedule(webSocketClientService::dispose, 2, TimeUnit.SECONDS); + this.webSocketClientService = null; + } + + scheduleReconnect(); + } + + @Override + public void onWebSocketError(Throwable throwable) { + logger.debug("WebSocket error: {} (thingUID={})", throwable.getMessage(), thing.getUID()); + this.connectionError = throwable.getMessage(); + } + + public List getApplianceMessages() { + return applianceMessages.getAllElements(); + } + + public @Nullable DeviceDescriptionService getDeviceDescriptionService() { + return deviceDescriptionService; + } + + public Optional getDeviceDescriptionServiceOptional() { + return Optional.ofNullable(deviceDescriptionService); + } + + public void registerApplianceMessageListener(Consumer consumer) { + applianceMessageConsumers.add(consumer); + } + + public void removeApplianceMessageListener(Consumer consumer) { + applianceMessageConsumers.remove(consumer); + } + + protected void onApplianceDescriptionChangeEvent(List deviceDescriptionChanges) { + deviceDescriptionChanges.forEach(deviceDescriptionChange -> { + // check for command changes + if (DeviceDescriptionType.COMMAND.equals(deviceDescriptionChange.type()) + || DeviceDescriptionType.COMMAND_LIST.equals(deviceDescriptionChange.type())) { + updateCommandDescriptions(); + } else if (DeviceDescriptionType.SELECTED_PROGRAM.equals(deviceDescriptionChange.type())) { + updateSelectedProgramDescription(); + } else if (DeviceDescriptionType.ACTIVE_PROGRAM.equals(deviceDescriptionChange.type())) { + updateActiveProgramDescription(); + } else if (OPERATION_STATE_KEY.equals(deviceDescriptionChange.key())) { + updateStatusDescriptionIfLinked(CHANNEL_OPERATION_STATE, OPERATION_STATE_KEY); + } else if (PROGRAM_PROGRESS_KEY.equals(deviceDescriptionChange.key())) { + getLinkedChannel(CHANNEL_PROGRAM_PROGRESS).ifPresent( + channel -> getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isOptionAvailableAndReadable(CHANNEL_PROGRAM_PROGRESS)) { + updateState(channel.getUID(), new QuantityType<>(0, PERCENT)); + } + })); + } else if (REMAINING_PROGRAM_TIME_KEY.equals(deviceDescriptionChange.key())) { + getLinkedChannel(CHANNEL_REMAINING_PROGRAM_TIME).ifPresent( + channel -> getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (!deviceDescriptionService.isOptionAvailableAndReadable(REMAINING_PROGRAM_TIME_KEY)) { + updateState(channel.getUID(), new QuantityType<>(0, SECOND)); + } + })); + } + + // update custom device description channels + getThing().getChannels().stream() + .filter(channel -> CHANNEL_TYPE_SWITCH_DESCRIPTION.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_STRING_DESCRIPTION.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_NUMBER_DESCRIPTION.equals(channel.getChannelTypeUID())) + .filter(channel -> isLinked(channel.getUID())).forEach(channel -> { + var configuration = channel.getConfiguration(); + var descriptionKeyConfiguration = configuration.get(CONFIGURATION_DESCRIPTION_KEY); + var attributeConfiguration = configuration.get(CONFIGURATION_ATTRIBUTE); + + if (descriptionKeyConfiguration instanceof String key + && attributeConfiguration instanceof String attribute + && key.equals(deviceDescriptionChange.key())) { + var changes = deviceDescriptionChange.changes(); + if (changes != null && changes.containsKey(attribute)) { + var change = changes.get(attribute); + var newValue = change == null ? null : change.to(); + + if (newValue == null) { + updateState(channel.getUID(), UnDefType.UNDEF); + } else if (CHANNEL_TYPE_SWITCH_DESCRIPTION.equals(channel.getChannelTypeUID())) { + if (newValue instanceof Boolean b) { + updateState(channel.getUID(), OnOffType.from(b)); + } + } else if (CHANNEL_TYPE_STRING_DESCRIPTION.equals(channel.getChannelTypeUID())) { + updateState(channel.getUID(), StringType.valueOf(newValue.toString())); + } else if (CHANNEL_TYPE_NUMBER_DESCRIPTION.equals(channel.getChannelTypeUID())) { + updateState(channel.getUID(), new DecimalType(newValue.toString())); + } + } + } + }); + }); + } + + protected void onApplianceValueEvent(Value value, Resource resource) { + switch (value.key()) { + case POWER_STATE_KEY -> updateStateIfLinked(CHANNEL_POWER_STATE, + () -> OnOffType.from(STATE_ON.equalsIgnoreCase(value.getValueAsString()))); + case DOOR_STATE_KEY -> updateStateIfLinked(CHANNEL_DOOR, + () -> STATE_OPEN.equals(value.value()) || STATE_AJAR.equals(value.value()) ? OpenClosedType.OPEN + : OpenClosedType.CLOSED); + case OPERATION_STATE_KEY -> { + var oldOperationState = keyValueStore.get(OPERATION_STATE_KEY); + var newOperationState = value.getValueAsString(); + keyValueStore.put(OPERATION_STATE_KEY, newOperationState); + + if (STATE_FINISHED.equals(oldOperationState) && !STATE_FINISHED.equals(newOperationState)) { + getLinkedChannel(CHANNEL_PROGRAM_PROGRESS) + .ifPresent(channel -> updateState(channel.getUID(), new QuantityType<>(0, PERCENT))); + } + + if (STATE_FINISHED.equals(value.getValueAsString())) { + getLinkedChannel(CHANNEL_PROGRAM_PROGRESS) + .ifPresent(channel -> updateState(channel.getUID(), new QuantityType<>(100, PERCENT))); + getLinkedChannel(CHANNEL_REMAINING_PROGRAM_TIME) + .ifPresent(channel -> updateState(channel.getUID(), new QuantityType<>(0, SECOND))); + } + getLinkedChannel(CHANNEL_OPERATION_STATE) + .ifPresent(channel -> updateState(channel.getUID(), new StringType(value.getValueAsString()))); + } + case REMOTE_CONTROL_START_ALLOWED_KEY -> updateStateIfLinked(CHANNEL_REMOTE_CONTROL_OR_START_ALLOWED, + () -> OnOffType.from(value.getValueAsBoolean())); + case SELECTED_PROGRAM_KEY -> { + keyValueStore.put(SELECTED_PROGRAM_KEY, value.getValueAsString()); + updateStateIfLinked(CHANNEL_SELECTED_PROGRAM, + () -> STATE_NO_PROGRAM.equals(value.getValueAsString()) ? UnDefType.UNDEF + : new StringType(value.getValueAsString())); + } + case ACTIVE_PROGRAM_KEY -> { + keyValueStore.put(ACTIVE_PROGRAM_KEY, value.getValueAsString()); + getLinkedChannel(CHANNEL_ACTIVE_PROGRAM).ifPresent(channel -> { + if (STATE_NO_PROGRAM.equals(value.getValueAsString())) { + updateState(channel.getUID(), UnDefType.UNDEF); + } else { + updateState(channel.getUID(), new StringType(value.getValueAsString())); + } + }); + } + case REMAINING_PROGRAM_TIME_KEY -> updateStateIfLinked(CHANNEL_REMAINING_PROGRAM_TIME, + () -> new QuantityType<>(value.getValueAsInt(), SECOND)); + case PROGRAM_PROGRESS_KEY -> + updateStateIfLinked(CHANNEL_PROGRAM_PROGRESS, () -> new QuantityType<>(value.getValueAsInt(), PERCENT)); + case CHILD_LOCK_KEY -> + updateStateIfLinked(CHANNEL_CHILD_LOCK, () -> OnOffType.from(value.getValueAsBoolean())); + } + + // fetch favorite program name + var matcher = FAVORITE_NAME_PATTERN.matcher(value.key()); + if (matcher.matches()) { + var favoriteNumber = Integer.parseInt(matcher.group(1)); + var favoriteKey = String.format(PROGRAM_FAVORITE_KEY_TEMPLATE, favoriteNumber); + var favoriteName = value.getValueAsString(); + if (!StringUtils.isBlank(favoriteName)) { + favoriteNames.put(favoriteKey, favoriteName); + } else { + favoriteNames.remove(favoriteKey); + } + } + + // update custom value channels + getThing().getChannels().stream() + .filter(channel -> CHANNEL_TYPE_SWITCH_VALUE.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_STRING_VALUE.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_NUMBER_VALUE.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_TRIGGER_VALUE.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_ENUM_SWITCH_VALUE.equals(channel.getChannelTypeUID())) + .filter(channel -> channel.getConfiguration().containsKey(CONFIGURATION_VALUE_KEY)) + .filter(channel -> value.key().equals(channel.getConfiguration().get(CONFIGURATION_VALUE_KEY))) + .filter(channel -> CHANNEL_TYPE_TRIGGER_VALUE.equals(channel.getChannelTypeUID()) + || isLinked(channel.getUID())) + .forEach(channel -> { + if (CHANNEL_TYPE_SWITCH_VALUE.equals(channel.getChannelTypeUID())) { + updateState(channel.getUID(), OnOffType.from(value.getValueAsBoolean())); + } else if (CHANNEL_TYPE_STRING_VALUE.equals(channel.getChannelTypeUID())) { + updateState(channel.getUID(), StringType.valueOf(value.getValueAsString())); + } else if (CHANNEL_TYPE_NUMBER_VALUE.equals(channel.getChannelTypeUID())) { + Unit unit = null; + if (channel.getConfiguration() + .get(CONFIGURATION_UNIT_KEY) instanceof String unitConfiguration) { + unit = UnitUtils.parseUnit(unitConfiguration); + } + if (unit != null) { + updateState(channel.getUID(), + new QuantityType<>(new BigDecimal(value.getValueAsString()), unit)); + } else { + updateState(channel.getUID(), new DecimalType(value.getValueAsString())); + } + } else if (CHANNEL_TYPE_TRIGGER_VALUE.equals(channel.getChannelTypeUID()) + && RO_VALUES.equals(resource)) { + triggerChannel(channel.getUID(), value.getValueAsString()); + } else if (CHANNEL_TYPE_ENUM_SWITCH_VALUE.equals(channel.getChannelTypeUID())) { + var onValueConfig = channel.getConfiguration().get(CONFIGURATION_ON_VALUE_KEY); + if (onValueConfig != null) { + var onValues = Arrays.stream(onValueConfig.toString().split(",")).map(String::trim) + .filter(v -> !StringUtils.isBlank(v)).collect(Collectors.toSet()); + updateState(channel.getUID(), OnOffType.from( + onValues.stream().anyMatch(v -> v.equalsIgnoreCase(value.getValueAsString())))); + } + } + }); + } + + protected Optional getLinkedChannel(String channelId) { + Channel channel = getThing().getChannel(channelId); + if (channel == null || !isLinked(channelId)) { + return Optional.empty(); + } else { + return Optional.of(channel); + } + } + + protected void updateStateIfLinked(String channelID, State state) { + getLinkedChannel(channelID).ifPresent(channel -> updateState(channel.getUID(), state)); + } + + protected void updateStateIfLinked(String channelID, Supplier stateSupplier) { + getLinkedChannel(channelID).ifPresent(channel -> updateState(channel.getUID(), stateSupplier.get())); + } + + public void sendGet(Resource resource) { + sendGet(resource, null); + } + + public void sendGet(Resource resource, @Nullable List data) { + send(GET, resource, data, null, null); + } + + public void sendNotify(Resource resource) { + send(NOTIFY, resource, null, null, null); + } + + public void send(SendMessageRequest request) { + List data = null; + var requestData = request.data(); + if (requestData != null) { + if (requestData instanceof List list) { + data = new ArrayList<>(list); + } else { + data = new ArrayList<>(); + data.add(requestData); + } + } + + send(request.action(), request.resource(), data, null, request.version()); + } + + public void send(Action action, Resource resource, @Nullable List data, @Nullable Long messageId, + @Nullable Integer versionObject) { + int version; + if (versionObject != null) { + version = versionObject; + } else { + var latestVersion = services.stream().filter(s -> s.service().equals(resource.service())).findFirst() + .map(ServiceData::version).orElse(null); + version = Objects.requireNonNullElse(latestVersion, 1); + } + + // special case GET services + if (GET.equals(action) && CI_SERVICES.equals(resource)) { + version = 1; + } + + long msgId = Objects.requireNonNullElseGet(messageId, () -> outgoingMessageId++); + + JsonArray dataList = null; + if (data != null) { + dataList = new JsonArray(); + data.stream().map(gson::toJsonTree).forEach(dataList::add); + } + var message = new Message(sessionId, msgId, resource, version, action, null, dataList); + var rawMessage = gson.toJson(message); + var webSocketClientService = this.webSocketClientService; + if (webSocketClientService != null) { + webSocketClientService.send(rawMessage); + } + + var applianceMessage = mapApplianceMessage(message, false); + applianceMessages.add(applianceMessage); + applianceMessageConsumers.forEach(consumer -> consumer.accept(applianceMessage)); + } + + protected void sendBooleanCommandIfAllowed(String commandKey) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isCommandAvailableAndWritable(commandKey)) { + mapCommandKey(commandKey).ifPresent( + commandUid -> send(Action.POST, RO_VALUES, List.of(new ValueData(commandUid, true)), null, 1)); + } else { + logger.info( + "The boolean command '{}' is either unavailable or in read-only mode. Command cannot be processed.", + commandKey); + } + }); + } + + protected void sendBooleanSettingIfAllowed(Command command, String settingKey) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isSettingAvailableAndWritable(settingKey)) { + mapSettingKey(settingKey).ifPresent(settingUid -> send(Action.POST, RO_VALUES, + List.of(new ValueData(settingUid, OnOffType.ON.equals(command))), null, 1)); + } else { + logger.info( + "The boolean setting '{}' is either unavailable or in read-only mode. Command '{}' cannot be processed.", + settingKey, command.toFullString()); + } + }); + } + + protected void sendBooleanOptionIfAllowed(Command command, String optionKey) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isOptionAvailableAndWritable(optionKey)) { + mapOptionKey(optionKey).ifPresent(optionUid -> send(Action.POST, RO_VALUES, + List.of(new ValueData(optionUid, OnOffType.ON.equals(command))), null, 1)); + } else { + logger.info( + "The boolean option '{}' is either unavailable or in read-only mode. Command '{}' cannot be processed.", + optionKey, command.toFullString()); + } + }); + } + + protected void sendIntegerOptionIfAllowed(QuantityType command, String optionKey) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isOptionAvailableAndWritable(optionKey)) { + mapOptionKey(optionKey).ifPresent(optionUid -> send(Action.POST, RO_VALUES, + List.of(new ValueData(optionUid, command.intValue())), null, 1)); + } else { + logger.info( + "The integer option '{}' is either unavailable or in read-only mode. Command '{}' cannot be processed.", + optionKey, command.toFullString()); + } + }); + } + + protected void sendIntegerSettingIfAllowed(QuantityType command, String settingKey) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isSettingAvailableAndWritable(settingKey)) { + mapSettingKey(settingKey).ifPresent(optionUid -> send(Action.POST, RO_VALUES, + List.of(new ValueData(optionUid, command.intValue())), null, 1)); + } else { + logger.info( + "The integer setting '{}' is either unavailable or in read-only mode. Command '{}' cannot be processed.", + settingKey, command.toFullString()); + } + }); + } + + protected void sendEnumSettingIfAllowed(Command command, String settingKey) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isSettingAvailableAndWritable(settingKey)) { + var setting = deviceDescriptionService.findSettingByKey(settingKey); + if (setting != null) { + var enumerationTypeKey = setting.enumerationTypeKey(); + if (enumerationTypeKey != null) { + mapEnumerationValueKey(enumerationTypeKey, command.toFullString()) + .ifPresent(enumValue -> send(Action.POST, RO_VALUES, + List.of(new ValueData(setting.uid(), enumValue)), null, 1)); + } + } + } else { + logger.info( + "The enumeration setting '{}' is either unavailable or in read-only mode. Command '{}' cannot be processed.", + settingKey, command.toFullString()); + } + }); + } + + protected void sendEnumOptionIfAllowed(Command command, String optionKey) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isOptionAvailableAndWritable(optionKey)) { + var option = deviceDescriptionService.findOptionByKey(optionKey); + if (option != null) { + var enumerationTypeKey = option.enumerationTypeKey(); + if (enumerationTypeKey != null) { + mapEnumerationValueKey(enumerationTypeKey, command.toFullString()) + .ifPresent(enumValue -> send(Action.POST, RO_VALUES, + List.of(new ValueData(option.uid(), enumValue)), null, 1)); + } + } + } else { + logger.info( + "The enumeration option '{}' is either unavailable or in read-only mode. Command '{}' cannot be processed.", + optionKey, command.toFullString()); + } + }); + } + + protected Optional mapStatusKey(String statusKey) { + return getDeviceDescriptionServiceOptional() + .map(deviceDescriptionService -> deviceDescriptionService.mapStatusKey(statusKey)); + } + + protected Optional mapSettingKey(String settingKey) { + return getDeviceDescriptionServiceOptional() + .map(deviceDescriptionService -> deviceDescriptionService.mapSettingKey(settingKey)); + } + + protected Optional mapEventKey(String eventKey) { + return getDeviceDescriptionServiceOptional() + .map(deviceDescriptionService -> deviceDescriptionService.mapEventKey(eventKey)); + } + + protected Optional mapCommandKey(String commandKey) { + return getDeviceDescriptionServiceOptional() + .map(deviceDescriptionService -> deviceDescriptionService.mapCommandKey(commandKey)); + } + + protected Optional mapOptionKey(String optionKey) { + return getDeviceDescriptionServiceOptional() + .map(deviceDescriptionService -> deviceDescriptionService.mapOptionKey(optionKey)); + } + + protected Optional mapProgramKey(String programKey) { + return getDeviceDescriptionServiceOptional() + .map(deviceDescriptionService -> deviceDescriptionService.mapProgramKey(programKey)); + } + + private Optional mapActiveProgramKey(String activeProgramKey) { + return getDeviceDescriptionServiceOptional() + .map(deviceDescriptionService -> deviceDescriptionService.mapActiveProgramKey(activeProgramKey)); + } + + private Optional mapSelectedProgramKey(String selectedProgramKey) { + return getDeviceDescriptionServiceOptional() + .map(deviceDescriptionService -> deviceDescriptionService.mapSelectedProgramKey(selectedProgramKey)); + } + + private Optional mapProtectionPortKey(String protectionPortKey) { + return getDeviceDescriptionServiceOptional() + .map(deviceDescriptionService -> deviceDescriptionService.mapProtectionPortKey(protectionPortKey)); + } + + protected Optional mapKey(String key) { + return mapStatusKey(key).or(() -> mapSettingKey(key)).or(() -> mapEventKey(key)).or(() -> mapCommandKey(key)) + .or(() -> mapOptionKey(key)).or(() -> mapProgramKey(key)).or(() -> mapActiveProgramKey(key)) + .or(() -> mapSelectedProgramKey(key)).or(() -> mapProtectionPortKey(key)); + } + + protected Optional mapEnumerationValueKeyToValue(int deviceDescriptionObjectUid, String valueKey) { + var deviceDescriptionService = this.deviceDescriptionService; + if (deviceDescriptionService == null) { + return Optional.empty(); + } + + var foundObject = deviceDescriptionService.getDeviceDescriptionObject(deviceDescriptionObjectUid); + if (foundObject == null) { + return Optional.empty(); + } + + if (foundObject.object() instanceof EnumerationTypeProvider enumerationTypeProvider) { + var enumerationTypeKey = enumerationTypeProvider.enumerationTypeKey(); + if (enumerationTypeKey != null) { + return Optional + .ofNullable(deviceDescriptionService.mapEnumerationValueKey(enumerationTypeKey, valueKey)); + } + } + return Optional.empty(); + } + + protected Optional mapEnumerationValueKey(String enumerationTypeKey, String valueKey) { + return getDeviceDescriptionServiceOptional().map(deviceDescriptionService -> deviceDescriptionService + .mapEnumerationValueKey(enumerationTypeKey, valueKey)); + } + + private String generateNonce() { + byte[] codeVerifier = new byte[32]; + secureRandom.nextBytes(codeVerifier); + return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier); + } + + private synchronized void scheduleReconnect() { + var reconnectFuture = this.reconnectFuture; + + if ((reconnectFuture == null || reconnectFuture.isCancelled() || reconnectFuture.isDone()) + && !disposeInitialized.get()) { + var configuration = this.configuration; + int delay = 1; + if (configuration != null) { + delay = configuration.connectionRetryDelay; + } + logger.trace("Schedule reconnect in {} minute(s) ({}).", delay, thing.getUID()); + this.reconnectFuture = scheduler.schedule(this::initialize, delay, TimeUnit.MINUTES); + } + } + + private synchronized void stopReconnectSchedule() { + ScheduledFuture reconnectFuture = this.reconnectFuture; + if (reconnectFuture != null) { + reconnectFuture.cancel(true); + } + } + + private synchronized void scheduleUpdateAllValuesFuture() { + var updateValuesFuture = this.updateValuesFuture; + + if ((updateValuesFuture == null || updateValuesFuture.isCancelled() || updateValuesFuture.isDone()) + && !disposeInitialized.get()) { + logger.trace("Schedule update all mandatory values in {} minute(s) - {} ({}).", + UPDATE_ALL_MANDATORY_VALUES_INTERVAL.toMinutes(), + LocalDateTime.now(ZONE_ID).plus(UPDATE_ALL_MANDATORY_VALUES_INTERVAL), thing.getUID()); + this.updateValuesFuture = scheduler.schedule(() -> sendGet(RO_ALL_MANDATORY_VALUES), + UPDATE_ALL_MANDATORY_VALUES_INTERVAL.toSeconds(), TimeUnit.SECONDS); + } + } + + private synchronized void stopUpdateAllValuesFuture() { + ScheduledFuture updateValuesFuture = this.updateValuesFuture; + if (updateValuesFuture != null) { + logger.trace("Cancel schedule to update all mandatory values ({}).", thing.getUID()); + updateValuesFuture.cancel(true); + } + } + + private ApplianceMessage mapApplianceMessage(Message message, boolean incoming) { + var deviceDescriptionService = this.deviceDescriptionService; + var featureMappingService = this.featureMappingService; + + // handle device description change messages + List deviceDescriptionChanges = null; + List specialMappedValues = null; + if (incoming && deviceDescriptionService != null && (RO_DESCRIPTION_CHANGE.equals(message.resource()) + || RO_ALL_DESCRIPTION_CHANGES.equals(message.resource()))) { + var descriptionChangeData = message.getDataAsList(DescriptionChangeData.class); + deviceDescriptionChanges = deviceDescriptionService.applyDescriptionChanges(descriptionChangeData); + + // some appliances emit value changes together with description changes + if (descriptionChangeData != null && featureMappingService != null) { + var featureMapping = featureMappingService.getFeatureMapping(); + var deviceDescriptionChangeValues = descriptionChangeData.stream() + .filter(description -> description.value() != null) + .map(description -> new ValueData(description.uid(), + Objects.requireNonNull(description.value()))) + .toList(); + specialMappedValues = DeviceDescriptionUtils.mapValues(deviceDescriptionService, featureMapping, + message.resource(), deviceDescriptionChangeValues, getThing().getThingTypeUID()); + } + } + + // handle value messages + List mappedValues = null; + if (incoming && featureMappingService != null && deviceDescriptionService != null + && (RO_VALUES.equals(message.resource()) || RO_ALL_MANDATORY_VALUES.equals(message.resource()))) { + var featureMapping = featureMappingService.getFeatureMapping(); + List valueDataList = message.getDataAsList(ValueData.class); + mappedValues = DeviceDescriptionUtils.mapValues(deviceDescriptionService, featureMapping, + message.resource(), valueDataList, getThing().getThingTypeUID()); + } + + // special case: desciption change and value messages + List combinedValues = null; + if (mappedValues != null || specialMappedValues != null) { + combinedValues = new ArrayList<>(); + if (mappedValues != null) { + combinedValues.addAll(mappedValues); + } + if (specialMappedValues != null) { + combinedValues.addAll(specialMappedValues); + } + } + + return new ApplianceMessage(OffsetDateTime.now(ZONE_ID), message.messageId(), + incoming ? MessageType.INCOMING : MessageType.OUTGOING, message.resource(), message.version(), + message.sessionId(), message.messageId(), message.action(), message.code(), message.data(), + (combinedValues == null || combinedValues.isEmpty()) ? null : combinedValues, deviceDescriptionChanges); + } + + private void updateSelectedProgramDescription() { + getLinkedChannel(CHANNEL_SELECTED_PROGRAM) + .ifPresent(channel -> getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + List programOptions = new ArrayList<>(); + + // check if selectedProgram value is writable + if (deviceDescriptionService.getSelectedProgram(true) != null) { + deviceDescriptionService.getPrograms(true).stream() + .map(command -> new CommandOption(command.key(), getProgramLabel(command.key()))) + .forEach(programOptions::add); + } else { + var selectedProgram = keyValueStore.get(SELECTED_PROGRAM_KEY); + if (selectedProgram != null) { + programOptions.add(new CommandOption(selectedProgram, getProgramLabel(selectedProgram))); + } + } + + setCommandOptions(channel.getUID(), programOptions); + })); + } + + private void updateActiveProgramDescription() { + getLinkedChannel(CHANNEL_ACTIVE_PROGRAM) + .ifPresent(channel -> getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var stateOptions = deviceDescriptionService.getPrograms(false).stream() + .map(program -> new StateOption(program.key(), getProgramLabel(program.key()))).toList(); + + setStateDescriptions(channel.getUID(), stateOptions); + })); + } + + private String getProgramLabel(String programKey) { + return favoriteNames.getOrDefault(programKey, mapKeyToLabel(programKey, translationProvider)); + } + + private void updateCommandDescriptions() { + // command channel + getLinkedChannel(CHANNEL_COMMAND) + .ifPresent(channel -> getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var commands = deviceDescriptionService.getCommands(true, true); + + // collect all commands of type boolean + var commandOptions = commands.stream() + .filter(command -> ContentType.BOOLEAN.equals(command.contentType())) + .map(command -> new CommandOption(command.key(), + mapKeyToLabel(command.key(), translationProvider))) + .toList(); + + setCommandOptions(channel.getUID(), commandOptions); + })); + + // program command + getLinkedChannel(CHANNEL_PROGRAM_COMMAND) + .ifPresent(channel -> getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var commands = deviceDescriptionService.getCommands(true, true); + + // collect all commands of type boolean + var commandOptions = commands.stream() + .filter(command -> ContentType.BOOLEAN.equals(command.contentType())) + .filter(command -> Set.of(RESUME_PROGRAM_KEY, PAUSE_PROGRAM_KEY).contains(command.key())) + .map(command -> { + var commandOptionCommand = switch (command.key()) { + case RESUME_PROGRAM_KEY -> COMMAND_RESUME; + case PAUSE_PROGRAM_KEY -> COMMAND_PAUSE; + default -> throw new IllegalStateException("Unexpected value: " + command.key()); + }; + var commandOptionLabel = switch (command.key()) { + case RESUME_PROGRAM_KEY -> translationProvider.getText(I18N_RESUME_PROGRAM); + case PAUSE_PROGRAM_KEY -> translationProvider.getText(I18N_PAUSE_PROGRAM); + default -> throw new IllegalStateException("Unexpected value: " + command.key()); + }; + return new CommandOption(commandOptionCommand, commandOptionLabel); + }).toList(); + if (commandOptions.isEmpty() && deviceDescriptionService.getActiveProgram(true) != null + && deviceDescriptionService.getSelectedProgram(true) != null) { + commandOptions = List + .of(new CommandOption(COMMAND_START, translationProvider.getText(I18N_START_PROGRAM))); + } + + setCommandOptions(channel.getUID(), commandOptions); + })); + } + + protected void updateReadonlyEnumOptionDescriptionIfLinked(String channelId, String optionKey) { + updateOptionDescriptionIfLinked(channelId, optionKey); + } + + protected void updateEnumOptionDescriptionIfLinked(String channelId, String optionKey) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isOptionAvailableAndWritable(optionKey)) { + updateOptionDescriptionIfLinked(channelId, optionKey); + } else { + var enumKey = keyValueStore.get(optionKey); + if (enumKey != null) { + updateOptionDescriptionToFixedValueKeyIfLinked(channelId, enumKey); + } + } + }); + } + + protected void updateOptionDescriptionToFixedValueKeyIfLinked(String optionChannel, String fixedOptionValueKey) { + getLinkedChannel(optionChannel).ifPresent(channel -> setStateDescriptions(channel.getUID(), List + .of(new StateOption(fixedOptionValueKey, mapKeyToLabel(fixedOptionValueKey, translationProvider))))); + } + + protected void updateOptionDescriptionIfLinked(String optionChannel, String optionKey) { + getLinkedChannel(optionChannel) + .ifPresent(channel -> getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + List stateOptions = new ArrayList<>(); + + var deviceOption = deviceDescriptionService.findOptionByKey(optionKey); + if (deviceOption != null) { + var enumerationType = deviceDescriptionService + .findEnumerationType(deviceOption.enumerationType()); + if (enumerationType != null) { + enumerationType.enumerations().entrySet().stream().filter(entry -> { + var enumValue = entry.getValue().value(); + var min = deviceOption.min(); + var max = deviceOption.max(); + return (min == null || enumValue >= min.intValue()) + && (max == null || enumValue <= max.intValue()); + }).sorted(Map.Entry.comparingByKey()).forEach(entry -> { + var valueKey = entry.getValue().valueKey(); + var valueKeyLabel = mapKeyToLabel(valueKey, translationProvider); + stateOptions.add(new StateOption(valueKey, valueKeyLabel)); + }); + } + } + + setStateDescriptions(channel.getUID(), stateOptions); + })); + } + + protected void updateStatusDescriptionIfLinked(String optionChannel, String statusKey) { + getLinkedChannel(optionChannel) + .ifPresent(channel -> getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + List stateOptions = new ArrayList<>(); + + var deviceStatus = deviceDescriptionService.findStatusByKey(statusKey); + if (deviceStatus != null) { + var enumerationType = deviceDescriptionService + .findEnumerationType(deviceStatus.enumerationType()); + if (enumerationType != null) { + enumerationType.enumerations().entrySet().stream().filter(entry -> { + var enumValue = entry.getValue().value(); + var min = deviceStatus.min(); + var max = deviceStatus.max(); + return (min == null || enumValue >= min.intValue()) + && (max == null || enumValue <= max.intValue()); + }).sorted(Map.Entry.comparingByKey()).forEach(entry -> { + var valueKey = entry.getValue().valueKey(); + var valueKeyLabel = mapKeyToLabel(valueKey, translationProvider); + stateOptions.add(new StateOption(valueKey, valueKeyLabel)); + }); + } + } + + setStateDescriptions(channel.getUID(), stateOptions); + })); + } + + protected void updateIntegerOptionDescriptionIfLinked(String optionChannel, String optionKey) { + getLinkedChannel(optionChannel) + .ifPresent(channel -> getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var deviceOption = deviceDescriptionService.findOptionByKey(optionKey); + if (deviceOption != null) { + var builder = StateDescriptionFragmentBuilder.create(); + var min = deviceOption.min(); + if (min != null) { + builder.withMinimum(new BigDecimal(min.toString())); + } + var max = deviceOption.max(); + if (max != null) { + builder.withMaximum(new BigDecimal(max.toString())); + } + var stepSize = deviceOption.stepSize(); + if (stepSize != null) { + builder.withStep(new BigDecimal(stepSize.toString())); + } + stateDescriptionProvider.setStateDescriptionFragment(channel.getUID(), builder.build()); + } + })); + } + + protected void setCommandOptions(ChannelUID channelUID, List commandOptions) { + if (logger.isDebugEnabled()) { + logger.debug("Setting command options for channel {} to {}", channelUID, + commandOptions.stream().map( + commandOption -> "%s->%s".formatted(commandOption.getCommand(), commandOption.getLabel())) + .toList()); + } + commandDescriptionProvider.setCommandOptions(channelUID, commandOptions); + } + + protected void setStateDescriptions(ChannelUID channelUID, List stateOptions) { + if (logger.isDebugEnabled()) { + logger.debug("Setting state descriptions for channel {} to {}", channelUID, stateOptions.stream() + .map(stateOption -> "%s->%s".formatted(stateOption.getValue(), stateOption.getLabel())).toList()); + } + stateDescriptionProvider.setStateOptions(channelUID, stateOptions); + } + + protected boolean addChannelIfNotExist(ThingBuilder thingBuilder, String channelId, String channelTypeId, + String itemType) { + return addChannelIfNotExist(thingBuilder, channelId, channelTypeId, itemType, null); + } + + protected boolean addChannelIfNotExist(ThingBuilder thingBuilder, String channelId, String channelTypeId, + String itemType, @Nullable String label) { + if (getThing().getChannel(channelId) == null) { + var channelTypeUID = new ChannelTypeUID(BINDING_ID, channelTypeId); + var channelUID = new ChannelUID(getThing().getUID(), channelId); + var newChannelBuilder = ChannelBuilder.create(channelUID, itemType).withType(channelTypeUID); + + if (label != null) { + newChannelBuilder.withLabel(label); + } + thingBuilder.withChannel(newChannelBuilder.build()); + return true; + } else { + return false; + } + } + + protected Map getKeyValueStore() { + return keyValueStore; + } + + protected HomeConnectDirectTranslationProvider getTranslationProvider() { + return translationProvider; + } + + private void initializeAllStates() { + Set.of(CHANNEL_COMMAND, CHANNEL_SELECTED_PROGRAM, CHANNEL_ACTIVE_PROGRAM, CHANNEL_OPERATION_STATE, + CHANNEL_CHILD_LOCK, CHANNEL_POWER_STATE, CHANNEL_REMOTE_CONTROL_OR_START_ALLOWED, CHANNEL_DOOR, + CHANNEL_REMAINING_PROGRAM_TIME, CHANNEL_PROGRAM_PROGRESS).forEach(this::initializeState); + + getThing().getChannels().stream() + .filter(channel -> CHANNEL_TYPE_SWITCH_DESCRIPTION.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_STRING_DESCRIPTION.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_NUMBER_DESCRIPTION.equals(channel.getChannelTypeUID())) + .filter(channel -> isLinked(channel.getUID())) + .forEach(channel -> initializeState(channel.getUID().getId())); + } + + private void initializeState(String channelId) { + switch (channelId) { + case CHANNEL_COMMAND, CHANNEL_PROGRAM_COMMAND -> updateCommandDescriptions(); + case CHANNEL_SELECTED_PROGRAM -> updateSelectedProgramDescription(); + case CHANNEL_ACTIVE_PROGRAM -> updateActiveProgramDescription(); + case CHANNEL_OPERATION_STATE -> updateStatusDescriptionIfLinked(channelId, OPERATION_STATE_KEY); + case CHANNEL_CHILD_LOCK, CHANNEL_POWER_STATE, CHANNEL_REMOTE_CONTROL_OR_START_ALLOWED -> + updateStateIfLinked(channelId, OnOffType.OFF); + case CHANNEL_DOOR -> updateStateIfLinked(channelId, OpenClosedType.CLOSED); + case CHANNEL_REMAINING_PROGRAM_TIME -> updateStateIfLinked(channelId, new QuantityType<>(0, SECOND)); + case CHANNEL_PROGRAM_PROGRESS -> updateStateIfLinked(channelId, new QuantityType<>(0, PERCENT)); + default -> getLinkedChannel(channelId).ifPresent(channel -> { + if (CHANNEL_TYPE_SWITCH_DESCRIPTION.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_STRING_DESCRIPTION.equals(channel.getChannelTypeUID()) + || CHANNEL_TYPE_NUMBER_DESCRIPTION.equals(channel.getChannelTypeUID())) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var configuration = channel.getConfiguration(); + var descriptionKeyConfiguration = configuration.get(CONFIGURATION_DESCRIPTION_KEY); + var attributeConfiguration = configuration.get(CONFIGURATION_ATTRIBUTE); + + if (descriptionKeyConfiguration instanceof String key + && attributeConfiguration instanceof String attribute) { + mapKey(key).ifPresent(uid -> { + var foundObject = deviceDescriptionService.getDeviceDescriptionObject(uid); + if (foundObject != null) { + var value = getAttributeValue(foundObject.object(), attribute); + if (value == null) { + updateState(channel.getUID(), UnDefType.UNDEF); + } else if (CHANNEL_TYPE_SWITCH_DESCRIPTION.equals(channel.getChannelTypeUID())) { + if (value instanceof Boolean b) { + updateState(channel.getUID(), OnOffType.from(b)); + } + } else if (CHANNEL_TYPE_STRING_DESCRIPTION.equals(channel.getChannelTypeUID())) { + updateState(channel.getUID(), StringType.valueOf(value.toString())); + } else if (CHANNEL_TYPE_NUMBER_DESCRIPTION.equals(channel.getChannelTypeUID())) { + updateState(channel.getUID(), new DecimalType(value.toString())); + } + } + }); + } + }); + } + }); + } + } + + private @Nullable Object getAttributeValue(Object object, String attribute) { + if (ATTRIBUTE_ACCESS.equals(attribute) && object instanceof AccessProvider provider) { + return provider.access().name(); + } else if (ATTRIBUTE_AVAILABLE.equals(attribute) && object instanceof AvailableProvider provider) { + return provider.available(); + } else if (object instanceof RangeProvider provider + && Set.of(ATTRIBUTE_MIN, ATTRIBUTE_MAX, ATTRIBUTE_STEP_SIZE).contains(attribute)) { + switch (attribute) { + case ATTRIBUTE_MIN -> { + return provider.min(); + } + case ATTRIBUTE_MAX -> { + return provider.max(); + } + case ATTRIBUTE_STEP_SIZE -> { + return provider.stepSize(); + } + } + } else if (object instanceof EnumerationTypeProvider provider) { + if (ATTRIBUTE_ENUMERATION_TYPE.equals(attribute)) { + return provider.enumerationType(); + } else if (ATTRIBUTE_ENUMERATION_TYPE_KEY.equals(attribute)) { + return provider.enumerationTypeKey(); + } + } + + return null; + } + + private boolean isUnsatisfiedLinkError(@Nullable Throwable throwable) { + int maxDepth = 10; + int currentDepth = 0; + boolean result = false; + + while (throwable != null && currentDepth < maxDepth) { + if (throwable instanceof UnsatisfiedLinkError) { + result = true; + } + throwable = throwable.getCause(); + currentDepth++; + } + + return result; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectCoffeeMakerHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectCoffeeMakerHandler.java new file mode 100644 index 0000000000..d1ace50d2a --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectCoffeeMakerHandler.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource.RO_ACTIVE_PROGRAM; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicCommandDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicStateDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ProgramData; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.openhab.core.types.CommandOption; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomeConnectDirectCoffeeMakerHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectCoffeeMakerHandler extends BaseHomeConnectDirectHandler { + + private final Logger logger; + + public HomeConnectDirectCoffeeMakerHandler(Thing thing, ApplianceProfileService applianceProfileService, + HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider, + HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider, String deviceId, + HomeConnectDirectConfiguration configuration, HomeConnectDirectTranslationProvider translationProvider) { + super(thing, applianceProfileService, commandDescriptionProvider, stateDescriptionProvider, deviceId, + configuration, translationProvider); + + this.logger = LoggerFactory.getLogger(HomeConnectDirectCoffeeMakerHandler.class); + } + + @Override + protected void initializeFinished() { + initializeAllStates(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + super.handleCommand(channelUID, command); + + if (CHANNEL_COFFEE_MAKER_PROGRAM_COMMAND.equals(channelUID.getId()) && command instanceof StringType) { + if (COMMAND_START.equalsIgnoreCase(command.toFullString())) { + var selectedProgram = getKeyValueStore().get(SELECTED_PROGRAM_KEY); + if (selectedProgram != null) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.getActiveProgram(true) != null) { + mapProgramKey(selectedProgram).ifPresent(programUid -> send(Action.POST, RO_ACTIVE_PROGRAM, + List.of(new ProgramData(programUid, null)), null, 1)); + } else { + logger.warn( + "The '{}' control is either unavailable or in read-only mode. Cannot start program.", + ACTIVE_PROGRAM_KEY); + } + }); + + } + } else if (COMMAND_STOP.equalsIgnoreCase(command.toFullString())) { + sendBooleanCommandIfAllowed(ABORT_PROGRAM_KEY); + } + } + } + + @Override + protected void onApplianceDescriptionChangeEvent(List deviceDescriptionChanges) { + super.onApplianceDescriptionChangeEvent(deviceDescriptionChanges); + + deviceDescriptionChanges.forEach(deviceDescriptionChange -> { + if (DeviceDescriptionType.COMMAND.equals(deviceDescriptionChange.type()) + || DeviceDescriptionType.COMMAND_LIST.equals(deviceDescriptionChange.type())) { + updateProgramCommandDescription(); + } else if (deviceDescriptionChange.key().equals(COFFEE_MAKER_PROCESS_PHASE_KEY)) { + updateStatusDescriptionIfLinked(CHANNEL_COFFEE_MAKER_PROCESS_PHASE, COFFEE_MAKER_PROCESS_PHASE_KEY); + } + }); + } + + @Override + protected void onApplianceValueEvent(Value value, Resource resource) { + super.onApplianceValueEvent(value, resource); + + switch (value.key()) { + case COFFEE_MAKER_PROCESS_PHASE_KEY -> + updateStateIfLinked(CHANNEL_COFFEE_MAKER_PROCESS_PHASE, new StringType(value.getValueAsString())); + case COFFEE_MAKER_COUNTDOWN_CLEANING_KEY -> updateStateIfLinked(CHANNEL_COFFEE_MAKER_COUNTDOWN_CLEANING, + () -> new DecimalType(value.getValueAsInt())); + case COFFEE_MAKER_COUNTDOWN_CALC_AND_CLEAN_KEY -> updateStateIfLinked( + CHANNEL_COFFEE_MAKER_COUNTDOWN_CALC_AND_CLEAN, () -> new DecimalType(value.getValueAsInt())); + case COFFEE_MAKER_COUNTDOWN_DESCALING_KEY -> updateStateIfLinked(CHANNEL_COFFEE_MAKER_COUNTDOWN_DESCALING, + () -> new DecimalType(value.getValueAsInt())); + case COFFEE_MAKER_COUNTDOWN_WATER_FILTER_KEY -> updateStateIfLinked( + CHANNEL_COFFEE_MAKER_COUNTDOWN_WATER_FILTER, () -> new DecimalType(value.getValueAsInt())); + case COFFEE_MAKER_WATER_TANK_EMPTY_KEY -> updateStateIfLinked(CHANNEL_COFFEE_MAKER_WATER_TANK_EMPTY, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case COFFEE_MAKER_WATER_TANK_NEARLY_EMPTY_KEY -> + updateStateIfLinked(CHANNEL_COFFEE_MAKER_WATER_TANK_NEARLY_EMPTY, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case COFFEE_MAKER_DRIP_TRAY_FULL_KEY -> updateStateIfLinked(CHANNEL_COFFEE_MAKER_DRIP_TRAY_FULL, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case COFFEE_MAKER_EMPTY_MILK_TANK_KEY -> updateStateIfLinked(CHANNEL_COFFEE_MAKER_EMPTY_MILK_TANK, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case COFFEE_MAKER_BEAN_CONTAINER_EMPTY_KEY -> updateStateIfLinked(CHANNEL_COFFEE_MAKER_BEAN_CONTAINER_EMPTY, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case SELECTED_PROGRAM_KEY, ACTIVE_PROGRAM_KEY -> updateProgramCommandDescription(); + } + } + + @Override + public void channelLinked(ChannelUID channelUID) { + super.channelLinked(channelUID); + initializeState(channelUID.getId()); + } + + private void initializeAllStates() { + Set.of(CHANNEL_COFFEE_MAKER_PROCESS_PHASE, CHANNEL_COFFEE_MAKER_WATER_TANK_EMPTY, + CHANNEL_COFFEE_MAKER_WATER_TANK_NEARLY_EMPTY, CHANNEL_COFFEE_MAKER_DRIP_TRAY_FULL, + CHANNEL_COFFEE_MAKER_EMPTY_MILK_TANK, CHANNEL_COFFEE_MAKER_BEAN_CONTAINER_EMPTY, + CHANNEL_COFFEE_MAKER_PROGRAM_COMMAND).forEach(this::initializeState); + } + + private void initializeState(String channelId) { + switch (channelId) { + case CHANNEL_COFFEE_MAKER_PROCESS_PHASE -> + updateStatusDescriptionIfLinked(channelId, COFFEE_MAKER_PROCESS_PHASE_KEY); + case CHANNEL_COFFEE_MAKER_PROGRAM_COMMAND -> updateProgramCommandDescription(); + case CHANNEL_COFFEE_MAKER_WATER_TANK_EMPTY, CHANNEL_COFFEE_MAKER_WATER_TANK_NEARLY_EMPTY, + CHANNEL_COFFEE_MAKER_DRIP_TRAY_FULL, CHANNEL_COFFEE_MAKER_EMPTY_MILK_TANK, + CHANNEL_COFFEE_MAKER_BEAN_CONTAINER_EMPTY -> + updateStateIfLinked(channelId, OnOffType.OFF); + } + } + + private void updateProgramCommandDescription() { + getLinkedChannel(CHANNEL_COFFEE_MAKER_PROGRAM_COMMAND).ifPresent(channel -> { + var commandOptions = new ArrayList(); + + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isCommandAvailableAndWritable(ABORT_PROGRAM_KEY)) { + commandOptions + .add(new CommandOption(COMMAND_STOP, getTranslationProvider().getText(I18N_STOP_PROGRAM))); + } else { + commandOptions.add( + new CommandOption(COMMAND_START, getTranslationProvider().getText(I18N_START_PROGRAM))); + } + }); + + setCommandOptions(channel.getUID(), commandOptions); + }); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectCooktopHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectCooktopHandler.java new file mode 100644 index 0000000000..0225a8d38d --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectCooktopHandler.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.CHANNEL_BUTTON_TONES; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.COOKING_BUTTON_TONES_KEY; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicCommandDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicStateDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; + +/** + * The {@link HomeConnectDirectCooktopHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectCooktopHandler extends BaseHomeConnectDirectHandler { + + public HomeConnectDirectCooktopHandler(Thing thing, ApplianceProfileService applianceProfileService, + HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider, + HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider, String deviceId, + HomeConnectDirectConfiguration configuration, HomeConnectDirectTranslationProvider translationProvider) { + super(thing, applianceProfileService, commandDescriptionProvider, stateDescriptionProvider, deviceId, + configuration, translationProvider); + } + + @Override + protected void initializeFinished() { + initializeAllStates(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + super.handleCommand(channelUID, command); + + if (CHANNEL_BUTTON_TONES.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanSettingIfAllowed(command, COOKING_BUTTON_TONES_KEY); + } + } + + @Override + protected void onApplianceValueEvent(Value value, Resource resource) { + super.onApplianceValueEvent(value, resource); + + if (value.key().equals(COOKING_BUTTON_TONES_KEY)) { + updateStateIfLinked(CHANNEL_BUTTON_TONES, OnOffType.from(value.getValueAsBoolean())); + } + } + + @Override + public void channelLinked(ChannelUID channelUID) { + super.channelLinked(channelUID); + initializeState(channelUID.getId()); + } + + private void initializeAllStates() { + initializeState(CHANNEL_BUTTON_TONES); + } + + private void initializeState(String channelId) { + if (CHANNEL_BUTTON_TONES.equals(channelId)) { + updateStateIfLinked(channelId, OnOffType.OFF); + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectDishwasherHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectDishwasherHandler.java new file mode 100644 index 0000000000..82fbe82add --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectDishwasherHandler.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; + +import java.util.Arrays; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicCommandDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicStateDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; + +/** + * The {@link HomeConnectDirectDishwasherHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectDishwasherHandler extends BaseHomeConnectDirectHandler { + + public HomeConnectDirectDishwasherHandler(Thing thing, ApplianceProfileService applianceProfileService, + HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider, + HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider, String deviceId, + HomeConnectDirectConfiguration configuration, HomeConnectDirectTranslationProvider translationProvider) { + super(thing, applianceProfileService, commandDescriptionProvider, stateDescriptionProvider, deviceId, + configuration, translationProvider); + } + + @Override + protected void initializeFinished() { + initializeAllStates(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + super.handleCommand(channelUID, command); + + if (CHANNEL_DISHWASHER_VARIO_SPEED_PLUS.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, DISHWASHER_VARIO_SPEED_PLUS_KEY); + } else if (CHANNEL_DISHWASHER_INTENSIV_ZONE.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, DISHWASHER_INTENSIV_ZONE_KEY); + } else if (CHANNEL_DISHWASHER_BRILLIANCE_DRY.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, DISHWASHER_BRILLIANCE_DRY_KEY); + } + } + + @Override + protected void onApplianceDescriptionChangeEvent(List deviceDescriptionChanges) { + super.onApplianceDescriptionChangeEvent(deviceDescriptionChanges); + + deviceDescriptionChanges.forEach(deviceDescriptionChange -> { + if (deviceDescriptionChange.key().equals(DISHWASHER_PROGRAM_PHASE_KEY)) { + updateStatusDescriptionIfLinked(CHANNEL_DISHWASHER_PROGRAM_PHASE, DISHWASHER_PROGRAM_PHASE_KEY); + } + }); + } + + @Override + protected void onApplianceValueEvent(Value value, Resource resource) { + super.onApplianceValueEvent(value, resource); + + switch (value.key()) { + case DISHWASHER_PROGRAM_PHASE_KEY -> + updateStateIfLinked(CHANNEL_DISHWASHER_PROGRAM_PHASE, new StringType(value.getValueAsString())); + case DISHWASHER_SALT_LACK_KEY -> updateStateIfLinked(CHANNEL_DISHWASHER_SALT_LACK, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case DISHWASHER_RINSE_AID_LACK_KEY -> updateStateIfLinked(CHANNEL_DISHWASHER_RINSE_AID_LACK, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case DISHWASHER_SALT_NEARLY_EMPTY_KEY -> updateStateIfLinked(CHANNEL_DISHWASHER_SALT_NEARLY_EMPTY, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case DISHWASHER_RINSE_AID_NEARLY_EMPTY_KEY -> updateStateIfLinked(CHANNEL_DISHWASHER_RINSE_AID_NEARLY_EMPTY, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case DISHWASHER_MACHINE_CARE_REMINDER_KEY -> updateStateIfLinked(CHANNEL_DISHWASHER_MACHINE_CARE_REMINDER, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case DISHWASHER_VARIO_SPEED_PLUS_KEY -> updateStateIfLinked(CHANNEL_DISHWASHER_VARIO_SPEED_PLUS, + () -> OnOffType.from(value.getValueAsBoolean())); + case DISHWASHER_INTENSIV_ZONE_KEY -> + updateStateIfLinked(CHANNEL_DISHWASHER_INTENSIV_ZONE, () -> OnOffType.from(value.getValueAsBoolean())); + case DISHWASHER_BRILLIANCE_DRY_KEY -> + updateStateIfLinked(CHANNEL_DISHWASHER_BRILLIANCE_DRY, () -> OnOffType.from(value.getValueAsBoolean())); + } + } + + @Override + public void channelLinked(ChannelUID channelUID) { + super.channelLinked(channelUID); + initializeState(channelUID.getId()); + } + + private void initializeAllStates() { + Arrays.asList(CHANNEL_DISHWASHER_PROGRAM_PHASE, CHANNEL_DISHWASHER_SALT_LACK, CHANNEL_DISHWASHER_RINSE_AID_LACK, + CHANNEL_DISHWASHER_SALT_NEARLY_EMPTY, CHANNEL_DISHWASHER_RINSE_AID_NEARLY_EMPTY, + CHANNEL_DISHWASHER_MACHINE_CARE_REMINDER, CHANNEL_DISHWASHER_VARIO_SPEED_PLUS, + CHANNEL_DISHWASHER_INTENSIV_ZONE, CHANNEL_DISHWASHER_BRILLIANCE_DRY).forEach(this::initializeState); + } + + private void initializeState(String channelId) { + switch (channelId) { + case CHANNEL_DISHWASHER_PROGRAM_PHASE -> + updateStatusDescriptionIfLinked(channelId, DISHWASHER_PROGRAM_PHASE_KEY); + case CHANNEL_DISHWASHER_SALT_LACK, CHANNEL_DISHWASHER_RINSE_AID_LACK, CHANNEL_DISHWASHER_SALT_NEARLY_EMPTY, + CHANNEL_DISHWASHER_RINSE_AID_NEARLY_EMPTY, CHANNEL_DISHWASHER_MACHINE_CARE_REMINDER, + CHANNEL_DISHWASHER_VARIO_SPEED_PLUS, CHANNEL_DISHWASHER_INTENSIV_ZONE, + CHANNEL_DISHWASHER_BRILLIANCE_DRY -> + updateStateIfLinked(channelId, OnOffType.OFF); + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectFridgeFreezerHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectFridgeFreezerHandler.java new file mode 100644 index 0000000000..36a0fff2c6 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectFridgeFreezerHandler.java @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT; +import static org.openhab.core.library.unit.SIUnits.CELSIUS; +import static org.openhab.core.library.unit.Units.PERCENT; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import javax.measure.Unit; +import javax.measure.quantity.Temperature; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicCommandDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicStateDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.DeviceDescriptionService; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ContentType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.core.library.CoreItemFactory; +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.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomeConnectDirectFridgeFreezerHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectFridgeFreezerHandler extends BaseHomeConnectDirectHandler { + + private static final String FREEZER = "Freezer"; + private static final String FRIDGE = "Fridge"; + private static final String CHILLER = "Chiller"; + + private final Logger logger; + + public HomeConnectDirectFridgeFreezerHandler(Thing thing, ApplianceProfileService applianceProfileService, + HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider, + HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider, String deviceId, + HomeConnectDirectConfiguration configuration, HomeConnectDirectTranslationProvider translationProvider) { + super(thing, applianceProfileService, commandDescriptionProvider, stateDescriptionProvider, deviceId, + configuration, translationProvider); + + this.logger = LoggerFactory.getLogger(HomeConnectDirectFridgeFreezerHandler.class); + } + + @Override + protected void initializeStarted() { + addDynamicChannels(); + } + + @Override + protected void initializeFinished() { + initializeAllStates(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + super.handleCommand(channelUID, command); + + if (CHANNEL_FRIDGE_SUPER_MODE.equals(channelUID.getId()) && command instanceof OnOffType) { + if (settingKeyExists(FRIDGE_SUPER_MODE_2_KEY)) { + sendBooleanSettingIfAllowed(command, FRIDGE_SUPER_MODE_2_KEY); + } else { + sendBooleanSettingIfAllowed(command, FRIDGE_SUPER_MODE_KEY); + } + } else if (CHANNEL_FREEZER_SUPER_MODE.equals(channelUID.getId()) && command instanceof OnOffType) { + if (settingKeyExists(FREEZER_SUPER_MODE_2_KEY)) { + sendBooleanSettingIfAllowed(command, FREEZER_SUPER_MODE_2_KEY); + } else { + sendBooleanSettingIfAllowed(command, FREEZER_SUPER_MODE_KEY); + } + } else if (CHANNEL_FRIDGE_SET_POINT_TEMPERATURE.equals(channelUID.getId()) + && (command instanceof QuantityType || command instanceof DecimalType)) { + if (settingKeyExists(FRIDGE_SET_POINT_TEMPERATURE_2_KEY)) { + sendTemperatureSetting(FRIDGE_SET_POINT_TEMPERATURE_2_KEY, command); + } else { + sendTemperatureSetting(FRIDGE_SET_POINT_TEMPERATURE_KEY, command); + } + } else if (CHANNEL_FREEZER_SET_POINT_TEMPERATURE.equals(channelUID.getId()) + && (command instanceof QuantityType || command instanceof DecimalType)) { + if (settingKeyExists(FREEZER_SET_POINT_TEMPERATURE_2_KEY)) { + sendTemperatureSetting(FREEZER_SET_POINT_TEMPERATURE_2_KEY, command); + } else { + sendTemperatureSetting(FREEZER_SET_POINT_TEMPERATURE_KEY, command); + } + } else if (CHANNEL_FRIDGE_CHILLER_SET_POINT_TEMPERATURE.equals(channelUID.getId()) + && (command instanceof QuantityType || command instanceof DecimalType)) { + if (settingKeyExists(FRIDGE_CHILLER_SET_POINT_TEMPERATURE_2_KEY)) { + sendTemperatureSetting(FRIDGE_CHILLER_SET_POINT_TEMPERATURE_2_KEY, command); + } else { + sendTemperatureSetting(FRIDGE_CHILLER_SET_POINT_TEMPERATURE_KEY, command); + } + } else if (CHANNEL_FRIDGE_CHILLER_PRESET.equals(channelUID.getId()) && command instanceof StringType) { + sendEnumSettingIfAllowed(command, FRIDGE_CHILLER_PRESET_KEY); + } else if (CHANNEL_FRIDGE_DISPENSER_ENABLED.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanSettingIfAllowed(command, FRIDGE_DISPENSER_ENABLED_KEY); + } else if (CHANNEL_FRIDGE_DISPENSER_PARTY_MODE.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanSettingIfAllowed(command, FRIDGE_DISPENSER_PARTY_MODE_KEY); + } + } + + @Override + protected void onApplianceDescriptionChangeEvent(List deviceDescriptionChanges) { + super.onApplianceDescriptionChangeEvent(deviceDescriptionChanges); + + deviceDescriptionChanges.forEach(deviceDescriptionChange -> { + var key = deviceDescriptionChange.key(); + if (key.equals(FRIDGE_CHILLER_PRESET_KEY)) { + updateEnumOptionDescriptionIfLinked(CHANNEL_FRIDGE_CHILLER_PRESET, key); + } + }); + } + + @Override + protected void onApplianceValueEvent(Value value, Resource resource) { + super.onApplianceValueEvent(value, resource); + + switch (value.key()) { + case FRIDGE_SET_POINT_TEMPERATURE_KEY, FRIDGE_SET_POINT_TEMPERATURE_2_KEY -> + updateStateIfLinked(CHANNEL_FRIDGE_SET_POINT_TEMPERATURE, + () -> new QuantityType<>(value.getValueAsInt(), getTemperatureUnitOfSetting(value.key()))); + case FREEZER_SET_POINT_TEMPERATURE_KEY, FREEZER_SET_POINT_TEMPERATURE_2_KEY -> + updateStateIfLinked(CHANNEL_FREEZER_SET_POINT_TEMPERATURE, + () -> new QuantityType<>(value.getValueAsInt(), getTemperatureUnitOfSetting(value.key()))); + case FRIDGE_CHILLER_SET_POINT_TEMPERATURE_KEY, FRIDGE_CHILLER_SET_POINT_TEMPERATURE_2_KEY -> + updateStateIfLinked(CHANNEL_FRIDGE_CHILLER_SET_POINT_TEMPERATURE, + () -> new QuantityType<>(value.getValueAsInt(), getTemperatureUnitOfSetting(value.key()))); + case FRIDGE_SUPER_MODE_KEY, FRIDGE_SUPER_MODE_2_KEY -> + updateStateIfLinked(CHANNEL_FRIDGE_SUPER_MODE, OnOffType.from(value.getValueAsBoolean())); + case FREEZER_SUPER_MODE_KEY, FREEZER_SUPER_MODE_2_KEY -> + updateStateIfLinked(CHANNEL_FREEZER_SUPER_MODE, OnOffType.from(value.getValueAsBoolean())); + case FRIDGE_DOOR_STATE_KEY, + FRIDGE_DOOR_STATE_2_KEY -> + updateStateIfLinked(CHANNEL_FRIDGE_DOOR_STATE, + () -> STATE_OPEN.equals(value.value()) || STATE_AJAR.equals(value.value()) ? OpenClosedType.OPEN + : OpenClosedType.CLOSED); + case FREEZER_DOOR_STATE_KEY, + FREEZER_DOOR_STATE_2_KEY -> + updateStateIfLinked(CHANNEL_FREEZER_DOOR_STATE, + () -> STATE_OPEN.equals(value.value()) || STATE_AJAR.equals(value.value()) ? OpenClosedType.OPEN + : OpenClosedType.CLOSED); + case FRIDGE_CHILLER_DOOR_STATE_KEY -> updateStateIfLinked(CHANNEL_FRIDGE_CHILLER_DOOR_STATE, + () -> STATE_OPEN.equals(value.value()) || STATE_AJAR.equals(value.value()) ? OpenClosedType.OPEN + : OpenClosedType.CLOSED); + case FRIDGE_DISPENSER_WATER_FILTER_SATURATION_KEY -> + updateStateIfLinked(CHANNEL_FRIDGE_DISPENSER_WATER_FILTER_SATURATION, + () -> new QuantityType<>(value.getValueAsInt(), PERCENT)); + case FRIDGE_CHILLER_PRESET_KEY -> getLinkedChannel(CHANNEL_FRIDGE_CHILLER_PRESET) + .ifPresent(channel -> updateState(channel.getUID(), new StringType(value.getValueAsString()))); + case FRIDGE_DISPENSER_ENABLED_KEY -> + updateStateIfLinked(CHANNEL_FRIDGE_DISPENSER_ENABLED, OnOffType.from(value.getValueAsBoolean())); + case FRIDGE_DISPENSER_PARTY_MODE_KEY -> + updateStateIfLinked(CHANNEL_FRIDGE_DISPENSER_PARTY_MODE, OnOffType.from(value.getValueAsBoolean())); + } + } + + @Override + public void channelLinked(ChannelUID channelUID) { + super.channelLinked(channelUID); + initializeState(channelUID.getId()); + } + + private void initializeAllStates() { + Set.of(CHANNEL_FRIDGE_SUPER_MODE, CHANNEL_FREEZER_SUPER_MODE, CHANNEL_FRIDGE_DOOR_STATE, + CHANNEL_FREEZER_DOOR_STATE, CHANNEL_FRIDGE_CHILLER_DOOR_STATE, + CHANNEL_FRIDGE_DISPENSER_WATER_FILTER_SATURATION, CHANNEL_FRIDGE_CHILLER_PRESET, + CHANNEL_FRIDGE_DISPENSER_PARTY_MODE, CHANNEL_FRIDGE_DISPENSER_ENABLED).forEach(this::initializeState); + } + + private void initializeState(String channelId) { + switch (channelId) { + case CHANNEL_FRIDGE_SUPER_MODE, CHANNEL_FREEZER_SUPER_MODE, CHANNEL_FRIDGE_DISPENSER_PARTY_MODE, + CHANNEL_FRIDGE_DISPENSER_ENABLED -> + updateState(channelId, OnOffType.OFF); + case CHANNEL_FRIDGE_DOOR_STATE, CHANNEL_FREEZER_DOOR_STATE, CHANNEL_FRIDGE_CHILLER_DOOR_STATE -> + updateState(channelId, OpenClosedType.CLOSED); + case CHANNEL_FRIDGE_DISPENSER_WATER_FILTER_SATURATION -> + updateState(channelId, new QuantityType<>(0, PERCENT)); + case CHANNEL_FRIDGE_CHILLER_PRESET -> + updateEnumOptionDescriptionIfLinked(channelId, FRIDGE_CHILLER_PRESET_KEY); + } + } + + private void addDynamicChannels() { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var thingBuilder = editThing(); + boolean channelsChanged = false; + + // setpoint temperatures + if (settingKeyExists(deviceDescriptionService, FRIDGE_SET_POINT_TEMPERATURE_KEY) + || settingKeyExists(deviceDescriptionService, FRIDGE_SET_POINT_TEMPERATURE_2_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FRIDGE_SET_POINT_TEMPERATURE, + CHANNEL_TYPE_FRIDGE_FREEZER_SET_POINT_TEMPERATURE, NUMBER_TEMPERATURE, + getTranslationProvider().getText(I18N_FRIDGE_FREEZER_SET_POINT_TEMPERATURE, + getTranslationProvider().getText(FRIDGE))); + } + + if (settingKeyExists(deviceDescriptionService, FREEZER_SET_POINT_TEMPERATURE_KEY) + || settingKeyExists(deviceDescriptionService, FREEZER_SET_POINT_TEMPERATURE_2_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FREEZER_SET_POINT_TEMPERATURE, + CHANNEL_TYPE_FRIDGE_FREEZER_SET_POINT_TEMPERATURE, NUMBER_TEMPERATURE, + getTranslationProvider().getText(I18N_FRIDGE_FREEZER_SET_POINT_TEMPERATURE, + getTranslationProvider().getText(FREEZER))); + } + + if (settingKeyExists(deviceDescriptionService, FRIDGE_CHILLER_SET_POINT_TEMPERATURE_KEY) + || settingKeyExists(deviceDescriptionService, FRIDGE_CHILLER_SET_POINT_TEMPERATURE_2_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FRIDGE_CHILLER_SET_POINT_TEMPERATURE, + CHANNEL_TYPE_FRIDGE_FREEZER_SET_POINT_TEMPERATURE, NUMBER_TEMPERATURE, + getTranslationProvider().getText(I18N_FRIDGE_FREEZER_SET_POINT_TEMPERATURE, + getTranslationProvider().getText(CHILLER))); + } + + // door states + var doorAdded = false; + if (statusKeyExists(deviceDescriptionService, FRIDGE_DOOR_STATE_KEY) + || statusKeyExists(deviceDescriptionService, FRIDGE_DOOR_STATE_2_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FRIDGE_DOOR_STATE, CHANNEL_TYPE_DOOR, + CoreItemFactory.CONTACT, getTranslationProvider().getText(I18N_FRIDGE_FREEZER_DOOR, + getTranslationProvider().getText(FRIDGE))); + doorAdded = true; + } + if (statusKeyExists(deviceDescriptionService, FRIDGE_CHILLER_DOOR_STATE_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FRIDGE_CHILLER_DOOR_STATE, + CHANNEL_TYPE_DOOR, CoreItemFactory.CONTACT, getTranslationProvider() + .getText(I18N_FRIDGE_FREEZER_DOOR, getTranslationProvider().getText(CHILLER))); + doorAdded = true; + } + if (statusKeyExists(deviceDescriptionService, FREEZER_DOOR_STATE_KEY) + || statusKeyExists(deviceDescriptionService, FREEZER_DOOR_STATE_2_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FREEZER_DOOR_STATE, CHANNEL_TYPE_DOOR, + CoreItemFactory.CONTACT, getTranslationProvider().getText(I18N_FRIDGE_FREEZER_DOOR, + getTranslationProvider().getText(FREEZER))); + doorAdded = true; + } + + if (!doorAdded) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_DOOR, CHANNEL_TYPE_DOOR, + CoreItemFactory.CONTACT, getTranslationProvider().getText(I18N_DOOR)); + } + + // super modes + if (settingKeyExists(deviceDescriptionService, FRIDGE_SUPER_MODE_KEY) + || settingKeyExists(deviceDescriptionService, FRIDGE_SUPER_MODE_2_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FRIDGE_SUPER_MODE, + CHANNEL_TYPE_FRIDGE_FREEZER_SUPER_MODE, CoreItemFactory.SWITCH, getTranslationProvider() + .getText(I18N_FRIDGE_FREEZER_SUPER_MODE, getTranslationProvider().getText(FRIDGE))); + } + + if (settingKeyExists(deviceDescriptionService, FREEZER_SUPER_MODE_KEY) + || settingKeyExists(deviceDescriptionService, FREEZER_SUPER_MODE_2_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FREEZER_SUPER_MODE, + CHANNEL_TYPE_FRIDGE_FREEZER_SUPER_MODE, CoreItemFactory.SWITCH, getTranslationProvider() + .getText(I18N_FRIDGE_FREEZER_SUPER_MODE, getTranslationProvider().getText(FREEZER))); + } + + // dispenser + if (statusKeyExists(deviceDescriptionService, FRIDGE_DISPENSER_WATER_FILTER_SATURATION_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FRIDGE_DISPENSER_WATER_FILTER_SATURATION, + CHANNEL_TYPE_FRIDGE_DISPENSER_WATER_FILTER_SATURATION, NUMBER_DIMENSIONLESS, + getTranslationProvider().getText(I18N_FRIDGE_DISPENSER_WATER_FILTER_SATURATION)); + } + if (settingKeyExists(deviceDescriptionService, FRIDGE_DISPENSER_ENABLED_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FRIDGE_DISPENSER_ENABLED, + CHANNEL_TYPE_FRIDGE_DISPENSER_ENABLED, CoreItemFactory.SWITCH, + getTranslationProvider().getText(I18N_FRIDGE_DISPENSER_ENABLED)); + } + if (settingKeyExists(deviceDescriptionService, FRIDGE_DISPENSER_PARTY_MODE_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FRIDGE_DISPENSER_PARTY_MODE, + CHANNEL_TYPE_FRIDGE_DISPENSER_PARTY_MODE, CoreItemFactory.SWITCH, + getTranslationProvider().getText(I18N_FRIDGE_DISPENSER_PARTY_MODE)); + } + + // chiller preset + if (settingKeyExists(deviceDescriptionService, FRIDGE_CHILLER_PRESET_KEY)) { + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_FRIDGE_CHILLER_PRESET, + CHANNEL_TYPE_FRIDGE_CHILLER_PRESET, CoreItemFactory.STRING, + getTranslationProvider().getText(I18N_FRIDGE_CHILLER_PRESET)); + } + + // update channels + if (channelsChanged) { + updateThing(thingBuilder.build()); + } + }); + } + + private Unit getTemperatureUnitOfSetting(String settingKey) { + var unit = getDeviceDescriptionServiceOptional().map(deviceDescriptionService -> { + var setting = deviceDescriptionService.findSettingByKey(settingKey); + if (setting != null) { + return ContentType.TEMPERATURE_CELSIUS.equals(setting.contentType()) ? CELSIUS : FAHRENHEIT; + } + return CELSIUS; + }).orElse(null); + return Objects.requireNonNullElse(unit, CELSIUS); + } + + private void sendTemperatureSetting(String settingKey, Command command) { + var unit = getTemperatureUnitOfSetting(settingKey); + QuantityType quantity; + if (command instanceof QuantityType qt) { + quantity = qt; + } else if (command instanceof DecimalType dt) { + quantity = new QuantityType<>(dt, unit); + } else { + throw new IllegalArgumentException( + "Unsupported command type: " + command.getClass().getName() + " for setting " + settingKey); + } + var temperatureQuantityType = quantity.toUnit(unit); + if (temperatureQuantityType != null) { + sendIntegerSettingIfAllowed(temperatureQuantityType, settingKey); + } else { + logger.warn("Could not set temperature! uid={}", getThing().getUID()); + } + } + + private boolean settingKeyExists(String settingKey) { + var deviceDescriptionService = getDeviceDescriptionService(); + return deviceDescriptionService != null && settingKeyExists(deviceDescriptionService, settingKey); + } + + private boolean settingKeyExists(DeviceDescriptionService deviceDescriptionService, String settingKey) { + return deviceDescriptionService.getSetting(settingKey, false, false, false) != null; + } + + private boolean statusKeyExists(DeviceDescriptionService deviceDescriptionService, String statusKey) { + return deviceDescriptionService.getStatus(statusKey, false, false, false) != null; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectHoodHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectHoodHandler.java new file mode 100644 index 0000000000..3a6b5c778a --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectHoodHandler.java @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource.RO_ACTIVE_PROGRAM; +import static org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource.RO_VALUES; +import static org.openhab.core.library.unit.Units.PERCENT; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicCommandDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicStateDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ProgramData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ValueData; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomeConnectDirectHoodHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectHoodHandler extends BaseHomeConnectDirectHandler { + + private final Logger logger; + + public HomeConnectDirectHoodHandler(Thing thing, ApplianceProfileService applianceProfileService, + HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider, + HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider, String deviceId, + HomeConnectDirectConfiguration configuration, HomeConnectDirectTranslationProvider translationProvider) { + super(thing, applianceProfileService, commandDescriptionProvider, stateDescriptionProvider, deviceId, + configuration, translationProvider); + + this.logger = LoggerFactory.getLogger(HomeConnectDirectHoodHandler.class); + } + + @Override + protected void initializeFinished() { + initializeAllStates(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + super.handleCommand(channelUID, command); + + if (CHANNEL_COOKING_LIGHT.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanSettingIfAllowed(command, COOKING_LIGHTING_KEY); + } else if (CHANNEL_COOKING_LIGHT_BRIGHTNESS.equals(channelUID.getId()) && command instanceof Number quantity) { + mapSettingKey(COOKING_LIGHTING_BRIGHTNESS_KEY).ifPresent(optionUid -> { + double value = quantity.doubleValue(); + + // percent value + if (value > 0 && value < 1) { + value *= 100; + } + if (value > 100) { + value = 100; + } + send(Action.POST, RO_VALUES, List.of(new ValueData(optionUid, (int) value)), null, 1); + }); + } else if (CHANNEL_BUTTON_TONES.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanSettingIfAllowed(command, COOKING_BUTTON_TONES_KEY); + } else if (CHANNEL_HOOD_VENTING_LEVEL.equals(channelUID.getId()) && command instanceof StringType) { + var programUid = mapProgramKey(STATE_HOOD_VENTING); + var optionUid = mapOptionKey(HOOD_VENTING_LEVEL_KEY); + var optionEnumValue = mapEnumerationValueKey(HOOD_VENTING_LEVEL_ENUM_KEY, command.toFullString()); + + logger.debug("Setting up venting level state. programUid={} optionUid={}, optionEnumValue={}", programUid, + optionUid, optionEnumValue); + if (Stream.of(programUid, optionUid, optionEnumValue).allMatch(Optional::isPresent)) { + send(Action.POST, RO_ACTIVE_PROGRAM, List.of(new ProgramData(programUid.get(), + List.of(new ValueData(optionUid.get(), optionEnumValue.get())))), null, 1); + } + } else if (CHANNEL_HOOD_INTENSIVE_LEVEL.equals(channelUID.getId()) && command instanceof StringType) { + var programUid = mapProgramKey(STATE_HOOD_VENTING); + var optionUid = mapOptionKey(HOOD_INTENSIVE_LEVEL_KEY); + var optionEnumValue = mapEnumerationValueKey(HOOD_INTENSIVE_LEVEL_ENUM_KEY, command.toFullString()); + + logger.debug("Setting up intensive level state. programUid={} optionUid={}, optionEnumValue={}", programUid, + optionUid, optionEnumValue); + if (Stream.of(programUid, optionUid, optionEnumValue).allMatch(Optional::isPresent)) { + send(Action.POST, RO_ACTIVE_PROGRAM, List.of(new ProgramData(programUid.get(), + List.of(new ValueData(optionUid.get(), optionEnumValue.get())))), null, 1); + } + } + } + + @Override + protected void onApplianceDescriptionChangeEvent(List deviceDescriptionChanges) { + super.onApplianceDescriptionChangeEvent(deviceDescriptionChanges); + + deviceDescriptionChanges.forEach(deviceDescriptionChange -> { + var key = deviceDescriptionChange.key(); + switch (key) { + case HOOD_VENTING_LEVEL_KEY -> updateEnumOptionDescriptionIfLinked(CHANNEL_HOOD_VENTING_LEVEL, key); + case HOOD_INTENSIVE_LEVEL_KEY -> updateEnumOptionDescriptionIfLinked(CHANNEL_HOOD_INTENSIVE_LEVEL, key); + case ROOT_OPTION_LIST_KEY -> { + updateEnumOptionDescriptionIfLinked(CHANNEL_HOOD_VENTING_LEVEL, key); + updateEnumOptionDescriptionIfLinked(CHANNEL_HOOD_INTENSIVE_LEVEL, key); + } + } + }); + } + + @Override + protected void onApplianceValueEvent(Value value, Resource resource) { + super.onApplianceValueEvent(value, resource); + + switch (value.key()) { + case COOKING_LIGHTING_KEY -> + updateStateIfLinked(CHANNEL_COOKING_LIGHT, OnOffType.from(value.getValueAsBoolean())); + case COOKING_LIGHTING_BRIGHTNESS_KEY -> updateStateIfLinked(CHANNEL_COOKING_LIGHT_BRIGHTNESS, + () -> new QuantityType<>(value.getValueAsInt(), PERCENT)); + case COOKING_BUTTON_TONES_KEY -> + updateStateIfLinked(CHANNEL_BUTTON_TONES, OnOffType.from(value.getValueAsBoolean())); + case HOOD_VENTING_LEVEL_KEY -> { + updateStateIfLinked(CHANNEL_HOOD_VENTING_LEVEL, new StringType(value.getValueAsString())); + if (!STATE_FAN_OFF.equals(value.value())) { + updateStateIfLinked(CHANNEL_HOOD_INTENSIVE_LEVEL, new StringType(STATE_INTENSIVE_STAGE_OFF)); + } + } + case HOOD_INTENSIVE_LEVEL_KEY -> { + updateStateIfLinked(CHANNEL_HOOD_INTENSIVE_LEVEL, new StringType(value.getValueAsString())); + if (!STATE_INTENSIVE_STAGE_OFF.equals(value.value())) { + updateStateIfLinked(CHANNEL_HOOD_VENTING_LEVEL, new StringType(STATE_FAN_OFF)); + } + } + case POWER_STATE_KEY -> { + if (STATE_OFF.equalsIgnoreCase(value.getValueAsString())) { + updateStateIfLinked(CHANNEL_HOOD_VENTING_LEVEL, new StringType(STATE_FAN_OFF)); + updateStateIfLinked(CHANNEL_HOOD_INTENSIVE_LEVEL, new StringType(STATE_INTENSIVE_STAGE_OFF)); + } + } + } + } + + @Override + public void channelLinked(ChannelUID channelUID) { + super.channelLinked(channelUID); + initializeState(channelUID.getId()); + } + + private void initializeAllStates() { + Set.of(CHANNEL_BUTTON_TONES, CHANNEL_HOOD_VENTING_LEVEL, CHANNEL_HOOD_INTENSIVE_LEVEL, CHANNEL_COOKING_LIGHT, + CHANNEL_COOKING_LIGHT_BRIGHTNESS).forEach(this::initializeState); + } + + private void initializeState(String channelId) { + switch (channelId) { + case CHANNEL_BUTTON_TONES, CHANNEL_COOKING_LIGHT -> updateStateIfLinked(channelId, OnOffType.OFF); + case CHANNEL_HOOD_VENTING_LEVEL -> { + updateState(channelId, new StringType(STATE_FAN_OFF)); + updateEnumOptionDescriptionIfLinked(channelId, HOOD_VENTING_LEVEL_KEY); + } + case CHANNEL_HOOD_INTENSIVE_LEVEL -> { + updateState(channelId, new StringType(STATE_INTENSIVE_STAGE_OFF)); + updateEnumOptionDescriptionIfLinked(channelId, HOOD_INTENSIVE_LEVEL_KEY); + } + case CHANNEL_COOKING_LIGHT_BRIGHTNESS -> updateStateIfLinked(channelId, new QuantityType<>(0, PERCENT)); + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectOvenHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectOvenHandler.java new file mode 100644 index 0000000000..b927807f3c --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectOvenHandler.java @@ -0,0 +1,537 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource.RO_ACTIVE_PROGRAM; +import static org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource.RO_ALL_MANDATORY_VALUES; +import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT; +import static org.openhab.core.library.unit.SIUnits.CELSIUS; +import static org.openhab.core.library.unit.Units.SECOND; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import javax.measure.Unit; +import javax.measure.quantity.Temperature; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.handler.model.DynamicChannel; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicCommandDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicStateDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.DeviceDescriptionService; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ContentType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Enumeration; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ProgramData; +import org.openhab.core.library.CoreItemFactory; +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.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.types.Command; +import org.openhab.core.types.CommandOption; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomeConnectDirectOvenHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectOvenHandler extends BaseHomeConnectDirectHandler { + + private static final String DEFAULT_CAVITY_NAME = "Main"; + private static final int DEFAULT_CAVITY_INDEX = 0; + private static final long POLLING_INTERVAL_RUN_SECONDS = 60; + private static final long POLLING_INTERVAL_COOLDOWN_SECONDS = 300; + private static final long POLLING_INTERVAL_IDLE_SECONDS = 900; + private static final int TEMPERATURE_THRESHOLD_HIGH = 50; + private static final int TEMPERATURE_THRESHOLD_LOW = 30; + + private final Logger logger; + private final CopyOnWriteArraySet doorChannels; + private final CopyOnWriteArraySet currentTemperatureChannels; + private final CopyOnWriteArraySet meatProbeChannels; + private final CopyOnWriteArraySet lightChannels; + private final CopyOnWriteArraySet meatProbePluggedChannels; + private final AtomicInteger maxCavityTemperature; + + private @Nullable ScheduledFuture pollingFuture; + private long currentPollingIntervalSeconds; + + public HomeConnectDirectOvenHandler(Thing thing, ApplianceProfileService applianceProfileService, + HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider, + HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider, String deviceId, + HomeConnectDirectConfiguration configuration, HomeConnectDirectTranslationProvider translationProvider) { + super(thing, applianceProfileService, commandDescriptionProvider, stateDescriptionProvider, deviceId, + configuration, translationProvider); + + this.logger = LoggerFactory.getLogger(HomeConnectDirectOvenHandler.class); + this.doorChannels = new CopyOnWriteArraySet<>(); + this.currentTemperatureChannels = new CopyOnWriteArraySet<>(); + this.meatProbeChannels = new CopyOnWriteArraySet<>(); + this.lightChannels = new CopyOnWriteArraySet<>(); + this.meatProbePluggedChannels = new CopyOnWriteArraySet<>(); + this.maxCavityTemperature = new AtomicInteger(0); + } + + @Override + protected void initializeStarted() { + addCavityChannels(); + } + + @Override + protected void initializeFinished() { + initializeAllStates(); + updateValuesPolling(); + } + + @Override + public void dispose() { + stopValuesPolling(); + super.dispose(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + super.handleCommand(channelUID, command); + + if (CHANNEL_OVEN_DURATION.equals(channelUID.getId()) && command instanceof QuantityType quantity) { + var durationQuantityType = quantity.toUnit(SECOND); + if (durationQuantityType != null) { + sendIntegerOptionIfAllowed(durationQuantityType, OVEN_DURATION_KEY); + } else { + logger.warn("Could not set duration! uid={}", getThing().getUID()); + } + } else if (CHANNEL_OVEN_SET_POINT_TEMPERATURE.equals(channelUID.getId()) + && (command instanceof QuantityType || command instanceof DecimalType)) { + var unit = getTemperatureUnitOfOption(OVEN_SET_POINT_TEMPERATURE_KEY); + QuantityType quantity = (command instanceof QuantityType qt) ? qt + : new QuantityType<>(((DecimalType) command), unit); + var temperatureQuantityType = quantity.toUnit(unit); + if (temperatureQuantityType != null) { + sendIntegerOptionIfAllowed(temperatureQuantityType, OVEN_SET_POINT_TEMPERATURE_KEY); + } else { + logger.warn("Could not set temperature! uid={}", getThing().getUID()); + } + } else if (CHANNEL_OVEN_PROGRAM_COMMAND.equals(channelUID.getId()) && command instanceof StringType) { + if (COMMAND_START.equalsIgnoreCase(command.toFullString())) { + var selectedProgram = getKeyValueStore().get(SELECTED_PROGRAM_KEY); + if (selectedProgram != null) { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.getActiveProgram(true) != null) { + mapProgramKey(selectedProgram).ifPresent(programUid -> send(Action.POST, RO_ACTIVE_PROGRAM, + List.of(new ProgramData(programUid, null)), null, 1)); + } else { + logger.info( + "The '{}' control is either unavailable or in read-only mode. Cannot start program.", + ACTIVE_PROGRAM_KEY); + } + }); + + } + } else if (COMMAND_PAUSE.equalsIgnoreCase(command.toFullString())) { + sendBooleanCommandIfAllowed(PAUSE_PROGRAM_KEY); + } else if (COMMAND_RESUME.equalsIgnoreCase(command.toFullString())) { + sendBooleanCommandIfAllowed(RESUME_PROGRAM_KEY); + } else if (COMMAND_STOP.equalsIgnoreCase(command.toFullString())) { + sendBooleanCommandIfAllowed(ABORT_PROGRAM_KEY); + } + } + + // dynamic stuff + lightChannels.stream().filter(dynamicChannel -> channelUID.getId().equals(dynamicChannel.channelName())) + .forEach(dynamicChannel -> sendBooleanSettingIfAllowed(command, dynamicChannel.key())); + } + + @Override + protected void onApplianceDescriptionChangeEvent(List deviceDescriptionChanges) { + super.onApplianceDescriptionChangeEvent(deviceDescriptionChanges); + + deviceDescriptionChanges.forEach(deviceDescriptionChange -> { + if (OVEN_DURATION_KEY.equals(deviceDescriptionChange.key())) { + updateIntegerOptionDescriptionIfLinked(CHANNEL_OVEN_DURATION, deviceDescriptionChange.key()); + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var durationOption = deviceDescriptionService.getOption(deviceDescriptionChange.key(), true, true, + false); + if (durationOption == null) { + updateStateIfLinked(CHANNEL_OVEN_DURATION, UnDefType.UNDEF); + } + }); + } else if (OVEN_SET_POINT_TEMPERATURE_KEY.equals(deviceDescriptionChange.key())) { + updateIntegerOptionDescriptionIfLinked(CHANNEL_OVEN_SET_POINT_TEMPERATURE, + deviceDescriptionChange.key()); + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var setPointOption = deviceDescriptionService.getOption(deviceDescriptionChange.key(), true, true, + false); + if (setPointOption == null) { + updateStateIfLinked(CHANNEL_OVEN_SET_POINT_TEMPERATURE, UnDefType.UNDEF); + } + }); + } else if (DeviceDescriptionType.COMMAND.equals(deviceDescriptionChange.type()) + || DeviceDescriptionType.COMMAND_LIST.equals(deviceDescriptionChange.type())) { + updateProgramCommandDescription(); + } + + // if temperature isn't available -> set to UNDEF + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> Stream + .of(currentTemperatureChannels, meatProbeChannels).flatMap(Set::stream) + .filter(dynamicChannel -> deviceDescriptionChange.key().equals(dynamicChannel.key())) + .filter(dynamicChannel -> !deviceDescriptionService + .isStatusAvailableAndReadable(dynamicChannel.key())) + .forEach(dynamicChannel -> updateStateIfLinked(dynamicChannel.channelName(), UnDefType.UNDEF))); + }); + } + + @Override + protected void onApplianceValueEvent(Value value, Resource resource) { + super.onApplianceValueEvent(value, resource); + + switch (value.key()) { + case OVEN_DURATION_KEY -> + updateStateIfLinked(CHANNEL_OVEN_DURATION, () -> new QuantityType<>(value.getValueAsInt(), SECOND)); + case OVEN_SET_POINT_TEMPERATURE_KEY -> updateStateIfLinked(CHANNEL_OVEN_SET_POINT_TEMPERATURE, + () -> new QuantityType<>(value.getValueAsInt(), getTemperatureUnitOfOption(value.key()))); + case SELECTED_PROGRAM_KEY, ACTIVE_PROGRAM_KEY -> updateProgramCommandDescription(); + case OPERATION_STATE_KEY -> updateValuesPolling(); + } + + // track current cavity temperature and re-evaluate polling + if (currentTemperatureChannels.stream().anyMatch(dc -> value.key().equals(dc.key()))) { + maxCavityTemperature.set(value.getValueAsInt()); + updateValuesPolling(); + } + + // dynamic stuff + doorChannels.forEach(dynamicChannel -> { + if (value.key().equals(dynamicChannel.key())) { + updateStateIfLinked(dynamicChannel.channelName(), + () -> STATE_OPEN.equals(value.value()) || STATE_AJAR.equals(value.value()) ? OpenClosedType.OPEN + : OpenClosedType.CLOSED); + } + }); + Stream.of(currentTemperatureChannels, meatProbeChannels).flatMap(Set::stream) + .filter(dynamicChannel -> value.key().equals(dynamicChannel.key())).forEach(dynamicChannel -> { + var unit = ContentType.TEMPERATURE_CELSIUS.equals(dynamicChannel.contentType()) ? CELSIUS + : FAHRENHEIT; + updateStateIfLinked(dynamicChannel.channelName(), new QuantityType<>(value.getValueAsInt(), unit)); + }); + Stream.of(lightChannels, meatProbePluggedChannels).flatMap(Set::stream) + .filter(dynamicChannel -> value.key().equals(dynamicChannel.key())) + .forEach(dynamicChannel -> updateStateIfLinked(dynamicChannel.channelName(), + () -> OnOffType.from(value.getValueAsBoolean()))); + } + + @Override + public void channelLinked(ChannelUID channelUID) { + super.channelLinked(channelUID); + initializeState(channelUID.getId()); + } + + private void initializeAllStates() { + initializeState(CHANNEL_OVEN_DURATION); + initializeState(CHANNEL_OVEN_SET_POINT_TEMPERATURE); + initializeState(CHANNEL_OVEN_PROGRAM_COMMAND); + Stream.of(doorChannels, currentTemperatureChannels, meatProbeChannels, lightChannels, meatProbePluggedChannels) + .flatMap(Set::stream).map(DynamicChannel::channelName).forEach(this::initializeState); + } + + private void initializeState(String channelId) { + if (CHANNEL_OVEN_DURATION.equals(channelId)) { + updateIntegerOptionDescriptionIfLinked(channelId, OVEN_DURATION_KEY); + updateStateIfLinked(channelId, UnDefType.UNDEF); + } else if (CHANNEL_OVEN_SET_POINT_TEMPERATURE.equals(channelId)) { + updateIntegerOptionDescriptionIfLinked(channelId, OVEN_SET_POINT_TEMPERATURE_KEY); + updateStateIfLinked(channelId, UnDefType.UNDEF); + } else if (CHANNEL_OVEN_PROGRAM_COMMAND.equals(channelId)) { + updateProgramCommandDescription(); + } else if (isChannelInSet(doorChannels, channelId)) { + updateStateIfLinked(channelId, OpenClosedType.CLOSED); + } else if (isChannelInSet(currentTemperatureChannels, channelId) + || isChannelInSet(meatProbeChannels, channelId)) { + updateStateIfLinked(channelId, UnDefType.UNDEF); + } else if (isChannelInSet(lightChannels, channelId)) { + updateStateIfLinked(channelId, OnOffType.OFF); + } else if (isChannelInSet(meatProbePluggedChannels, channelId)) { + updateStateIfLinked(channelId, OnOffType.OFF); + } + } + + private boolean isChannelInSet(Set channels, String channelId) { + return channels.stream().anyMatch(dynamicChannel -> channelId.equals(dynamicChannel.channelName())); + } + + private void addCavityChannels() { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var thingBuilder = editThing(); + var channelsChanged = false; + + var cavitySelectorEnumType = deviceDescriptionService.findEnumerationType(OVEN_CAVITY_SELECTOR_ENUM_KEY); + if (cavitySelectorEnumType != null) { + for (Enumeration enumeration : cavitySelectorEnumType.enumerations().values()) { + var cavityIndex = enumeration.value(); + var cavityKey = enumeration.valueKey(); + + // door state + var doorStateKey = String.format(OVEN_DOOR_STATE_KEY_TEMPLATE, cavityIndex); + var doorChannel = String.format(CHANNEL_OVEN_DOOR_TEMPLATE, cavityIndex); + var doorStateStatusDescription = deviceDescriptionService.findStatusByKey(doorStateKey); + if (doorStateStatusDescription != null) { + doorChannels.add(new DynamicChannel(doorChannel, doorStateKey, + doorStateStatusDescription.contentType())); + channelsChanged |= addChannelIfNotExist(thingBuilder, doorChannel, CHANNEL_TYPE_DOOR, + CoreItemFactory.CONTACT, getTranslationProvider().getText(I18N_OVEN_DOOR, + getTranslationProvider().getText(cavityKey))); + } + + // temperatures + channelsChanged |= registerTemperatureChannelWithKeyTemplate(deviceDescriptionService, thingBuilder, + cavityIndex, OVEN_CURRENT_TEMPERATURE_KEY_TEMPLATE, CHANNEL_OVEN_TEMPERATURE_TEMPLATE, + CHANNEL_TYPE_OVEN_CURRENT_TEMPERATURE, I18N_OVEN_CURRENT_TEMPERATURE, + currentTemperatureChannels, cavityKey); + channelsChanged |= registerTemperatureChannelWithKeyTemplate(deviceDescriptionService, thingBuilder, + cavityIndex, OVEN_CURRENT_MEAT_PROBE_TEMPERATURE_KEY_TEMPLATE, + CHANNEL_OVEN_CURRENT_MEAT_PROBE_TEMPERATURE_TEMPLATE, + CHANNEL_TYPE_OVEN_CURRENT_MEAT_PROBE_TEMPERATURE, I18N_OVEN_CURRENT_MEAT_PROBE_TEMPERATURE, + meatProbeChannels, cavityKey); + + // lights + var lightKey = String.format(OVEN_CAVITY_LIGHT_KEY_TEMPLATE, cavityIndex); + var lightChannel = String.format(CHANNEL_OVEN_CAVITY_LIGHT_TEMPLATE, cavityIndex); + var lightSettingDescription = deviceDescriptionService.findSettingByKey(lightKey); + if (lightSettingDescription != null) { + lightChannels + .add(new DynamicChannel(lightChannel, lightKey, lightSettingDescription.contentType())); + channelsChanged |= addChannelIfNotExist(thingBuilder, lightChannel, + CHANNEL_TYPE_OVEN_CAVITY_LIGHT, CoreItemFactory.SWITCH, getTranslationProvider() + .getText(I18N_OVEN_CAVITY_LIGHT, getTranslationProvider().getText(cavityKey))); + } + + // meat probe plugged + var meatProbePluggedKey = String.format(OVEN_MEAT_PROBE_PLUGGED_KEY_TEMPLATE, cavityIndex); + var meatProbePluggedChannel = String.format(CHANNEL_OVEN_MEAT_PROBE_PLUGGED_TEMPLATE, cavityIndex); + var meatProbePluggedStatusDescription = deviceDescriptionService + .findStatusByKey(meatProbePluggedKey); + if (meatProbePluggedStatusDescription != null) { + meatProbePluggedChannels.add(new DynamicChannel(meatProbePluggedChannel, meatProbePluggedKey, + meatProbePluggedStatusDescription.contentType())); + channelsChanged |= addChannelIfNotExist(thingBuilder, meatProbePluggedChannel, + CHANNEL_TYPE_OVEN_MEAT_PROBE_PLUGGED, CoreItemFactory.SWITCH, + getTranslationProvider().getText(I18N_OVEN_MEAT_PROBE_PLUGGED, + getTranslationProvider().getText(cavityKey))); + } + } + } + + // temperatures (main cavity) + channelsChanged |= registerTemperatureChannel(deviceDescriptionService, thingBuilder, DEFAULT_CAVITY_INDEX, + OVEN_CURRENT_TEMPERATURE_KEY, CHANNEL_OVEN_TEMPERATURE_TEMPLATE, + CHANNEL_TYPE_OVEN_CURRENT_TEMPERATURE, I18N_OVEN_CURRENT_TEMPERATURE, currentTemperatureChannels, + DEFAULT_CAVITY_NAME); + channelsChanged |= registerTemperatureChannel(deviceDescriptionService, thingBuilder, DEFAULT_CAVITY_INDEX, + OVEN_CURRENT_MEAT_PROBE_TEMPERATURE_KEY, CHANNEL_OVEN_CURRENT_MEAT_PROBE_TEMPERATURE_TEMPLATE, + CHANNEL_TYPE_OVEN_CURRENT_MEAT_PROBE_TEMPERATURE, I18N_OVEN_CURRENT_MEAT_PROBE_TEMPERATURE, + meatProbeChannels, DEFAULT_CAVITY_NAME); + + // meat probe plugged (main cavity) + if (deviceDescriptionService.findStatusByKey(OVEN_MEAT_PROBE_PLUGGED_KEY) != null) { + var meatProbePluggedChannel = String.format(CHANNEL_OVEN_MEAT_PROBE_PLUGGED_TEMPLATE, + DEFAULT_CAVITY_INDEX); + channelsChanged |= addChannelIfNotExist(thingBuilder, meatProbePluggedChannel, + CHANNEL_TYPE_OVEN_MEAT_PROBE_PLUGGED, CoreItemFactory.SWITCH, getTranslationProvider().getText( + I18N_OVEN_MEAT_PROBE_PLUGGED, getTranslationProvider().getText(DEFAULT_CAVITY_NAME))); + } + + // door fallback + if (doorChannels.isEmpty()) { + var doorChannel = String.format(CHANNEL_OVEN_DOOR_TEMPLATE, DEFAULT_CAVITY_INDEX); + doorChannels.add(new DynamicChannel(doorChannel, DOOR_STATE_KEY, ContentType.ENUMERATION)); + channelsChanged |= addChannelIfNotExist(thingBuilder, doorChannel, CHANNEL_TYPE_DOOR, + CoreItemFactory.CONTACT, getTranslationProvider().getText(I18N_OVEN_DOOR, + getTranslationProvider().getText(DEFAULT_CAVITY_NAME))); + } + + if (channelsChanged) { + updateThing(thingBuilder.build()); + } + }); + } + + private boolean registerTemperatureChannel(DeviceDescriptionService deviceDescriptionService, + ThingBuilder thingBuilder, int cavityIndex, String key, String channelTemplate, String channelType, + String i18nKey, Set targetSet, String cavityKey) { + var channelName = String.format(channelTemplate, cavityIndex); + var statusDescription = deviceDescriptionService.findStatusByKey(key); + + if (statusDescription != null) { + targetSet.add(new DynamicChannel(channelName, key, statusDescription.contentType())); + return addChannelIfNotExist(thingBuilder, channelName, channelType, NUMBER_TEMPERATURE, + getTranslationProvider().getText(i18nKey, getTranslationProvider().getText(cavityKey))); + } + return false; + } + + private boolean registerTemperatureChannelWithKeyTemplate(DeviceDescriptionService deviceDescriptionService, + ThingBuilder thingBuilder, int cavityIndex, String keyTemplate, String channelTemplate, String channelType, + String i18nKey, Set targetSet, String cavityKey) { + var key = String.format(keyTemplate, cavityIndex); + return registerTemperatureChannel(deviceDescriptionService, thingBuilder, cavityIndex, key, channelTemplate, + channelType, i18nKey, targetSet, cavityKey); + } + + private Unit getTemperatureUnitOfOption(String optionKey) { + var unit = getDeviceDescriptionServiceOptional().map(deviceDescriptionService -> { + var option = deviceDescriptionService.findOptionByKey(optionKey); + if (option != null) { + return ContentType.TEMPERATURE_CELSIUS.equals(option.contentType()) ? CELSIUS : FAHRENHEIT; + } + return CELSIUS; + }).orElse(null); + return Objects.requireNonNullElse(unit, CELSIUS); + } + + private void updateProgramCommandDescription() { + getLinkedChannel(CHANNEL_OVEN_PROGRAM_COMMAND).ifPresent(channel -> { + var commandOptions = new ArrayList(); + + if (!STATE_NO_PROGRAM.equals(getKeyValueStore().getOrDefault(SELECTED_PROGRAM_KEY, STATE_NO_PROGRAM)) + || !STATE_NO_PROGRAM + .equals(getKeyValueStore().getOrDefault(ACTIVE_PROGRAM_KEY, STATE_NO_PROGRAM))) { + if (STATE_NO_PROGRAM.equals(getKeyValueStore().getOrDefault(ACTIVE_PROGRAM_KEY, STATE_NO_PROGRAM)) + && !STATE_NO_PROGRAM + .equals(getKeyValueStore().getOrDefault(SELECTED_PROGRAM_KEY, STATE_NO_PROGRAM))) { + // no active program and program selected -> show start option + commandOptions.add( + new CommandOption(COMMAND_START, getTranslationProvider().getText(I18N_START_PROGRAM))); + } else { + commandOptions + .add(new CommandOption(COMMAND_STOP, getTranslationProvider().getText(I18N_STOP_PROGRAM))); + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (deviceDescriptionService.isCommandAvailableAndWritable(RESUME_PROGRAM_KEY)) { + commandOptions.add(new CommandOption(COMMAND_RESUME, + getTranslationProvider().getText(I18N_RESUME_PROGRAM))); + } else if (deviceDescriptionService.isCommandAvailableAndWritable(PAUSE_PROGRAM_KEY)) { + commandOptions.add(new CommandOption(COMMAND_PAUSE, + getTranslationProvider().getText(I18N_PAUSE_PROGRAM))); + } + }); + } + } + + setCommandOptions(channel.getUID(), commandOptions); + }); + } + + /** + * Checks if any registered temperature status has notifyOnChange set to false. + */ + private boolean hasTemperatureStatusWithoutNotifyOnChange() { + var deviceDescriptionService = getDeviceDescriptionServiceOptional().orElse(null); + if (deviceDescriptionService == null) { + return false; + } + + return Stream.of(currentTemperatureChannels, meatProbeChannels).flatMap(Set::stream).map(DynamicChannel::key) + .map(deviceDescriptionService::findStatusByKey).filter(Objects::nonNull) + .anyMatch(status -> !status.notifyOnChange()); + } + + /** + * Determines the required polling interval based on the current operation state and cavity temperature, + * then starts, adjusts, or stops polling accordingly. + * + * Polling rules: + * - Operation state "Run" and notifyOnChange is false: poll every 60 seconds + * - Not running, temperature > 50°C: poll every 60 seconds + * - Not running, temperature 30-50°C: poll every 5 minutes + * - Not running, temperature < 30°C: stop polling + */ + private void updateValuesPolling() { + if (!hasTemperatureStatusWithoutNotifyOnChange()) { + stopValuesPolling(); + return; + } + + var operationState = getKeyValueStore().get(OPERATION_STATE_KEY); + if (STATE_RUN.equals(operationState)) { + scheduleValuesPolling(POLLING_INTERVAL_RUN_SECONDS); + return; + } + + // after leaving "Run" state, keep polling based on the current cavity temperature + // to track the cool-down phase + var temperature = maxCavityTemperature.get(); + if (temperature > TEMPERATURE_THRESHOLD_HIGH) { + scheduleValuesPolling(POLLING_INTERVAL_RUN_SECONDS); + } else if (temperature >= TEMPERATURE_THRESHOLD_LOW) { + scheduleValuesPolling(POLLING_INTERVAL_COOLDOWN_SECONDS); + } else { + scheduleValuesPolling(POLLING_INTERVAL_IDLE_SECONDS); + } + } + + private synchronized void scheduleValuesPolling(long intervalSeconds) { + var pollingFuture = this.pollingFuture; + + // restart polling if interval changed + if (pollingFuture != null && !pollingFuture.isCancelled() && !pollingFuture.isDone() + && currentPollingIntervalSeconds != intervalSeconds) { + logger.debug("Polling interval changed from {} to {} second(s), rescheduling ({}).", + currentPollingIntervalSeconds, intervalSeconds, getThing().getUID()); + pollingFuture.cancel(false); + pollingFuture = null; + } + + if (pollingFuture == null || pollingFuture.isCancelled() || pollingFuture.isDone()) { + logger.debug("Schedule mandatory values polling every {} second(s) ({}).", intervalSeconds, + getThing().getUID()); + currentPollingIntervalSeconds = intervalSeconds; + this.pollingFuture = scheduler.scheduleWithFixedDelay(() -> sendGet(RO_ALL_MANDATORY_VALUES), + intervalSeconds, intervalSeconds, TimeUnit.SECONDS); + } + } + + private synchronized void stopValuesPolling() { + var pollingFuture = this.pollingFuture; + + if (pollingFuture != null) { + logger.debug("Stop mandatory values polling ({}).", getThing().getUID()); + pollingFuture.cancel(true); + this.pollingFuture = null; + currentPollingIntervalSeconds = 0; + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectWasherDryerHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectWasherDryerHandler.java new file mode 100644 index 0000000000..89f8ca9920 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/HomeConnectDirectWasherDryerHandler.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.core.library.unit.SIUnits.GRAM; + +import java.util.List; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.i18n.HomeConnectDirectTranslationProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicCommandDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.provider.HomeConnectDirectDynamicStateDescriptionProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.DeviceDescriptionService; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; + +/** + * The {@link HomeConnectDirectWasherDryerHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectWasherDryerHandler extends BaseHomeConnectDirectHandler { + public HomeConnectDirectWasherDryerHandler(Thing thing, ApplianceProfileService applianceProfileService, + HomeConnectDirectDynamicCommandDescriptionProvider commandDescriptionProvider, + HomeConnectDirectDynamicStateDescriptionProvider stateDescriptionProvider, String deviceId, + HomeConnectDirectConfiguration configuration, HomeConnectDirectTranslationProvider translationProvider) { + super(thing, applianceProfileService, commandDescriptionProvider, stateDescriptionProvider, deviceId, + configuration, translationProvider); + } + + @Override + protected void initializeStarted() { + addIDosChannels(); + } + + @Override + protected void initializeFinished() { + initializeAllStates(); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + super.handleCommand(channelUID, command); + + if (CHANNEL_WASHER_I_DOS_1_ACTIVE.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, WASHER_I_DOS_1_ACTIVE_KEY); + } else if (CHANNEL_WASHER_I_DOS_2_ACTIVE.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, WASHER_I_DOS_2_ACTIVE_KEY); + } else if (CHANNEL_WASHER_TEMPERATURE.equals(channelUID.getId()) && command instanceof StringType) { + sendEnumOptionIfAllowed(command, WASHER_TEMPERATURE_KEY); + } else if (CHANNEL_WASHER_SPIN_SPEED.equals(channelUID.getId()) && command instanceof StringType) { + sendEnumOptionIfAllowed(command, WASHER_SPIN_SPEED_KEY); + } else if (CHANNEL_WASHER_RINSE_PLUS.equals(channelUID.getId()) && command instanceof StringType) { + sendEnumOptionIfAllowed(command, WASHER_RINSE_PLUS_KEY); + } else if (CHANNEL_WASHER_STAINS.equals(channelUID.getId()) && command instanceof StringType) { + sendEnumOptionIfAllowed(command, WASHER_STAINS_KEY); + } else if (CHANNEL_DRYER_DRYING_TARGET.equals(channelUID.getId()) && command instanceof StringType) { + sendEnumOptionIfAllowed(command, DRYER_DRYING_TARGET_KEY); + } else if (CHANNEL_DRYER_WRINKLE_GUARD.equals(channelUID.getId()) && command instanceof StringType) { + sendEnumOptionIfAllowed(command, DRYER_WRINKLE_GUARD_KEY); + } else if (CHANNEL_WASHER_SPEED_PERFECT.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, WASHER_SPEED_PERFECT_KEY); + } else if (CHANNEL_WASHER_WATER_PLUS.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, WASHER_WATER_PLUS_KEY); + } else if (CHANNEL_WASHER_PREWASH.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, WASHER_PREWASH_KEY); + } else if (CHANNEL_WASHER_RINSE_HOLD.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, WASHER_RINSE_HOLD_KEY); + } else if (CHANNEL_WASHER_LESS_IRONING.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, WASHER_LESS_IRONING_KEY); + } else if (CHANNEL_WASHER_SILENT_WASH.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, WASHER_SILENT_WASH_KEY); + } else if (CHANNEL_WASHER_SOAK.equals(channelUID.getId()) && command instanceof OnOffType) { + sendBooleanOptionIfAllowed(command, WASHER_SOAK_KEY); + } + } + + @Override + protected void onApplianceDescriptionChangeEvent(List deviceDescriptionChanges) { + super.onApplianceDescriptionChangeEvent(deviceDescriptionChanges); + + deviceDescriptionChanges.forEach(deviceDescriptionChange -> { + var key = deviceDescriptionChange.key(); + switch (key) { + case LAUNDRY_CARE_PROCESS_PHASE_KEY, DRYER_PROCESS_PHASE_KEY -> + updateReadonlyEnumOptionDescriptionIfLinked(CHANNEL_LAUNDRY_CARE_PROCESS_PHASE, key); + case WASHER_TEMPERATURE_KEY -> updateEnumOptionDescriptionIfLinked(CHANNEL_WASHER_TEMPERATURE, key); + case WASHER_SPIN_SPEED_KEY -> updateEnumOptionDescriptionIfLinked(CHANNEL_WASHER_SPIN_SPEED, key); + case WASHER_RINSE_PLUS_KEY -> updateEnumOptionDescriptionIfLinked(CHANNEL_WASHER_RINSE_PLUS, key); + case WASHER_STAINS_KEY -> updateEnumOptionDescriptionIfLinked(CHANNEL_WASHER_STAINS, key); + case DRYER_DRYING_TARGET_KEY -> updateEnumOptionDescriptionIfLinked(CHANNEL_DRYER_DRYING_TARGET, key); + case DRYER_WRINKLE_GUARD_KEY -> updateEnumOptionDescriptionIfLinked(CHANNEL_DRYER_WRINKLE_GUARD, key); + case ROOT_OPTION_LIST_KEY, LAUNDRY_CARE_OPTION_LIST_KEY -> { + updateReadonlyEnumOptionDescriptionIfLinked(CHANNEL_LAUNDRY_CARE_PROCESS_PHASE, key); + updateEnumOptionDescriptionIfLinked(CHANNEL_WASHER_TEMPERATURE, key); + updateEnumOptionDescriptionIfLinked(CHANNEL_WASHER_SPIN_SPEED, key); + updateEnumOptionDescriptionIfLinked(CHANNEL_WASHER_RINSE_PLUS, key); + updateEnumOptionDescriptionIfLinked(CHANNEL_WASHER_STAINS, key); + updateEnumOptionDescriptionIfLinked(CHANNEL_DRYER_DRYING_TARGET, key); + updateEnumOptionDescriptionIfLinked(CHANNEL_DRYER_WRINKLE_GUARD, key); + } + } + }); + } + + @Override + protected void onApplianceValueEvent(Value value, Resource resource) { + super.onApplianceValueEvent(value, resource); + + switch (value.key()) { + case LAUNDRY_CARE_PROCESS_PHASE_KEY, DRYER_PROCESS_PHASE_KEY -> + updateStateIfLinked(CHANNEL_LAUNDRY_CARE_PROCESS_PHASE, new StringType(value.getValueAsString())); + case WASHER_I_DOS_1_FILL_LEVEL_POOR_KEY -> updateStateIfLinked(CHANNEL_WASHER_I_DOS_1_FILL_LEVEL_POOR, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_POOR.equalsIgnoreCase(value.getValueAsString()))); + case WASHER_I_DOS_2_FILL_LEVEL_POOR_KEY -> updateStateIfLinked(CHANNEL_WASHER_I_DOS_2_FILL_LEVEL_POOR, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_POOR.equalsIgnoreCase(value.getValueAsString()))); + case WASHER_I_DOS_1_ACTIVE_KEY -> + updateStateIfLinked(CHANNEL_WASHER_I_DOS_1_ACTIVE, () -> OnOffType.from(value.getValueAsBoolean())); + case WASHER_I_DOS_2_ACTIVE_KEY -> + updateStateIfLinked(CHANNEL_WASHER_I_DOS_2_ACTIVE, () -> OnOffType.from(value.getValueAsBoolean())); + case WASHER_TEMPERATURE_KEY -> getLinkedChannel(CHANNEL_WASHER_TEMPERATURE).ifPresent(channel -> { + getKeyValueStore().put(WASHER_TEMPERATURE_KEY, value.getValueAsString()); + updateState(channel.getUID(), new StringType(value.getValueAsString())); + }); + case WASHER_SPIN_SPEED_KEY -> getLinkedChannel(CHANNEL_WASHER_SPIN_SPEED).ifPresent(channel -> { + getKeyValueStore().put(WASHER_SPIN_SPEED_KEY, value.getValueAsString()); + updateState(channel.getUID(), new StringType(value.getValueAsString())); + }); + case WASHER_RINSE_PLUS_KEY -> getLinkedChannel(CHANNEL_WASHER_RINSE_PLUS).ifPresent(channel -> { + getKeyValueStore().put(WASHER_RINSE_PLUS_KEY, value.getValueAsString()); + updateState(channel.getUID(), new StringType(value.getValueAsString())); + }); + case WASHER_STAINS_KEY -> getLinkedChannel(CHANNEL_WASHER_STAINS).ifPresent(channel -> { + getKeyValueStore().put(WASHER_STAINS_KEY, value.getValueAsString()); + updateState(channel.getUID(), new StringType(value.getValueAsString())); + }); + case LAUNDRY_LOAD_INFORMATION_KEY, LAUNDRY_LOAD_KEY -> updateStateIfLinked(CHANNEL_LAUNDRY_LOAD_INFORMATION, + () -> new QuantityType<>(value.getValueAsInt(), GRAM)); + case LAUNDRY_LOAD_RECOMMENDATION_KEY -> updateStateIfLinked(CHANNEL_LAUNDRY_LOAD_RECOMMENDATION, + () -> new QuantityType<>(value.getValueAsInt(), GRAM)); + case LAUNDRY_DRUM_CLEAN_REMINDER_KEY -> updateStateIfLinked(CHANNEL_LAUNDRY_DRUM_CLEAN_REMINDER, + () -> OnOffType.from(STATE_PRESENT.equalsIgnoreCase(value.getValueAsString()) + || STATE_CONFIRMED.equalsIgnoreCase(value.getValueAsString()))); + case DRYER_DRYING_TARGET_KEY -> getLinkedChannel(CHANNEL_DRYER_DRYING_TARGET).ifPresent(channel -> { + getKeyValueStore().put(DRYER_DRYING_TARGET_KEY, value.getValueAsString()); + updateState(channel.getUID(), new StringType(value.getValueAsString())); + }); + case DRYER_WRINKLE_GUARD_KEY -> getLinkedChannel(CHANNEL_DRYER_WRINKLE_GUARD).ifPresent(channel -> { + getKeyValueStore().put(DRYER_WRINKLE_GUARD_KEY, value.getValueAsString()); + updateState(channel.getUID(), new StringType(value.getValueAsString())); + }); + case WASHER_SPEED_PERFECT_KEY -> + updateStateIfLinked(CHANNEL_WASHER_SPEED_PERFECT, () -> OnOffType.from(value.getValueAsBoolean())); + case WASHER_WATER_PLUS_KEY -> + updateStateIfLinked(CHANNEL_WASHER_WATER_PLUS, () -> OnOffType.from(value.getValueAsBoolean())); + case WASHER_PREWASH_KEY -> + updateStateIfLinked(CHANNEL_WASHER_PREWASH, () -> OnOffType.from(value.getValueAsBoolean())); + case WASHER_RINSE_HOLD_KEY -> + updateStateIfLinked(CHANNEL_WASHER_RINSE_HOLD, () -> OnOffType.from(value.getValueAsBoolean())); + case WASHER_LESS_IRONING_KEY -> + updateStateIfLinked(CHANNEL_WASHER_LESS_IRONING, () -> OnOffType.from(value.getValueAsBoolean())); + case WASHER_SILENT_WASH_KEY -> + updateStateIfLinked(CHANNEL_WASHER_SILENT_WASH, () -> OnOffType.from(value.getValueAsBoolean())); + case WASHER_SOAK_KEY -> + updateStateIfLinked(CHANNEL_WASHER_SOAK, () -> OnOffType.from(value.getValueAsBoolean())); + } + } + + @Override + public void channelLinked(ChannelUID channelUID) { + super.channelLinked(channelUID); + initializeState(channelUID.getId()); + } + + private void initializeAllStates() { + Set.of(CHANNEL_LAUNDRY_CARE_PROCESS_PHASE, CHANNEL_WASHER_TEMPERATURE, CHANNEL_WASHER_SPIN_SPEED, + CHANNEL_WASHER_RINSE_PLUS, CHANNEL_WASHER_STAINS, CHANNEL_DRYER_DRYING_TARGET, + CHANNEL_DRYER_WRINKLE_GUARD, CHANNEL_LAUNDRY_DRUM_CLEAN_REMINDER, CHANNEL_WASHER_SPEED_PERFECT, + CHANNEL_LAUNDRY_LOAD_INFORMATION, CHANNEL_LAUNDRY_LOAD_RECOMMENDATION, CHANNEL_WASHER_WATER_PLUS, + CHANNEL_WASHER_PREWASH, CHANNEL_WASHER_RINSE_HOLD, CHANNEL_WASHER_LESS_IRONING, + CHANNEL_WASHER_SILENT_WASH, CHANNEL_WASHER_SOAK, CHANNEL_WASHER_I_DOS_1_ACTIVE, + CHANNEL_WASHER_I_DOS_2_ACTIVE, CHANNEL_WASHER_I_DOS_1_FILL_LEVEL_POOR, + CHANNEL_WASHER_I_DOS_2_FILL_LEVEL_POOR).forEach(this::initializeState); + } + + private void initializeState(String channelId) { + switch (channelId) { + case CHANNEL_LAUNDRY_CARE_PROCESS_PHASE -> + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + if (optionKeyExists(deviceDescriptionService, LAUNDRY_CARE_PROCESS_PHASE_KEY)) { + updateReadonlyEnumOptionDescriptionIfLinked(channelId, LAUNDRY_CARE_PROCESS_PHASE_KEY); + } else if (optionKeyExists(deviceDescriptionService, DRYER_PROCESS_PHASE_KEY)) { + updateReadonlyEnumOptionDescriptionIfLinked(channelId, DRYER_PROCESS_PHASE_KEY); + } + }); + case CHANNEL_WASHER_TEMPERATURE -> updateEnumOptionDescriptionIfLinked(channelId, WASHER_TEMPERATURE_KEY); + case CHANNEL_WASHER_SPIN_SPEED -> updateEnumOptionDescriptionIfLinked(channelId, WASHER_SPIN_SPEED_KEY); + case CHANNEL_WASHER_RINSE_PLUS -> updateEnumOptionDescriptionIfLinked(channelId, WASHER_RINSE_PLUS_KEY); + case CHANNEL_WASHER_STAINS -> updateEnumOptionDescriptionIfLinked(channelId, WASHER_STAINS_KEY); + case CHANNEL_DRYER_DRYING_TARGET -> updateEnumOptionDescriptionIfLinked(channelId, DRYER_DRYING_TARGET_KEY); + case CHANNEL_DRYER_WRINKLE_GUARD -> updateEnumOptionDescriptionIfLinked(channelId, DRYER_WRINKLE_GUARD_KEY); + case CHANNEL_LAUNDRY_DRUM_CLEAN_REMINDER, CHANNEL_WASHER_SPEED_PERFECT, CHANNEL_WASHER_WATER_PLUS, + CHANNEL_WASHER_PREWASH, CHANNEL_WASHER_RINSE_HOLD, CHANNEL_WASHER_LESS_IRONING, + CHANNEL_WASHER_SILENT_WASH, CHANNEL_WASHER_SOAK, CHANNEL_WASHER_I_DOS_1_ACTIVE, + CHANNEL_WASHER_I_DOS_2_ACTIVE, CHANNEL_WASHER_I_DOS_1_FILL_LEVEL_POOR, + CHANNEL_WASHER_I_DOS_2_FILL_LEVEL_POOR -> + updateStateIfLinked(channelId, OnOffType.OFF); + case CHANNEL_LAUNDRY_LOAD_INFORMATION, CHANNEL_LAUNDRY_LOAD_RECOMMENDATION -> + updateStateIfLinked(channelId, () -> new QuantityType<>(0, GRAM)); + } + } + + private void addIDosChannels() { + getDeviceDescriptionServiceOptional().ifPresent(deviceDescriptionService -> { + var iDosAvailable = deviceDescriptionService.getPrograms(false).stream().anyMatch(program -> program + .options().stream().anyMatch(opt -> WASHER_I_DOS_1_ACTIVE_KEY.equals(opt.refKey()))); + + if (iDosAvailable) { + var thingBuilder = editThing(); + boolean channelsChanged = false; + + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_WASHER_I_DOS_1_ACTIVE, + CHANNEL_TYPE_I_DOS_ACTIVE, CoreItemFactory.SWITCH, + getTranslationProvider().getText(I18N_I_DOS_ACTIVE, 1)); + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_WASHER_I_DOS_2_ACTIVE, + CHANNEL_TYPE_I_DOS_ACTIVE, CoreItemFactory.SWITCH, + getTranslationProvider().getText(I18N_I_DOS_ACTIVE, 2)); + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_WASHER_I_DOS_1_FILL_LEVEL_POOR, + CHANNEL_TYPE_I_DOS_FILL_LEVEL_POOR, CoreItemFactory.SWITCH, + getTranslationProvider().getText(I18N_I_DOS_FILL_LEVEL_POOR, 1)); + channelsChanged |= addChannelIfNotExist(thingBuilder, CHANNEL_WASHER_I_DOS_2_FILL_LEVEL_POOR, + CHANNEL_TYPE_I_DOS_FILL_LEVEL_POOR, CoreItemFactory.SWITCH, + getTranslationProvider().getText(I18N_I_DOS_FILL_LEVEL_POOR, 2)); + + if (channelsChanged) { + updateThing(thingBuilder.build()); + } + } + }); + } + + private boolean optionKeyExists(DeviceDescriptionService deviceDescriptionService, String optionKey) { + return deviceDescriptionService.getOption(optionKey, false, false, false) != null; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/ApplianceMessage.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/ApplianceMessage.java new file mode 100644 index 0000000000..2064af084e --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/ApplianceMessage.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler.model; + +import static org.openhab.binding.homeconnectdirect.internal.common.utils.ConfigurationUtils.createGson; + +import java.time.OffsetDateTime; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.reflect.TypeToken; + +/** + * Appliance message model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ApplianceMessage(OffsetDateTime dateTime, long id, MessageType type, Resource resource, int version, + long sessionId, long messageId, Action action, @Nullable Integer code, @Nullable JsonArray rawPayload, + @Nullable List values, @Nullable List descriptionChanges) { + + private static final Gson GSON = createGson(); + + public @Nullable List getRawPayloadAsList(Class clazz) { + var dataJsonArray = rawPayload(); + if (dataJsonArray != null) { + return GSON.fromJson(dataJsonArray, TypeToken.getParameterized(List.class, clazz).getType()); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/DynamicChannel.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/DynamicChannel.java new file mode 100644 index 0000000000..1a115b937d --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/DynamicChannel.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ContentType; + +/** + * Dynamic channel model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record DynamicChannel(String channelName, String key, ContentType contentType) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/MessageType.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/MessageType.java new file mode 100644 index 0000000000..5c3ddcfe68 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/MessageType.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Appliance message type. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum MessageType { + INCOMING, + OUTGOING, +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/SendMessageRequest.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/SendMessageRequest.java new file mode 100644 index 0000000000..f2017d31bb --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/SendMessageRequest.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; + +/** + * Send message request model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record SendMessageRequest(Action action, Resource resource, @Nullable Object data, @Nullable Integer version) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/Value.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/Value.java new file mode 100644 index 0000000000..6bf21d51c6 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/handler/model/Value.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.handler.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ContentType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DataType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; + +/** + * Appliance value model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Value(int uid, String key, Object value, Object rawValue, DeviceDescriptionType type, + @Nullable ContentType contentType, @Nullable DataType dataType, @Nullable Integer enumerationType) { + + public int getValueAsInt() { + if (value instanceof Number number) { + return number.intValue(); + } + try { + return Double.valueOf(String.valueOf(value)).intValue(); + } catch (NumberFormatException e) { + return 0; + } + } + + public double getValueAsDouble() { + if (value instanceof Number number) { + return number.doubleValue(); + } + try { + return Double.parseDouble(String.valueOf(value)); + } catch (NumberFormatException e) { + return 0; + } + } + + public boolean getValueAsBoolean() { + return Boolean.parseBoolean(String.valueOf(value)); + } + + public String getValueAsString() { + return String.valueOf(value); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/i18n/HomeConnectDirectTranslationProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/i18n/HomeConnectDirectTranslationProvider.java new file mode 100644 index 0000000000..70d3d40bd0 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/i18n/HomeConnectDirectTranslationProvider.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.i18n; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; + +/** + * {@link HomeConnectDirectTranslationProvider} provides i18n message lookup. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class HomeConnectDirectTranslationProvider { + + private final @Nullable Bundle bundle; + private final TranslationProvider translationProvider; + private final LocaleProvider localeProvider; + + public HomeConnectDirectTranslationProvider(TranslationProvider translationProvider, + LocaleProvider localeProvider) { + this.bundle = FrameworkUtil.getBundle(this.getClass()); + this.translationProvider = translationProvider; + this.localeProvider = localeProvider; + } + + public String getText(String key, @Nullable Object... arguments) { + var localBundle = bundle; + if (localBundle == null) { + return key; + } + var text = translationProvider.getText(localBundle, key, null, localeProvider.getLocale(), arguments); + return Objects.requireNonNullElse(text, key); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/provider/HomeConnectDirectDynamicCommandDescriptionProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/provider/HomeConnectDirectDynamicCommandDescriptionProvider.java new file mode 100644 index 0000000000..2a60bb6d4d --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/provider/HomeConnectDirectDynamicCommandDescriptionProvider.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.provider; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.thing.binding.BaseDynamicCommandDescriptionProvider; +import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.type.DynamicCommandDescriptionProvider; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link HomeConnectDirectDynamicCommandDescriptionProvider} is responsible for handling dynamic commands. + * + * @author Jonas Brüstel - Initial contribution + */ +@Component(service = { DynamicCommandDescriptionProvider.class, + HomeConnectDirectDynamicCommandDescriptionProvider.class }) +@NonNullByDefault +public class HomeConnectDirectDynamicCommandDescriptionProvider extends BaseDynamicCommandDescriptionProvider { + + @Activate + public HomeConnectDirectDynamicCommandDescriptionProvider(@Reference EventPublisher eventPublisher, + @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, + @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) { + this.eventPublisher = eventPublisher; + this.itemChannelLinkRegistry = itemChannelLinkRegistry; + this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/provider/HomeConnectDirectDynamicStateDescriptionProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/provider/HomeConnectDirectDynamicStateDescriptionProvider.java new file mode 100644 index 0000000000..422d9e0ed7 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/provider/HomeConnectDirectDynamicStateDescriptionProvider.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.provider; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider; +import org.openhab.core.thing.events.ThingEventFactory; +import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService; +import org.openhab.core.thing.link.ItemChannelLinkRegistry; +import org.openhab.core.thing.type.DynamicStateDescriptionProvider; +import org.openhab.core.types.StateDescription; +import org.openhab.core.types.StateDescriptionFragment; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link HomeConnectDirectDynamicStateDescriptionProvider} is responsible for handling dynamic values. + * + * @author Jonas Brüstel - Initial contribution + */ +@Component(service = { DynamicStateDescriptionProvider.class, HomeConnectDirectDynamicStateDescriptionProvider.class }) +@NonNullByDefault +public class HomeConnectDirectDynamicStateDescriptionProvider extends BaseDynamicStateDescriptionProvider { + + protected final Map stateDescriptionFragmentMap; + + @Activate + public HomeConnectDirectDynamicStateDescriptionProvider(@Reference EventPublisher eventPublisher, + @Reference ItemChannelLinkRegistry itemChannelLinkRegistry, + @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) { + this.eventPublisher = eventPublisher; + this.itemChannelLinkRegistry = itemChannelLinkRegistry; + this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService; + this.stateDescriptionFragmentMap = new ConcurrentHashMap<>(); + } + + @Override + public @Nullable StateDescription getStateDescription(Channel channel, @Nullable StateDescription original, + @Nullable Locale locale) { + if (stateDescriptionFragmentMap.containsKey(channel.getUID())) { + return Objects.requireNonNull(stateDescriptionFragmentMap.get(channel.getUID())).toStateDescription(); + } else { + return super.getStateDescription(channel, original, locale); + } + } + + public void setStateDescriptionFragment(ChannelUID channelUID, StateDescriptionFragment stateDescriptionFragment) { + var oldStateDescriptionFragment = stateDescriptionFragmentMap.get(channelUID); + var itemChannelLinkRegistry = this.itemChannelLinkRegistry; + + if (!stateDescriptionFragment.equals(oldStateDescriptionFragment)) { + stateDescriptionFragmentMap.put(channelUID, stateDescriptionFragment); + postEvent(ThingEventFactory.createChannelDescriptionChangedEvent(channelUID, + itemChannelLinkRegistry != null ? itemChannelLinkRegistry.getLinkedItemNames(channelUID) : Set.of(), + stateDescriptionFragment, oldStateDescriptionFragment)); + } + } + + @Override + @Deactivate + public void deactivate() { + stateDescriptionFragmentMap.clear(); + super.deactivate(); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionService.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionService.java new file mode 100644 index 0000000000..e9b8169f99 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionService.java @@ -0,0 +1,1712 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.xml.exception.ParseException; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Access; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ActiveProgram; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Command; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.CommandList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescription; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Enumeration; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.EnumerationType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.EnumerationTypeList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Event; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.EventList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Option; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.OptionList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Program; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ProgramGroup; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ProgramOption; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ProtectionPort; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.SelectedProgram; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Setting; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.SettingList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Status; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.StatusList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.Change; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.RangeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.xml.converter.DeviceDescriptionConverter; +import org.openhab.binding.homeconnectdirect.internal.service.feature.model.FeatureMapping; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.DescriptionChangeData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.XStreamException; +import com.thoughtworks.xstream.core.MapBackedDataHolder; +import com.thoughtworks.xstream.io.xml.StaxDriver; + +/** + * Service for parsing and managing device descriptions. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class DeviceDescriptionService { + + private final String thingId; + private @Nullable StatusList statusList; + private @Nullable SettingList settingList; + private @Nullable EventList eventList; + private @Nullable CommandList commandList; + private @Nullable OptionList optionList; + private @Nullable ProgramGroup programGroup; + private @Nullable ActiveProgram activeProgram; + private @Nullable SelectedProgram selectedProgram; + private @Nullable ProtectionPort protectionPort; + private final @Nullable EnumerationTypeList enumerationTypeList; + private final Logger logger; + + public DeviceDescriptionService(String thingId, Path descriptionFile, FeatureMapping featureMapping) + throws ParseException { + this.thingId = thingId; + var deviceDescription = createInitialState(descriptionFile, featureMapping); + this.statusList = deviceDescription.statusList(); + this.settingList = deviceDescription.settingList(); + this.eventList = deviceDescription.eventList(); + this.commandList = deviceDescription.commandList(); + this.optionList = deviceDescription.optionList(); + this.programGroup = deviceDescription.programGroup(); + this.activeProgram = deviceDescription.activeProgram(); + this.selectedProgram = deviceDescription.selectedProgram(); + this.protectionPort = deviceDescription.protectionPort(); + this.enumerationTypeList = deviceDescription.enumerationTypeList(); + this.logger = LoggerFactory.getLogger(DeviceDescriptionService.class); + } + + public DeviceDescriptionService(String thingId, InputStream is, FeatureMapping featureMapping) + throws ParseException { + this.thingId = thingId; + var deviceDescription = createInitialState(is, featureMapping); + this.statusList = deviceDescription.statusList(); + this.settingList = deviceDescription.settingList(); + this.eventList = deviceDescription.eventList(); + this.commandList = deviceDescription.commandList(); + this.optionList = deviceDescription.optionList(); + this.programGroup = deviceDescription.programGroup(); + this.activeProgram = deviceDescription.activeProgram(); + this.selectedProgram = deviceDescription.selectedProgram(); + this.protectionPort = deviceDescription.protectionPort(); + this.enumerationTypeList = deviceDescription.enumerationTypeList(); + this.logger = LoggerFactory.getLogger(DeviceDescriptionService.class); + } + + // -------------------------------------------------------------------------------- + // General Accessors + // -------------------------------------------------------------------------------- + + public DeviceDescription getDeviceDescription() { + return new DeviceDescription(statusList, settingList, eventList, commandList, optionList, programGroup, + activeProgram, selectedProgram, protectionPort, enumerationTypeList); + } + + public @Nullable FoundObject getDeviceDescriptionObject(int uid) { + var status = findInStatusList(this.statusList, uid); + if (status != null) { + return status; + } + + var setting = findInSettingList(this.settingList, uid); + if (setting != null) { + return setting; + } + + var event = findInEventList(this.eventList, uid); + if (event != null) { + return event; + } + + var command = findInCommandList(this.commandList, uid); + if (command != null) { + return command; + } + + var option = findInOptionList(this.optionList, uid); + if (option != null) { + return option; + } + + var program = findInProgramGroup(this.programGroup, uid); + if (program != null) { + return program; + } + + var activeProgram = this.activeProgram; + if (activeProgram != null && activeProgram.uid() == uid) { + return new FoundObject(activeProgram, DeviceDescriptionType.ACTIVE_PROGRAM); + } + var selectedProgram = this.selectedProgram; + if (selectedProgram != null && selectedProgram.uid() == uid) { + return new FoundObject(selectedProgram, DeviceDescriptionType.SELECTED_PROGRAM); + } + var protectionPort = this.protectionPort; + if (protectionPort != null && protectionPort.uid() == uid) { + return new FoundObject(protectionPort, DeviceDescriptionType.PROTECTION_PORT); + } + + return null; + } + + public @Nullable Object getDeviceDescriptionObject(int uid, @Nullable Integer parentUid, + DeviceDescriptionType deviceDescriptionType) { + var foundObject = switch (deviceDescriptionType) { + case STATUS, STATUS_LIST -> findInStatusList(statusList, uid); + case SETTING, SETTING_LIST -> findInSettingList(settingList, uid); + case EVENT, EVENT_LIST -> findInEventList(eventList, uid); + case COMMAND, COMMAND_LIST -> findInCommandList(commandList, uid); + case OPTION, OPTION_LIST -> findInOptionList(optionList, uid); + case PROGRAM, PROGRAM_GROUP -> findInProgramGroup(programGroup, uid); + case ACTIVE_PROGRAM -> { + var activeProgram = this.activeProgram; + if (activeProgram != null && activeProgram.uid() == uid) { + yield new FoundObject(activeProgram, DeviceDescriptionType.ACTIVE_PROGRAM); + } + yield null; + } + case SELECTED_PROGRAM -> { + var selectedProgram = this.selectedProgram; + if (selectedProgram != null && selectedProgram.uid() == uid) { + yield new FoundObject(selectedProgram, DeviceDescriptionType.SELECTED_PROGRAM); + } + yield null; + } + case PROTECTION_PORT -> { + var protectionPort = this.protectionPort; + if (protectionPort != null && protectionPort.uid() == uid) { + yield new FoundObject(protectionPort, DeviceDescriptionType.PROTECTION_PORT); + } + yield null; + } + case PROGRAM_OPTION -> { + if (parentUid != null) { + var programOption = findProgramOption(parentUid, uid); + if (programOption != null) { + yield new FoundObject(programOption, DeviceDescriptionType.PROGRAM_OPTION); + } + } + yield null; + } + case ENUMERATION_TYPE -> { + var enumerationTypeList = this.enumerationTypeList; + if (enumerationTypeList != null) { + var enumerationType = enumerationTypeList.enumerationTypes().getByKey1(uid); + if (enumerationType != null) { + yield new FoundObject(enumerationType, DeviceDescriptionType.ENUMERATION_TYPE); + } + } + yield null; + } + default -> null; + }; + + if (foundObject == null) { + return null; + } else { + return foundObject.object(); + } + } + + // -------------------------------------------------------------------------------- + // Description Changes + // -------------------------------------------------------------------------------- + + public @Nullable List applyDescriptionChanges( + @Nullable List changes) { + if (changes == null) { + return null; + } + + return changes.stream().map(descriptionChangeData -> { + var uid = descriptionChangeData.uid(); + var parentUid = descriptionChangeData.parentUid(); + var available = descriptionChangeData.available(); + var access = descriptionChangeData.access(); + var min = descriptionChangeData.min(); + var max = descriptionChangeData.max(); + var stepSize = descriptionChangeData.stepSize(); + var defaultValue = descriptionChangeData.defaultValue(); + var enumType = descriptionChangeData.enumType(); + Object updatedObject = null; + + // correct parentUid + if (parentUid != null && parentUid == 0) { + parentUid = null; + } + + // parent + String parentKey = null; + DeviceDescriptionType parentDeviceDescriptionType = null; + if (parentUid != null) { + parentKey = String.valueOf(parentUid); + parentDeviceDescriptionType = DeviceDescriptionType.UNKNOWN; + var existingParentObject = getDeviceDescriptionObject(parentUid); + if (existingParentObject != null) { + parentDeviceDescriptionType = existingParentObject.type(); + if (existingParentObject.object() instanceof KeyProvider keyProvider) { + parentKey = keyProvider.key(); + } + } + } + + // find existing description and apply change + var changeMap = new HashMap(); + var deviceDescriptionType = DeviceDescriptionType.UNKNOWN; + var key = String.valueOf(uid); + var existingObject = getDeviceDescriptionObject(uid); + if (existingObject != null) { + // fix for missing parentUid (0 or null) for non-root objects + if (parentUid == null && !isRoot(uid)) { + Integer foundParent = findParentUid(uid); + if (foundParent != null) { + parentUid = foundParent; + + parentKey = String.valueOf(parentUid); + parentDeviceDescriptionType = DeviceDescriptionType.UNKNOWN; + var existingParentObject = getDeviceDescriptionObject(parentUid); + if (existingParentObject != null) { + parentDeviceDescriptionType = existingParentObject.type(); + if (existingParentObject.object() instanceof KeyProvider keyProvider) { + parentKey = keyProvider.key(); + } + } + } + } + + deviceDescriptionType = existingObject.type(); + + // special handling for program option changes + if (DeviceDescriptionType.OPTION.equals(deviceDescriptionType) + && DeviceDescriptionType.PROGRAM.equals(parentDeviceDescriptionType)) { + var programOption = findProgramOption(Objects.requireNonNull(parentUid), uid); + if (programOption != null) { + existingObject = new FoundObject(programOption, DeviceDescriptionType.PROGRAM_OPTION); + deviceDescriptionType = DeviceDescriptionType.PROGRAM_OPTION; + key = programOption.refKey(); + } else { + logger.warn("ProgramOption not found in Program optionUid: {}, programUid: {}.", uid, + parentUid); + } + } + + if (existingObject.object() instanceof KeyProvider keyProvider) { + key = keyProvider.key(); + } + + if (existingObject.object() instanceof AccessProvider accessProvider && access != null + && !Objects.equals(access.name(), accessProvider.access().name())) { + changeMap.put("access", new Change(accessProvider.access().name(), access.name())); + } + + if (existingObject.object() instanceof AvailableProvider availableProvider && available != null + && !Objects.equals(available, availableProvider.available())) { + changeMap.put("available", new Change(availableProvider.available(), available)); + } + + if (existingObject.object() instanceof RangeProvider rangeProvider) { + if (min != null && notEquals(min, rangeProvider.min())) { + changeMap.put("min", new Change(rangeProvider.min(), min)); + } + if (max != null && notEquals(max, rangeProvider.max())) { + changeMap.put("max", new Change(rangeProvider.max(), max)); + } + if (stepSize != null && notEquals(stepSize, rangeProvider.stepSize())) { + changeMap.put("stepSize", new Change(rangeProvider.stepSize(), stepSize)); + } + } + + if (existingObject.object() instanceof EnumerationTypeProvider enumerationTypeProvider + && enumType != null && !Objects.equals(enumType, enumerationTypeProvider.enumerationType())) { + changeMap.put("enumerationType", new Change(enumerationTypeProvider.enumerationType(), enumType)); + } + + // default value + if (defaultValue != null) { + if (existingObject.object() instanceof Option option + && !Objects.equals(defaultValue, option.defaultValue())) { + changeMap.put("defaultValue", new Change(option.defaultValue(), defaultValue)); + } else if (existingObject.object() instanceof ProgramOption programOption + && !Objects.equals(defaultValue, programOption.defaultValue())) { + changeMap.put("defaultValue", new Change(programOption.defaultValue(), defaultValue)); + } + } + + updatedObject = applyUpdate(existingObject, descriptionChangeData, parentUid); + } else { + logger.trace("Could not apply device description change. Unknown uid: {} thingId: {}", uid, thingId); + } + + return new DeviceDescriptionChange(uid, key, deviceDescriptionType, parentUid, parentKey, + parentDeviceDescriptionType, changeMap.isEmpty() ? null : changeMap, updatedObject); + }).toList(); + } + + // -------------------------------------------------------------------------------- + // Enumerations + // -------------------------------------------------------------------------------- + + public @Nullable EnumerationType findEnumerationType(@Nullable Integer enumerationTypeKey) { + if (enumerationTypeKey == null) { + return null; + } + + var enumerationTypeList = this.enumerationTypeList; + if (enumerationTypeList != null) { + return enumerationTypeList.enumerationTypes().getByKey1(enumerationTypeKey); + } + return null; + } + + public @Nullable EnumerationType findEnumerationType(@Nullable String enumerationTypeKey) { + if (enumerationTypeKey == null) { + return null; + } + + var enumerationTypeList = this.enumerationTypeList; + if (enumerationTypeList != null) { + return enumerationTypeList.enumerationTypes().getByKey2(enumerationTypeKey); + } + return null; + } + + public @Nullable Enumeration findEnumeration(int enumerationType, int enumerationId) { + var enumerationTypeList = this.enumerationTypeList; + if (enumerationTypeList != null) { + var enumType = enumerationTypeList.enumerationTypes().getByKey1(enumerationType); + if (enumType != null) { + return enumType.enumerations().getByKey1(enumerationId); + } + } + return null; + } + + public @Nullable Integer mapEnumerationValueKey(String enumerationTypeKey, String valueKey) { + var enumerationTypeList = this.enumerationTypeList; + if (enumerationTypeList != null) { + var enumType = enumerationTypeList.enumerationTypes().getByKey2(enumerationTypeKey); + if (enumType != null) { + var enumeration = enumType.enumerations().getByKey2(valueKey); + if (enumeration != null) { + return enumeration.value(); + } + } + } + return null; + } + + // -------------------------------------------------------------------------------- + // Status + // -------------------------------------------------------------------------------- + + public @Nullable Status findStatusByKey(String key) { + return findStatusByKey(this.statusList, key); + } + + public boolean isStatusAvailableAndReadable(String key) { + return getStatus(key, true, true, false) != null; + } + + public @Nullable Status getStatus(String key, boolean checkAvailable, boolean checkReadAccess, + boolean checkWriteAccess) { + if (!checkAvailable && !checkReadAccess && !checkWriteAccess) { + return findStatusByKey(key); + } + return findStatusRecursive(this.statusList, key, checkAvailable, checkReadAccess, checkWriteAccess); + } + + public @Nullable Integer mapStatusKey(String key) { + return findStatusUidByKey(this.statusList, key); + } + + // -------------------------------------------------------------------------------- + // Setting + // -------------------------------------------------------------------------------- + + public @Nullable Setting findSettingByKey(String key) { + return findSettingByKey(this.settingList, key); + } + + public boolean isSettingAvailableAndReadable(String key) { + return getSetting(key, true, true, false) != null; + } + + public boolean isSettingAvailableAndWritable(String key) { + return getSetting(key, true, false, true) != null; + } + + public @Nullable Setting getSetting(String key, boolean checkAvailable, boolean checkReadAccess, + boolean checkWriteAccess) { + if (!checkAvailable && !checkReadAccess && !checkWriteAccess) { + return findSettingByKey(key); + } + return findSettingRecursive(this.settingList, key, checkAvailable, checkReadAccess, checkWriteAccess); + } + + public @Nullable Integer mapSettingKey(String key) { + return findSettingUidByKey(this.settingList, key); + } + + // -------------------------------------------------------------------------------- + // Event + // -------------------------------------------------------------------------------- + + public @Nullable Event findEventByKey(String key) { + return findEventByKey(this.eventList, key); + } + + public @Nullable Integer mapEventKey(String key) { + return findEventUidByKey(this.eventList, key); + } + + // -------------------------------------------------------------------------------- + // Command + // -------------------------------------------------------------------------------- + + public @Nullable Command findCommandByKey(String key) { + return findCommandByKey(this.commandList, key); + } + + public boolean isCommandAvailableAndWritable(String key) { + return getCommand(key, true, true) != null; + } + + public @Nullable Command getCommand(String key, boolean checkAvailable, boolean checkWriteAccess) { + if (!checkAvailable && !checkWriteAccess) { + return findCommandByKey(key); + } + return findCommandRecursive(this.commandList, key, checkAvailable, checkWriteAccess); + } + + public List getCommands(boolean checkAvailable, boolean checkWriteAccess) { + List commands = new ArrayList<>(); + var commandList = this.commandList; + if (commandList != null) { + collectCommands(commandList, commands, checkAvailable, checkWriteAccess); + } + return commands; + } + + public @Nullable Integer mapCommandKey(String key) { + return findCommandUidByKey(this.commandList, key); + } + + // -------------------------------------------------------------------------------- + // Option + // -------------------------------------------------------------------------------- + + public @Nullable Option findOptionByKey(String key) { + return findOptionByKey(this.optionList, key); + } + + public boolean isOptionAvailableAndReadable(String key) { + return getOption(key, true, true, false) != null; + } + + public boolean isOptionAvailableAndWritable(String key) { + return getOption(key, true, false, true) != null; + } + + public @Nullable Option getOption(String key, boolean checkAvailable, boolean checkReadAccess, + boolean checkWriteAccess) { + if (!checkAvailable && !checkReadAccess && !checkWriteAccess) { + return findOptionByKey(key); + } + return findOptionRecursive(this.optionList, key, checkAvailable, checkReadAccess, checkWriteAccess); + } + + public @Nullable Integer mapOptionKey(String key) { + return findOptionUidByKey(this.optionList, key); + } + + // -------------------------------------------------------------------------------- + // Program + // -------------------------------------------------------------------------------- + + public @Nullable Program findProgram(int uid) { + var foundObject = findInProgramGroup(programGroup, uid); + if (foundObject != null && foundObject.object() instanceof Program program) { + return program; + } else { + return null; + } + } + + public List getPrograms(boolean checkAvailable) { + List programs = new ArrayList<>(); + var programGroup = this.programGroup; + if (programGroup != null) { + collectPrograms(programGroup, programs, checkAvailable); + } + return programs; + } + + public @Nullable ProgramOption findProgramOption(int programUid, int optionUid) { + var parentProgram = findProgram(programUid); + if (parentProgram != null) { + return parentProgram.options().stream().filter(pOption -> pOption.refUid() == optionUid).findFirst() + .orElse(null); + } + return null; + } + + public @Nullable ActiveProgram getActiveProgram(boolean checkWriteAccess) { + var activeProgram = this.activeProgram; + if (activeProgram == null) { + return null; + } + + if (checkWriteAccess && activeProgram.access() != Access.READ_WRITE) { + return null; + } + + return activeProgram; + } + + public @Nullable SelectedProgram getSelectedProgram(boolean checkWriteAccess) { + var selectedProgram = this.selectedProgram; + if (selectedProgram == null) { + return null; + } + + if (checkWriteAccess && selectedProgram.access() != Access.READ_WRITE) { + return null; + } + + return selectedProgram; + } + + public @Nullable Integer mapProgramKey(String key) { + return findProgramUidByKey(this.programGroup, key); + } + + public @Nullable Integer mapActiveProgramKey(String key) { + var activeProgram = this.activeProgram; + if (activeProgram != null && activeProgram.key().equals(key)) { + return activeProgram.uid(); + } + return null; + } + + public @Nullable Integer mapSelectedProgramKey(String key) { + var selectedProgram = this.selectedProgram; + if (selectedProgram != null && selectedProgram.key().equals(key)) { + return selectedProgram.uid(); + } + return null; + } + + // -------------------------------------------------------------------------------- + // Protection Port + // -------------------------------------------------------------------------------- + + public @Nullable Integer mapProtectionPortKey(String key) { + var protectionPort = this.protectionPort; + if (protectionPort != null && protectionPort.key().equals(key)) { + return protectionPort.uid(); + } + return null; + } + + // -------------------------------------------------------------------------------- + // Private Helper + // -------------------------------------------------------------------------------- + + private @Nullable Status findStatusByKey(@Nullable StatusList list, String key) { + if (list == null) { + return null; + } + var item = list.statuses().getByKey2(key); + if (item != null) { + return item; + } + for (var entry : list.statusLists().entrySet()) { + var found = findStatusByKey(entry.getValue(), key); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Status findStatusRecursive(@Nullable StatusList list, String key, boolean checkAvailable, + boolean checkReadAccess, boolean checkWriteAccess) { + if (list == null) { + return null; + } + + if (checkAvailable && !list.available()) { + return null; + } + + if (checkReadAccess && !hasReadAccess(list.access())) { + return null; + } + + if (checkWriteAccess && !hasWriteAccess(list.access())) { + return null; + } + + var item = list.statuses().getByKey2(key); + if (item != null) { + if (checkAvailable && !item.available()) { + return null; + } + if (checkReadAccess && !hasReadAccess(item.access())) { + return null; + } + if (checkWriteAccess && !hasWriteAccess(item.access())) { + return null; + } + return item; + } + for (var entry : list.statusLists().entrySet()) { + var found = findStatusRecursive(entry.getValue(), key, checkAvailable, checkReadAccess, checkWriteAccess); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Integer findStatusUidByKey(@Nullable StatusList statusList, String key) { + if (statusList == null) { + return null; + } + + var status = statusList.statuses().getByKey2(key); + if (status != null) { + return status.uid(); + } + + for (var entry : statusList.statusLists().entrySet()) { + var uid = findStatusUidByKey(entry.getValue(), key); + if (uid != null) { + return uid; + } + } + return null; + } + + private @Nullable Setting findSettingByKey(@Nullable SettingList list, String key) { + if (list == null) { + return null; + } + var item = list.settings().getByKey2(key); + if (item != null) { + return item; + } + for (var entry : list.settingLists().entrySet()) { + var found = findSettingByKey(entry.getValue(), key); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Setting findSettingRecursive(@Nullable SettingList list, String key, boolean checkAvailable, + boolean checkReadAccess, boolean checkWriteAccess) { + if (list == null) { + return null; + } + + if (checkAvailable && !list.available()) { + return null; + } + + if (checkReadAccess && !hasReadAccess(list.access())) { + return null; + } + + if (checkWriteAccess && !hasWriteAccess(list.access())) { + return null; + } + + var item = list.settings().getByKey2(key); + if (item != null) { + if (checkAvailable && !item.available()) { + return null; + } + if (checkReadAccess && !hasReadAccess(item.access())) { + return null; + } + if (checkWriteAccess && !hasWriteAccess(item.access())) { + return null; + } + return item; + } + for (var entry : list.settingLists().entrySet()) { + var found = findSettingRecursive(entry.getValue(), key, checkAvailable, checkReadAccess, checkWriteAccess); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Integer findSettingUidByKey(@Nullable SettingList settingList, String key) { + if (settingList == null) { + return null; + } + + var setting = settingList.settings().getByKey2(key); + if (setting != null) { + return setting.uid(); + } + + for (var entry : settingList.settingLists().entrySet()) { + var uid = findSettingUidByKey(entry.getValue(), key); + if (uid != null) { + return uid; + } + } + return null; + } + + private @Nullable Event findEventByKey(@Nullable EventList list, String key) { + if (list == null) { + return null; + } + var item = list.events().getByKey2(key); + if (item != null) { + return item; + } + for (var entry : list.eventLists().entrySet()) { + var found = findEventByKey(entry.getValue(), key); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Integer findEventUidByKey(@Nullable EventList eventList, String key) { + if (eventList == null) { + return null; + } + + var event = eventList.events().getByKey2(key); + if (event != null) { + return event.uid(); + } + + for (var entry : eventList.eventLists().entrySet()) { + var uid = findEventUidByKey(entry.getValue(), key); + if (uid != null) { + return uid; + } + } + return null; + } + + private @Nullable Command findCommandByKey(@Nullable CommandList list, String key) { + if (list == null) { + return null; + } + var item = list.commands().getByKey2(key); + if (item != null) { + return item; + } + for (var entry : list.commandLists().entrySet()) { + var found = findCommandByKey(entry.getValue(), key); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Command findCommandRecursive(@Nullable CommandList list, String key, boolean checkAvailable, + boolean checkWriteAccess) { + if (list == null) { + return null; + } + + if (checkAvailable && !list.available()) { + return null; + } + + if (checkWriteAccess && !hasWriteAccess(list.access())) { + return null; + } + + var item = list.commands().getByKey2(key); + if (item != null) { + if (checkAvailable && !item.available()) { + return null; + } + if (checkWriteAccess && !hasWriteAccess(item.access())) { + return null; + } + return item; + } + for (var entry : list.commandLists().entrySet()) { + var found = findCommandRecursive(entry.getValue(), key, checkAvailable, checkWriteAccess); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Integer findCommandUidByKey(@Nullable CommandList commandList, String key) { + if (commandList == null) { + return null; + } + + var command = commandList.commands().getByKey2(key); + if (command != null) { + return command.uid(); + } + + for (var entry : commandList.commandLists().entrySet()) { + var uid = findCommandUidByKey(entry.getValue(), key); + if (uid != null) { + return uid; + } + } + return null; + } + + private @Nullable Option findOptionByKey(@Nullable OptionList list, String key) { + if (list == null) { + return null; + } + var item = list.options().getByKey2(key); + if (item != null) { + return item; + } + for (var entry : list.optionLists().entrySet()) { + var found = findOptionByKey(entry.getValue(), key); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Option findOptionRecursive(@Nullable OptionList list, String key, boolean checkAvailable, + boolean checkReadAccess, boolean checkWriteAccess) { + if (list == null) { + return null; + } + + if (checkAvailable && !list.available()) { + return null; + } + + if (checkReadAccess && !hasReadAccess(list.access())) { + return null; + } + + if (checkWriteAccess && !hasWriteAccess(list.access())) { + return null; + } + + var item = list.options().getByKey2(key); + if (item != null) { + if (checkAvailable && !item.available()) { + return null; + } + if (checkReadAccess && !hasReadAccess(item.access())) { + return null; + } + if (checkWriteAccess && !hasWriteAccess(item.access())) { + return null; + } + return item; + } + for (var entry : list.optionLists().entrySet()) { + var found = findOptionRecursive(entry.getValue(), key, checkAvailable, checkReadAccess, checkWriteAccess); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Integer findOptionUidByKey(@Nullable OptionList optionList, String key) { + if (optionList == null) { + return null; + } + + var option = optionList.options().getByKey2(key); + if (option != null) { + return option.uid(); + } + + for (var entry : optionList.optionLists().entrySet()) { + var uid = findOptionUidByKey(entry.getValue(), key); + if (uid != null) { + return uid; + } + } + return null; + } + + private @Nullable Integer findProgramUidByKey(@Nullable ProgramGroup programGroup, String key) { + if (programGroup == null) { + return null; + } + + var program = programGroup.programs().getByKey2(key); + if (program != null) { + return program.uid(); + } + + for (var entry : programGroup.programGroups().entrySet()) { + var uid = findProgramUidByKey(entry.getValue(), key); + if (uid != null) { + return uid; + } + } + return null; + } + + private void collectPrograms(ProgramGroup group, List programs, boolean checkAvailable) { + if (checkAvailable && !group.available()) { + return; + } + + for (var entry : group.programs().entrySet()) { + var program = entry.getValue(); + if (!checkAvailable || program.available()) { + programs.add(program); + } + } + + for (var entry : group.programGroups().entrySet()) { + collectPrograms(entry.getValue(), programs, checkAvailable); + } + } + + private void collectCommands(CommandList list, List commands, boolean checkAvailable, + boolean checkWriteAccess) { + if (checkAvailable && !list.available()) { + return; + } + + if (checkWriteAccess && list.access() != Access.WRITE_ONLY) { + return; + } + + for (var entry : list.commands().entrySet()) { + var command = entry.getValue(); + if (checkAvailable && !command.available()) { + continue; + } + if (checkWriteAccess && command.access() != Access.WRITE_ONLY) { + continue; + } + commands.add(command); + } + + for (var entry : list.commandLists().entrySet()) { + collectCommands(entry.getValue(), commands, checkAvailable, checkWriteAccess); + } + } + + private DeviceDescription createInitialState(Path descriptionFile, FeatureMapping featureMapping) { + try { + var xstream = createXStream(); + + var dataHolder = new MapBackedDataHolder(); + dataHolder.put(FeatureMapping.class.getName(), featureMapping); + var reader = new StaxDriver().createReader(descriptionFile.toFile()); + + return (DeviceDescription) xstream.unmarshal(reader, null, dataHolder); + } catch (XStreamException e) { + throw new ParseException("Could not deserialize XML '%s'".formatted(descriptionFile), e); + } + } + + private DeviceDescription createInitialState(InputStream is, FeatureMapping featureMapping) { + try { + var xstream = createXStream(); + + var dataHolder = new MapBackedDataHolder(); + dataHolder.put(FeatureMapping.class.getName(), featureMapping); + var reader = new StaxDriver().createReader(is); + + return (DeviceDescription) xstream.unmarshal(reader, null, dataHolder); + } catch (XStreamException e) { + throw new ParseException("Could not deserialize XML from input stream", e); + } + } + + private boolean notEquals(@Nullable Number n1, @Nullable Number n2) { + return !equals(n1, n2); + } + + private boolean equals(@Nullable Number n1, @Nullable Number n2) { + if (n1 == null && n2 == null) { + return true; + } + + if (n1 == null || n2 == null) { + return false; + } + + if (Objects.equals(n1, n2)) { + return true; + } + + BigDecimal bd1 = new BigDecimal(n1.toString()); + BigDecimal bd2 = new BigDecimal(n2.toString()); + + return bd1.compareTo(bd2) == 0; + } + + private XStream createXStream() { + var xstream = new XStream(new StaxDriver()); + xstream.allowTypesByWildcard(new String[] { DeviceDescriptionService.class.getPackageName() + ".**" }); + xstream.setClassLoader(getClass().getClassLoader()); + xstream.ignoreUnknownElements(); + xstream.alias("device", DeviceDescription.class); + xstream.registerConverter(new DeviceDescriptionConverter()); + return xstream; + } + + private @Nullable FoundObject findInStatusList(@Nullable StatusList statusList, int uid) { + if (statusList == null) { + return null; + } + if (statusList.uid() == uid) { + return new FoundObject(statusList, DeviceDescriptionType.STATUS_LIST); + } + var status = statusList.statuses().getByKey1(uid); + if (status != null) { + return new FoundObject(status, DeviceDescriptionType.STATUS); + } + + var subList = statusList.statusLists().getByKey1(uid); + if (subList != null) { + return new FoundObject(subList, DeviceDescriptionType.STATUS_LIST); + } + + for (var entry : statusList.statusLists().entrySet()) { + StatusList list = entry.getValue(); + var found = findInStatusList(list, uid); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable FoundObject findInSettingList(@Nullable SettingList settingList, int uid) { + if (settingList == null) { + return null; + } + if (settingList.uid() == uid) { + return new FoundObject(settingList, DeviceDescriptionType.SETTING_LIST); + } + var setting = settingList.settings().getByKey1(uid); + if (setting != null) { + return new FoundObject(setting, DeviceDescriptionType.SETTING); + } + + var subList = settingList.settingLists().getByKey1(uid); + if (subList != null) { + return new FoundObject(subList, DeviceDescriptionType.SETTING_LIST); + } + + for (var entry : settingList.settingLists().entrySet()) { + SettingList list = entry.getValue(); + var found = findInSettingList(list, uid); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable FoundObject findInEventList(@Nullable EventList eventList, int uid) { + if (eventList == null) { + return null; + } + if (eventList.uid() == uid) { + return new FoundObject(eventList, DeviceDescriptionType.EVENT_LIST); + } + var event = eventList.events().getByKey1(uid); + if (event != null) { + return new FoundObject(event, DeviceDescriptionType.EVENT); + } + + var subList = eventList.eventLists().getByKey1(uid); + if (subList != null) { + return new FoundObject(subList, DeviceDescriptionType.EVENT_LIST); + } + + for (var entry : eventList.eventLists().entrySet()) { + EventList list = entry.getValue(); + var found = findInEventList(list, uid); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable FoundObject findInCommandList(@Nullable CommandList commandList, int uid) { + if (commandList == null) { + return null; + } + if (commandList.uid() == uid) { + return new FoundObject(commandList, DeviceDescriptionType.COMMAND_LIST); + } + var command = commandList.commands().getByKey1(uid); + if (command != null) { + return new FoundObject(command, DeviceDescriptionType.COMMAND); + } + + var subList = commandList.commandLists().getByKey1(uid); + if (subList != null) { + return new FoundObject(subList, DeviceDescriptionType.COMMAND_LIST); + } + + for (var entry : commandList.commandLists().entrySet()) { + CommandList list = entry.getValue(); + var found = findInCommandList(list, uid); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable FoundObject findInOptionList(@Nullable OptionList optionList, int uid) { + if (optionList == null) { + return null; + } + if (optionList.uid() == uid) { + return new FoundObject(optionList, DeviceDescriptionType.OPTION_LIST); + } + var option = optionList.options().getByKey1(uid); + if (option != null) { + return new FoundObject(option, DeviceDescriptionType.OPTION); + } + + var subList = optionList.optionLists().getByKey1(uid); + if (subList != null) { + return new FoundObject(subList, DeviceDescriptionType.OPTION_LIST); + } + + for (var entry : optionList.optionLists().entrySet()) { + OptionList list = entry.getValue(); + var found = findInOptionList(list, uid); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable FoundObject findInProgramGroup(@Nullable ProgramGroup programGroup, int uid) { + if (programGroup == null) { + return null; + } + if (programGroup.uid() == uid) { + return new FoundObject(programGroup, DeviceDescriptionType.PROGRAM_GROUP); + } + var program = programGroup.programs().getByKey1(uid); + if (program != null) { + return new FoundObject(program, DeviceDescriptionType.PROGRAM); + } + + var subGroup = programGroup.programGroups().getByKey1(uid); + if (subGroup != null) { + return new FoundObject(subGroup, DeviceDescriptionType.PROGRAM_GROUP); + } + + for (var entry : programGroup.programGroups().entrySet()) { + ProgramGroup group = entry.getValue(); + var found = findInProgramGroup(group, uid); + if (found != null) { + return found; + } + } + return null; + } + + private @Nullable Object applyUpdate(FoundObject foundObject, DescriptionChangeData change, + @Nullable Integer parentUid) { + Object object = foundObject.object(); + Object newObject = switch (object) { + case Status foundObjectStatus -> updateStatus(foundObjectStatus, change); + case StatusList foundObjectStatusList -> updateStatusList(foundObjectStatusList, change); + case Setting foundObjectSetting -> updateSetting(foundObjectSetting, change); + case SettingList foundObjectSettingList -> updateSettingList(foundObjectSettingList, change); + case Event foundObjectEvent -> updateEvent(foundObjectEvent, change); + case EventList foundObjectEventList -> updateEventList(foundObjectEventList, change); + case Command foundObjectCommand -> updateCommand(foundObjectCommand, change); + case CommandList foundObjectCommandList -> updateCommandList(foundObjectCommandList, change); + case Option foundObjectOption -> updateOption(foundObjectOption, change); + case OptionList foundObjectOptionList -> updateOptionList(foundObjectOptionList, change); + case ProgramOption foundObjectProgramOption -> updateProgramOption(foundObjectProgramOption, change); + case Program foundObjectProgram -> updateProgram(foundObjectProgram, change); + case ProgramGroup foundObjectProgramGroup -> updateProgramGroup(foundObjectProgramGroup, change); + case ActiveProgram foundObjectActiveProgram -> updateActiveProgram(foundObjectActiveProgram, change); + case SelectedProgram foundObjectSelectedProgram -> + updateSelectedProgram(foundObjectSelectedProgram, change); + case ProtectionPort foundObjectProtectionPort -> updateProtectionPort(foundObjectProtectionPort, change); + default -> null; + }; + + if (newObject != null) { + if (parentUid == null) { + updateRootComponent(newObject); + } else { + updateParent(parentUid, newObject); + } + } + + return newObject; + } + + private void updateRootComponent(Object newComponent) { + switch (newComponent) { + case StatusList s -> this.statusList = s; + case SettingList s -> this.settingList = s; + case EventList s -> this.eventList = s; + case CommandList s -> this.commandList = s; + case OptionList s -> this.optionList = s; + case ProgramGroup s -> this.programGroup = s; + case ActiveProgram s -> this.activeProgram = s; + case SelectedProgram s -> this.selectedProgram = s; + case ProtectionPort s -> this.protectionPort = s; + default -> { + // do nothing + } + } + } + + private void updateParent(int parentUid, Object newObject) { + var parentFound = getDeviceDescriptionObject(parentUid); + if (parentFound == null) { + logger.warn("Could not find parent with uid {} to update child", parentUid); + return; + } + + Object parent = parentFound.object(); + if (parent instanceof StatusList list && newObject instanceof Status status) { + list.statuses().put(status.uid(), status.key(), status); + } else if (parent instanceof StatusList list && newObject instanceof StatusList subList) { + list.statusLists().put(subList.uid(), subList.key(), subList); + } else if (parent instanceof SettingList list && newObject instanceof Setting setting) { + list.settings().put(setting.uid(), setting.key(), setting); + } else if (parent instanceof SettingList list && newObject instanceof SettingList subList) { + list.settingLists().put(subList.uid(), subList.key(), subList); + } else if (parent instanceof EventList list && newObject instanceof Event event) { + list.events().put(event.uid(), event.key(), event); + } else if (parent instanceof EventList list && newObject instanceof EventList subList) { + list.eventLists().put(subList.uid(), subList.key(), subList); + } else if (parent instanceof CommandList list && newObject instanceof Command command) { + list.commands().put(command.uid(), command.key(), command); + } else if (parent instanceof CommandList list && newObject instanceof CommandList subList) { + list.commandLists().put(subList.uid(), subList.key(), subList); + } else if (parent instanceof OptionList list && newObject instanceof Option option) { + list.options().put(option.uid(), option.key(), option); + } else if (parent instanceof OptionList list && newObject instanceof OptionList subList) { + list.optionLists().put(subList.uid(), subList.key(), subList); + } else if (parent instanceof ProgramGroup group && newObject instanceof Program program) { + group.programs().put(program.uid(), program.key(), program); + } else if (parent instanceof ProgramGroup group && newObject instanceof ProgramGroup subGroup) { + group.programGroups().put(subGroup.uid(), subGroup.key(), subGroup); + } else if (parent instanceof Program program && newObject instanceof ProgramOption option) { + // Program options are in a List, so we need to replace + var options = program.options(); + for (int i = 0; i < options.size(); i++) { + if (options.get(i).refUid() == option.refUid()) { + options.set(i, option); + break; + } + } + } + } + + private Status updateStatus(Status status, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new Status(status.uid(), status.key(), status.contentType(), status.dataType(), + change.min() != null ? change.min() : status.min(), change.max() != null ? change.max() : status.max(), + change.stepSize() != null ? change.stepSize() : status.stepSize(), + change.enumType() != null ? change.enumType() : status.enumerationType(), + change.enumType() != null ? resolveEnumTypeKey(change.enumType()) : status.enumerationTypeKey(), + changedAvailable != null ? changedAvailable : status.available(), + changedAccess != null ? mapAccess(changedAccess) : status.access(), status.notifyOnChange(), + status.initValue()); + } + + private StatusList updateStatusList(StatusList list, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new StatusList(list.uid(), list.key(), changedAvailable != null ? changedAvailable : list.available(), + changedAccess != null ? mapAccess(changedAccess) : list.access(), list.statusLists(), list.statuses()); + } + + private Setting updateSetting(Setting setting, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new Setting(setting.uid(), setting.key(), setting.contentType(), setting.dataType(), + change.min() != null ? change.min() : setting.min(), + change.max() != null ? change.max() : setting.max(), + change.stepSize() != null ? change.stepSize() : setting.stepSize(), + changedAvailable != null ? changedAvailable : setting.available(), + changedAccess != null ? mapAccess(changedAccess) : setting.access(), setting.initValue(), + change.enumType() != null ? change.enumType() : setting.enumerationType(), + change.enumType() != null ? resolveEnumTypeKey(change.enumType()) : setting.enumerationTypeKey(), + setting.notifyOnChange(), setting.passwordProtected()); + } + + private SettingList updateSettingList(SettingList list, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new SettingList(list.uid(), list.key(), changedAvailable != null ? changedAvailable : list.available(), + changedAccess != null ? mapAccess(changedAccess) : list.access(), list.settingLists(), list.settings()); + } + + private Event updateEvent(Event event, DescriptionChangeData change) { + var changedEnumType = change.enumType(); + var changedKey = resolveEnumTypeKey(changedEnumType); + return new Event(event.uid(), event.key(), event.contentType(), event.dataType(), event.handling(), + event.level(), + changedEnumType != null ? changedEnumType : Objects.requireNonNull(event.enumerationType()), + changedKey != null ? changedKey : event.enumerationTypeKey()); + } + + private EventList updateEventList(EventList list, DescriptionChangeData change) { + // nothing to change + return list; + } + + private Command updateCommand(Command command, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new Command(command.uid(), command.key(), command.contentType(), command.dataType(), + changedAvailable != null ? changedAvailable : command.available(), + changedAccess != null ? mapAccess(changedAccess) : command.access(), + change.enumType() != null ? change.enumType() : command.enumerationType(), + change.enumType() != null ? resolveEnumTypeKey(change.enumType()) : command.enumerationTypeKey(), + change.min() != null ? change.min() : command.min(), + change.max() != null ? change.max() : command.max(), + change.stepSize() != null ? change.stepSize() : command.stepSize(), command.passwordProtected(), + command.notifyOnChange()); + } + + private CommandList updateCommandList(CommandList list, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new CommandList(list.uid(), list.key(), changedAvailable != null ? changedAvailable : list.available(), + changedAccess != null ? mapAccess(changedAccess) : list.access(), list.commandLists(), list.commands()); + } + + private Option updateOption(Option option, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new Option(option.uid(), option.key(), option.contentType(), option.dataType(), + change.min() != null ? change.min() : option.min(), change.max() != null ? change.max() : option.max(), + change.stepSize() != null ? change.stepSize() : option.stepSize(), + change.defaultValue() != null ? change.defaultValue() : option.defaultValue(), option.initValue(), + change.enumType() != null ? change.enumType() : option.enumerationType(), + change.enumType() != null ? resolveEnumTypeKey(change.enumType()) : option.enumerationTypeKey(), + changedAvailable != null ? changedAvailable : option.available(), + changedAccess != null ? mapAccess(changedAccess) : option.access(), option.notifyOnChange(), + option.liveUpdate()); + } + + private OptionList updateOptionList(OptionList list, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new OptionList(list.uid(), list.key(), changedAvailable != null ? changedAvailable : list.available(), + changedAccess != null ? mapAccess(changedAccess) : list.access(), list.optionLists(), list.options()); + } + + private ProgramOption updateProgramOption(ProgramOption option, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedDefaultValue = change.defaultValue(); + var changedAvailable = change.available(); + + return new ProgramOption(option.refUid(), option.refKey(), + changedAvailable != null ? changedAvailable : option.available(), + changedAccess != null ? mapAccess(changedAccess) : option.access(), option.liveUpdate(), + change.min() != null ? change.min() : option.min(), change.max() != null ? change.max() : option.max(), + change.stepSize() != null ? change.stepSize() : option.stepSize(), + changedDefaultValue != null ? changedDefaultValue : option.defaultValue(), + change.enumType() != null ? change.enumType() : option.enumerationType(), + change.enumType() != null ? resolveEnumTypeKey(change.enumType()) : option.enumerationTypeKey()); + } + + private Program updateProgram(Program program, DescriptionChangeData change) { + var changedAvailable = change.available(); + return new Program(program.uid(), program.key(), + changedAvailable != null ? changedAvailable : program.available(), program.execution(), + program.options()); + } + + private ProgramGroup updateProgramGroup(ProgramGroup group, DescriptionChangeData change) { + var changedAvailable = change.available(); + return new ProgramGroup(group.uid(), group.key(), + changedAvailable != null ? changedAvailable : group.available(), group.programGroups(), + group.programs()); + } + + private ActiveProgram updateActiveProgram(ActiveProgram program, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new ActiveProgram(program.uid(), program.key(), + changedAvailable != null ? changedAvailable : program.available(), + changedAccess != null ? mapAccess(changedAccess) : program.access(), program.fullOptionSet(), + program.validate()); + } + + private SelectedProgram updateSelectedProgram(SelectedProgram program, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new SelectedProgram(program.uid(), program.key(), + changedAvailable != null ? changedAvailable : program.available(), + changedAccess != null ? mapAccess(changedAccess) : program.access(), program.fullOptionSet(), + program.validate()); + } + + private ProtectionPort updateProtectionPort(ProtectionPort port, DescriptionChangeData change) { + var changedAccess = change.access(); + var changedAvailable = change.available(); + + return new ProtectionPort(port.uid(), port.key(), + changedAvailable != null ? changedAvailable : port.available(), + changedAccess != null ? mapAccess(changedAccess) : port.access()); + } + + private Access mapAccess(org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Access access) { + return switch (access) { + case READ -> Access.READ; + case READ_WRITE -> Access.READ_WRITE; + case READ_STATIC -> Access.READ_STATIC; + case WRITE_ONLY -> Access.WRITE_ONLY; + default -> Access.NONE; + }; + } + + private boolean hasReadAccess(Access access) { + return access == Access.READ || access == Access.READ_WRITE || access == Access.READ_STATIC; + } + + private boolean hasWriteAccess(Access access) { + return access == Access.READ_WRITE || access == Access.WRITE_ONLY; + } + + private @Nullable String resolveEnumTypeKey(@Nullable Integer enumId) { + if (enumId == null) { + return null; + } + + var enumerationTypeList = this.enumerationTypeList; + if (enumerationTypeList != null) { + var enumType = enumerationTypeList.enumerationTypes().getByKey1(enumId); + if (enumType != null) { + return enumType.enKey(); + } + } + return String.valueOf(enumId); + } + + private boolean isRoot(int uid) { + var statusList = this.statusList; + var settingList = this.settingList; + var eventList = this.eventList; + var commandList = this.commandList; + var optionList = this.optionList; + var programGroup = this.programGroup; + var activeProgram = this.activeProgram; + var selectedProgram = this.selectedProgram; + var protectionPort = this.protectionPort; + + return (statusList != null && statusList.uid() == uid) || (settingList != null && settingList.uid() == uid) + || (eventList != null && eventList.uid() == uid) || (commandList != null && commandList.uid() == uid) + || (optionList != null && optionList.uid() == uid) + || (programGroup != null && programGroup.uid() == uid) + || (activeProgram != null && activeProgram.uid() == uid) + || (selectedProgram != null && selectedProgram.uid() == uid) + || (protectionPort != null && protectionPort.uid() == uid); + } + + private @Nullable Integer findParentUid(int childUid) { + Integer parentUid = findParentUidInStatusList(this.statusList, childUid); + if (parentUid != null) { + return parentUid; + } + + parentUid = findParentUidInSettingList(this.settingList, childUid); + if (parentUid != null) { + return parentUid; + } + + parentUid = findParentUidInEventList(this.eventList, childUid); + if (parentUid != null) { + return parentUid; + } + + parentUid = findParentUidInCommandList(this.commandList, childUid); + if (parentUid != null) { + return parentUid; + } + + parentUid = findParentUidInOptionList(this.optionList, childUid); + if (parentUid != null) { + return parentUid; + } + + parentUid = findParentUidInProgramGroup(this.programGroup, childUid); + if (parentUid != null) { + return parentUid; + } + + return null; + } + + private @Nullable Integer findParentUidInStatusList(@Nullable StatusList list, int childUid) { + if (list == null) { + return null; + } + if (list.statuses().getByKey1(childUid) != null || list.statusLists().getByKey1(childUid) != null) { + return list.uid(); + } + for (var entry : list.statusLists().entrySet()) { + var parent = findParentUidInStatusList(entry.getValue(), childUid); + if (parent != null) { + return parent; + } + } + return null; + } + + private @Nullable Integer findParentUidInSettingList(@Nullable SettingList list, int childUid) { + if (list == null) { + return null; + } + if (list.settings().getByKey1(childUid) != null || list.settingLists().getByKey1(childUid) != null) { + return list.uid(); + } + for (var entry : list.settingLists().entrySet()) { + var parent = findParentUidInSettingList(entry.getValue(), childUid); + if (parent != null) { + return parent; + } + } + return null; + } + + private @Nullable Integer findParentUidInEventList(@Nullable EventList list, int childUid) { + if (list == null) { + return null; + } + if (list.events().getByKey1(childUid) != null || list.eventLists().getByKey1(childUid) != null) { + return list.uid(); + } + for (var entry : list.eventLists().entrySet()) { + var parent = findParentUidInEventList(entry.getValue(), childUid); + if (parent != null) { + return parent; + } + } + return null; + } + + private @Nullable Integer findParentUidInCommandList(@Nullable CommandList list, int childUid) { + if (list == null) { + return null; + } + if (list.commands().getByKey1(childUid) != null || list.commandLists().getByKey1(childUid) != null) { + return list.uid(); + } + for (var entry : list.commandLists().entrySet()) { + var parent = findParentUidInCommandList(entry.getValue(), childUid); + if (parent != null) { + return parent; + } + } + return null; + } + + private @Nullable Integer findParentUidInOptionList(@Nullable OptionList list, int childUid) { + if (list == null) { + return null; + } + if (list.options().getByKey1(childUid) != null || list.optionLists().getByKey1(childUid) != null) { + return list.uid(); + } + for (var entry : list.optionLists().entrySet()) { + var parent = findParentUidInOptionList(entry.getValue(), childUid); + if (parent != null) { + return parent; + } + } + return null; + } + + private @Nullable Integer findParentUidInProgramGroup(@Nullable ProgramGroup group, int childUid) { + if (group == null) { + return null; + } + if (group.programs().getByKey1(childUid) != null || group.programGroups().getByKey1(childUid) != null) { + return group.uid(); + } + + for (var entry : group.programs().entrySet()) { + var program = entry.getValue(); + for (var option : program.options()) { + if (option.refUid() == childUid) { + return program.uid(); + } + } + } + + for (var entry : group.programGroups().entrySet()) { + var parent = findParentUidInProgramGroup(entry.getValue(), childUid); + if (parent != null) { + return parent; + } + } + return null; + } + + public record FoundObject(Object object, DeviceDescriptionType type) { + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionUtils.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionUtils.java new file mode 100644 index 0000000000..f504853c64 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionUtils.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.THING_TYPE_WARMING_DRAWER; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ContentType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DataType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ProtocolType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.ContentTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.DataTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.feature.model.FeatureMapping; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ValueData; +import org.openhab.core.thing.ThingTypeUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility methods for device description processing. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class DeviceDescriptionUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(DeviceDescriptionUtils.class); + + private DeviceDescriptionUtils() { + // Utility class + } + + public static @Nullable List mapValues(DeviceDescriptionService deviceDescriptionService, + FeatureMapping featureMapping, Resource resource, @Nullable List valueDataList, + @Nullable ThingTypeUID thingTypeUID) { + List mappedValues = null; + if (valueDataList != null) { + var values = valueDataList.stream().map(valueData -> { + var valueDescription = deviceDescriptionService.getDeviceDescriptionObject(valueData.uid()); + + // key + var key = featureMapping.mapFeatureIdToKey(valueData.uid()); + + // type + var type = valueDescription != null ? valueDescription.type() : DeviceDescriptionType.UNKNOWN; + + // contentType + ContentType contentType = null; + if (valueDescription != null + && valueDescription.object() instanceof ContentTypeProvider contentTypeProvider) { + contentType = contentTypeProvider.contentType(); + } + + // dataType + DataType dataType = null; + if (valueDescription != null + && valueDescription.object() instanceof DataTypeProvider dataTypeProvider) { + dataType = dataTypeProvider.dataType(); + } + + // value + Object value = valueData.value(); + Integer enumerationTypeId = null; + if (DeviceDescriptionType.SELECTED_PROGRAM.equals(type) + || DeviceDescriptionType.ACTIVE_PROGRAM.equals(type)) { + var programUid = mapObjectToInteger(valueData.value()); + if (programUid != null) { + var program = deviceDescriptionService.findProgram(programUid); + if (program != null) { + value = program.key(); + } + } + } else if (ContentType.ENUMERATION.equals(contentType)) { + if (valueDescription != null + && valueDescription.object() instanceof EnumerationTypeProvider enumerationTypeProvider) { + var enumTypeId = enumerationTypeProvider.enumerationType(); + enumerationTypeId = enumTypeId; + var enumValue = mapObjectToInteger(valueData.value()); + if (enumTypeId != null && enumValue != null) { + var enumeration = deviceDescriptionService.findEnumeration(enumTypeId, enumValue); + if (enumeration != null) { + value = enumeration.valueKey(); + } + } + } + } + + // Fixed-point scaling: when protocolType is FLOAT but wire encoding is a raw integer type, + // the value is a fixed-point integer that needs to be divided by 10. + // Only applied to warming drawers as they encode temperature values as fixed-point integers. + if (THING_TYPE_WARMING_DRAWER.equals(thingTypeUID) && contentType != null + && ProtocolType.FLOAT.equals(contentType.protocolType) && dataType != null + && dataType.isRawInteger() && value instanceof Number number) { + LOGGER.debug( + "Fixed-point scaling applied: key={}, contentType={}, dataType={}, rawValue={}, scaledValue={}", + key, contentType, dataType, number, number.doubleValue() / 10.0); + value = number.doubleValue() / 10.0; + } + + return new Value(valueData.uid(), key, value, valueData.value(), type, contentType, dataType, + enumerationTypeId); + }).toList(); + + if (!values.isEmpty()) { + mappedValues = values; + } + } + return mappedValues; + } + + public static @Nullable Integer mapObjectToInteger(@Nullable Object object) { + if (object == null) { + return null; + } + return switch (object) { + case Integer intValue -> intValue; + case Long longValue -> longValue.intValue(); + case Float floatValue when floatValue % 1 == 0 -> floatValue.intValue(); + case Double doubleValue when doubleValue % 1 == 0 -> doubleValue.intValue(); + default -> null; + }; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Access.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Access.java new file mode 100644 index 0000000000..9ea62c56cc --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Access.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Access level model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum Access { + READ_WRITE, + READ, + READ_STATIC, + WRITE_ONLY, + NONE +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ActiveProgram.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ActiveProgram.java new file mode 100644 index 0000000000..f4cdedc704 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ActiveProgram.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Active program model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ActiveProgram(int uid, String key, boolean available, Access access, boolean fullOptionSet, + @Nullable Boolean validate) implements AccessProvider, AvailableProvider, KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Command.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Command.java new file mode 100644 index 0000000000..cd78acd43f --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Command.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.ContentTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.DataTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.RangeProvider; + +/** + * Command model from the device description. + * + * @author Jonas Brüstel - Initial contribution + * + * @param access possible values: WRITE_ONLY, NONE + * @param dataType the data type identifier (refDID) from the device description + */ +@NonNullByDefault +public record Command(int uid, String key, ContentType contentType, @Nullable DataType dataType, boolean available, + Access access, @Nullable Integer enumerationType, @Nullable String enumerationTypeKey, @Nullable Number min, + @Nullable Number max, @Nullable Number stepSize, boolean passwordProtected, boolean notifyOnChange) + implements + AccessProvider, + AvailableProvider, + RangeProvider, + KeyProvider, + EnumerationTypeProvider, + ContentTypeProvider, + DataTypeProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/CommandList.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/CommandList.java new file mode 100644 index 0000000000..c2cb95b715 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/CommandList.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Command list model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record CommandList(int uid, String key, boolean available, Access access, + DoubleKeyMap commandLists, + DoubleKeyMap commands) implements AccessProvider, AvailableProvider, KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ContentType.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ContentType.java new file mode 100644 index 0000000000..46f4ac10d5 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ContentType.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import java.util.StringJoiner; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Content type model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum ContentType { + UNKNOWN(0, "unknown", ProtocolType.STRING), + BOOLEAN(0x01, "boolean", ProtocolType.BOOLEAN), + INTEGER(0x02, "integer", ProtocolType.INTEGER), + ENUMERATION(0x03, "enumeration", ProtocolType.INTEGER), + FLOAT(0x04, "float", ProtocolType.FLOAT), + STRING(0x05, "string", ProtocolType.STRING), + DATE_TIME(0x06, "dateTime", ProtocolType.STRING), + TEMPERATURE_CELSIUS(0x07, "temperatureCelsius", ProtocolType.FLOAT), + TEMPERATURE_FAHRENHEIT(0x08, "temperatureFahrenheit", ProtocolType.FLOAT), + HEX_BINARY(0x0A, "hexBinary", ProtocolType.STRING), + TIME_SPAN(0x10, "timeSpan", ProtocolType.INTEGER), + PERCENT(0x11, "percent", ProtocolType.FLOAT), + DBM(0x12, "dbm", ProtocolType.INTEGER), + WEIGHT(0x13, "weight", ProtocolType.INTEGER), + LIQUID_VOLUME(0x14, "liquidVolume", ProtocolType.INTEGER), + UID_VALUE(0x15, "uidValue", ProtocolType.INTEGER), + DATE(0x16, "date", ProtocolType.STRING), + TIME(0x17, "time", ProtocolType.STRING), + WATER_HARDNESS(0x18, "waterHardness", ProtocolType.INTEGER), + POINT_2D(0x19, "point2D", ProtocolType.OBJECT), + POSE_2D(0x1A, "pose2D", ProtocolType.OBJECT), + LINE_2D(0x1B, "line2D", ProtocolType.OBJECT), + RGB(0x1E, "rgb", ProtocolType.STRING), + RPM(0x1F, "rpm", ProtocolType.INTEGER), + FLOW_RATE(0x20, "flowRate", ProtocolType.INTEGER), + LENGTH(0x21, "length", ProtocolType.FLOAT), + AREA(0x22, "area", ProtocolType.FLOAT), + POWER(0x23, "power", ProtocolType.FLOAT), + ENERGY(0x24, "energy", ProtocolType.FLOAT), + BIG_INTEGER(0x25, "bigInteger", ProtocolType.INTEGER), + IDENTIFIER(0x26, "identifier", ProtocolType.INTEGER), + SPEED(0x27, "speed", ProtocolType.FLOAT), + PROGRAM_INSTRUCTION(0x28, "programInstruction", ProtocolType.OBJECT), + WEIGHT_POUND(0x29, "weightPound", ProtocolType.FLOAT), + LOCALE_STRING(0x2A, "localeString", ProtocolType.OBJECT), + TEASPOON(0x2B, "teaspoon", ProtocolType.FLOAT), + TABLESPOON(0x2C, "tablespoon", ProtocolType.FLOAT), + CUP(0x2D, "cup", ProtocolType.FLOAT), + PIECE(0x2E, "piece", ProtocolType.FLOAT), + BYTE_LENGTH(0x2F, "byteLength", ProtocolType.INTEGER), + UUID(0x30, "uuid", ProtocolType.STRING), + TIMEZONE(0x31, "timezone", ProtocolType.OBJECT), + CSV(0x32, "csv", ProtocolType.STRING), + LEAF(0x33, "leaf", ProtocolType.FLOAT), + BUNCH(0x34, "bunch", ProtocolType.FLOAT), + CASKET(0x35, "casket", ProtocolType.FLOAT), + PINCH(0x36, "pinch", ProtocolType.FLOAT), + STALK(0x37, "stalk", ProtocolType.FLOAT), + STICK(0x38, "stick", ProtocolType.FLOAT), + BRANCH(0x39, "branch", ProtocolType.FLOAT), + TRAY(0x3A, "tray", ProtocolType.INTEGER), + PORTION(0x3B, "portion", ProtocolType.INTEGER), + UTC_DATE_TIME(0x3D, "utcDateTime", ProtocolType.STRING), + PROGRAM_RUN_SUMMARY(0x3E, "programRunSummary", ProtocolType.OBJECT), + PROGRAM_SESSION_SUMMARY(0x3F, "programSessionSummary", ProtocolType.OBJECT), + LIQUID_VOLUME_THROUGHPUT(0x40, "liquidVolumeThroughput", ProtocolType.FLOAT), + WEIGHT_OUNCES(0x41, "weightOunces", ProtocolType.FLOAT), + RECTANGLE_2D(0x42, "rectangle2D", ProtocolType.OBJECT), + BOOLEAN_LIST(0x81, "booleanList", ProtocolType.OBJECT), + INTEGER_LIST(0x82, "integerList", ProtocolType.OBJECT), + ENUMERATION_LIST(0x83, "enumerationList", ProtocolType.OBJECT), + FLOAT_LIST(0x84, "floatList", ProtocolType.OBJECT), + STRING_LIST(0x85, "stringList", ProtocolType.OBJECT), + DATE_TIME_LIST(0x86, "dateTimeList", ProtocolType.OBJECT), + TEMPERATURE_CELSIUS_LIST(0x87, "temperatureCelsiusList", ProtocolType.OBJECT), + TEMPERATURE_FAHRENHEIT_LIST(0x88, "temperatureFahrenheitList", ProtocolType.OBJECT), + HEX_BINARY_LIST(0x8A, "hexBinaryList", ProtocolType.OBJECT), + TIME_SPAN_LIST(0x90, "timeSpanList", ProtocolType.OBJECT), + PERCENT_LIST(0x91, "percentList", ProtocolType.OBJECT), + DBM_LIST(0x92, "dBmList", ProtocolType.OBJECT), + WEIGHT_LIST(0x93, "weightList", ProtocolType.OBJECT), + LIQUID_VOLUME_LIST(0x94, "liquidVolumeList", ProtocolType.OBJECT), + UID_VALUE_LIST(0x95, "uidValueList", ProtocolType.OBJECT), + DATE_LIST(0x96, "dateList", ProtocolType.OBJECT), + TIME_LIST(0x97, "timeList", ProtocolType.OBJECT), + WATER_HARDNESS_LIST(0x98, "waterHardnessList", ProtocolType.OBJECT), + POINT_2D_LIST(0x99, "point2DList", ProtocolType.OBJECT), + POSE_2D_LIST(0x9A, "pose2DList", ProtocolType.OBJECT), + LINE_2D_LIST(0x9B, "line2DList", ProtocolType.OBJECT), + PATH(0x9C, "path", ProtocolType.OBJECT), + POLYGON(0x9D, "polygon", ProtocolType.OBJECT), + RGB_LIST(0x9E, "rgbList", ProtocolType.OBJECT), + RPM_LIST(0x9F, "rpmList", ProtocolType.OBJECT), + FLOW_RATE_LIST(0xA0, "flowRateList", ProtocolType.OBJECT), + LENGTH_LIST(0xA1, "lengthList", ProtocolType.OBJECT), + AREA_LIST(0xA2, "areaList", ProtocolType.OBJECT), + POWER_LIST(0xA3, "powerList", ProtocolType.OBJECT), + ENERGY_LIST(0xA4, "energyList", ProtocolType.OBJECT), + BIG_INTEGER_LIST(0xA5, "bigIntegerList", ProtocolType.OBJECT), + IDENTIFIER_LIST(0xA6, "identifierList", ProtocolType.OBJECT), + SPEED_LIST(0xA7, "speedList", ProtocolType.OBJECT), + PROGRAM_INSTRUCTION_LIST(0xA8, "programInstructionList", ProtocolType.OBJECT), + WEIGHT_POUND_LIST(0xA9, "weightPoundList", ProtocolType.OBJECT), + LOCALE_STRING_LIST(0xAA, "localeStringList", ProtocolType.OBJECT), + TEASPOON_LIST(0xAB, "teaspoonList", ProtocolType.OBJECT), + TABLESPOON_LIST(0xAC, "tablespoonList", ProtocolType.OBJECT), + CUP_LIST(0xAD, "cupList", ProtocolType.OBJECT), + PIECE_LIST(0xAE, "pieceList", ProtocolType.OBJECT), + BYTE_LENGTH_LIST(0xAF, "byteLengthList", ProtocolType.OBJECT), + UUID_LIST(0xB0, "uuidList", ProtocolType.OBJECT), + TIMEZONE_LIST(0xB1, "timezoneList", ProtocolType.OBJECT), + CSV_LIST(0xB2, "csvList", ProtocolType.OBJECT), + LEAF_LIST(0xB3, "leafList", ProtocolType.OBJECT), + BUNCH_LIST(0xB4, "bunchList", ProtocolType.OBJECT), + CASKET_LIST(0xB5, "casketList", ProtocolType.OBJECT), + PINCH_LIST(0xB6, "pinchList", ProtocolType.OBJECT), + STALK_LIST(0xB7, "stalkList", ProtocolType.OBJECT), + STICK_LIST(0xB8, "stickList", ProtocolType.OBJECT), + BRANCH_LIST(0xB9, "branchList", ProtocolType.OBJECT), + TRAY_LIST(0xBA, "trayList", ProtocolType.OBJECT), + PORTION_LIST(0xBB, "portionList", ProtocolType.OBJECT), + UTC_DATE_TIME_LIST(0xBD, "utcDateTimeList", ProtocolType.OBJECT), + PROGRAM_RUN_SUMMARY_LIST(0xBE, "programRunSummaryList", ProtocolType.OBJECT), + PROGRAM_SESSION_SUMMARY_LIST(0xBF, "programSessionSummaryList", ProtocolType.OBJECT), + LIQUID_VOLUME_THROUGHPUT_LIST(0xC0, "liquidVolumeThroughputList", ProtocolType.OBJECT), + WEIGHT_OUNCES_LIST(0xC1, "weightOuncesList", ProtocolType.OBJECT), + RECTANGLE_2D_LIST(0xC2, "rectangle2DList", ProtocolType.OBJECT); + + public final int id; + public final String type; + public final ProtocolType protocolType; + + ContentType(int id, String type, ProtocolType protocolType) { + this.id = id; + this.type = type; + this.protocolType = protocolType; + } + + @Override + public String toString() { + return new StringJoiner(", ", ContentType.class.getSimpleName() + "[", "]").add("id=" + id) + .add("type='" + type + "'").add("protocolType=" + protocolType).toString(); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DataType.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DataType.java new file mode 100644 index 0000000000..99740b2222 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DataType.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import java.util.StringJoiner; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Data Type Description (DTD) identifiers from the device description. + * Based on HC_INT_BSH_DTD.xml. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum DataType { + BOOLEAN(0x00, "Boolean"), + UINT8(0x80, "UInt8"), + UINT16(0x81, "UInt16"), + UINT32(0x82, "UInt32"), + UINT64(0x83, "UInt64"), + INT8(0x84, "Int8"), + INT16(0x85, "Int16"), + INT32(0x86, "Int32"), + INT64(0x87, "Int64"), + FLOAT(0x88, "Float"), + DOUBLE(0x89, "Double"), + DATE_TIME(0x8A, "DateTime"), + STRING(0x8B, "String"), + BYTE_STRING(0x8C, "ByteString"), + GENERIC_JSON_DATA(0x8D, "GenericJsonData"), + + // Conversion types: COM converts between content transfer type and internal representation + FLOAT_UINT8(0xA0, "Float_UInt8"), + FLOAT_UINT16(0xA1, "Float_UInt16"), + FLOAT_UINT32(0xA2, "Float_UInt32"), + FLOAT_UINT64(0xA3, "Float_UInt64"), + FLOAT_INT8(0xA4, "Float_Int8"), + FLOAT_INT16(0xA5, "Float_Int16"), + FLOAT_INT32(0xA6, "Float_Int32"), + FLOAT_INT64(0xA7, "Float_Int64"), + DATE_DATE_TIME(0xA8, "Date_DateTime"), + TIME_DATE_TIME(0xA9, "Time_DateTime"), + MAC_BYTE_ARRAY6(0xAA, "MAC_ByteArray6"), + RGB(0xAB, "RGB"), + + // Composite types + POINT2D_UINT16(0x0101, "Point2D_UInt16"), + POINT2D_FLOAT(0x0108, "Point2D_Float"), + LINE2D_UINT16(0x0121, "Line2D_UInt16"), + LINE2D_FLOAT(0x0128, "Line2D_Float"), + POSE2D_UINT16(0x0141, "Pose2D_UInt16"), + POSE2D_FLOAT(0x0148, "Pose2D_Float"); + + public final int id; + public final String type; + + DataType(int id, String type) { + this.id = id; + this.type = type; + } + + /** + * Returns true if this data type represents a raw integer wire encoding (UInt8-Int64). + * When a contentType has protocolType FLOAT but the wire encoding is a raw integer, + * the value is a fixed-point integer that needs scaling. + */ + public boolean isRawInteger() { + return this == UINT8 || this == UINT16 || this == UINT32 || this == UINT64 || this == INT8 || this == INT16 + || this == INT32 || this == INT64; + } + + @Override + public String toString() { + return new StringJoiner(", ", DataType.class.getSimpleName() + "[", "]").add("id=" + id) + .add("type='" + type + "'").toString(); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DeviceDescription.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DeviceDescription.java new file mode 100644 index 0000000000..a960743974 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DeviceDescription.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Device description root model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record DeviceDescription(@Nullable StatusList statusList, @Nullable SettingList settingList, + @Nullable EventList eventList, @Nullable CommandList commandList, @Nullable OptionList optionList, + @Nullable ProgramGroup programGroup, @Nullable ActiveProgram activeProgram, + @Nullable SelectedProgram selectedProgram, @Nullable ProtectionPort protectionPort, + @Nullable EnumerationTypeList enumerationTypeList) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DeviceDescriptionType.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DeviceDescriptionType.java new file mode 100644 index 0000000000..360659afac --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/DeviceDescriptionType.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Device description type model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum DeviceDescriptionType { + STATUS_LIST, + STATUS, + SETTING_LIST, + SETTING, + EVENT_LIST, + EVENT, + COMMAND_LIST, + COMMAND, + OPTION_LIST, + OPTION, + PROGRAM_GROUP, + PROGRAM, + PROGRAM_OPTION, + SELECTED_PROGRAM, + PROTECTION_PORT, + ACTIVE_PROGRAM, + ENUMERATION_TYPE, + UNKNOWN +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Enumeration.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Enumeration.java new file mode 100644 index 0000000000..99d24f7143 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Enumeration.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Enumeration(int value, String valueKey) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EnumerationType.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EnumerationType.java new file mode 100644 index 0000000000..bef47e3d0f --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EnumerationType.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; + +/** + * Enumeration type model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record EnumerationType(int enId, String enKey, @Nullable Integer subsetOf, @Nullable String subsetOfKey, + DoubleKeyMap enumerations) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EnumerationTypeList.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EnumerationTypeList.java new file mode 100644 index 0000000000..598f9453c7 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EnumerationTypeList.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; + +/** + * Enumeration type list model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record EnumerationTypeList(DoubleKeyMap enumerationTypes) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Event.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Event.java new file mode 100644 index 0000000000..566d62f6f6 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Event.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.ContentTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.DataTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Event model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Event(int uid, String key, ContentType contentType, @Nullable DataType dataType, Handling handling, + Level level, @Nullable Integer enumerationType, String enumerationTypeKey) + implements + KeyProvider, + ContentTypeProvider, + EnumerationTypeProvider, + DataTypeProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EventList.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EventList.java new file mode 100644 index 0000000000..b737ca43ba --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/EventList.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Event list model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record EventList(int uid, String key, DoubleKeyMap eventLists, + DoubleKeyMap events) implements KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Execution.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Execution.java new file mode 100644 index 0000000000..9fffef0439 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Execution.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Execution model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum Execution { + NONE, + SELECT_ONLY, + START_ONLY, + SELECT_AND_START +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Handling.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Handling.java new file mode 100644 index 0000000000..e7c4fd3214 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Handling.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Handling model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum Handling { + NONE, + ACKNOWLEDGE, + DECISION +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Level.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Level.java new file mode 100644 index 0000000000..ab93bd7514 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Level.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Level model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum Level { + HINT, + INFO, + WARNING, + ALERT, + CRITICAL +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Option.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Option.java new file mode 100644 index 0000000000..f814d132fb --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Option.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.ContentTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.DataTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.RangeProvider; + +/** + * Option model from the device description. + * + * @author Jonas Brüstel - Initial contribution + * + * @param access NONE, READ, READ_WRITE, READ_STATIC + * @param dataType the data type identifier (refDID) from the device description + */ +@NonNullByDefault +public record Option(int uid, String key, ContentType contentType, @Nullable DataType dataType, @Nullable Number min, + @Nullable Number max, @Nullable Number stepSize, @Nullable String defaultValue, @Nullable String initValue, + @Nullable Integer enumerationType, @Nullable String enumerationTypeKey, boolean available, Access access, + boolean notifyOnChange, boolean liveUpdate) + implements + AccessProvider, + AvailableProvider, + RangeProvider, + KeyProvider, + EnumerationTypeProvider, + ContentTypeProvider, + DataTypeProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/OptionList.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/OptionList.java new file mode 100644 index 0000000000..dde9f66512 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/OptionList.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Option list model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record OptionList(int uid, String key, boolean available, Access access, + DoubleKeyMap optionLists, + DoubleKeyMap options) implements AccessProvider, AvailableProvider, KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Program.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Program.java new file mode 100644 index 0000000000..81d1681d54 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Program.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Program model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Program(int uid, String key, boolean available, Execution execution, + List options) implements AvailableProvider, KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProgramGroup.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProgramGroup.java new file mode 100644 index 0000000000..e428389dd4 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProgramGroup.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Program group model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ProgramGroup(int uid, String key, boolean available, + DoubleKeyMap programGroups, + DoubleKeyMap programs) implements AvailableProvider, KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProgramOption.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProgramOption.java new file mode 100644 index 0000000000..47c393885a --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProgramOption.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.RangeProvider; + +/** + * Program option model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ProgramOption(int refUid, String refKey, boolean available, Access access, boolean liveUpdate, + @Nullable Number min, @Nullable Number max, @Nullable Number stepSize, String defaultValue, + @Nullable Integer enumerationType, @Nullable String enumerationTypeKey) + implements + AccessProvider, + AvailableProvider, + RangeProvider, + EnumerationTypeProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProtectionPort.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProtectionPort.java new file mode 100644 index 0000000000..b0ec499922 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProtectionPort.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Protection port model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ProtectionPort(int uid, String key, boolean available, + Access access) implements AccessProvider, AvailableProvider, KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProtocolType.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProtocolType.java new file mode 100644 index 0000000000..a2dbf73f49 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/ProtocolType.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Protocol type model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum ProtocolType { + BOOLEAN("Boolean"), + INTEGER("Integer"), + FLOAT("Float"), + STRING("String"), + OBJECT("Object"); + + private final String value; + + ProtocolType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/SelectedProgram.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/SelectedProgram.java new file mode 100644 index 0000000000..b1b97b5e21 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/SelectedProgram.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Selected program model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record SelectedProgram(int uid, String key, boolean available, Access access, boolean fullOptionSet, + @Nullable Boolean validate) implements AccessProvider, AvailableProvider, KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Setting.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Setting.java new file mode 100644 index 0000000000..b07d505bf8 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Setting.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.ContentTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.DataTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.RangeProvider; + +/** + * Setting model from the device description. + * + * @author Jonas Brüstel - Initial contribution + * + * @param dataType the data type identifier (refDID) from the device description + */ +@NonNullByDefault +public record Setting(int uid, String key, ContentType contentType, @Nullable DataType dataType, @Nullable Number min, + @Nullable Number max, @Nullable Number stepSize, boolean available, Access access, @Nullable String initValue, + @Nullable Integer enumerationType, @Nullable String enumerationTypeKey, boolean notifyOnChange, + boolean passwordProtected) + implements + AccessProvider, + AvailableProvider, + RangeProvider, + KeyProvider, + EnumerationTypeProvider, + ContentTypeProvider, + DataTypeProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/SettingList.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/SettingList.java new file mode 100644 index 0000000000..04fbeeb3f8 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/SettingList.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Setting list model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record SettingList(int uid, String key, boolean available, Access access, + DoubleKeyMap settingLists, + DoubleKeyMap settings) implements AccessProvider, AvailableProvider, KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Status.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Status.java new file mode 100644 index 0000000000..809c3e034f --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/Status.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.ContentTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.DataTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.RangeProvider; + +/** + * Status model from the device description. + * + * @author Jonas Brüstel - Initial contribution + * + * @param access possible values: READ, READ_STATIC, NONE + * @param dataType the data type identifier (refDID) from the device description + */ +@NonNullByDefault +public record Status(int uid, String key, ContentType contentType, @Nullable DataType dataType, @Nullable Number min, + @Nullable Number max, @Nullable Number stepSize, @Nullable Integer enumerationType, + @Nullable String enumerationTypeKey, boolean available, Access access, boolean notifyOnChange, + @Nullable String initValue) + implements + AccessProvider, + AvailableProvider, + RangeProvider, + KeyProvider, + EnumerationTypeProvider, + ContentTypeProvider, + DataTypeProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/StatusList.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/StatusList.java new file mode 100644 index 0000000000..9a4aca80d2 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/StatusList.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.KeyProvider; + +/** + * Status list model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record StatusList(int uid, String key, boolean available, Access access, + DoubleKeyMap statusLists, + DoubleKeyMap statuses) implements AccessProvider, AvailableProvider, KeyProvider { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/change/Change.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/change/Change.java new file mode 100644 index 0000000000..7e084ac7a8 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/change/Change.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model.change; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Change model from the device description. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Change(@Nullable Object from, @Nullable Object to) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/change/DeviceDescriptionChange.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/change/DeviceDescriptionChange.java new file mode 100644 index 0000000000..a5f8592a39 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/change/DeviceDescriptionChange.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model.change; + +import java.util.HashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; + +/** + * Device description change model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record DeviceDescriptionChange(int uid, String key, DeviceDescriptionType type, @Nullable Integer parentUid, + @Nullable String parentKey, @Nullable DeviceDescriptionType parentType, + @Nullable HashMap changes, @Nullable Object object) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/AccessProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/AccessProvider.java new file mode 100644 index 0000000000..1648d65f9b --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/AccessProvider.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model.provider; + +import org.eclipse.jdt.annotation.DefaultLocation; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Access; + +/** + * Provider interface for access information. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault(value = { DefaultLocation.TYPE_BOUND }) +public interface AccessProvider { + /** + * Get the access information. + * + * @return the access information + */ + Access access(); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/AvailableProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/AvailableProvider.java new file mode 100644 index 0000000000..673540f23b --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/AvailableProvider.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model.provider; + +import org.eclipse.jdt.annotation.DefaultLocation; +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Provider interface for availability information. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault(value = { DefaultLocation.TYPE_BOUND }) +public interface AvailableProvider { + /** + * Get the availability information. + * + * @return {@code true} if available, {@code false} otherwise + */ + boolean available(); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/ContentTypeProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/ContentTypeProvider.java new file mode 100644 index 0000000000..e2e8904567 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/ContentTypeProvider.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model.provider; + +import org.eclipse.jdt.annotation.DefaultLocation; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ContentType; + +/** + * Provider interface for content type information. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault(value = { DefaultLocation.TYPE_BOUND }) +public interface ContentTypeProvider { + /** + * Get the content type. + * + * @return the content type + */ + ContentType contentType(); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/DataTypeProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/DataTypeProvider.java new file mode 100644 index 0000000000..77233f04ba --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/DataTypeProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model.provider; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DataType; + +/** + * Provider interface for data type information. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public interface DataTypeProvider { + /** + * Get the data type. + * + * @return the data type, or {@code null} if not set + */ + @Nullable + DataType dataType(); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/EnumerationTypeProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/EnumerationTypeProvider.java new file mode 100644 index 0000000000..4ab207750a --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/EnumerationTypeProvider.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model.provider; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Provider interface for enumeration type information. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public interface EnumerationTypeProvider { + /** + * Get the enumeration type. + * + * @return the enumeration type, or {@code null} if not set + */ + @Nullable + Integer enumerationType(); + + /** + * Get the enumeration type key. + * + * @return the enumeration type key, or {@code null} if not set + */ + @Nullable + String enumerationTypeKey(); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/KeyProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/KeyProvider.java new file mode 100644 index 0000000000..0ca3622f41 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/KeyProvider.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model.provider; + +import org.eclipse.jdt.annotation.DefaultLocation; +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Provider interface for key information. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault(value = { DefaultLocation.TYPE_BOUND }) +public interface KeyProvider { + /** + * Get the key. + * + * @return the key + */ + String key(); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/RangeProvider.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/RangeProvider.java new file mode 100644 index 0000000000..fbd62bfe7b --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/model/provider/RangeProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.model.provider; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Provider interface for range information. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public interface RangeProvider { + /** + * Get the minimum value. + * + * @return the minimum value, or {@code null} if not set + */ + @Nullable + Number min(); + + /** + * Get the maximum value. + * + * @return the maximum value, or {@code null} if not set + */ + @Nullable + Number max(); + + /** + * Get the step size. + * + * @return the step size, or {@code null} if not set + */ + @Nullable + Number stepSize(); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/xml/converter/DeviceDescriptionConverter.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/xml/converter/DeviceDescriptionConverter.java new file mode 100644 index 0000000000..679f8fd9d5 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/description/xml/converter/DeviceDescriptionConverter.java @@ -0,0 +1,543 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description.xml.converter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; +import org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils; +import org.openhab.binding.homeconnectdirect.internal.common.xml.converter.AbstractConverter; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Access; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ActiveProgram; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Command; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.CommandList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ContentType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DataType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescription; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Enumeration; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.EnumerationType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.EnumerationTypeList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Event; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.EventList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Execution; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Handling; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Level; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Option; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.OptionList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Program; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ProgramGroup; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ProgramOption; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ProtectionPort; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.SelectedProgram; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Setting; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.SettingList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Status; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.StatusList; +import org.openhab.binding.homeconnectdirect.internal.service.feature.model.FeatureMapping; + +import com.thoughtworks.xstream.io.HierarchicalStreamReader; + +/** + * XStream converter for device description XML. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class DeviceDescriptionConverter extends AbstractConverter { + + @Override + public DeviceDescription process(HierarchicalStreamReader reader, @Nullable FeatureMapping featureMapping) { + List statusList = new ArrayList<>(); + List settingList = new ArrayList<>(); + List eventList = new ArrayList<>(); + List commandList = new ArrayList<>(); + List optionList = new ArrayList<>(); + List programGroups = new ArrayList<>(); + List activeProgramList = new ArrayList<>(); + List selectedProgramList = new ArrayList<>(); + List protectionPortList = new ArrayList<>(); + List enumerationTypeList = new ArrayList<>(); + + read(reader, featureMapping, statusList, settingList, eventList, commandList, optionList, programGroups, + activeProgramList, selectedProgramList, protectionPortList, enumerationTypeList); + + return new DeviceDescription(statusList.stream().findFirst().orElse(null), + settingList.stream().findFirst().orElse(null), eventList.stream().findFirst().orElse(null), + commandList.stream().findFirst().orElse(null), optionList.stream().findFirst().orElse(null), + programGroups.stream().findFirst().orElse(null), activeProgramList.stream().findFirst().orElse(null), + selectedProgramList.stream().findFirst().orElse(null), + protectionPortList.stream().findFirst().orElse(null), + enumerationTypeList.stream().findFirst().orElse(null)); + } + + private void read(HierarchicalStreamReader reader, @Nullable FeatureMapping featureMapping, + List statusList, List settingList, List eventList, + List commandList, List optionList, List programGroups, + List activeProgramList, List selectedProgramList, + List protectionPortList, List enumerationTypeList) { + if (featureMapping == null) { + return; + } + + while (reader.hasMoreChildren()) { + reader.moveDown(); + String nodeName = reader.getNodeName(); + + if ("statusList".equals(nodeName)) { + statusList.add(readStatusList(reader, featureMapping)); + } else if ("settingList".equals(nodeName)) { + settingList.add(readSettingList(reader, featureMapping)); + } else if ("eventList".equals(nodeName)) { + eventList.add(readEventList(reader, featureMapping)); + } else if ("commandList".equals(nodeName)) { + commandList.add(readCommandList(reader, featureMapping)); + } else if ("optionList".equals(nodeName)) { + optionList.add(readOptionList(reader, featureMapping)); + } else if ("programGroup".equals(nodeName)) { + programGroups.add(readProgramGroup(reader, featureMapping)); + } else if ("activeProgram".equals(nodeName)) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = mapBoolean(reader.getAttribute("available"), true); + var access = mapAccess(reader.getAttribute("access")); + var fullOptionSet = mapBoolean(reader.getAttribute("fullOptionSet"), true); + var validateString = reader.getAttribute("validate"); + Boolean validate = validateString == null ? null : Boolean.parseBoolean(validateString); + + activeProgramList.add(new ActiveProgram(uid, key, available, access, fullOptionSet, validate)); + } else if ("selectedProgram".equals(nodeName)) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = mapBoolean(reader.getAttribute("available"), true); + var access = mapAccess(reader.getAttribute("access")); + var fullOptionSet = mapBoolean(reader.getAttribute("fullOptionSet"), true); + var validateString = reader.getAttribute("validate"); + Boolean validate = validateString == null ? null : Boolean.parseBoolean(validateString); + + selectedProgramList.add(new SelectedProgram(uid, key, available, access, fullOptionSet, validate)); + } else if ("protectionPort".equals(nodeName)) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = mapBoolean(reader.getAttribute("available"), false); + var access = mapAccess(reader.getAttribute("access")); + + protectionPortList.add(new ProtectionPort(uid, key, available, access)); + } else if ("enumerationTypeList".equals(nodeName)) { + enumerationTypeList.add(readEnumerationTypeList(reader, featureMapping)); + } + + reader.moveUp(); + } + } + + private EnumerationTypeList readEnumerationTypeList(HierarchicalStreamReader reader, + FeatureMapping featureMapping) { + DoubleKeyMap enumerationTypes = new DoubleKeyMap<>(); + while (reader.hasMoreChildren()) { + reader.moveDown(); + if ("enumerationType".equals(reader.getNodeName())) { + var enumerationType = readEnumerationType(reader, featureMapping); + enumerationTypes.put(enumerationType.enId(), enumerationType.enKey(), enumerationType); + } + reader.moveUp(); + } + return new EnumerationTypeList(enumerationTypes); + } + + private EnumerationType readEnumerationType(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var enId = mapHexId(reader.getAttribute("enid")); + var subsetOf = mapHexIdNullable(reader.getAttribute("subsetOf")); + var subsetOfKey = featureMapping.mapEnumIdToKeyNullable(subsetOf); + String enKey; + if (subsetOfKey != null) { + enKey = "%s.%d.SubSetOf.%d".formatted(subsetOfKey, enId, subsetOf); + } else { + enKey = featureMapping.mapEnumIdToKey(enId); + } + + DoubleKeyMap enumerations = new DoubleKeyMap<>(); + while (reader.hasMoreChildren()) { + reader.moveDown(); + if ("enumeration".equals(reader.getNodeName())) { + var value = mapIntegerNullable(reader.getAttribute("value")); + if (value != null) { + var valueKey = featureMapping.mapEnumValueToKey(Objects.requireNonNullElse(subsetOf, enId), value); + var enumeration = new Enumeration(value, valueKey); + enumerations.put(enumeration.value(), enumeration.valueKey(), enumeration); + } + } + reader.moveUp(); + } + return new EnumerationType(enId, enKey, subsetOf, subsetOfKey, enumerations); + } + + private SettingList readSettingList(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = Boolean.parseBoolean(reader.getAttribute("available")); + var access = mapAccess(reader.getAttribute("access")); + + DoubleKeyMap subLists = new DoubleKeyMap<>(); + DoubleKeyMap settings = new DoubleKeyMap<>(); + + while (reader.hasMoreChildren()) { + reader.moveDown(); + String nodeName = reader.getNodeName(); + if ("setting".equals(nodeName)) { + var setting = readSetting(reader, featureMapping); + settings.put(setting.uid(), setting.key(), setting); + } else if ("settingList".equals(nodeName)) { + var settingList = readSettingList(reader, featureMapping); + subLists.put(settingList.uid(), settingList.key(), settingList); + } + reader.moveUp(); + } + + return new SettingList(uid, key, available, access, subLists, settings); + } + + private Setting readSetting(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); // required + var key = featureMapping.mapFeatureIdToKey(uid); + var contentType = mapContentType(mapHexId(reader.getAttribute("refCID"))); // required + var dataType = mapDataType(reader.getAttribute("refDID")); + var min = mapNumberNullable(reader.getAttribute("min")); + var max = mapNumberNullable(reader.getAttribute("max")); + var stepSize = mapNumberNullable(reader.getAttribute("stepSize")); + var available = Boolean.parseBoolean(reader.getAttribute("available")); // required + var access = mapAccess(reader.getAttribute("access")); // required + var initValue = reader.getAttribute("initValue"); + var enumerationType = mapHexIdNullable(reader.getAttribute("enumerationType")); + var enumerationKey = featureMapping.mapEnumIdToKeyNullable(enumerationType); + var notifyOnChange = mapBoolean(reader.getAttribute("notifyOnChange"), true); + var passwordProtected = mapBoolean(reader.getAttribute("passwordProtected"), false); + + return new Setting(uid, key, contentType, dataType, min, max, stepSize, available, access, initValue, + enumerationType, enumerationKey, notifyOnChange, passwordProtected); + } + + private EventList readEventList(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + + DoubleKeyMap subLists = new DoubleKeyMap<>(); + DoubleKeyMap events = new DoubleKeyMap<>(); + + while (reader.hasMoreChildren()) { + reader.moveDown(); + String nodeName = reader.getNodeName(); + if ("event".equals(nodeName)) { + var event = readEvent(reader, featureMapping); + events.put(event.uid(), event.key(), event); + } else if ("eventList".equals(nodeName)) { + var eventList = readEventList(reader, featureMapping); + subLists.put(eventList.uid(), eventList.key(), eventList); + } + reader.moveUp(); + } + + return new EventList(uid, key, subLists, events); + } + + private Event readEvent(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); // required + var key = featureMapping.mapFeatureIdToKey(uid); + var contentType = mapContentType(mapHexId(reader.getAttribute("refCID"), 3)); // fixed "03" + var dataType = mapDataType(reader.getAttribute("refDID")); + var handling = mapHandling(reader.getAttribute("handling")); // required + var level = mapLevel(reader.getAttribute("level")); // required + var enumerationType = mapHexId(reader.getAttribute("enumerationType"), 1); // fixed "0001" + var enumerationKey = featureMapping.mapEnumIdToKey(enumerationType); + + return new Event(uid, key, contentType, dataType, handling, level, enumerationType, enumerationKey); + } + + private StatusList readStatusList(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = Boolean.parseBoolean(reader.getAttribute("available")); + var access = mapAccess(reader.getAttribute("access")); + + DoubleKeyMap subLists = new DoubleKeyMap<>(); + DoubleKeyMap statusMap = new DoubleKeyMap<>(); + + while (reader.hasMoreChildren()) { + reader.moveDown(); + String nodeName = reader.getNodeName(); + if ("status".equals(nodeName)) { + var status = readStatus(reader, featureMapping); + statusMap.put(status.uid(), status.key(), status); + } else if ("statusList".equals(nodeName)) { + var statusList = readStatusList(reader, featureMapping); + subLists.put(statusList.uid(), statusList.key(), statusList); + } + reader.moveUp(); + } + + return new StatusList(uid, key, available, access, subLists, statusMap); + } + + private Status readStatus(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); // required + var key = featureMapping.mapFeatureIdToKey(uid); + var contentType = mapContentType(mapHexId(reader.getAttribute("refCID"))); + var dataType = mapDataType(reader.getAttribute("refDID")); + var min = mapNumberNullable(reader.getAttribute("min")); + var max = mapNumberNullable(reader.getAttribute("max")); + var stepSize = mapNumberNullable(reader.getAttribute("stepSize")); + var enumerationType = mapHexIdNullable(reader.getAttribute("enumerationType")); + var enumerationKey = featureMapping.mapEnumIdToKeyNullable(enumerationType); + var available = Boolean.parseBoolean(reader.getAttribute("available")); // required + var access = mapAccess(reader.getAttribute("access")); // required + var notifyOnChange = mapBoolean(reader.getAttribute("notifyOnChange"), true); + var initValue = reader.getAttribute("initValue"); + + return new Status(uid, key, contentType, dataType, min, max, stepSize, enumerationType, enumerationKey, + available, access, notifyOnChange, initValue); + } + + private CommandList readCommandList(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = Boolean.parseBoolean(reader.getAttribute("available")); + var access = mapAccess(reader.getAttribute("access")); + + DoubleKeyMap commandLists = new DoubleKeyMap<>(); + DoubleKeyMap commands = new DoubleKeyMap<>(); + + while (reader.hasMoreChildren()) { + reader.moveDown(); + String nodeName = reader.getNodeName(); + if ("command".equals(nodeName)) { + var command = readCommand(reader, featureMapping); + commands.put(command.uid(), command.key(), command); + } else if ("commandList".equals(nodeName)) { + var commandList = readCommandList(reader, featureMapping); + commandLists.put(commandList.uid(), commandList.key(), commandList); + } + reader.moveUp(); + } + + return new CommandList(uid, key, available, access, commandLists, commands); + } + + private Command readCommand(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); // required + var key = featureMapping.mapFeatureIdToKey(uid); + var contentType = mapContentType(mapHexIdNullable(reader.getAttribute("refCID"))); // required + var dataType = mapDataType(reader.getAttribute("refDID")); + var available = Boolean.parseBoolean(reader.getAttribute("available")); // required + var access = mapAccess(reader.getAttribute("access")); // required + var enumerationType = mapHexIdNullable(reader.getAttribute("enumerationType")); + var enumerationKey = featureMapping.mapEnumIdToKeyNullable(enumerationType); + var min = mapNumberNullable(reader.getAttribute("min")); + var max = mapNumberNullable(reader.getAttribute("max")); + var stepSize = mapNumberNullable(reader.getAttribute("stepSize")); + var passwordProtected = mapBoolean(reader.getAttribute("passwordProtected"), false); + var notifyOnChange = mapBoolean(reader.getAttribute("notifyOnChange"), false); // fixed "false" + + return new Command(uid, key, contentType, dataType, available, access, enumerationType, enumerationKey, min, + max, stepSize, passwordProtected, notifyOnChange); + } + + private OptionList readOptionList(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = Boolean.parseBoolean(reader.getAttribute("available")); + var access = mapAccess(reader.getAttribute("access")); + + DoubleKeyMap optionLists = new DoubleKeyMap<>(); + DoubleKeyMap options = new DoubleKeyMap<>(); + + while (reader.hasMoreChildren()) { + reader.moveDown(); + String nodeName = reader.getNodeName(); + if ("option".equals(nodeName) && !StringUtils.isBlank(reader.getAttribute("uid"))) { + var option = readOption(reader, featureMapping); + options.put(option.uid(), option.key(), option); + } else if ("optionList".equals(nodeName)) { + var optionList = readOptionList(reader, featureMapping); + optionLists.put(optionList.uid(), optionList.key(), optionList); + } + reader.moveUp(); + } + + return new OptionList(uid, key, available, access, optionLists, options); + } + + private Option readOption(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); // required + var key = featureMapping.mapFeatureIdToKey(uid); + var contentType = mapContentType(mapHexIdNullable(reader.getAttribute("refCID"))); // required + var dataType = mapDataType(reader.getAttribute("refDID")); + var min = mapNumberNullable(reader.getAttribute("min")); + var max = mapNumberNullable(reader.getAttribute("max")); + var stepSize = mapNumberNullable(reader.getAttribute("stepSize")); + var defaultValue = reader.getAttribute("default"); + var initValue = reader.getAttribute("initValue"); + var enumerationType = mapHexIdNullable(reader.getAttribute("enumerationType")); + var enumerationKey = featureMapping.mapEnumIdToKeyNullable(enumerationType); + var available = Boolean.parseBoolean(reader.getAttribute("available")); // required + var access = mapAccess(reader.getAttribute("access")); // required + var notifyOnChange = mapBoolean(reader.getAttribute("notifyOnChange"), true); + var liveUpdate = mapBoolean(reader.getAttribute("liveUpdate"), false); + + return new Option(uid, key, contentType, dataType, min, max, stepSize, defaultValue, initValue, enumerationType, + enumerationKey, available, access, notifyOnChange, liveUpdate); + } + + private ProgramGroup readProgramGroup(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = Boolean.parseBoolean(reader.getAttribute("available")); + + DoubleKeyMap programGroups = new DoubleKeyMap<>(); + DoubleKeyMap programs = new DoubleKeyMap<>(); + + while (reader.hasMoreChildren()) { + reader.moveDown(); + String nodeName = reader.getNodeName(); + if ("programGroup".equals(nodeName)) { + var programGroup = readProgramGroup(reader, featureMapping); + programGroups.put(programGroup.uid(), programGroup.key(), programGroup); + } else if ("program".equals(nodeName)) { + var program = readProgram(reader, featureMapping); + programs.put(program.uid(), program.key(), program); + } + reader.moveUp(); + } + + return new ProgramGroup(uid, key, available, programGroups, programs); + } + + private Program readProgram(HierarchicalStreamReader reader, FeatureMapping featureMapping) { + var uid = mapHexId(reader.getAttribute("uid")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = Boolean.parseBoolean(reader.getAttribute("available")); + var execution = mapExecution(reader.getAttribute("execution"), Execution.SELECT_AND_START); + var programOptions = new ArrayList(); + readProgramOptions(reader, programOptions, featureMapping); + + return new Program(uid, key, available, execution, programOptions); + } + + private void readProgramOptions(HierarchicalStreamReader reader, List optionList, + FeatureMapping featureMapping) { + while (reader.hasMoreChildren()) { + reader.moveDown(); + String nodeName = reader.getNodeName(); + if ("option".equals(nodeName)) { + var uid = mapHexId(reader.getAttribute("refUID")); + var key = featureMapping.mapFeatureIdToKey(uid); + var available = Boolean.parseBoolean(reader.getAttribute("available")); + var access = mapAccess(reader.getAttribute("access")); + var liveUpdate = mapBoolean(reader.getAttribute("liveUpdate"), false); + var min = mapNumberNullable(reader.getAttribute("min")); + var max = mapNumberNullable(reader.getAttribute("max")); + var stepSize = mapNumberNullable(reader.getAttribute("stepSize")); + var defaultValue = reader.getAttribute("default"); + var enumerationType = mapHexIdNullable(reader.getAttribute("enumerationType")); + var enumerationKey = featureMapping.mapEnumIdToKeyNullable(enumerationType); + + if (defaultValue == null) { + defaultValue = "..."; + } + optionList.add(new ProgramOption(uid, key, available, access, liveUpdate, min, max, stepSize, + defaultValue, enumerationType, enumerationKey)); + } + reader.moveUp(); + } + } + + private Access mapAccess(@Nullable String access) { + if (access == null) { + return Access.NONE; + } + + return switch (access) { + case "read" -> Access.READ; + case "readWrite" -> Access.READ_WRITE; + case "readStatic" -> Access.READ_STATIC; + case "writeOnly" -> Access.WRITE_ONLY; + default -> Access.NONE; + }; + } + + private Execution mapExecution(@Nullable String execution, Execution defaultExecution) { + if (execution == null) { + return defaultExecution; + } + + return switch (execution) { + case "selectOnly" -> Execution.SELECT_ONLY; + case "startOnly" -> Execution.START_ONLY; + case "selectAndStart" -> Execution.SELECT_AND_START; + default -> Execution.NONE; + }; + } + + private Handling mapHandling(@Nullable String handling) { + if (handling == null) { + return Handling.NONE; + } + + return switch (handling) { + case "acknowledge" -> Handling.ACKNOWLEDGE; + case "decision" -> Handling.DECISION; + default -> Handling.NONE; + }; + } + + private Level mapLevel(@Nullable String level) { + if (level == null) { + return Level.HINT; + } + + return switch (level) { + case "info" -> Level.INFO; + case "alert" -> Level.ALERT; + case "critical" -> Level.CRITICAL; + case "warning" -> Level.WARNING; + default -> Level.HINT; + }; + } + + private ContentType mapContentType(@Nullable Integer cid) { + if (cid == null) { + return ContentType.UNKNOWN; + } + for (ContentType contentType : ContentType.values()) { + if (contentType.id == cid) { + return contentType; + } + } + return ContentType.UNKNOWN; + } + + private @Nullable DataType mapDataType(@Nullable String hexIdString) { + var did = mapHexIdNullable(hexIdString); + if (did == null) { + return null; + } + for (DataType dataType : DataType.values()) { + if (dataType.id == did) { + return dataType; + } + } + return null; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/FeatureMappingService.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/FeatureMappingService.java new file mode 100644 index 0000000000..a783fc4e33 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/FeatureMappingService.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.feature; + +import java.io.InputStream; +import java.nio.file.Path; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.common.xml.exception.ParseException; +import org.openhab.binding.homeconnectdirect.internal.service.feature.model.FeatureMapping; +import org.openhab.binding.homeconnectdirect.internal.service.feature.xml.converter.FeatureMappingConverter; + +import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.XStreamException; +import com.thoughtworks.xstream.io.xml.StaxDriver; + +/** + * Service for managing feature mappings. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class FeatureMappingService { + + private final FeatureMapping featureMapping; + + public FeatureMappingService(Path featureMappingFile) throws ParseException { + featureMapping = readFeatureMappingFile(featureMappingFile); + } + + public FeatureMappingService(InputStream inputStream) throws ParseException { + featureMapping = readFeatureMappingFile(inputStream); + } + + public FeatureMapping getFeatureMapping() { + return featureMapping; + } + + private FeatureMapping readFeatureMappingFile(Path featureMappingFile) throws ParseException { + var xstream = createXStream(); + try { + return (FeatureMapping) xstream.fromXML(featureMappingFile.toFile()); + } catch (XStreamException e) { + throw new ParseException("Could not deserialize XML '%s'".formatted(featureMappingFile), e); + } + } + + private FeatureMapping readFeatureMappingFile(InputStream inputStream) throws ParseException { + var xstream = createXStream(); + try { + return (FeatureMapping) xstream.fromXML(inputStream); + } catch (XStreamException e) { + throw new ParseException("Could not deserialize XML input stream", e); + } + } + + private XStream createXStream() { + var xstream = new XStream(new StaxDriver()); + xstream.allowTypesByWildcard(new String[] { FeatureMappingService.class.getPackageName() + ".**" }); + xstream.setClassLoader(getClass().getClassLoader()); + xstream.ignoreUnknownElements(); + xstream.alias("featureMappingFile", FeatureMapping.class); + xstream.registerConverter(new FeatureMappingConverter()); + return xstream; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/model/EnumDescription.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/model/EnumDescription.java new file mode 100644 index 0000000000..f5bb53b50a --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/model/EnumDescription.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.feature.model; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enum description model for feature mapping. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record EnumDescription(int enId, String enKey, Map values) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/model/FeatureMapping.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/model/FeatureMapping.java new file mode 100644 index 0000000000..96d70b9cef --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/model/FeatureMapping.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.feature.model; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; + +/** + * Feature mapping model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record FeatureMapping(Map features, Map errors, + DoubleKeyMap enums) { + + public String mapFeatureIdToKey(Integer uid) { + return features.getOrDefault(uid, String.valueOf(uid)); + } + + public String mapErrorIdToKey(Integer eid) { + return errors.getOrDefault(eid, String.valueOf(eid)); + } + + public String mapEnumIdToKey(Integer enid) { + EnumDescription desc = enums.getByKey1(enid); + return desc != null ? desc.enKey() : String.valueOf(enid); + } + + public @Nullable String mapEnumIdToKeyNullable(@Nullable Integer enid) { + if (enid == null) { + return null; + } + return mapEnumIdToKey(enid); + } + + public String mapEnumValueToKey(Integer enid, Integer enumValue) { + EnumDescription desc = enums.getByKey1(enid); + if (desc != null) { + String value = desc.values().get(enumValue); + if (value != null) { + return value; + } + } + return String.valueOf(enumValue); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/xml/converter/FeatureMappingConverter.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/xml/converter/FeatureMappingConverter.java new file mode 100644 index 0000000000..5048bd122c --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/feature/xml/converter/FeatureMappingConverter.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.feature.xml.converter; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.DoubleKeyMap; +import org.openhab.binding.homeconnectdirect.internal.common.xml.converter.AbstractConverter; +import org.openhab.binding.homeconnectdirect.internal.service.feature.model.EnumDescription; +import org.openhab.binding.homeconnectdirect.internal.service.feature.model.FeatureMapping; + +import com.thoughtworks.xstream.io.HierarchicalStreamReader; + +/** + * XStream converter for feature mapping XML. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class FeatureMappingConverter extends AbstractConverter { + + @Override + public FeatureMapping process(HierarchicalStreamReader reader, @Nullable Void contextObject) { + Map featureMap = new HashMap<>(); + Map errorMap = new HashMap<>(); + DoubleKeyMap enums = new DoubleKeyMap<>(); + + read(reader, featureMap, errorMap, enums); + return new FeatureMapping(featureMap, errorMap, enums); + } + + private void read(HierarchicalStreamReader reader, Map featureMap, Map errorMap, + DoubleKeyMap enums) { + while (reader.hasMoreChildren()) { + reader.moveDown(); + String nodeName = reader.getNodeName(); + + if ("featureDescription".equals(nodeName)) { + read(reader, featureMap, errorMap, enums); + } else if ("feature".equals(nodeName)) { + var uid = mapHexId(reader.getAttribute("refUID")); + var value = reader.getValue(); + featureMap.put(uid, value); + } else if ("errorDescription".equals(nodeName)) { + read(reader, featureMap, errorMap, enums); + } else if ("error".equals(nodeName)) { + var id = mapHexId(reader.getAttribute("refEID")); + var value = reader.getValue(); + errorMap.put(id, value); + } else if ("enumDescriptionList".equals(nodeName)) { + read(reader, featureMap, errorMap, enums); + } else if ("enumDescription".equals(nodeName)) { + var id = mapHexId(reader.getAttribute("refENID")); + var key = reader.getAttribute("enumKey"); + var values = new HashMap(); + + while (reader.hasMoreChildren()) { + reader.moveDown(); + if ("enumMember".equals(reader.getNodeName())) { + var enumValue = mapIntegerNullable(reader.getAttribute("refValue")); + var mappingValue = reader.getValue(); + if (enumValue != null) { + values.put(enumValue, mappingValue); + } + } + reader.moveUp(); + } + + var enumDescription = new EnumDescription(id, key, values); + enums.put(id, key, enumDescription); + } + + reader.moveUp(); + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/ApplianceProfileService.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/ApplianceProfileService.java new file mode 100644 index 0000000000..66c323c39e --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/ApplianceProfileService.java @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.profile; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.BINDING_PROFILES_PATH; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.LOCALE; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.endsWith; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.json.adapter.OffsetDateTimeAdapter; +import org.openhab.binding.homeconnectdirect.internal.service.profile.json.adapter.ApplianceProfileAdapter; +import org.openhab.binding.homeconnectdirect.internal.service.profile.model.ApplianceProfile; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ServiceScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.google.gson.ToNumberPolicy; + +/** + * + * Home Connect Direct appliance profile service. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +@Component(service = ApplianceProfileService.class, scope = ServiceScope.SINGLETON, immediate = true) +public class ApplianceProfileService { + + private static final String JSON_EXTENSION = ".json"; + private static final String XML_EXTENSION = ".xml"; + + private final Logger logger; + private final Gson gson; + private final String profileDirectory; + + @Activate + public ApplianceProfileService() { + this(BINDING_PROFILES_PATH); + } + + public ApplianceProfileService(String profileDirectory) { + this.logger = LoggerFactory.getLogger(ApplianceProfileService.class); + this.gson = new GsonBuilder().registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeAdapter()) + .registerTypeAdapter(ApplianceProfile.class, new ApplianceProfileAdapter()) + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create(); + this.profileDirectory = profileDirectory; + + createProfileDirectory(); + } + + public List getProfiles() { + var profiles = new ArrayList(); + try { + File directory = new File(profileDirectory); + File[] jsonFiles = directory.listFiles((dir, name) -> name.toLowerCase(LOCALE).endsWith(JSON_EXTENSION)); + + if (jsonFiles != null) { + for (File jsonFile : jsonFiles) { + try (var reader = new FileReader(jsonFile, StandardCharsets.UTF_8)) { + profiles.add(gson.fromJson(reader, ApplianceProfile.class)); + } + } + } + } catch (SecurityException | JsonParseException | IOException e) { + logger.error("Could not read profile files! error={}", e.getMessage()); + } + + return profiles; + } + + public @Nullable ApplianceProfile getProfile(String haId) { + return getProfiles().stream().filter(applianceProfile -> Objects.equals(applianceProfile.haId(), haId)) + .findFirst().orElse(null); + } + + public void deleteProfile(String haId) { + try { + File directory = new File(profileDirectory); + File[] jsonFiles = directory.listFiles((dir, name) -> name.toLowerCase(LOCALE).endsWith(JSON_EXTENSION)); + + if (jsonFiles != null) { + for (File jsonFile : jsonFiles) { + try (var reader = new FileReader(jsonFile, StandardCharsets.UTF_8)) { + var profile = gson.fromJson(reader, ApplianceProfile.class); + if (Objects.equals(profile.haId(), haId)) { + Files.delete(safePath(profileDirectory, profile.featureMappingFileName())); + Files.delete(safePath(profileDirectory, profile.deviceDescriptionFileName())); + Files.delete(safePath(profileDirectory, jsonFile.getName())); + } + } + } + } + } catch (SecurityException | IOException | JsonParseException e) { + logger.error("Could not delete profile files! error={}", e.getMessage()); + } + } + + public Optional uploadProfileZip(InputStream inputStream) { + Path profilePath = null; + List extractedFiles = new ArrayList<>(); + + try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (!entry.isDirectory() + && (endsWith(entry.getName(), JSON_EXTENSION) || endsWith(entry.getName(), XML_EXTENSION))) { + var path = safePath(profileDirectory, entry.getName()); + Files.copy(zipInputStream, path, StandardCopyOption.REPLACE_EXISTING); + extractedFiles.add(path); + + if (endsWith(entry.getName(), JSON_EXTENSION)) { + profilePath = path; + } + } + } + + if (profilePath != null) { + try (FileReader reader = new FileReader(profilePath.toFile(), StandardCharsets.UTF_8)) { + return Optional.of(gson.fromJson(reader, ApplianceProfile.class)); + } + } + } catch (IOException | JsonParseException e) { + logger.debug("Could not save profile! error={}", e.getMessage()); + } + + // Rollback extracted files on failure + for (Path file : extractedFiles) { + try { + Files.deleteIfExists(file); + } catch (IOException e) { + logger.debug("Could not delete extracted file! path={}", file, e); + } + } + + return Optional.empty(); + } + + public void downloadProfileZip(String haId, OutputStream outputStream) { + var profile = getProfile(haId); + if (profile == null) { + return; + } + + var profileJsonContent = gson.toJson(profile); + try (ZipOutputStream zos = new ZipOutputStream(outputStream, StandardCharsets.UTF_8); + OutputStreamWriter writer = new OutputStreamWriter(zos, StandardCharsets.UTF_8)) { + // json + ZipEntry zipEntry = new ZipEntry(haId + JSON_EXTENSION); + zos.putNextEntry(zipEntry); + writer.write(profileJsonContent); + writer.flush(); + zos.closeEntry(); + + // original XMLs + for (Path path : List.of(safePath(profileDirectory, profile.deviceDescriptionFileName()), + safePath(profileDirectory, profile.featureMappingFileName()))) { + if (Files.exists(path)) { + ZipEntry fileEntry = new ZipEntry(path.getFileName().toString()); + zos.putNextEntry(fileEntry); + Files.copy(path, zos); + zos.closeEntry(); + } else { + logger.warn("Profile file {} does not exist!", profile.deviceDescriptionFileName()); + } + } + } catch (IOException ignored) { + // ignore exception + } + } + + private Path safePath(String directory, String fileName) throws IOException { + Path resolved = Path.of(directory, fileName).normalize(); + if (!resolved.startsWith(Path.of(directory).normalize())) { + throw new IOException("Path traversal attempt detected: " + fileName); + } + return resolved; + } + + private void createProfileDirectory() { + try { + Files.createDirectories(Paths.get(profileDirectory)); + } catch (IOException e) { + logger.error("Could not create profile directory! directory={}", profileDirectory, e); + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/json/adapter/ApplianceProfileAdapter.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/json/adapter/ApplianceProfileAdapter.java new file mode 100644 index 0000000000..6687bc9045 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/json/adapter/ApplianceProfileAdapter.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.profile.json.adapter; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.ZONE_ID; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.profile.model.AesCredentials; +import org.openhab.binding.homeconnectdirect.internal.service.profile.model.ApplianceProfile; +import org.openhab.binding.homeconnectdirect.internal.service.profile.model.Credentials; +import org.openhab.binding.homeconnectdirect.internal.service.profile.model.TlsCredentials; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * Gson adapter for appliance profile serialization and deserialization. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class ApplianceProfileAdapter extends TypeAdapter { + + @Override + public void write(@Nullable JsonWriter out, @Nullable ApplianceProfile value) throws IOException { + if (out == null || value == null) { + return; + } + out.beginObject(); + out.name("haId").value(value.haId()); + out.name("type").value(value.type()); + out.name("serialNumber").value(value.serialNumber()); + out.name("brand").value(value.brand()); + out.name("vib").value(value.vib()); + out.name("mac").value(value.mac()); + + Credentials credentials = value.credentials(); + if (credentials instanceof AesCredentials(String key, String iv)) { + out.name("connectionType").value("AES"); + out.name("key").value(key); + out.name("iv").value(iv); + } else if (credentials instanceof TlsCredentials(String key)) { + out.name("connectionType").value("TLS"); + out.name("key").value(key); + } + + out.name("featureMappingFileName").value(value.featureMappingFileName()); + out.name("deviceDescriptionFileName").value(value.deviceDescriptionFileName()); + out.name("created").value(value.created().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + out.endObject(); + } + + @Override + public @Nullable ApplianceProfile read(@Nullable JsonReader in) throws IOException { + if (in == null || in.peek() == JsonToken.NULL) { + return null; + } + + String haId = ""; + String type = ""; + String serialNumber = ""; + String brand = ""; + String vib = ""; + String mac = ""; + String connectionType = "AES"; + String key = ""; + String iv = ""; + String featureMappingFileName = ""; + String deviceDescriptionFileName = ""; + OffsetDateTime created = OffsetDateTime.now(ZONE_ID); + + in.beginObject(); + while (in.hasNext()) { + switch (in.nextName()) { + case "haId" -> haId = in.nextString(); + case "type" -> type = in.nextString(); + case "serialNumber" -> serialNumber = in.nextString(); + case "brand" -> brand = in.nextString(); + case "vib" -> vib = in.nextString(); + case "mac" -> mac = in.nextString(); + case "connectionType" -> connectionType = in.nextString(); + case "key" -> key = in.nextString(); + case "iv" -> iv = in.nextString(); + case "featureMappingFileName" -> featureMappingFileName = in.nextString(); + case "deviceDescriptionFileName" -> deviceDescriptionFileName = in.nextString(); + case "created" -> { + String createdStr = in.nextString(); + created = OffsetDateTime.parse(createdStr, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + default -> in.skipValue(); + } + } + in.endObject(); + + Credentials credentials = "TLS".equals(connectionType) ? new TlsCredentials(key) : new AesCredentials(key, iv); + + return new ApplianceProfile(haId, type, serialNumber, brand, vib, mac, credentials, featureMappingFileName, + deviceDescriptionFileName, created); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/AesCredentials.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/AesCredentials.java new file mode 100644 index 0000000000..6f1ef5bd1d --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/AesCredentials.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.profile.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * AES credentials model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record AesCredentials(String key, String iv) implements Credentials { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/ApplianceProfile.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/ApplianceProfile.java new file mode 100644 index 0000000000..ba0ea63b1e --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/ApplianceProfile.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.profile.model; + +import java.time.OffsetDateTime; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Appliance profile model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ApplianceProfile(String haId, String type, String serialNumber, String brand, String vib, String mac, + Credentials credentials, String featureMappingFileName, String deviceDescriptionFileName, + OffsetDateTime created) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/Credentials.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/Credentials.java new file mode 100644 index 0000000000..1fc02cf56b --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/Credentials.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.profile.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Credentials base model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public sealed interface Credentials permits AesCredentials, TlsCredentials { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/TlsCredentials.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/TlsCredentials.java new file mode 100644 index 0000000000..91aa194527 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/profile/model/TlsCredentials.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.profile.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * TLS credentials model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record TlsCredentials(String key) implements Credentials { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/AbstractWebSocketClientService.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/AbstractWebSocketClientService.java new file mode 100644 index 0000000000..3b32191d5b --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/AbstractWebSocketClientService.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket; + +import static org.eclipse.jetty.websocket.api.extensions.Frame.Type.PING; +import static org.eclipse.jetty.websocket.api.extensions.Frame.Type.PONG; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.time.Instant; +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.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketFrame; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.api.extensions.Frame; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.exception.WebSocketClientServiceException; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstract base class for WebSocket client services. + * + * @author Jonas Brüstel - Initial contribution + */ +@WebSocket +@NonNullByDefault +public abstract class AbstractWebSocketClientService implements WebSocketClientService { + + private final Logger logger; + private final WebSocketHandler webSocketHandler; + private final ScheduledExecutorService scheduler; + private final URI applianceUri; + private final Thing thing; + + private @Nullable Instant lastMessageReceived; + private @Nullable Session session; + private @Nullable ScheduledFuture pingFuture; + private @Nullable ScheduledFuture staleConnectionFuture; + private @Nullable WebSocketClient webSocketClient; + + public AbstractWebSocketClientService(Thing thing, URI applianceUri, WebSocketHandler webSocketHandler, + ScheduledExecutorService scheduler) { + logger = LoggerFactory.getLogger(AbstractWebSocketClientService.class); + this.webSocketHandler = webSocketHandler; + this.scheduler = scheduler; + this.applianceUri = applianceUri; + this.thing = thing; + } + + @Override + public void connect() throws WebSocketClientServiceException { + logger.debug("Connecting to {} ({}).", applianceUri, getThingUID()); + try { + var webSocketClient = getWebSocketClient(); + if (webSocketClient != null) { + webSocketClient.start(); + webSocketClient.connect(this, applianceUri).get(); + } + } catch (Exception e) { + logger.debug("Could not connect to {} ({}). error={}", applianceUri, getThingUID(), e.getMessage()); + throw new WebSocketClientServiceException("Could not connect to " + applianceUri, e); + } + } + + @Override + public void dispose() { + stopConnectionChecks(); + var webSocketClient = getWebSocketClient(); + if (webSocketClient != null) { + try { + logger.debug("Stop web socket client ({}).", applianceUri); + webSocketClient.stop(); + } catch (Exception ignored) { + // best-effort stop during cleanup + } + + logger.debug("Destroy web socket client ({}).", applianceUri); + webSocketClient.destroy(); + } + } + + @OnWebSocketConnect + public void onConnect(Session session) { + setSession(session); + getWebSocketHandler().onWebSocketConnect(); + updateLastMessageReceived(); + startConnectionChecks(session); + } + + @OnWebSocketFrame + public void onFrame(Session session, Frame frame) { + if (PONG.equals(frame.getType())) { + logger.trace("<< PONG ({})", thing.getUID()); + } else if (PING.equals(frame.getType())) { + logger.trace("<< PING ({})", thing.getUID()); + sendPong(session); + } + + updateLastMessageReceived(); + } + + @OnWebSocketClose + public void onClose(Session session, int statusCode, String reason) { + logger.debug("Closed websocket connection. status={}, reason={} thingUID={}", statusCode, reason, + thing.getUID()); + stopConnectionChecks(); + getWebSocketHandler().onWebSocketClose(); + } + + @OnWebSocketError + public void onError(@Nullable Session session, Throwable error) throws Exception { + stopConnectionChecks(); + getWebSocketHandler().onWebSocketError(error); + + if (session == null || !session.isOpen()) { + getWebSocketHandler().onWebSocketClose(); + } + } + + protected WebSocketHandler getWebSocketHandler() { + return webSocketHandler; + } + + protected @Nullable Instant getLastMessageReceived() { + return lastMessageReceived; + } + + protected void updateLastMessageReceived() { + lastMessageReceived = Instant.now(); + } + + protected @Nullable Session getSession() { + return session; + } + + protected void setSession(Session session) { + this.session = session; + } + + protected ThingUID getThingUID() { + return thing.getUID(); + } + + protected @Nullable WebSocketClient getWebSocketClient() { + return webSocketClient; + } + + protected void setWebSocketClient(WebSocketClient webSocketClient) { + this.webSocketClient = webSocketClient; + } + + protected void sendPong(@Nullable Session session) { + if (session != null && session.isOpen()) { + try { + session.getRemote().sendPong(ByteBuffer.allocate(0)); + logger.trace(">> PONG ({})", thing.getUID()); + } catch (IOException e) { + logger.warn("Could not send PONG! error={} thingUID={}", e.getMessage(), thing.getUID()); + } + } + } + + protected void sendPing(@Nullable Session session) { + if (session != null && session.isOpen()) { + try { + session.getRemote().sendPing(ByteBuffer.allocate(0)); + logger.trace(">> PING ({})", thing.getUID()); + } catch (IOException e) { + logger.warn("Could not send PING! error={} thingUID={}", e.getMessage(), thing.getUID()); + } + } + } + + protected synchronized void startConnectionChecks(Session session) { + pingFuture = scheduler.scheduleWithFixedDelay(() -> sendPing(session), WS_PING_INITIAL_DELAY.toSeconds(), + WS_PING_INTERVAL.toSeconds(), TimeUnit.SECONDS); + + staleConnectionFuture = scheduler.scheduleWithFixedDelay(() -> { + if (session.isOpen()) { + var lastMessage = getLastMessageReceived(); + var now = Instant.now(); + if (lastMessage != null && now.isAfter(lastMessage.plus(WS_INACTIVITY_TIMEOUT))) { + logger.debug("Last message received {} seconds ago. -> reconnect. (thingUID={})", + Duration.between(lastMessage, now).toSeconds(), thing.getUID()); + try { + session.disconnect(); + } catch (IOException e) { + logger.error("Could not disconnect from session! error={}", e.getMessage()); + } + } + } + }, WS_INACTIVITY_CHECK_INITIAL_DELAY.toSeconds(), WS_INACTIVITY_CHECK_INTERVAL.toSeconds(), TimeUnit.SECONDS); + } + + protected synchronized void stopConnectionChecks() { + ScheduledFuture pingFuture = this.pingFuture; + if (pingFuture != null) { + pingFuture.cancel(true); + } + + ScheduledFuture staleConnectionFuture = this.staleConnectionFuture; + if (staleConnectionFuture != null) { + staleConnectionFuture.cancel(true); + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/ConscryptPskKeyManager.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/ConscryptPskKeyManager.java new file mode 100644 index 0000000000..b74c96b361 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/ConscryptPskKeyManager.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket; + +import java.net.Socket; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import javax.net.ssl.SSLEngine; + +import org.conscrypt.PSKKeyManager; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * PSK key manager implementation using Conscrypt. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +@SuppressWarnings("deprecation") +public class ConscryptPskKeyManager implements PSKKeyManager { + + private static final String PSK_ALGORITHM = "PSK"; + + private final String identityHint; + private final byte[] key; + + public ConscryptPskKeyManager(String identityHint, byte[] key) { + this.identityHint = identityHint; + this.key = key; + } + + @Override + public @Nullable String chooseServerKeyIdentityHint(@Nullable Socket socket) { + return identityHint; + } + + @Override + public @Nullable String chooseServerKeyIdentityHint(@Nullable SSLEngine sslEngine) { + return identityHint; + } + + @Override + public @Nullable String chooseClientKeyIdentity(@Nullable String s, @Nullable Socket socket) { + return identityHint; + } + + @Override + public @Nullable String chooseClientKeyIdentity(@Nullable String s, @Nullable SSLEngine sslEngine) { + return identityHint; + } + + @Override + public @Nullable SecretKey getKey(@Nullable String s, @Nullable String s1, @Nullable Socket socket) { + return new SecretKeySpec(key, PSK_ALGORITHM); + } + + @Override + public @Nullable SecretKey getKey(@Nullable String s, @Nullable String s1, @Nullable SSLEngine sslEngine) { + return new SecretKeySpec(key, PSK_ALGORITHM); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketAesClientService.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketAesClientService.java new file mode 100644 index 0000000000..acc44517bb --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketAesClientService.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.concurrent.ScheduledExecutorService; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.exception.WebSocketClientServiceException; +import org.openhab.core.thing.Thing; +import org.openhab.core.util.HexUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * WebSocket client service using AES encryption. + * + * @author Jonas Brüstel - Initial contribution + */ +@WebSocket +@NonNullByDefault +public class WebSocketAesClientService extends AbstractWebSocketClientService { + + private static final String HMAC_SHA_256 = "HmacSHA256"; + private static final String AES_CBC_NO_PADDING = "AES/CBC/NoPadding"; + private static final String AES = "AES"; + private static final String ENC = "ENC"; + private static final String MAC = "MAC"; + + private final Cipher aesEncrypt; + private final Cipher aesDecrypt; + private final byte[] iv; + private final byte[] macKey; + private final Logger logger; + + private byte[] lastRxHmac; + private byte[] lastTxHmac; + + public WebSocketAesClientService(Thing thing, URI uri, String base64EncodedKey, + String base64EncodedInitializationVector, WebSocketHandler webSocketHandler, + ScheduledExecutorService scheduler) throws WebSocketClientServiceException { + super(thing, uri, webSocketHandler, scheduler); + + try { + logger = LoggerFactory.getLogger(WebSocketAesClientService.class); + + byte[] key = Base64.getUrlDecoder().decode(base64EncodedKey); + iv = Base64.getUrlDecoder().decode(base64EncodedInitializationVector); + + // init AES + byte[] encryptionKey = hmac(key, ENC.getBytes(StandardCharsets.UTF_8)); + SecretKeySpec keySpec = new SecretKeySpec(encryptionKey, AES); + IvParameterSpec ivSpec = new IvParameterSpec(iv); + aesEncrypt = Cipher.getInstance(AES_CBC_NO_PADDING); + aesEncrypt.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + aesDecrypt = Cipher.getInstance(AES_CBC_NO_PADDING); + aesDecrypt.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); + + // init HMAC + macKey = hmac(key, MAC.getBytes(StandardCharsets.UTF_8)); + lastRxHmac = new byte[16]; + lastTxHmac = new byte[16]; + + // websocket + setWebSocketClient(new WebSocketClient(new HttpClient())); + } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException e) { + throw new WebSocketClientServiceException(e.getMessage(), e); + } catch (InvalidKeyException e) { + var message = e.getMessage(); + try { + if (javax.crypto.Cipher.getMaxAllowedKeyLength(AES) < 256) { + message = "The current cryptographic policy is set to 'limited', which restricts the use of stronger encryption algorithms and key lengths. " + + "To resolve this issue, ensure that the 'crypto.policy' property is set to 'unlimited' in the 'java.security' file located at: " + + "'/conf/security/java.security'. The unlimited policy is supported natively in your Java version."; + } + } catch (NoSuchAlgorithmException ignored) { + // AES algorithm check failed, use original exception message + } + throw new WebSocketClientServiceException(message, e); + } + } + + @Override + public void send(String message) { + try { + var encryptedMessage = encrypt(message); + var session = getSession(); + + if (session != null && session.isOpen()) { + logger.debug(">> {} ({})", message, getThingUID()); + logger.trace(">> {} ({})", HexUtils.bytesToHex(encryptedMessage), getWebSocketHandler()); + ByteBuffer buffer = ByteBuffer.wrap(encryptedMessage); + session.getRemote().sendBytes(buffer); + } + } catch (Exception e) { + logger.error("Failed to send message! error={} thingUID={}", e.getMessage(), getThingUID()); + } + } + + @OnWebSocketMessage + public void onBinaryMessage(Session session, InputStream inputStream) + throws IOException, NoSuchAlgorithmException, InvalidKeyException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int bytesRead; + + try (inputStream) { + while ((bytesRead = inputStream.read(buffer)) != -1) { + output.write(buffer, 0, bytesRead); + } + byte[] message = output.toByteArray(); + + logger.trace("<< {} ({})", HexUtils.bytesToHex(message), getThingUID()); + var decryptedMessage = decrypt(message); + var stringMessage = new String(decryptedMessage, StandardCharsets.UTF_8); + logger.debug("<< {} ({})", stringMessage, getThingUID()); + getWebSocketHandler().onWebSocketMessage(stringMessage, this); + } + } + + private byte[] hmac(byte[] key, byte[] msg) throws NoSuchAlgorithmException, InvalidKeyException { + Mac mac = Mac.getInstance(HMAC_SHA_256); + SecretKeySpec secretKey = new SecretKeySpec(key, HMAC_SHA_256); + mac.init(secretKey); + return mac.doFinal(msg); + } + + private byte[] decrypt(byte[] buf) throws NoSuchAlgorithmException, InvalidKeyException { + if (buf.length < 32) { + logger.error("Can not decrypt invalid message! Short message? {}", HexUtils.bytesToHex(buf)); + } + + if (buf.length % 16 != 0) { + logger.error("Unaligned message? Probably bad padding: {}", HexUtils.bytesToHex(buf)); + } + + // Split the message into the encrypted message and the first 16 bytes of the HMAC + byte[] encryptedMessage = Arrays.copyOfRange(buf, 0, buf.length - 16); + byte[] applianceHmac = Arrays.copyOfRange(buf, buf.length - 16, buf.length); + + // Compute the expected HMAC on the encrypted message + byte[] directionAndLastHmac = concatenateByteArrays(new byte[] { 0x43 }, this.lastRxHmac); + byte[] ourHmac = createHmacMessage(directionAndLastHmac, encryptedMessage); + + if (!Arrays.equals(applianceHmac, ourHmac)) { + logger.error("HMAC failure! appliance={} ourHmac={}, msgLength={}", HexUtils.bytesToHex(applianceHmac), + HexUtils.bytesToHex(ourHmac), buf.length); + } + + this.lastRxHmac = applianceHmac; + + // Decrypt the message with CBC, so the last message block is mixed in + byte[] msg; + msg = aesDecrypt.update(encryptedMessage); + + // Check for padding and trim it off the end + int padLen = msg[msg.length - 1] & 0xFF; // Convert to unsigned integer + if (msg.length < padLen) { + logger.error("Padding error! {}", HexUtils.bytesToHex(msg)); + } + logger.trace("padding length={}", padLen); + + return Arrays.copyOfRange(msg, 0, msg.length - padLen); + } + + public byte[] encrypt(String clearMsg) throws NoSuchAlgorithmException, InvalidKeyException { + // Convert the UTF-8 string into a byte array + byte[] clearMsgBytes = clearMsg.getBytes(StandardCharsets.UTF_8); + logger.trace("encrypt: clearMsg={}", HexUtils.bytesToHex(clearMsgBytes)); + + // Pad the buffer, adding an extra block if necessary + int padLen = 16 - (clearMsgBytes.length % 16); + if (padLen == 1) { + padLen += 16; + } + logger.trace("encrypt: padLen={}", padLen); + byte[] pad = new byte[padLen]; + pad[0] = 0x00; + + // Generate random bytes for the padding + SecureRandom random = new SecureRandom(); + byte[] randomBytes = new byte[padLen - 2]; + random.nextBytes(randomBytes); + System.arraycopy(randomBytes, 0, pad, 1, padLen - 2); + + pad[padLen - 1] = (byte) padLen; + + // Combine clear message and padding + byte[] paddedMsg = concatenateByteArrays(clearMsgBytes, pad); + + // Encrypt the padded message with CBC, so there is chained state from the last cipher block sent + byte[] encMsg; + encMsg = aesEncrypt.update(paddedMsg); + + // Compute the HMAC of the encrypted message, chaining the HMAC of the previous message plus direction 'E' + byte[] directionAndLastHmac = concatenateByteArrays(new byte[] { 0x45 }, this.lastTxHmac); + this.lastTxHmac = createHmacMessage(directionAndLastHmac, encMsg); + + // Append the new HMAC to the message + return concatenateByteArrays(encMsg, this.lastTxHmac); + } + + // HMAC an inbound or outbound message, chaining the last HMAC + private byte[] createHmacMessage(byte[] direction, byte[] encMsg) + throws NoSuchAlgorithmException, InvalidKeyException { + byte[] hmacMsg = concatenateByteArrays(iv, direction, encMsg); + byte[] fullHmac = hmac(macKey, hmacMsg); + return Arrays.copyOfRange(fullHmac, 0, 16); + } + + private byte[] concatenateByteArrays(byte[]... arrays) { + int totalLength = 0; + for (byte[] array : arrays) { + totalLength += array.length; + } + byte[] result = new byte[totalLength]; + int currentIndex = 0; + for (byte[] array : arrays) { + System.arraycopy(array, 0, result, currentIndex, array.length); + currentIndex += array.length; + } + return result; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketClientService.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketClientService.java new file mode 100644 index 0000000000..a580d764de --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketClientService.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.exception.WebSocketClientServiceException; + +/** + * WebSocket client service interface. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public interface WebSocketClientService { + /** + * Establish the WebSocket connection to the appliance. + * + * @throws WebSocketClientServiceException if the connection could not be established + */ + void connect() throws WebSocketClientServiceException; + + /** + * Close the connection and release all associated resources. + */ + void dispose(); + + /** + * Send a message to the appliance. + * + * @param message the raw message to send + */ + void send(String message); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketHandler.java new file mode 100644 index 0000000000..e326f9cea0 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketHandler.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Handler for incoming WebSocket messages. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public interface WebSocketHandler { + /** + * Called when the WebSocket connection has been established. + */ + void onWebSocketConnect(); + + /** + * Called when a message has been received from the appliance. + * + * @param rawMessage the raw message received + * @param websocketClientService the client service the message was received on + */ + void onWebSocketMessage(String rawMessage, WebSocketClientService websocketClientService); + + /** + * Called when the WebSocket connection has been closed. + */ + void onWebSocketClose(); + + /** + * Called when an error occurred on the WebSocket connection. + * + * @param throwable the error that occurred + */ + void onWebSocketError(Throwable throwable); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketTlsConscryptClientService.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketTlsConscryptClientService.java new file mode 100644 index 0000000000..3d06fc144c --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/WebSocketTlsConscryptClientService.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.CONSCRYPT_PROVIDER; + +import java.io.IOException; +import java.net.URI; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.concurrent.ScheduledExecutorService; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; + +import org.conscrypt.Conscrypt; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.GZIPContentDecoder; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.exception.WebSocketClientServiceException; +import org.openhab.core.thing.Thing; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * WebSocket client service using TLS with Conscrypt. + * + * @author Jonas Brüstel - Initial contribution + */ +@WebSocket +@NonNullByDefault +public class WebSocketTlsConscryptClientService extends AbstractWebSocketClientService { + + private static final String PSK_IDENTITY = "HCCOM_Local_App"; + private static final String TLSV_1_2 = "TLSv1.2"; + private static final String TLS_ECDHE_PSK_WITH_CHACHA_20_POLY_1305_SHA_256 = "TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256"; + private static final String TLS = "TLS"; + private static final Provider PROVIDER = Conscrypt.newProviderBuilder().setName(CONSCRYPT_PROVIDER).build(); + + private final Logger logger; + + public WebSocketTlsConscryptClientService(Thing thing, URI uri, String base64EncodedKey, + WebSocketHandler webSocketHandler, ScheduledExecutorService scheduler) + throws WebSocketClientServiceException { + super(thing, uri, webSocketHandler, scheduler); + try { + logger = LoggerFactory.getLogger(WebSocketTlsConscryptClientService.class); + byte[] key = Base64.getUrlDecoder().decode(base64EncodedKey); + + // http client setup + SSLContext sslContext = SSLContext.getInstance(TLS, PROVIDER); + sslContext.init(new KeyManager[] { new ConscryptPskKeyManager(PSK_IDENTITY, key) }, new TrustManager[0], + new SecureRandom()); + + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + sslContextFactory.setSslContext(sslContext); + sslContextFactory.setIncludeCipherSuites(TLS_ECDHE_PSK_WITH_CHACHA_20_POLY_1305_SHA_256); + sslContextFactory.setIncludeProtocols(TLSV_1_2); + + var httpClient = new HttpClient(sslContextFactory); + httpClient.getContentDecoderFactories().add(new GZIPContentDecoder.Factory()); + + // websocket + setWebSocketClient(new WebSocketClient(httpClient)); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new WebSocketClientServiceException(e.getMessage(), e); + } + } + + @Override + public void send(String message) { + try { + var session = getSession(); + if (session != null && session.isOpen()) { + logger.debug(">> {} ({})", message, getThingUID()); + session.getRemote().sendString(message); + } + } catch (IOException e) { + logger.error("Failed to send message! error={} thingUID={}", e.getMessage(), getThingUID()); + } + } + + @OnWebSocketMessage + public void onMessage(Session session, String message) { + logger.debug("<< {} ({})", message, getThingUID()); + getWebSocketHandler().onWebSocketMessage(message, this); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/exception/WebSocketClientServiceException.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/exception/WebSocketClientServiceException.java new file mode 100644 index 0000000000..9cd84dbe9d --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/exception/WebSocketClientServiceException.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.exception; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Exception thrown by WebSocket client services. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class WebSocketClientServiceException extends Exception { + + @Serial + private static final long serialVersionUID = -8417710695707997749L; + + public WebSocketClientServiceException(@Nullable String message) { + super(message); + } + + public WebSocketClientServiceException(@Nullable String message, @Nullable Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Access.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Access.java new file mode 100644 index 0000000000..ca631d72f1 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Access.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * WebSocket access model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum Access { + READ, + @SerializedName("READWRITE") + READ_WRITE, + @SerializedName("READSTATIC") + READ_STATIC, + @SerializedName("WRITEONLY") + WRITE_ONLY, + NONE +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Action.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Action.java new file mode 100644 index 0000000000..4e47874548 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Action.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * WebSocket action model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public enum Action { + GET, + POST, + RESPONSE, + NOTIFY +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/DescriptionChangeData.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/DescriptionChangeData.java new file mode 100644 index 0000000000..22a83b04df --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/DescriptionChangeData.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model; + +import com.google.gson.annotations.SerializedName; + +/** + * WebSocket description change data model. + * + * @author Jonas Brüstel - Initial contribution + */ +@Deprecated +public record DescriptionChangeData(int uid, @SerializedName("parentUID") int parentUid, Boolean available, + Access access, Integer min, Integer max, Integer stepSize, @SerializedName("default") Object defaultValue, + Integer enumType + +) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Message.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Message.java new file mode 100644 index 0000000000..a62392c9bd --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Message.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.serializer.ResourceAdapter; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.ToNumberPolicy; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; + +/** + * WebSocket message model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Message(@SerializedName("sID") long sessionId, @SerializedName("msgID") long messageId, Resource resource, + int version, Action action, @Nullable Integer code, @Nullable JsonArray data) { + + private static final Gson GSON = new GsonBuilder().registerTypeAdapter(Resource.class, new ResourceAdapter()) + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create(); + + public @Nullable List getDataAsList(Class clazz) { + var dataJsonArray = data(); + if (dataJsonArray != null) { + return GSON.fromJson(dataJsonArray, TypeToken.getParameterized(List.class, clazz).getType()); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Resource.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Resource.java new file mode 100644 index 0000000000..e119e0b6f0 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/Resource.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * WebSocket resource model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Resource(String service, String endpoint) { + public static final String IZ = "iz"; + public static final String NI = "ni"; + public static final String CE = "ce"; + public static final String CI = "ci"; + public static final String EI = "ei"; + public static final String RO = "ro"; + + public static final Resource RO_VALUES = new Resource(RO, "values"); + public static final Resource RO_DESCRIPTION_CHANGE = new Resource(RO, "descriptionChange"); + public static final Resource RO_ALL_MANDATORY_VALUES = new Resource(RO, "allMandatoryValues"); + public static final Resource RO_ALL_DESCRIPTION_CHANGES = new Resource(RO, "allDescriptionChanges"); + public static final Resource RO_ACTIVE_PROGRAM = new Resource(RO, "activeProgram"); + public static final Resource RO_SELECTED_PROGRAM = new Resource(RO, "selectedProgram"); + + public static final Resource EI_INITIAL_VALUES = new Resource(EI, "initialValues"); + public static final Resource EI_DEVICE_READY = new Resource(EI, "deviceReady"); + + public static final Resource CI_SERVICES = new Resource(CI, "services"); + public static final Resource CI_REGISTERED_DEVICES = new Resource(CI, "registeredDevices"); + public static final Resource CI_PAIRABLE_DEVICES = new Resource(CI, "pairableDevices"); + public static final Resource CI_DELREGISTRATION = new Resource(CI, "delregistration"); + public static final Resource CI_NETWORK_DETAILS = new Resource(CI, "networkDetails"); + public static final Resource CI_NETWORK_DETAILS2 = new Resource(CI, "networkDetails2"); + public static final Resource CI_WIFI_NETWORKS = new Resource(CI, "wifiNetworks"); + public static final Resource CI_WIFI_SETTING = new Resource(CI, "wifiSetting"); + public static final Resource CI_WIFI_SETTING2 = new Resource(CI, "wifiSetting2"); + public static final Resource CI_TZ_INFO = new Resource(CI, "tzInfo"); + public static final Resource CI_INFO = new Resource(CI, "info"); + public static final Resource CI_AUTHENTICATION = new Resource(CI, "authentication"); + public static final Resource CI_REGISTER = new Resource(CI, "register"); + public static final Resource CI_DEREGISTER = new Resource(CI, "deregister"); + + public static final Resource CE_SERVER_DEVICE_TYPE = new Resource(CE, "serverDeviceType"); + public static final Resource CE_SERVER_CREDENTIAL = new Resource(CE, "serverCredential"); + public static final Resource CE_CLIENT_CREDENTIAL = new Resource(CE, "clientCredential"); + public static final Resource CE_HUB_INFORMATION = new Resource(CE, "hubInformation"); + public static final Resource CE_HUB_CONNECTED = new Resource(CE, "hubConnected"); + public static final Resource CE_STATUS = new Resource(CE, "status"); + + public static final Resource NI_CONFIG = new Resource(NI, "config"); + public static final Resource NI_INFO = new Resource(NI, "info"); + + public static final Resource IZ_SERVICES = new Resource(IZ, "services"); + public static final Resource IZ_INFO = new Resource(IZ, "info"); +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ApplianceInfoData.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ApplianceInfoData.java new file mode 100644 index 0000000000..823df25fe2 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ApplianceInfoData.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * Appliance info data model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ApplianceInfoData(@SerializedName("deviceID") String deviceId, String eNumber, String brand, String vib, + String mac, @SerializedName("hwVersion") String hardwareVersion, + @SerializedName("swVersion") String softwareVersion, String haVersion, String deviceType, String deviceInfo, + String serialNumber, String fdString, String shipSki) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/DescriptionChangeData.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/DescriptionChangeData.java new file mode 100644 index 0000000000..47b72c39f9 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/DescriptionChangeData.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Access; + +import com.google.gson.annotations.SerializedName; + +/** + * Description change data model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record DescriptionChangeData(int uid, @SerializedName("parentUID") @Nullable Integer parentUid, + @Nullable Boolean available, @Nullable Access access, @Nullable Number min, @Nullable Number max, + @Nullable Number stepSize, @SerializedName("default") @Nullable String defaultValue, @Nullable Integer enumType, + @Nullable Object value) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/DeviceData.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/DeviceData.java new file mode 100644 index 0000000000..0efc9f55b9 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/DeviceData.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * Device data model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record DeviceData(Object deviceType, String deviceName, @SerializedName("deviceID") String deviceId) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/FirstMessageIdData.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/FirstMessageIdData.java new file mode 100644 index 0000000000..d4376cb5b3 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/FirstMessageIdData.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * First message ID data model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record FirstMessageIdData(@SerializedName("edMsgID") long messageId) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ProgramData.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ProgramData.java new file mode 100644 index 0000000000..4a06058d10 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ProgramData.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Program data model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ProgramData(int program, @Nullable List options) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ServiceData.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ServiceData.java new file mode 100644 index 0000000000..7db1c779e6 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ServiceData.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Service data model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ServiceData(String service, int version) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ValueData.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ValueData.java new file mode 100644 index 0000000000..1a9c995d26 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/model/data/ValueData.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Value data model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ValueData(int uid, Object value) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/serializer/ResourceAdapter.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/serializer/ResourceAdapter.java new file mode 100644 index 0000000000..db2743bab4 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/service/websocket/serializer/ResourceAdapter.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.websocket.serializer; + +import java.lang.reflect.Type; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +/** + * Gson adapter for {@link Resource} serialization and deserialization. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class ResourceAdapter implements JsonDeserializer, JsonSerializer { + @Override + public @Nullable Resource deserialize(JsonElement jsonElement, Type type, + JsonDeserializationContext jsonDeserializationContext) throws JsonParseException { + String resourceString = jsonElement.getAsString(); + String[] parts = resourceString.split("/", 3); + String service = parts.length > 1 ? parts[1] : ""; + String endpoint = parts.length > 2 ? parts[2] : ""; + + return new Resource(service, endpoint); + } + + @Override + public JsonElement serialize(Resource resource, Type type, JsonSerializationContext jsonSerializationContext) { + String resourceString = "/" + resource.service() + "/" + resource.endpoint(); + return new JsonPrimitive(resourceString); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/HomeConnectDirectServlet.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/HomeConnectDirectServlet.java new file mode 100644 index 0000000000..f20df1091a --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/HomeConnectDirectServlet.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet; + +import static javax.ws.rs.core.HttpHeaders.ACCEPT; +import static javax.ws.rs.core.HttpHeaders.CONTENT_TYPE; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.SERVLET_ASSETS_PATH; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.SERVLET_BASE_PATH; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.*; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletConstants.*; + +import java.io.IOException; +import java.io.Serial; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.servlet.MultipartConfigElement; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.homeconnectdirect.internal.common.utils.ConfigurationUtils; +import org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.servlet.handler.api.ApplianceRequestHandler; +import org.openhab.binding.homeconnectdirect.internal.servlet.handler.api.LogRequestHandler; +import org.openhab.binding.homeconnectdirect.internal.servlet.handler.api.MessageRequestHandler; +import org.openhab.binding.homeconnectdirect.internal.servlet.handler.api.ProfileRequestHandler; +import org.openhab.binding.homeconnectdirect.internal.servlet.handler.page.AuthRequestHandler; +import org.openhab.binding.homeconnectdirect.internal.servlet.handler.page.IndexRequestHandler; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.Error; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandler; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerContext; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerException; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.Route; +import org.openhab.core.thing.ThingRegistry; +import org.osgi.service.cm.ConfigurationAdmin; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ServiceScope; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * + * Home Connect Direct UI servlet. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +@Component(service = HomeConnectDirectServlet.class, scope = ServiceScope.SINGLETON, immediate = true) +public class HomeConnectDirectServlet extends HttpServlet { + + @Serial + private static final long serialVersionUID = -283768147024034490L; + + private static final String ASSET_CLASSPATH = "assets"; + private static final String MULTIPART_KEY = "org.eclipse.jetty.multipartConfig"; + + private static final String PATH_API_PROFILE = PATH_API_PROFILES + SLASH + OPENING_BRACE + HA_ID + CLOSING_BRACE; + private static final String PATH_API_THING_MESSAGES = PATH_API_MESSAGES + SLASH + OPENING_BRACE + UID + + CLOSING_BRACE; + private static final String PATH_API_THING_MESSAGE_RESOURCES = PATH_API_THING_MESSAGES + "/resources"; + private static final String PATH_API_THING_MESSAGE_VALUE_KEYS = PATH_API_THING_MESSAGES + "/value-keys"; + private static final String PATH_API_THING_MESSAGE_DESCRIPTION_CHANGE_KEYS = PATH_API_THING_MESSAGES + + "/description-change-keys"; + private static final String PATH_API_APPLIANCE_YAML = PATH_API_APPLIANCES + SLASH + OPENING_BRACE + UID + + CLOSING_BRACE + "/yaml"; + private static final String PATH_API_APPLIANCE_DSL = PATH_API_APPLIANCES + SLASH + OPENING_BRACE + UID + + CLOSING_BRACE + "/dsl"; + private static final String PATH_API_APPLIANCE_DEVICE_DESCRIPTION = PATH_API_APPLIANCES + SLASH + OPENING_BRACE + + UID + CLOSING_BRACE + "/device-description"; + private static final String PATH_API_LOG = PATH_API_LOGS + SLASH + OPENING_BRACE + LOG_FILE_ID + CLOSING_BRACE; + private static final String PATH_API_LOG_MESSAGES = PATH_API_LOG + "/messages"; + private static final String PATH_API_LOG_MESSAGE_RESOURCES = PATH_API_LOG + "/resources"; + private static final String PATH_API_LOG_MESSAGE_VALUE_KEYS = PATH_API_LOG + "/value-keys"; + private static final String PATH_API_LOG_MESSAGE_DEVICE_DESCRIPTION = PATH_API_LOG + "/device-description"; + private static final String PATH_API_LOG_MESSAGE_DESCRIPTION_CHANGE_KEYS = PATH_API_LOG + + "/description-change-keys"; + private static final String PATH_AUTH = "/auth"; + private static final String PATH_TOKEN = PATH_AUTH + "/token"; + private static final List PATHS_INDEX = List.of("/", "/appliances", "/appliances/*", "/profiles", "/logs", + "/logs/*"); + private static final String PATH_LOGIN = PATH_AUTH + "/login"; + private static final String PATH_LOGOUT = PATH_AUTH + "/logout"; + + private final Logger logger; + private final HttpService httpService; + private final ThingRegistry thingRegistry; + private final ApplianceProfileService applianceProfileService; + private final ConfigurationAdmin configurationAdmin; + private final MultipartConfigElement multipartConfig; + private final Gson gson; + private final ServletSecurityContext securityContext; + private final List routes; + + @Activate + public HomeConnectDirectServlet(@Reference HttpService httpService, @Reference ThingRegistry thingRegistry, + @Reference ApplianceProfileService applianceProfileService, + @Reference ConfigurationAdmin configurationAdmin) { + logger = LoggerFactory.getLogger(HomeConnectDirectServlet.class); + this.httpService = httpService; + this.thingRegistry = thingRegistry; + this.applianceProfileService = applianceProfileService; + this.configurationAdmin = configurationAdmin; + this.gson = ConfigurationUtils.createGson(); + this.securityContext = ServletSecurityContext.get(); + this.routes = new ArrayList<>(); + + // register servlet + try { + logger.debug("Initialize Home Connect Direct servlet ({})", SERVLET_BASE_PATH); + httpService.registerServlet(SERVLET_BASE_PATH, this, null, httpService.createDefaultHttpContext()); + httpService.registerResources(SERVLET_ASSETS_PATH, ASSET_CLASSPATH, null); + } catch (ServletException | NamespaceException e) { + logger.warn("Could not register Home Connect servlet! ({})", SERVLET_BASE_PATH, e); + } + + // multipart config (maxFileSize=5MB, maxRequestSize=10MB, fileSizeThreshold=1MB) + multipartConfig = new MultipartConfigElement(EMPTY_STRING, 5242880, 10485760, 1048576); + } + + @Override + public void init() { + var indexHandler = new IndexRequestHandler(); + var authHandler = new AuthRequestHandler(securityContext); + var applianceHandler = new ApplianceRequestHandler(); + var profileHandler = new ProfileRequestHandler(applianceProfileService); + var messageHandler = new MessageRequestHandler(applianceProfileService); + var logHandler = new LogRequestHandler(); + + // html + PATHS_INDEX.forEach(path -> registerHtmlRoute(path, indexHandler::indexPage)); + registerHtmlRoute(PATH_LOGIN, authHandler::loginPage); + + // auth api + registerRoute(POST, PATH_TOKEN, List.of(CONTENT_TYPE_JSON), List.of(CONTENT_TYPE_JSON), false, + authHandler::handleLogin); + registerRoute(POST, PATH_LOGOUT, authHandler::handleLogout); + + // api + registerRoute(GET, PATH_API_APPLIANCES, applianceHandler::getAppliances); + registerRoute(GET, PATH_API_APPLIANCE_YAML, applianceHandler::createYamlCode); + registerRoute(GET, PATH_API_APPLIANCE_DSL, applianceHandler::createDslCode); + registerRoute(GET, PATH_API_APPLIANCE_DEVICE_DESCRIPTION, applianceHandler::getDeviceDescription); + registerRoute(GET, PATH_API_PROFILES, profileHandler::getProfiles); + registerRoute(POST, PATH_API_PROFILES, List.of(CONTENT_TYPE_FORM_DATA), List.of(CONTENT_TYPE_JSON), true, + profileHandler::uploadProfile); + registerRoute(GET, PATH_API_PROFILE, List.of(), List.of(CONTENT_TYPE_ZIP), true, + profileHandler::downloadProfile); + registerRoute(DELETE, PATH_API_PROFILE, profileHandler::deleteProfile); + registerRoute(GET, PATH_API_THING_MESSAGES, List.of(), List.of(CONTENT_TYPE_ZIP), true, + messageHandler::saveOrDownloadMessages); + registerRoute(POST, PATH_API_THING_MESSAGES, messageHandler::sendMessage); + registerRoute(GET, PATH_API_THING_MESSAGE_RESOURCES, messageHandler::getMessageResources); + registerRoute(GET, PATH_API_THING_MESSAGE_VALUE_KEYS, messageHandler::getMessageValueKeys); + registerRoute(GET, PATH_API_THING_MESSAGE_DESCRIPTION_CHANGE_KEYS, + messageHandler::getMessageDescriptionChangeKeys); + registerRoute(GET, PATH_API_LOGS, logHandler::getLogs); + registerRoute(GET, PATH_API_LOG, List.of(), List.of(CONTENT_TYPE_ZIP), true, logHandler::downloadLog); + registerRoute(DELETE, PATH_API_LOG, logHandler::deleteLog); + registerRoute(GET, PATH_API_LOG_MESSAGES, logHandler::getLogMessages); + registerRoute(GET, PATH_API_LOG_MESSAGE_RESOURCES, logHandler::getLogMessageResources); + registerRoute(GET, PATH_API_LOG_MESSAGE_VALUE_KEYS, logHandler::getLogMessageValueKeys); + registerRoute(GET, PATH_API_LOG_MESSAGE_DESCRIPTION_CHANGE_KEYS, + logHandler::getLogMessageDescriptionChangeKeys); + registerRoute(GET, PATH_API_LOG_MESSAGE_DEVICE_DESCRIPTION, logHandler::getDeviceDescription); + registerRoute(POST, PATH_API_LOGS, List.of(CONTENT_TYPE_FORM_DATA), List.of(CONTENT_TYPE_JSON), true, + logHandler::uploadLog); + } + + @Override + public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + var path = request.getPathInfo(); + var method = request.getMethod(); + var accept = request.getHeader(ACCEPT); + var contentType = request.getHeader(CONTENT_TYPE); + + if (path == null || path.isEmpty()) { + path = SLASH; + } + + // configure multipart + request.setAttribute(MULTIPART_KEY, multipartConfig); + + // routing + for (Route route : routes) { + Matcher matcher = route.path().matcher(path); + + if (equalsIgnoreCase(route.method(), method) && matcher.matches() && matchesAccept(route, accept) + && matchesContentType(route, contentType)) { + var vars = new HashMap(); + var pathVariables = route.pathVariables().stream().toList(); + for (int i = 0; i < pathVariables.size(); i++) { + vars.put(pathVariables.get(i), matcher.group(i + 1)); + } + + try { + if (!route.secured() || securityContext.isValidAuthorization(request, configurationAdmin)) { + route.handler().handle(new RequestHandlerContext(request, response, vars, configurationAdmin, + gson, thingRegistry)); + } else { + sendError(request, response, UNAUTHORIZED, HttpStatus.UNAUTHORIZED_401); + } + return; + } catch (RequestHandlerException e) { + logger.warn("Could not handle request! path: {} error: {}", path, e.getMessage(), e); + sendError(request, response, "Internal Server Error", HttpStatus.INTERNAL_SERVER_ERROR_500); + return; + } + } + } + sendError(request, response, NOT_FOUND, HttpStatus.NOT_FOUND_404); + } + + private boolean matchesAccept(Route route, @Nullable String accept) { + if (route.produces().isEmpty()) { + return true; + } else if (accept == null) { + return false; + } else { + for (String contentType : route.produces()) { + if (StringUtils.containsIgnoreCase(accept, contentType)) { + return true; + } + } + return false; + } + } + + private boolean matchesContentType(Route route, @Nullable String contentType) { + if (route.consumes().isEmpty()) { + return true; + } else if (contentType == null) { + return false; + } else { + for (String allowedContentType : route.consumes()) { + if (StringUtils.containsIgnoreCase(contentType, allowedContentType)) { + return true; + } + } + return false; + } + } + + private void registerHtmlRoute(String path, RequestHandler handler) { + registerRoute(ServletConstants.GET, path, List.of(), List.of(CONTENT_TYPE_HTML), false, handler); + } + + private void registerRoute(String method, String path, RequestHandler handler) { + var consumes = List.of(CONTENT_TYPE_JSON); + if (method.equals(GET) || method.equals(DELETE)) { + consumes = List.of(); + } + registerRoute(method, path, consumes, List.of(CONTENT_TYPE_JSON), true, handler); + } + + private void registerRoute(String method, String path, List consumes, List produces, + boolean secured, RequestHandler handler) { + var vars = new HashSet(); + + StringBuilder regex = new StringBuilder("^"); + int lastIndex = 0; + + Matcher matcher = Pattern.compile("\\{([^/]+)\\}|\\*\\*|\\*").matcher(path); + while (matcher.find()) { + regex.append(Pattern.quote(path.substring(lastIndex, matcher.start()))); + if (matcher.group(1) != null) { + vars.add(matcher.group(1)); + regex.append("([^/]+)"); + } else if ("**".equals(matcher.group())) { + regex.append(".*"); + } else { + regex.append("[^/]+"); + } + lastIndex = matcher.end(); + } + regex.append(Pattern.quote(path.substring(lastIndex))); + regex.append("$"); + + routes.add(new Route(method, Pattern.compile(regex.toString()), consumes, produces, vars, secured, handler)); + } + + private void sendError(HttpServletRequest request, HttpServletResponse response, String message, int status) + throws IOException { + if (StringUtils.containsIgnoreCase(request.getHeader(ACCEPT), CONTENT_TYPE_JSON)) { + response.setContentType(CONTENT_TYPE_JSON); + response.setStatus(status); + response.getWriter().write(gson.toJson(new Error(status, message))); + } else { + response.setContentType(CONTENT_TYPE_HTML); + response.sendError(status, message); + } + } + + @Deactivate + protected void dispose() { + securityContext.clearAllTokens(); + httpService.unregister(SERVLET_BASE_PATH); + httpService.unregister(SERVLET_ASSETS_PATH); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/HomeConnectDirectWebSocketServlet.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/HomeConnectDirectWebSocketServlet.java new file mode 100644 index 0000000000..4b22b09547 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/HomeConnectDirectWebSocketServlet.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.SLASH; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletUtils.filterOutMessage; + +import java.io.IOException; +import java.io.Serial; +import java.util.function.Consumer; + +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.servlet.WebSocketServlet; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.openhab.binding.homeconnectdirect.internal.common.utils.ConfigurationUtils; +import org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils; +import org.openhab.binding.homeconnectdirect.internal.handler.BaseHomeConnectDirectHandler; +import org.openhab.binding.homeconnectdirect.internal.handler.model.ApplianceMessage; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.MessageFilter; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.osgi.service.cm.ConfigurationAdmin; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * + * Home Connect Direct web socket servlet. Proxy values from the appliance to the web console. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +@WebServlet(urlPatterns = { SERVLET_WEB_SOCKET_PATTERN }) +@Component(service = HttpServlet.class, configurationPolicy = ConfigurationPolicy.OPTIONAL) +public class HomeConnectDirectWebSocketServlet extends WebSocketServlet { + + @Serial + private static final long serialVersionUID = 3_406_770_341_849_696_274L; + + private final Logger logger; + private final ThingRegistry thingRegistry; + private final ConfigurationAdmin configurationAdmin; + private final ServletSecurityContext securityContext; + private final Gson gson; + + @Activate + public HomeConnectDirectWebSocketServlet(@Reference ThingRegistry thingRegistry, + @Reference ConfigurationAdmin configurationAdmin) { + this.logger = LoggerFactory.getLogger(HomeConnectDirectWebSocketServlet.class); + this.thingRegistry = thingRegistry; + this.configurationAdmin = configurationAdmin; + this.securityContext = ServletSecurityContext.get(); + this.gson = ConfigurationUtils.createGson(); + } + + @Override + public void configure(@Nullable WebSocketServletFactory factory) { + if (factory != null) { + factory.setCreator((servletUpgradeRequest, servletUpgradeResponse) -> { + + if (!securityContext.isValidAuthorization(servletUpgradeRequest.getHttpServletRequest(), + configurationAdmin)) { + logger.debug("WebSocket authorization failed!"); + servletUpgradeResponse.setStatusCode(HttpStatus.UNAUTHORIZED_401); + return null; + } + + var filter = getFilterFromRequest(servletUpgradeRequest.getHttpServletRequest()); + var path = servletUpgradeRequest.getRequestURI().getPath(); + var uid = StringUtils.substringAfter(path, SERVLET_WEB_SOCKET_PATH + SLASH); + var thingHandler = getThingHandler(uid); + + if (thingHandler != null) { + return new HomeConnectDirectWebSocketHandler(thingHandler, gson, filter); + } else { + logger.debug("Could not find ThingHandler for WebSocket connection! uid={}", uid); + servletUpgradeResponse.setStatusCode(HttpStatus.NOT_FOUND_404); + return null; + } + }); + } else { + logger.warn("Could not configure WebSocket Servlet!"); + } + } + + private @Nullable BaseHomeConnectDirectHandler getThingHandler(String uid) { + return thingRegistry.stream().filter(thing -> SUPPORTED_THING_TYPES.contains(thing.getThingTypeUID())) + .filter(thing -> thing.getUID().toString().equals(uid)).map(Thing::getHandler) + .filter(BaseHomeConnectDirectHandler.class::isInstance).map(BaseHomeConnectDirectHandler.class::cast) + .findFirst().orElse(null); + } + + protected @Nullable MessageFilter getFilterFromRequest(HttpServletRequest request) { + var filterParameter = request.getParameter("filter"); + if (filterParameter != null) { + try { + return gson.fromJson(filterParameter, MessageFilter.class); + } catch (Exception e) { + logger.debug("Could not parse filter from request! error={}", e.getMessage()); + } + } + return null; + } + + @WebSocket + protected static class HomeConnectDirectWebSocketHandler { + private static final String PING_MESSAGE = "PING"; + private static final String PONG_MESSAGE = "PONG"; + + private final Logger logger; + private final BaseHomeConnectDirectHandler thingHandler; + private final Gson gson; + private final Consumer eventConsumer; + private final @Nullable MessageFilter filter; + private @Nullable Session session; + + public HomeConnectDirectWebSocketHandler(BaseHomeConnectDirectHandler thingHandler, Gson gson, + @Nullable MessageFilter filter) { + this.thingHandler = thingHandler; + this.logger = LoggerFactory.getLogger(HomeConnectDirectWebSocketHandler.class); + this.gson = gson; + this.eventConsumer = this::sendMessage; + this.filter = filter; + } + + @OnWebSocketConnect + public void onConnect(Session session) { + this.session = session; + thingHandler.registerApplianceMessageListener(eventConsumer); + thingHandler.getApplianceMessages().forEach(this::sendMessage); + } + + @OnWebSocketMessage + public void onMessage(Session session, String message) { + if (PING_MESSAGE.equals(message)) { + try { + session.getRemote().sendString(PONG_MESSAGE); + } catch (IOException e) { + logger.debug("Could not send PONG! error={}", e.getMessage()); + } + } + } + + @OnWebSocketClose + public void onClose(int statusCode, String reason) { + this.session = null; + thingHandler.removeApplianceMessageListener(eventConsumer); + } + + @OnWebSocketError + public void onError(Throwable cause) { + logger.debug("WebSocket error occurred: {}", cause.getMessage()); + this.session = null; + thingHandler.removeApplianceMessageListener(eventConsumer); + } + + public void sendMessage(ApplianceMessage message) { + var currentSession = this.session; + if (currentSession != null && !filterOutMessage(message, filter)) { + try { + currentSession.getRemote().sendString(gson.toJson(message)); + } catch (IOException e) { + logger.debug("Could not send web socket message! error={}", e.getMessage()); + } + } + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletConstants.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletConstants.java new file mode 100644 index 0000000000..e27f9b5eb0 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletConstants.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.BINDING_ID; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Servlet constants. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class ServletConstants { + + private ServletConstants() { + // prevent instantiation + } + + public static final String PATH_API = "/api"; + public static final String PATH_API_PROFILES = PATH_API + "/profiles"; + public static final String PATH_API_APPLIANCES = PATH_API + "/appliances"; + public static final String PATH_API_MESSAGES = PATH_API + "/messages"; + public static final String PATH_API_LOGS = PATH_API + "/logs"; + + public static final String HA_ID = "haId"; + public static final String UID = "uid"; + public static final String LOG_FILE_ID = "fileId"; + public static final String ZIP_FILE_PART = "zipFile"; + public static final String PROXY_FILES_PART = "proxyFiles"; + + public static final String GET = "GET"; + public static final String POST = "POST"; + public static final String DELETE = "DELETE"; + + public static final String CONTENT_TYPE_JSON = "application/json"; + public static final String CONTENT_TYPE_ZIP = "application/zip"; + public static final String CONTENT_TYPE_FORM_DATA = "multipart/form-data"; + public static final String CONTENT_TYPE_HTML_UTF8 = "text/html; charset=UTF-8"; + public static final String CONTENT_TYPE_HTML = "text/html"; + public static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition"; + public static final String HEADER_X_FRAME_OPTIONS = "X-Frame-Options"; + public static final String HEADER_PREFER = "Prefer"; + + public static final String PROFILE_DOWNLOAD_FILENAME_TEMPLATE = BINDING_ID + "-%s-%s-%s-%s_%s.zip"; + public static final String LOG_DOWNLOAD_FILENAME_TEMPLATE = "log-" + BINDING_ID + "-%s-%s-%s_%s_%s.zip.log"; + public static final String CONTENT_DISPOSITION_VALUE_TEMPLATE = "attachment; filename=\"%s\""; + public static final String UNAUTHORIZED = "Unauthorized"; + public static final String NOT_FOUND = "Not Found"; + public static final String SAME_ORIGIN = "SAMEORIGIN"; + public static final String PREFER_PERSIST = "handling=persist"; +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletSecurityContext.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletSecurityContext.java new file mode 100644 index 0000000000..9eee3f0abe --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletSecurityContext.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.ZONE_ID; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import javax.servlet.http.HttpServletRequest; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.utils.ConfigurationUtils; +import org.openhab.core.util.StringUtils; +import org.osgi.service.cm.ConfigurationAdmin; + +/** + * + * Home Connect Direct servlet security context. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class ServletSecurityContext { + + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private static final int TOKEN_LENGTH = 32; + private static final int TOKEN_EXPIRATION_HOURS = 24; + + @Nullable + private static ServletSecurityContext instance; + + private final Map activeTokens = new ConcurrentHashMap<>(); + + private ServletSecurityContext() { + // Singleton class + } + + /** + * Get the servlet security context. + * + * @return context + */ + @NonNullByDefault({}) + public static synchronized ServletSecurityContext get() { + if (instance == null) { + instance = new ServletSecurityContext(); + } + return instance; + } + + /** + * Validate an authorization token. + * + * @param request HTTP request + * @param configurationAdmin Configuration admin service + * @return true if valid + */ + public boolean isValidAuthorization(HttpServletRequest request, ConfigurationAdmin configurationAdmin) { + var configuration = ConfigurationUtils.getConfiguration(configurationAdmin); + if (!configuration.loginEnabled) { + return true; + } + + String token; + var authHeader = request.getHeader(HEADER_AUTHORIZATION); + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + token = authHeader.substring(BEARER_PREFIX.length()); + } else { + token = request.getParameter("token"); + } + + if (token != null && activeTokens.containsKey(token)) { + LocalDateTime expiration = activeTokens.get(token); + if (expiration != null && expiration.isAfter(LocalDateTime.now(ZONE_ID))) { + return true; + } else { + activeTokens.remove(token); // Token expired + } + } + return false; + } + + /** + * Invalidate an authorization token. + * + * @param request HTTP request + */ + public void invalidateAuthorization(HttpServletRequest request) { + String authHeader = request.getHeader(HEADER_AUTHORIZATION); + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + String token = authHeader.substring(BEARER_PREFIX.length()); + activeTokens.remove(token); + } + } + + /** + * Create and register a new authorization token. + * + * @return token + */ + public String createAndRegisterAuthorizationToken() { + String token = StringUtils.getRandomAlphanumeric(TOKEN_LENGTH); + activeTokens.put(token, LocalDateTime.now(ZONE_ID).plusHours(TOKEN_EXPIRATION_HOURS)); + return token; + } + + /** + * Clear all active tokens. + */ + public void clearAllTokens() { + activeTokens.clear(); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletUtils.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletUtils.java new file mode 100644 index 0000000000..3417b2b881 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/ServletUtils.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet; + +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils; +import org.openhab.binding.homeconnectdirect.internal.handler.model.ApplianceMessage; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.MessageFilter; + +/** + * Servlet utility methods. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class ServletUtils { + private ServletUtils() { + // utils class + } + + public static boolean filterOutMessage(ApplianceMessage message, @Nullable MessageFilter filter) { + if (filter == null) { + return false; + } + var start = filter.start(); + var end = filter.end(); + var type = filter.type(); + var resources = filter.resources(); + var actions = filter.actions(); + var valueKeys = filter.valueKeys(); + var descriptionChangeKeys = filter.descriptionChangeKeys(); + + // filter by date/time + if (start != null && message.dateTime().isBefore(roundDown(start))) { + return true; + } + if (end != null && message.dateTime().isAfter(roundUp(end))) { + return true; + } + + // filter by type + if (type != null && message.type() != type) { + return true; + } + + // filter by resources + if (resources != null && !resources.isEmpty() && !resources.contains(message.resource())) { + return true; + } + + // filter by actions + if (actions != null && !actions.isEmpty() && !actions.contains(message.action())) { + return true; + } + + // filter by value keys + var inValues = false; + if (valueKeys != null && !valueKeys.isEmpty()) { + var values = message.values(); + if (values != null) { + inValues = values.stream().map(Value::key).anyMatch( + name -> valueKeys.stream().anyMatch(search -> StringUtils.containsIgnoreCase(name, search))); + } + } + + // filter by description change keys + var inDescriptions = false; + var descriptionChanges = message.descriptionChanges(); + if (descriptionChangeKeys != null && !descriptionChangeKeys.isEmpty() && descriptionChanges != null) { + inDescriptions = descriptionChanges.stream().map(DeviceDescriptionChange::key) + .anyMatch(key -> descriptionChangeKeys.stream() + .anyMatch(search -> StringUtils.containsIgnoreCase(key, search))); + } + + // OR logic between value and description change keys + if ((descriptionChangeKeys != null && !descriptionChangeKeys.isEmpty()) + || (valueKeys != null) && !valueKeys.isEmpty()) { + return !inValues && !inDescriptions; + } + + return false; + } + + public static String replacePlaceholders(String template, Function valueProvider) { + StringBuilder sb = new StringBuilder(template.length()); + int cursor = 0; + while (cursor < template.length()) { + int start = template.indexOf("${", cursor); + if (start == -1) { + sb.append(template, cursor, template.length()); + break; + } + int end = template.indexOf("}", start); + if (end == -1) { + sb.append(template, cursor, template.length()); + break; + } + + sb.append(template, cursor, start); + String key = template.substring(start + 2, end); + String value = valueProvider.apply(key); + sb.append(value != null ? value : "${" + key + "}"); + cursor = end + 1; + } + return sb.toString(); + } + + public static @Nullable Integer mapInteger(@Nullable String value) { + if (value == null) { + return null; + } + + try { + return Integer.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } + + private static OffsetDateTime roundDown(OffsetDateTime dateTime) { + return dateTime.truncatedTo(ChronoUnit.MINUTES); + } + + private static OffsetDateTime roundUp(OffsetDateTime dateTime) { + OffsetDateTime truncated = dateTime.truncatedTo(ChronoUnit.MINUTES); + return truncated.plusMinutes(1).minus(1, ChronoUnit.NANOS); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/ApplianceRequestHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/ApplianceRequestHandler.java new file mode 100644 index 0000000000..2bc536eb83 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/ApplianceRequestHandler.java @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.handler.api; + +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.EMPTY_STRING; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletConstants.UID; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletUtils.mapInteger; + +import java.util.ArrayList; +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.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.homeconnectdirect.internal.handler.BaseHomeConnectDirectHandler; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.ApiCollectionResponse; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.Appliance; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.Error; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerContext; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerException; +import org.openhab.core.thing.Thing; + +/** + * Request handler for appliance API endpoints. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class ApplianceRequestHandler { + + public void getAppliances(RequestHandlerContext context) throws RequestHandlerException { + context.sendJson( + new ApiCollectionResponse<>(context.getApplianceThings().stream().map(this::mapToAppliance).toList())); + } + + public void getDeviceDescription(RequestHandlerContext context) throws RequestHandlerException { + var thing = getThingOrSendError(context); + if (thing == null) { + return; + } + + var uid = mapInteger(context.getQueryParameter("uid")); + var parentUid = mapInteger(context.getQueryParameter("parentUid")); + var typeParameter = context.getQueryParameter("type"); + var deviceDescriptionType = mapDeviceDescriptionType(typeParameter); + + if (thing.getHandler() instanceof BaseHomeConnectDirectHandler baseHomeConnectDirectHandler) { + var deviceDescriptionService = baseHomeConnectDirectHandler.getDeviceDescriptionService(); + if (deviceDescriptionService != null) { + if (uid != null && deviceDescriptionType != null) { + var descriptionObject = deviceDescriptionService.getDeviceDescriptionObject(uid, parentUid, + deviceDescriptionType); + if (descriptionObject != null) { + context.sendJson(descriptionObject); + } else { + context.sendJson( + new Error(HttpStatus.NOT_FOUND_404, + String.format("Device description with uid '%s' not found", uid)), + HttpStatus.NOT_FOUND_404); + } + } else { + context.sendJson(deviceDescriptionService.getDeviceDescription()); + } + } else { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "Unknown type parameter"), + HttpStatus.BAD_REQUEST_400); + } + } + } + + public void createYamlCode(RequestHandlerContext context) throws RequestHandlerException { + var thing = getThingOrSendError(context); + if (thing == null) { + return; + } + + Map result = new LinkedHashMap<>(); + result.put("version", 1); + + Map things = new LinkedHashMap<>(); + Map thingMap = new LinkedHashMap<>(); + + var label = thing.getLabel(); + thingMap.put("label", label != null ? label : thing.getUID().toString()); + + var config = thing.getConfiguration().getProperties(); + if (!config.isEmpty()) { + thingMap.put("config", config); + } + + var channels = thing.getChannels(); + if (!channels.isEmpty()) { + Map channelsMap = new LinkedHashMap<>(); + for (var channel : channels) { + var channelTypeUID = channel.getChannelTypeUID(); + if (channelTypeUID != null) { + Map channelMap = new LinkedHashMap<>(); + String type = channelTypeUID.getId(); + channelMap.put("type", type); + + if ("number".equals(type) || "string".equals(type) || "switch".equals(type) + || "trigger".equals(type) || "enum-switch".equals(type)) { + var channelLabel = channel.getLabel(); + if (channelLabel != null) { + channelMap.put("label", channelLabel); + } + var channelDescription = channel.getDescription(); + if (channelDescription != null) { + channelMap.put("description", channelDescription); + } + } + + var channelConfig = channel.getConfiguration().getProperties(); + if (!channelConfig.isEmpty()) { + channelMap.put("config", channelConfig); + } + channelsMap.put(channel.getUID().getId(), channelMap); + } + } + thingMap.put("channels", channelsMap); + } + + things.put(thing.getUID().toString(), thingMap); + result.put("things", things); + + context.sendJson(result); + } + + public void createDslCode(RequestHandlerContext context) throws RequestHandlerException { + var thing = getThingOrSendError(context); + if (thing == null) { + return; + } + + StringBuilder dsl = new StringBuilder(); + dsl.append("Thing "); + dsl.append(thing.getUID()); + var thingLabel = thing.getLabel(); + dsl.append(" \"").append(escapeDslString(thingLabel != null ? thingLabel : thing.getUID().toString())) + .append("\""); + + var config = thing.getConfiguration().getProperties(); + if (!config.isEmpty()) { + dsl.append(" ["); + List configEntries = new ArrayList<>(); + config.forEach((key, value) -> configEntries.add(formatConfigValue(key, value))); + dsl.append(String.join(", ", configEntries)); + dsl.append("]"); + } + + var channels = thing.getChannels(); + if (!channels.isEmpty()) { + dsl.append(" {\n Channels:\n"); + for (var channel : channels) { + var channelTypeUID = channel.getChannelTypeUID(); + if (channelTypeUID != null) { + var type = channelTypeUID.getId(); + if ("number".equals(type) || "string".equals(type) || "switch".equals(type) + || "trigger".equals(type) || "enum-switch".equals(type)) { + dsl.append(" Type ").append(type).append(" : ").append(channel.getUID().getId()); + + var channelLabel = channel.getLabel(); + if (channelLabel != null) { + dsl.append(" \"").append(escapeDslString(channelLabel)).append("\""); + } + + var channelConfig = channel.getConfiguration().getProperties(); + if (!channelConfig.isEmpty()) { + dsl.append(" ["); + List channelConfigEntries = new ArrayList<>(); + channelConfig + .forEach((key, value) -> channelConfigEntries.add(formatConfigValue(key, value))); + dsl.append(String.join(", ", channelConfigEntries)); + dsl.append("]"); + } + dsl.append("\n"); + } + } + } + dsl.append("}"); + } + + context.sendJson(new DslCode(dsl.toString())); + } + + private @Nullable Thing getThingOrSendError(RequestHandlerContext context) throws RequestHandlerException { + var uid = context.getVariable(UID); + if (uid == null) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "Missing uid"), HttpStatus.BAD_REQUEST_400); + return null; + } + + var thing = context.getApplianceThing(uid); + if (thing != null && thing.getHandler() instanceof BaseHomeConnectDirectHandler handler) { + return handler.getThing(); + } else { + context.sendJson( + new Error(HttpStatus.NOT_FOUND_404, String.format("Appliance with thing ID '%s' not found", uid)), + HttpStatus.NOT_FOUND_404); + return null; + } + } + + private Appliance mapToAppliance(Thing thing) { + var configuration = thing.getConfiguration(); + var label = thing.getLabel(); + var uid = thing.getUID().toString(); + var haId = configuration.get("haId"); + var address = configuration.get("address"); + int messageCount = 0; + + if (thing.getHandler() instanceof BaseHomeConnectDirectHandler handler) { + messageCount = handler.getApplianceMessages().size(); + } + + return new Appliance(uid, haId != null ? haId.toString() : EMPTY_STRING, + label != null ? label : thing.getUID().toString(), address != null ? address.toString() : EMPTY_STRING, + thing.getStatus().toString(), messageCount); + } + + private String formatConfigValue(String key, Object value) { + if (value instanceof Number || value instanceof Boolean) { + return key + "=" + value; + } + return key + "=\"" + escapeDslString(value.toString()) + "\""; + } + + private String escapeDslString(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", + "\\t"); + } + + private @Nullable DeviceDescriptionType mapDeviceDescriptionType(@Nullable String typeString) { + if (typeString == null) { + return null; + } + + try { + return DeviceDescriptionType.valueOf(typeString); + } catch (IllegalArgumentException e) { + return null; + } + } + + private record DslCode(String code) { + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/LogRequestHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/LogRequestHandler.java new file mode 100644 index 0000000000..8f8ac61f6d --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/LogRequestHandler.java @@ -0,0 +1,855 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.handler.api; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.BINDING_LOGS_PATH; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.ZONE_ID; +import static org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource.*; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletConstants.*; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletUtils.filterOutMessage; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletUtils.mapInteger; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import javax.servlet.ServletException; +import javax.servlet.http.Part; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.homeconnectdirect.internal.handler.model.ApplianceMessage; +import org.openhab.binding.homeconnectdirect.internal.handler.model.MessageType; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.service.description.DeviceDescriptionService; +import org.openhab.binding.homeconnectdirect.internal.service.description.DeviceDescriptionUtils; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.feature.FeatureMappingService; +import org.openhab.binding.homeconnectdirect.internal.service.feature.model.FeatureMapping; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.DescriptionChangeData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.ValueData; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.ApiCollectionResponse; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.Error; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.LogFile; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.MessageFilter; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerContext; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerException; +import org.openhab.core.cache.ExpiringCacheMap; +import org.openhab.core.util.HexUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +/** + * Request handler for log API endpoints. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class LogRequestHandler { + + private static final int DEFAULT_CACHE_TTL_IN_MIN = 10; + + private static final String MESSAGE_FIELD_DATE_TIME = "dateTime"; + private static final String MESSAGE_FIELD_TYPE = "type"; + private static final String MESSAGE_FIELD_RESOURCE = "resource"; + private static final String MESSAGE_FIELD_ACTION = "action"; + + private final Logger logger; + private final ExpiringCacheMap> logMessagesCache; + private final ExpiringCacheMap featureMappingCache; + private final ExpiringCacheMap initialDeviceDescriptionServiceCache; + + public LogRequestHandler() { + this.logger = LoggerFactory.getLogger(LogRequestHandler.class); + this.logMessagesCache = new ExpiringCacheMap<>(Duration.ofMinutes(DEFAULT_CACHE_TTL_IN_MIN)); + this.featureMappingCache = new ExpiringCacheMap<>(Duration.ofMinutes(DEFAULT_CACHE_TTL_IN_MIN)); + this.initialDeviceDescriptionServiceCache = new ExpiringCacheMap<>( + Duration.ofMinutes(DEFAULT_CACHE_TTL_IN_MIN)); + + createLogsDirectory(); + } + + public void getLogs(RequestHandlerContext context) throws RequestHandlerException { + context.sendJson(new ApiCollectionResponse<>(getLogFiles())); + } + + public void getDeviceDescription(RequestHandlerContext context) throws RequestHandlerException { + var logFile = getLogFileOrSendError(context); + if (logFile == null) { + return; + } + var featureMapping = getLogFileFeatureMapping(logFile); + if (featureMapping == null) { + context.sendJson(new Error(HttpStatus.INTERNAL_SERVER_ERROR_500, "Missing feature mapping XML!"), + HttpStatus.INTERNAL_SERVER_ERROR_500); + return; + } + var deviceDescriptionService = getLogFileInitialDeviceDescription(logFile, featureMapping); + if (deviceDescriptionService == null) { + context.sendJson(new Error(HttpStatus.INTERNAL_SERVER_ERROR_500, "Missing device description XML!"), + HttpStatus.INTERNAL_SERVER_ERROR_500); + return; + } + + // apply changes to device description + getLogFileMessages(logFile, context.getGson()).stream() + .filter(applianceMessage -> MessageType.INCOMING.equals(applianceMessage.type())) + .filter(applianceMessage -> RO_DESCRIPTION_CHANGE.equals(applianceMessage.resource()) + || RO_ALL_DESCRIPTION_CHANGES.equals(applianceMessage.resource())) + .sorted(Comparator.comparing(ApplianceMessage::dateTime)) + .map(applianceMessage -> applianceMessage.getRawPayloadAsList(DescriptionChangeData.class)) + .filter(Objects::nonNull).forEach(deviceDescriptionService::applyDescriptionChanges); + + var uid = mapInteger(context.getQueryParameter("uid")); + var parentUid = mapInteger(context.getQueryParameter("parentUid")); + var typeParameter = context.getQueryParameter("type"); + var deviceDescriptionType = mapDeviceDescriptionType(typeParameter); + + if (uid != null && deviceDescriptionType != null) { + var descriptionObject = deviceDescriptionService.getDeviceDescriptionObject(uid, parentUid, + deviceDescriptionType); + if (descriptionObject != null) { + context.sendJson(descriptionObject); + } else { + context.sendJson( + new Error(HttpStatus.NOT_FOUND_404, + String.format("Device description with uid '%s' not found", uid)), + HttpStatus.NOT_FOUND_404); + } + } else { + context.sendJson(deviceDescriptionService.getDeviceDescription()); + } + } + + public void uploadLog(RequestHandlerContext context) throws RequestHandlerException { + var request = context.getRequest(); + + try { + var filePart = request.getPart(ZIP_FILE_PART); + if (filePart != null && filePart.getSubmittedFileName() != null) { + handleZipUpload(context, filePart); + } else { + var parts = request.getParts().stream().filter(p -> PROXY_FILES_PART.equals(p.getName())).toList(); + if (parts.size() == 3) { + handleProxyUpload(context, parts); + } else { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "No valid file(s) submitted"), + HttpStatus.BAD_REQUEST_400); + } + } + } catch (IOException | ServletException e) { + throw new RequestHandlerException("Could not upload log", e); + } + } + + private void handleZipUpload(RequestHandlerContext context, Part filePart) throws RequestHandlerException { + var fileName = Paths.get(filePart.getSubmittedFileName()).getFileName().toString(); + if (!fileName.endsWith(".zip") && !fileName.endsWith(".zip.log")) { + context.sendJson( + new Error(HttpStatus.UNSUPPORTED_MEDIA_TYPE_415, "Only zip (or zip.log) files are supported"), + HttpStatus.UNSUPPORTED_MEDIA_TYPE_415); + return; + } + + Path tempZipPath = null; + try { + tempZipPath = Files.createTempFile("hcd-upload-", ".zip"); + + try (InputStream inputStream = filePart.getInputStream(); + ZipInputStream zis = new ZipInputStream(inputStream); + OutputStream outputStream = Files.newOutputStream(tempZipPath); + ZipOutputStream zos = new ZipOutputStream(outputStream)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + ZipEntry newEntry = new ZipEntry(entry.getName()); + zos.putNextEntry(newEntry); + + if (entry.getName().endsWith(".json")) { + processMessagesJson(zis, zos, context.getGson()); + } else { + zis.transferTo(zos); + } + zos.closeEntry(); + } + } + + finalizeUpload(context, tempZipPath, fileName); + } catch (IOException e) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "Invalid json file. Could not read file."), + HttpStatus.BAD_REQUEST_400); + } finally { + try { + if (tempZipPath != null) { + Files.deleteIfExists(tempZipPath); + } + } catch (IOException e) { + logger.warn("Could not delete temp file {}", tempZipPath, e); + } + } + } + + private void handleProxyUpload(RequestHandlerContext context, List parts) throws RequestHandlerException { + Part jsonPart = null; + Part deviceDescriptionPart = null; + Part featureMappingPart = null; + + for (Part part : parts) { + String name = part.getSubmittedFileName(); + if (name != null) { + if (name.endsWith(".json")) { + jsonPart = part; + } else if (name.endsWith("_DeviceDescription.xml")) { + deviceDescriptionPart = part; + } else if (name.endsWith("_FeatureMapping.xml")) { + featureMappingPart = part; + } + } + } + + if (jsonPart == null || deviceDescriptionPart == null || featureMappingPart == null) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "Missing required files (*.json, *.xml)"), + HttpStatus.BAD_REQUEST_400); + return; + } + + String fileName = Paths.get(jsonPart.getSubmittedFileName()).getFileName().toString(); + String zipFileName = fileName.substring(0, fileName.lastIndexOf('.')) + ".zip"; + Path tempZipPath = null; + Path tempDeviceDescriptionPath = null; + Path tempFeatureMappingPath = null; + + try { + // Create temp files for XMLs to parse them + tempDeviceDescriptionPath = Files.createTempFile("hcd-dd-", ".xml"); + tempFeatureMappingPath = Files.createTempFile("hcd-fm-", ".xml"); + try (InputStream is = deviceDescriptionPart.getInputStream()) { + Files.copy(is, tempDeviceDescriptionPath, StandardCopyOption.REPLACE_EXISTING); + } + try (InputStream is = featureMappingPart.getInputStream()) { + Files.copy(is, tempFeatureMappingPath, StandardCopyOption.REPLACE_EXISTING); + } + var featureMapping = new FeatureMappingService(tempFeatureMappingPath).getFeatureMapping(); + var deviceDescriptionService = new DeviceDescriptionService("unknown", tempDeviceDescriptionPath, + featureMapping); + + tempZipPath = Files.createTempFile("hcd-upload-proxy-", ".zip"); + + try (OutputStream outputStream = Files.newOutputStream(tempZipPath); + ZipOutputStream zos = new ZipOutputStream(outputStream)) { + // Process JSON + ZipEntry jsonEntry = new ZipEntry(jsonPart.getSubmittedFileName()); + zos.putNextEntry(jsonEntry); + try (InputStream is = jsonPart.getInputStream()) { + processProxyMessagesJson(is, zos, context.getGson(), deviceDescriptionService, featureMapping); + } + zos.closeEntry(); + + // Add XMLs to Zip + ZipEntry deviceDescriptionEntry = new ZipEntry(deviceDescriptionPart.getSubmittedFileName()); + zos.putNextEntry(deviceDescriptionEntry); + Files.copy(tempDeviceDescriptionPath, zos); + zos.closeEntry(); + + ZipEntry featureMappingEntry = new ZipEntry(featureMappingPart.getSubmittedFileName()); + zos.putNextEntry(featureMappingEntry); + Files.copy(tempFeatureMappingPath, zos); + zos.closeEntry(); + } + + finalizeUpload(context, tempZipPath, zipFileName); + } catch (IOException e) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "No valid HA proxy files submitted"), + HttpStatus.BAD_REQUEST_400); + } finally { + try { + if (tempZipPath != null) { + Files.deleteIfExists(tempZipPath); + } + if (tempDeviceDescriptionPath != null) { + Files.deleteIfExists(tempDeviceDescriptionPath); + } + if (tempFeatureMappingPath != null) { + Files.deleteIfExists(tempFeatureMappingPath); + } + } catch (IOException e) { + logger.warn("Could not delete temp xml files", e); + } + } + } + + private void finalizeUpload(RequestHandlerContext context, Path tempZipPath, String fileName) + throws RequestHandlerException, IOException { + var targetPath = Paths.get(BINDING_LOGS_PATH, fileName); + Files.move(tempZipPath, targetPath, StandardCopyOption.REPLACE_EXISTING); + + if (!isValidLogFile(targetPath)) { + try { + Files.deleteIfExists(targetPath); + } catch (IOException e) { + logger.warn("Could not delete invalid log file {}", targetPath, e); + } + + context.sendJson( + new Error(HttpStatus.BAD_REQUEST_400, + "Invalid log file. Must contain .json, *_DeviceDescription.xml and *_FeatureMapping.xml"), + HttpStatus.BAD_REQUEST_400); + return; + } + + context.sendNoContent(); + } + + private void processMessagesJson(InputStream inputStream, ZipOutputStream zos, Gson gson) throws IOException { + var json = new String(inputStream.readAllBytes(), UTF_8); + + if (!isValidMessagesJson(json, gson)) { + throw new IOException("Invalid json log file"); + } + + OutputStreamWriter writer = new OutputStreamWriter(zos, UTF_8); + writer.write(json); + writer.flush(); + } + + /** + * Validates that the given json represents a well formed array of appliance messages. + *

+ * The payload is first parsed with the typed adapters (which validate e.g. dates and resources). Because gson + * ignores the {@code @NonNullByDefault} annotations of the {@link ApplianceMessage} record, the mandatory fields + * are + * additionally verified to be present and non-null on the raw json. + * + * @param json the messages json to validate + * @param gson the gson instance (with the appliance message adapters registered) used for parsing + * @return {@code true} if the json is a (possibly empty) array of objects that all contain the mandatory fields, + * {@code false} if the json cannot be parsed or a mandatory field is missing or null + */ + static boolean isValidMessagesJson(String json, Gson gson) { + try { + // parse with the typed adapters to ensure dates, resources, ... are well formed + ApplianceMessage[] messages = gson.fromJson(json, ApplianceMessage[].class); + var array = gson.fromJson(json, JsonArray.class); + if (messages == null || array == null) { + return false; + } + + // gson ignores the record's null annotations, so verify the mandatory fields on the raw json + for (JsonElement element : array) { + if (!element.isJsonObject()) { + return false; + } + var object = element.getAsJsonObject(); + if (!hasNonNullMember(object, MESSAGE_FIELD_DATE_TIME) || !hasNonNullMember(object, MESSAGE_FIELD_TYPE) + || !hasNonNullMember(object, MESSAGE_FIELD_RESOURCE) + || !hasNonNullMember(object, MESSAGE_FIELD_ACTION)) { + return false; + } + } + return true; + } catch (JsonParseException | DateTimeException | NumberFormatException e) { + return false; + } + } + + /** + * Checks whether the given json object contains a member with the given name that is not json null. + * + * @param object the json object to inspect + * @param memberName the name of the member to check + * @return {@code true} if the member is present and not json null, {@code false} otherwise + */ + private static boolean hasNonNullMember(JsonObject object, String memberName) { + return object.has(memberName) && !object.get(memberName).isJsonNull(); + } + + private void processProxyMessagesJson(InputStream is, OutputStream os, Gson gson, + DeviceDescriptionService deviceDescriptionService, FeatureMapping featureMapping) throws IOException { + var json = new String(is.readAllBytes(), UTF_8); + JsonElement root; + + try { + root = gson.fromJson(json, JsonElement.class); + if (root == null) { + throw new IOException("Invalid json log file"); + } + } catch (JsonSyntaxException e) { + throw new IOException("Invalid JSON in messages json file", e); + } + + var isValid = true; + if (root.isJsonArray()) { + JsonArray array = root.getAsJsonArray(); + JsonArray newArray = new JsonArray(); + List entries = new ArrayList<>(); + + for (JsonElement element : array) { + if (element.isJsonObject()) { + try { + var proxyLogEntry = gson.fromJson(element, ProxyLogEntry.class); + if (proxyLogEntry != null) { + entries.add(proxyLogEntry); + } else { + isValid = false; + } + } catch (JsonSyntaxException e) { + logger.warn("Failed to parse proxy log entry", e); + isValid = false; + } + } else { + isValid = false; + } + } + + if (isValid) { + try { + entries.sort( + Comparator.comparing(e -> OffsetDateTime.parse(Objects.requireNonNull(e.timestamp())))); + + List convertedMessages = new ArrayList<>(); + for (ProxyLogEntry entry : entries) { + convertedMessages + .add(convertProxyMessage(entry, gson, deviceDescriptionService, featureMapping)); + } + + convertedMessages.sort(Comparator.comparing(ApplianceMessage::dateTime).reversed()); + convertedMessages.forEach(msg -> newArray.add(gson.toJsonTree(msg))); + + root = newArray; + } catch (Exception e) { + logger.warn("Failed to convert legacy message", e); + isValid = false; + } + } + } else { + isValid = false; + } + + if (!isValid) { + throw new IOException("Invalid json log file"); + } + + OutputStreamWriter writer = new OutputStreamWriter(os, UTF_8); + gson.toJson(root, writer); + writer.flush(); + } + + private ApplianceMessage convertProxyMessage(ProxyLogEntry logEntry, Gson gson, + DeviceDescriptionService deviceDescriptionService, FeatureMapping featureMapping) throws IOException { + var msgObj = logEntry.message(); + var logEntrySender = logEntry.sender(); + var logEntryTimestamp = logEntry.timestamp(); + var logEntrySessionId = logEntry.sessionId(); + + // check + if (msgObj == null || !msgObj.has("sID") || !msgObj.has("msgID") || !msgObj.has("version") + || !msgObj.has("resource") || !msgObj.has("action") || logEntrySender == null + || logEntryTimestamp == null || logEntrySessionId == null) { + throw new IOException("Invalid log entry message"); + } + + try { + var dateTime = OffsetDateTime.parse(logEntryTimestamp); + var sessionId = Long.parseLong(logEntrySessionId, 16); + + var type = logEntrySender == ProxySender.HOME_APPLIANCE ? MessageType.INCOMING : MessageType.OUTGOING; + + var id = msgObj.get("sID").getAsLong(); + var messageId = msgObj.get("msgID").getAsLong(); + var version = msgObj.get("version").getAsInt(); + var code = msgObj.has("code") ? msgObj.get("code").getAsInt() : null; + + var resource = Objects.requireNonNull(gson.fromJson(msgObj.get("resource"), Resource.class)); + var action = Objects.requireNonNull(gson.fromJson(msgObj.get("action"), Action.class)); + + JsonArray dataList = null; + if (msgObj.has("data")) { + dataList = gson.fromJson(msgObj.get("data"), new TypeToken() { + }.getType()); + } + + // handle device description change messages + List deviceDescriptionChanges = null; + if (dataList != null && MessageType.INCOMING.equals(type) + && (RO_DESCRIPTION_CHANGE.equals(resource) || RO_ALL_DESCRIPTION_CHANGES.equals(resource))) { + List descriptionChangeData = gson.fromJson(dataList, + TypeToken.getParameterized(List.class, DescriptionChangeData.class).getType()); + deviceDescriptionChanges = deviceDescriptionService.applyDescriptionChanges(descriptionChangeData); + } + + // handle value messages + List values = null; + if (dataList != null && MessageType.INCOMING.equals(type) + && (RO_VALUES.equals(resource) || RO_ALL_MANDATORY_VALUES.equals(resource))) { + List valueDataList = gson.fromJson(dataList, + TypeToken.getParameterized(List.class, ValueData.class).getType()); + values = DeviceDescriptionUtils.mapValues(deviceDescriptionService, featureMapping, resource, + valueDataList, null); + } + + return new ApplianceMessage(dateTime, id, type, resource, version, sessionId, messageId, action, code, + dataList, values, deviceDescriptionChanges); + } catch (JsonSyntaxException | NumberFormatException | IllegalStateException | DateTimeParseException e) { + throw new IOException("Invalid Json format"); + } + } + + private boolean isValidLogFile(Path path) { + boolean hasJson = false; + boolean hasDeviceDescription = false; + boolean hasFeatureMapping = false; + + try (ZipFile zipFile = new ZipFile(path.toFile())) { + var entries = zipFile.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry == null || entry.isDirectory()) { + continue; + } + String name = entry.getName(); + if (name.endsWith(".json")) { + hasJson = true; + } else if (name.endsWith("_DeviceDescription.xml")) { + hasDeviceDescription = true; + } else if (name.endsWith("_FeatureMapping.xml")) { + hasFeatureMapping = true; + } + } + } catch (IOException e) { + return false; + } + + return hasJson && hasDeviceDescription && hasFeatureMapping; + } + + public void downloadLog(RequestHandlerContext context) throws RequestHandlerException { + var logFile = getLogFileOrSendError(context); + if (logFile == null) { + return; + } + + var response = context.getResponse(); + response.setContentType(CONTENT_TYPE_ZIP); + response.setHeader(HEADER_CONTENT_DISPOSITION, + String.format(CONTENT_DISPOSITION_VALUE_TEMPLATE, logFile.fileName())); + + try { + Files.copy(Paths.get(BINDING_LOGS_PATH + File.separator + logFile.fileName()), response.getOutputStream()); + } catch (IOException e) { + throw new RequestHandlerException("Could not read log file", e); + } + } + + public void deleteLog(RequestHandlerContext context) throws RequestHandlerException { + var logFile = getLogFileOrSendError(context); + if (logFile == null) { + return; + } + + try { + Files.deleteIfExists(Paths.get(BINDING_LOGS_PATH + File.separator + logFile.fileName())); + context.sendNoContent(); + } catch (IOException e) { + throw new RequestHandlerException("Could not read log file", e); + } + } + + public void getLogMessages(RequestHandlerContext context) throws RequestHandlerException { + var logFile = getLogFileOrSendError(context); + if (logFile == null) { + return; + } + + MessageFilter filter = null; + var filterParameter = context.getQueryParameter("filter"); + if (filterParameter != null) { + try { + filter = context.getGson().fromJson(filterParameter, MessageFilter.class); + } catch (JsonSyntaxException e) { + logger.debug("Could not parse filter from request! error={}", e.getMessage()); + } + } + + context.sendJson(new ApiCollectionResponse<>( + getFilteredLogFileMessages(getLogFileMessages(logFile, context.getGson()), filter))); + } + + public void getLogMessageResources(RequestHandlerContext context) throws RequestHandlerException { + var logFile = getLogFileOrSendError(context); + if (logFile == null) { + return; + } + + context.sendJson(new ApiCollectionResponse<>(getLogFileMessages(logFile, context.getGson()).stream() + .map(ApplianceMessage::resource).distinct().toList())); + } + + public void getLogMessageValueKeys(RequestHandlerContext context) throws RequestHandlerException { + var logFile = getLogFileOrSendError(context); + if (logFile == null) { + return; + } + + var keys = getLogFileMessages(logFile, context.getGson()).stream().flatMap(applianceMessage -> { + var values = applianceMessage.values(); + if (values == null) { + return Stream.empty(); + } else { + return values.stream().map(Value::key); + } + }).distinct().toList(); + context.sendJson(new ApiCollectionResponse<>(keys)); + } + + public void getLogMessageDescriptionChangeKeys(RequestHandlerContext context) throws RequestHandlerException { + var logFile = getLogFileOrSendError(context); + if (logFile == null) { + return; + } + + var keys = getLogFileMessages(logFile, context.getGson()).stream().flatMap(applianceMessage -> { + var changes = applianceMessage.descriptionChanges(); + if (changes == null) { + return Stream.empty(); + } else { + return changes.stream().map(DeviceDescriptionChange::key); + } + }).distinct().toList(); + context.sendJson(new ApiCollectionResponse<>(keys)); + } + + private @Nullable LogFile getLogFileOrSendError(RequestHandlerContext context) throws RequestHandlerException { + var fileId = context.getVariable(LOG_FILE_ID); + if (fileId == null) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "Missing fileId"), HttpStatus.BAD_REQUEST_400); + return null; + } + + var logFile = getLogFiles().stream().filter(f -> f.id().equals(fileId)).findFirst().orElse(null); + if (logFile == null) { + context.sendJson( + new Error(HttpStatus.NOT_FOUND_404, String.format("Log file with ID '%s' not found", fileId)), + HttpStatus.NOT_FOUND_404); + } + + return logFile; + } + + private List getLogFiles() { + var result = new ArrayList(); + Path directory = Paths.get(BINDING_LOGS_PATH); + + try (DirectoryStream stream = Files.newDirectoryStream(directory)) { + for (Path path : stream) { + var id = calculateFileId(path); + var fileName = path.getFileName().toString(); + var fileSize = Files.size(path); + var createdAt = Files.getLastModifiedTime(path).toInstant().atZone(ZONE_ID).toOffsetDateTime(); + + result.add(new LogFile(id, fileName, fileSize, createdAt)); + } + } catch (IOException | NoSuchAlgorithmException e) { + logger.error("Could not read logs directory: {} error: {}", BINDING_LOGS_PATH, e.getMessage()); + } + + result.sort(Comparator.comparing(LogFile::createdAt).reversed()); + + return result; + } + + private String calculateFileId(Path path) throws IOException, NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("MD5"); + + // include filename in hash + md.update(path.getFileName().toString().getBytes(UTF_8)); + + try (InputStream is = Files.newInputStream(path); DigestInputStream dis = new DigestInputStream(is, md)) { + dis.transferTo(OutputStream.nullOutputStream()); + } + return HexUtils.bytesToHex(md.digest()); + } + + private List getLogFileMessages(LogFile logFile, Gson gson) { + var result = new ArrayList(); + var filePath = Path.of(BINDING_LOGS_PATH, logFile.fileName()); + if (!Files.exists(filePath)) { + return result; + } + + // check cache + var cachedResult = logMessagesCache.get(logFile.id()); + if (cachedResult != null) { + return cachedResult; + } + + // get messages from zip file + try (ZipFile zipFile = new ZipFile(filePath.toFile())) { + var entries = zipFile.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry != null && entry.getName().endsWith(".json")) { + try (InputStream is = zipFile.getInputStream(entry); + InputStreamReader reader = new InputStreamReader(is, UTF_8)) { + ApplianceMessage[] messagesArray = gson.fromJson(reader, ApplianceMessage[].class); + result.addAll(List.of(messagesArray)); + } + } + } + } catch (IOException e) { + logger.error("Could not read log file '{}'. error: {}", logFile.fileName(), e.getMessage()); + } + result.sort(Comparator.comparing(ApplianceMessage::dateTime).reversed()); + + logMessagesCache.put(logFile.id(), () -> result); + return result; + } + + private List getFilteredLogFileMessages(List messages, + @Nullable MessageFilter filter) { + return messages.stream().filter(applianceMessage -> !filterOutMessage(applianceMessage, filter)).toList(); + } + + private @Nullable FeatureMapping getLogFileFeatureMapping(LogFile logFile) { + var filePath = Path.of(BINDING_LOGS_PATH, logFile.fileName()); + if (!Files.exists(filePath)) { + return null; + } + + // check cache + var cachedResult = featureMappingCache.get(logFile.id()); + if (cachedResult != null) { + return cachedResult; + } + + // get featureMapping from zip file + FeatureMapping result = null; + try (ZipFile zipFile = new ZipFile(filePath.toFile())) { + var entries = zipFile.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry != null && entry.getName().endsWith("_FeatureMapping.xml")) { + try (InputStream is = zipFile.getInputStream(entry)) { + result = new FeatureMappingService(is).getFeatureMapping(); + } + } + } + } catch (IOException e) { + logger.error("Could not read feature mapping from log file '{}'. error: {}", logFile.fileName(), + e.getMessage()); + } + + FeatureMapping finalResult = result; + featureMappingCache.put(logFile.id(), () -> finalResult); + return result; + } + + private @Nullable DeviceDescriptionService getLogFileInitialDeviceDescription(LogFile logFile, + FeatureMapping featureMapping) { + var filePath = Path.of(BINDING_LOGS_PATH, logFile.fileName()); + if (!Files.exists(filePath)) { + return null; + } + + // check cache + var cachedResult = initialDeviceDescriptionServiceCache.get(logFile.id()); + if (cachedResult != null) { + return cachedResult; + } + + // get device description file from zip file + DeviceDescriptionService result = null; + try (ZipFile zipFile = new ZipFile(filePath.toFile())) { + var entries = zipFile.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry != null && entry.getName().endsWith("_DeviceDescription.xml")) { + try (InputStream is = zipFile.getInputStream(entry)) { + result = new DeviceDescriptionService("unknown", is, featureMapping); + } + } + } + } catch (IOException e) { + logger.error("Could not read device description from log file '{}'. error: {}", logFile.fileName(), + e.getMessage()); + } + + DeviceDescriptionService finalResult = result; + initialDeviceDescriptionServiceCache.put(logFile.id(), () -> finalResult); + return result; + } + + private void createLogsDirectory() { + try { + Files.createDirectories(Paths.get(BINDING_LOGS_PATH)); + } catch (IOException e) { + logger.error("Could not create logs directory! directory={}", BINDING_LOGS_PATH, e); + } + } + + private @Nullable DeviceDescriptionType mapDeviceDescriptionType(@Nullable String typeString) { + if (typeString == null) { + return null; + } + + try { + return DeviceDescriptionType.valueOf(typeString); + } catch (IllegalArgumentException e) { + return null; + } + } + + private record ProxyLogEntry(@Nullable String timestamp, @Nullable String sessionId, @Nullable ProxySender sender, + @Nullable JsonObject message) { + } + + private enum ProxySender { + APP, + HOME_APPLIANCE + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/MessageRequestHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/MessageRequestHandler.java new file mode 100644 index 0000000000..ae0eaa6d73 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/MessageRequestHandler.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.handler.api; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.*; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.convertToKebabCase; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.toLowercase; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletConstants.*; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.homeconnectdirect.internal.handler.BaseHomeConnectDirectHandler; +import org.openhab.binding.homeconnectdirect.internal.handler.model.ApplianceMessage; +import org.openhab.binding.homeconnectdirect.internal.handler.model.SendMessageRequest; +import org.openhab.binding.homeconnectdirect.internal.handler.model.Value; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.ApiCollectionResponse; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.Error; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerContext; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Request handler for message API endpoints. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class MessageRequestHandler { + + private static final String MESSAGES_JSON = "messages.json"; + + private final ApplianceProfileService applianceProfileService; + private final DateTimeFormatter fileNameDateFormatter; + private final Logger logger; + + public MessageRequestHandler(ApplianceProfileService applianceProfileService) { + this.applianceProfileService = applianceProfileService; + this.fileNameDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); + logger = LoggerFactory.getLogger(MessageRequestHandler.class); + } + + public void saveOrDownloadMessages(RequestHandlerContext context) throws RequestHandlerException { + var response = context.getResponse(); + var thingHandler = getHandlerOrSendError(context); + if (thingHandler == null) { + return; + } + + var preferHeader = context.getRequest().getHeader(HEADER_PREFER); + var haId = context.getHaIdFromThing(thingHandler.getThing()); + if (haId == null) { + throw new RequestHandlerException("Missing haId from thing"); + } + + var applianceProfile = applianceProfileService.getProfile(haId); + if (applianceProfile == null) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, + String.format("Could not create message log zip for appliance with uid '%s'. Profile not found. ", + thingHandler.getThing().getUID())), + HttpStatus.BAD_REQUEST_400); + return; + } + + var type = convertToKebabCase(toLowercase(applianceProfile.type())); + var brand = toLowercase(applianceProfile.brand()); + var vib = toLowercase(applianceProfile.vib()); + var filename = String.format(LOG_DOWNLOAD_FILENAME_TEMPLATE, type, brand, vib, + LocalDateTime.now(ZONE_ID).format(fileNameDateFormatter), + org.openhab.core.util.StringUtils.getRandomHex(4)); + + try { + OutputStream outputStream; + // persist log file on server + if (PREFER_PERSIST.equalsIgnoreCase(preferHeader)) { + outputStream = Files.newOutputStream(Paths.get(BINDING_LOGS_PATH + File.separator + filename)); + context.sendCreated(); + } + // direct download + else { + response.setContentType(CONTENT_TYPE_ZIP); + response.setHeader(HEADER_CONTENT_DISPOSITION, + String.format(CONTENT_DISPOSITION_VALUE_TEMPLATE, filename)); + outputStream = response.getOutputStream(); + } + + try (ZipOutputStream zos = new ZipOutputStream(outputStream, UTF_8); + OutputStreamWriter writer = new OutputStreamWriter(zos, UTF_8)) { + // home appliance messages + ZipEntry zipEntry = new ZipEntry(MESSAGES_JSON); + zos.putNextEntry(zipEntry); + writer.write(context.getGson().toJson(thingHandler.getApplianceMessages())); + writer.flush(); + zos.closeEntry(); + + // add original description and feature mapping XML files + for (Path path : List.of(Paths.get(BINDING_PROFILES_PATH, applianceProfile.deviceDescriptionFileName()), + Paths.get(BINDING_PROFILES_PATH, applianceProfile.featureMappingFileName()))) { + if (Files.exists(path)) { + ZipEntry fileEntry = new ZipEntry(path.getFileName().toString()); + zos.putNextEntry(fileEntry); + Files.copy(path, zos); + zos.closeEntry(); + } else { + logger.warn("Profile file '{}' does not exist!", path); + } + } + } + } catch (IOException e) { + throw new RequestHandlerException( + String.format("Error while creating message log zip for appliance with ID '%s'. error: %s", haId, + e.getMessage()), + e); + } + } + + public void sendMessage(RequestHandlerContext context) throws RequestHandlerException { + var thingHandler = getHandlerOrSendError(context); + if (thingHandler == null) { + return; + } + + var request = context.getRequestObject(SendMessageRequest.class); + if (request == null) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "Invalid request body"), HttpStatus.BAD_REQUEST_400); + return; + } + thingHandler.send(request); + + context.sendNoContent(); + } + + public void getMessageResources(RequestHandlerContext context) throws RequestHandlerException { + var thingHandler = getHandlerOrSendError(context); + if (thingHandler == null) { + return; + } + + Set resources = new HashSet<>(); + // Add static resources from Resource class + for (Field field : Resource.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) && field.getType().equals(Resource.class)) { + try { + var staticResource = (Resource) field.get(null); + if (staticResource != null) { + resources.add(staticResource); + } + } catch (IllegalAccessException e) { + logger.warn("Could not access static resource field", e); + } + } + } + // Add already used resources + resources.addAll(thingHandler.getApplianceMessages().stream().map(ApplianceMessage::resource).toList()); + + var sortedResources = resources.stream() + .sorted(Comparator.comparing(Resource::service).thenComparing(Resource::endpoint)).toList(); + + context.sendJson(new ApiCollectionResponse<>(sortedResources)); + } + + public void getMessageValueKeys(RequestHandlerContext context) throws RequestHandlerException { + var thingHandler = getHandlerOrSendError(context); + if (thingHandler == null) { + return; + } + + var keys = thingHandler.getApplianceMessages().stream().flatMap(applianceMessage -> { + var values = applianceMessage.values(); + if (values != null) { + return values.stream().map(Value::key); + } else { + return Stream.empty(); + } + }).distinct().toList(); + + context.sendJson(new ApiCollectionResponse<>(keys)); + } + + public void getMessageDescriptionChangeKeys(RequestHandlerContext context) throws RequestHandlerException { + var thingHandler = getHandlerOrSendError(context); + if (thingHandler == null) { + return; + } + + var keys = thingHandler.getApplianceMessages().stream().flatMap(applianceMessage -> { + var changes = applianceMessage.descriptionChanges(); + if (changes != null) { + return changes.stream().map(DeviceDescriptionChange::key); + } else { + return Stream.empty(); + } + }).distinct().toList(); + + context.sendJson(new ApiCollectionResponse<>(keys)); + } + + private @Nullable BaseHomeConnectDirectHandler getHandlerOrSendError(RequestHandlerContext context) + throws RequestHandlerException { + var uid = context.getVariable(UID); + if (uid == null) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "Missing uid"), HttpStatus.BAD_REQUEST_400); + return null; + } + + var thing = context.getApplianceThing(uid); + if (thing != null && thing.getHandler() instanceof BaseHomeConnectDirectHandler handler) { + return handler; + } else { + context.sendJson( + new Error(HttpStatus.NOT_FOUND_404, String.format("Appliance with thing ID '%s' not found", uid)), + HttpStatus.NOT_FOUND_404); + return null; + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/ProfileRequestHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/ProfileRequestHandler.java new file mode 100644 index 0000000000..453ae246ac --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/ProfileRequestHandler.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.handler.api; + +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.ZONE_ID; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.StringUtils.*; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletConstants.*; + +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; + +import javax.servlet.ServletException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.homeconnectdirect.internal.service.profile.ApplianceProfileService; +import org.openhab.binding.homeconnectdirect.internal.service.profile.model.ApplianceProfile; +import org.openhab.binding.homeconnectdirect.internal.service.profile.model.TlsCredentials; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.ApiCollectionResponse; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.Error; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.Profile; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerContext; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerException; + +/** + * Request handler for profile API endpoints. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class ProfileRequestHandler { + + private final ApplianceProfileService applianceProfileService; + private final DateTimeFormatter fileNameDateFormatter; + + public ProfileRequestHandler(ApplianceProfileService applianceProfileService) { + this.applianceProfileService = applianceProfileService; + this.fileNameDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); + } + + public void getProfiles(RequestHandlerContext context) throws RequestHandlerException { + var profiles = applianceProfileService.getProfiles().stream().map(this::mapToProfile) + .sorted((p1, p2) -> p2.created().compareTo(p1.created())).toList(); + + context.sendJson(new ApiCollectionResponse<>(profiles)); + } + + public void uploadProfile(RequestHandlerContext context) throws RequestHandlerException { + var request = context.getRequest(); + + try { + var filePart = request.getPart(ZIP_FILE_PART); + if (filePart == null || filePart.getSubmittedFileName() == null) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "No file submitted"), + HttpStatus.BAD_REQUEST_400); + return; + } + + var fileName = filePart.getSubmittedFileName(); + if (!fileName.endsWith(".zip")) { + context.sendJson(new Error(HttpStatus.UNSUPPORTED_MEDIA_TYPE_415, "Only zip files are supported"), + HttpStatus.UNSUPPORTED_MEDIA_TYPE_415); + return; + } + + Optional profile; + try (InputStream inputStream = filePart.getInputStream()) { + profile = applianceProfileService.uploadProfileZip(inputStream); + } + + if (profile.isPresent()) { + context.sendNoContent(); + } else { + context.sendJson(new Error(HttpStatus.INTERNAL_SERVER_ERROR_500, "Failed to process profile"), + HttpStatus.INTERNAL_SERVER_ERROR_500); + } + } catch (IOException | ServletException e) { + throw new RequestHandlerException("Could not upload profile zip", e); + } + } + + public void downloadProfile(RequestHandlerContext context) throws RequestHandlerException { + var applianceProfile = getProfileOrSendError(context); + if (applianceProfile == null) { + return; + } + + var type = sanitize(convertToKebabCase(toLowercase(applianceProfile.type()))); + var brand = sanitize(toLowercase(applianceProfile.brand())); + var vib = sanitize(toLowercase(applianceProfile.vib())); + var mac = !isBlank(applianceProfile.mac()) + ? sanitize(toLowercase(applianceProfile.mac().replace(HYPHEN, EMPTY_STRING))) + : EMPTY_STRING; + var filename = String.format(PROFILE_DOWNLOAD_FILENAME_TEMPLATE, type, brand, vib, mac, + LocalDateTime.now(ZONE_ID).format(fileNameDateFormatter)); + + var response = context.getResponse(); + response.setContentType(CONTENT_TYPE_ZIP); + response.setHeader(HEADER_CONTENT_DISPOSITION, String.format(CONTENT_DISPOSITION_VALUE_TEMPLATE, filename)); + + try { + applianceProfileService.downloadProfileZip(applianceProfile.haId(), response.getOutputStream()); + } catch (IOException e) { + throw new RequestHandlerException("Could not download profile zip", e); + } + } + + public void deleteProfile(RequestHandlerContext context) throws RequestHandlerException { + var applianceProfile = getProfileOrSendError(context); + if (applianceProfile == null) { + return; + } + + applianceProfileService.deleteProfile(applianceProfile.haId()); + context.sendNoContent(); + } + + private @Nullable ApplianceProfile getProfileOrSendError(RequestHandlerContext context) + throws RequestHandlerException { + var haId = context.getVariable(HA_ID); + if (haId == null) { + context.sendJson(new Error(HttpStatus.BAD_REQUEST_400, "Missing haId"), HttpStatus.BAD_REQUEST_400); + return null; + } + + var applianceProfile = applianceProfileService.getProfile(haId); + if (applianceProfile == null) { + context.sendJson( + new Error(HttpStatus.NOT_FOUND_404, + String.format("Profile with Home Appliance ID '%s' not found", haId)), + HttpStatus.NOT_FOUND_404); + return null; + } + + return applianceProfile; + } + + private Profile mapToProfile(ApplianceProfile applianceProfile) { + return new Profile(applianceProfile.haId(), applianceProfile.brand(), applianceProfile.type(), + applianceProfile.vib(), applianceProfile.credentials() instanceof TlsCredentials ? "WSS" : "WS", + applianceProfile.created()); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/page/AuthRequestHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/page/AuthRequestHandler.java new file mode 100644 index 0000000000..ab2a7846ff --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/page/AuthRequestHandler.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.handler.page; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.SERVLET_ASSETS_PATH; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.SERVLET_BASE_PATH; + +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; + +import javax.servlet.http.HttpSession; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.homeconnectdirect.internal.servlet.ServletSecurityContext; +import org.openhab.binding.homeconnectdirect.internal.servlet.ServletUtils; +import org.openhab.binding.homeconnectdirect.internal.servlet.model.LoginForm; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerContext; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerException; + +/** + * Request handler for authentication page. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class AuthRequestHandler { + + private static final String CSRF_SESSION_ATTRIBUTE = "HCD_CSRF_TOKEN"; + private static final String TEMPLATE = "/templates/login.html"; + + private final ServletSecurityContext securityContext; + + public AuthRequestHandler(ServletSecurityContext securityContext) { + this.securityContext = securityContext; + } + + public void loginPage(RequestHandlerContext context) throws RequestHandlerException { + HttpSession session = context.getRequest().getSession(); // get or create + String csrfToken = UUID.randomUUID().toString(); + session.setAttribute(CSRF_SESSION_ATTRIBUTE, csrfToken); + + try (InputStream is = getClass().getResourceAsStream(TEMPLATE)) { + if (is == null) { + throw new RequestHandlerException("Template not found: " + TEMPLATE); + } + + String template = new String(is.readAllBytes(), UTF_8); + String content = ServletUtils.replacePlaceholders(template, key -> switch (key) { + case "servletPath" -> SERVLET_BASE_PATH; + case "assetPath" -> SERVLET_ASSETS_PATH; + case "csrfToken" -> csrfToken; + default -> null; + }); + context.sendHtml(content); + } catch (IOException e) { + throw new RequestHandlerException("Error reading template " + TEMPLATE, e); + } + } + + public void handleLogin(RequestHandlerContext context) throws RequestHandlerException { + var session = context.getRequest().getSession(true); + if (session == null) { + throw new RequestHandlerException("Could not create session."); + } + + var expectedToken = (String) session.getAttribute(CSRF_SESSION_ATTRIBUTE); + session.removeAttribute(CSRF_SESSION_ATTRIBUTE); // Use token only once + var formData = context.getRequestObject(LoginForm.class); + + var configuredPassword = context.getConfiguration().loginPassword; + if (formData == null || expectedToken == null || !expectedToken.equals(formData.csrfToken())) { + context.sendJson( + new ErrorMessage(HttpStatus.FORBIDDEN_403, "Invalid CSRF Token", createAndSaveCsrfToken(session)), + HttpStatus.FORBIDDEN_403); + } else if (configuredPassword != null && !configuredPassword.isBlank() + && configuredPassword.equals(formData.password())) { + var token = securityContext.createAndRegisterAuthorizationToken(); + context.sendJson(new TokenMessage(token)); + session.invalidate(); + } else { + context.sendJson( + new ErrorMessage(HttpStatus.UNAUTHORIZED_401, "Invalid password", createAndSaveCsrfToken(session)), + HttpStatus.UNAUTHORIZED_401); + } + } + + public void handleLogout(RequestHandlerContext context) { + securityContext.invalidateAuthorization(context.getRequest()); + HttpSession session = context.getRequest().getSession(false); + if (session != null) { + session.invalidate(); + } + context.sendNoContent(); + } + + private String createAndSaveCsrfToken(HttpSession session) { + var newToken = UUID.randomUUID().toString(); + session.setAttribute(CSRF_SESSION_ATTRIBUTE, newToken); + return newToken; + } + + private record ErrorMessage(int code, String error, String newCsrfToken) { + // error message + } + + private record TokenMessage(String token) { + // token message + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/page/IndexRequestHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/page/IndexRequestHandler.java new file mode 100644 index 0000000000..0cc0a6efa5 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/page/IndexRequestHandler.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.handler.page; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.SERVLET_ASSETS_PATH; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.SERVLET_BASE_PATH; +import static org.openhab.binding.homeconnectdirect.internal.common.utils.ConfigurationUtils.getDeviceId; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletConstants.*; + +import java.io.IOException; +import java.io.InputStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homeconnectdirect.internal.servlet.ServletUtils; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerContext; +import org.openhab.binding.homeconnectdirect.internal.servlet.routing.RequestHandlerException; + +/** + * Request handler for index page. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class IndexRequestHandler { + + private static final String TEMPLATE = "/templates/index.html"; + + public void indexPage(RequestHandlerContext ctx) throws RequestHandlerException { + try (InputStream is = getClass().getResourceAsStream(TEMPLATE)) { + if (is == null) { + throw new RequestHandlerException("Template not found: " + TEMPLATE); + } + + String template = new String(is.readAllBytes(), UTF_8); + String content = ServletUtils.replacePlaceholders(template, key -> switch (key) { + case "loginEnabled" -> Boolean.toString(ctx.getConfiguration().loginEnabled); + case "servletPath" -> SERVLET_BASE_PATH; + case "assetPath" -> SERVLET_ASSETS_PATH; + case "bindingDeviceId" -> getDeviceId(); + case "apiAppliancesPath" -> PATH_API_APPLIANCES; + case "apiProfilesPath" -> PATH_API_PROFILES; + case "apiMessagesPath" -> PATH_API_MESSAGES; + case "apiLogsPath" -> PATH_API_LOGS; + case "messageQueueSize" -> String.valueOf(ctx.getConfiguration().messageQueueSize); + default -> null; + }); + ctx.sendHtml(content); + } catch (IOException e) { + throw new RequestHandlerException("Error reading template " + TEMPLATE, e); + } + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/ApiCollectionResponse.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/ApiCollectionResponse.java new file mode 100644 index 0000000000..03b3ba0b11 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/ApiCollectionResponse.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.model; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * API collection response model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record ApiCollectionResponse (List data) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Appliance.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Appliance.java new file mode 100644 index 0000000000..945874d193 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Appliance.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Appliance servlet model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Appliance(String uid, String haId, String label, String address, String status, int messageCount) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Error.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Error.java new file mode 100644 index 0000000000..0315b739ec --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Error.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Error response model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Error(int code, String error) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/LogFile.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/LogFile.java new file mode 100644 index 0000000000..effe98775a --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/LogFile.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.model; + +import java.time.OffsetDateTime; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Log file model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record LogFile(String id, String fileName, long fileSize, OffsetDateTime createdAt) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/LoginForm.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/LoginForm.java new file mode 100644 index 0000000000..c77528dff5 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/LoginForm.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; + +/** + * Login form model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record LoginForm(@Nullable String password, @Nullable @SerializedName("_csrf") String csrfToken) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/MessageFilter.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/MessageFilter.java new file mode 100644 index 0000000000..43d7b29408 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/MessageFilter.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.model; + +import java.time.OffsetDateTime; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homeconnectdirect.internal.handler.model.MessageType; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; + +/** + * Message filter model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record MessageFilter(@Nullable OffsetDateTime start, @Nullable OffsetDateTime end, @Nullable MessageType type, + @Nullable Set resources, @Nullable Set actions, @Nullable Set valueKeys, + @Nullable Set descriptionChangeKeys) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Profile.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Profile.java new file mode 100644 index 0000000000..b5a8ac4513 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Profile.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.model; + +import java.time.OffsetDateTime; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Profile servlet model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Profile(String haId, String brand, String type, String vib, String connectionType, + OffsetDateTime created) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Program.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Program.java new file mode 100644 index 0000000000..b6ba5bd2ef --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/model/Program.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Program servlet model. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Program(int uid, String key) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandler.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandler.java new file mode 100644 index 0000000000..523ba3317d --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandler.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.routing; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Request handler interface for servlet routing. + * + * @author Jonas Brüstel - Initial contribution + */ +@FunctionalInterface +@NonNullByDefault +public interface RequestHandler { + /** + * Handle an incoming request. + * + * @param ctx the request handler context + * @throws RequestHandlerException if the request could not be handled + */ + void handle(RequestHandlerContext ctx) throws RequestHandlerException; +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandlerContext.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandlerContext.java new file mode 100644 index 0000000000..465a70372b --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandlerContext.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.routing; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.openhab.binding.homeconnectdirect.internal.HomeConnectDirectBindingConstants.SUPPORTED_THING_TYPES; +import static org.openhab.binding.homeconnectdirect.internal.servlet.ServletConstants.*; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.homeconnectdirect.internal.common.utils.ConfigurationUtils; +import org.openhab.binding.homeconnectdirect.internal.configuration.HomeConnectDirectConfiguration; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.osgi.service.cm.ConfigurationAdmin; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * Context object for request handlers. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class RequestHandlerContext { + private final HttpServletRequest request; + private final HttpServletResponse response; + private final Map pathVariables; + private final ConfigurationAdmin configurationAdmin; + private final Gson gson; + private final ThingRegistry thingRegistry; + + public RequestHandlerContext(HttpServletRequest request, HttpServletResponse response, + Map pathVariables, ConfigurationAdmin configurationAdmin, Gson gson, + ThingRegistry thingRegistry) { + this.request = request; + this.response = response; + this.pathVariables = pathVariables; + this.configurationAdmin = configurationAdmin; + this.gson = gson; + this.thingRegistry = thingRegistry; + } + + public @Nullable String getVariable(String name) { + return pathVariables.get(name); + } + + public @Nullable String getQueryParameter(String name) { + return request.getParameter(name); + } + + public HttpServletRequest getRequest() { + return request; + } + + public HttpServletResponse getResponse() { + return response; + } + + public HomeConnectDirectConfiguration getConfiguration() { + return ConfigurationUtils.getConfiguration(configurationAdmin); + } + + public String getRequestBody() throws RequestHandlerException { + try { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = request.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } + return sb.toString(); + } catch (IOException e) { + throw new RequestHandlerException("Could not read request body.", e); + } + } + + public @Nullable T getRequestObject(Class type) throws RequestHandlerException { + try { + return gson.fromJson(getRequestBody(), type); + } catch (JsonSyntaxException e) { + throw new RequestHandlerException("Could not parse request body to " + type.getSimpleName(), e); + } + } + + public void sendHtml(String content) throws RequestHandlerException { + response.setContentType(CONTENT_TYPE_HTML_UTF8); + response.setCharacterEncoding(UTF_8.name()); + response.setHeader(HEADER_X_FRAME_OPTIONS, SAME_ORIGIN); + + try { + response.getWriter().write(content); + } catch (IOException e) { + throw new RequestHandlerException("Failed to send HTML content.", e); + } + } + + public void sendNoContent() { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + public void sendCreated() { + response.setStatus(HttpServletResponse.SC_CREATED); + } + + public void sendJson(Object responseObject) throws RequestHandlerException { + sendJson(responseObject, HttpStatus.OK_200); + } + + public void sendJson(Object responseObject, int status) throws RequestHandlerException { + response.setContentType(CONTENT_TYPE_JSON); + response.setCharacterEncoding(UTF_8.name()); + response.setStatus(status); + + try { + response.getWriter().write(gson.toJson(responseObject)); + } catch (IOException e) { + throw new RequestHandlerException("Failed to send JSON content.", e); + } + } + + public List getApplianceThings() { + return thingRegistry.stream().filter(thing -> SUPPORTED_THING_TYPES.contains(thing.getThingTypeUID())).toList(); + } + + public @Nullable Thing getApplianceThing(String uid) { + return thingRegistry.stream().filter(thing -> SUPPORTED_THING_TYPES.contains(thing.getThingTypeUID())) + .filter(thing -> thing.getUID().toString().equals(uid)).findFirst().orElse(null); + } + + public @Nullable String getHaIdFromThing(Thing thing) { + var configuration = thing.getConfiguration(); + var haId = configuration.get("haId"); + return haId != null ? haId.toString() : null; + } + + public Gson getGson() { + return gson; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandlerException.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandlerException.java new file mode 100644 index 0000000000..88debe8436 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/RequestHandlerException.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.routing; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception thrown by request handlers. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public class RequestHandlerException extends Exception { + + @Serial + private static final long serialVersionUID = 852_133_612_771_426_467L; + + public RequestHandlerException(final String message) { + super(message); + } + + public RequestHandlerException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/Route.java b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/Route.java new file mode 100644 index 0000000000..14d2fd88a8 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/java/org/openhab/binding/homeconnectdirect/internal/servlet/routing/Route.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.routing; + +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Route definition for servlet routing. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +public record Route(String method, Pattern path, List consumes, List produces, + Set pathVariables, boolean secured, RequestHandler handler) { +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000..29d9d1e549 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,45 @@ + + + + binding + Home Connect Direct Binding + The binding integrates the Home Connect devices into openHAB. It connects to household appliances from + brands such as Bosch and Siemens. The connection is established locally with the devices. + local + + + + false + + This configuration parameter enables you to activate or deactivate password protection for the console + interface. When enabled, users must provide a password to access the console. + + + + password + + Console password + + + + This is the maximum size of the message queue per home appliance. It specifies the maximum number of + messages that can be stored. The higher this value is, the more memory is used. + 300 + + + + + + mdns + + + mdnsServiceType + _homeconnect._tcp.local. + + + + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/config/config.xml new file mode 100644 index 0000000000..4b0eccb94c --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/config/config.xml @@ -0,0 +1,73 @@ + + + + + + + Unique identifier representing a specific home appliance. + + + + Address of home appliance (hostname or IP) + + + time + 1 + + Specifies the amount of time (in minutes) to wait before attempting to reconnect after a disconnection + occurs, whether due to an offline device or a connection error. + true + + + + + + Key to filter value messages (e.g. "BSH.Common.Setting.AllowBackendConnection") + + + + + + Key to identify the device description (e.g. "BSH.Common.Option.ProgramProgress") + + + + The attribute of the description to map + + + + + + + + + + + + + + + Key to filter value messages (e.g. "BSH.Common.Status.DoorState") + + + + Comma-separated list of enumeration value keys that map to the ON state (e.g. "Open" or + "On,Present"). + Any other value is mapped to OFF. This channel is read-only. + + + + + + Key to filter value messages (e.g. "BSH.Common.Setting.AllowBackendConnection") + + + + Unit of measurement (optional). The unit is used for representing the value in the GUI as well as for + converting incoming values (like from '°F' to '°C'). Examples: "°C", "°F", "%%" (escaped percent) + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/i18n/homeconnectdirect.properties b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/i18n/homeconnectdirect.properties new file mode 100644 index 0000000000..2cc2e638c2 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/i18n/homeconnectdirect.properties @@ -0,0 +1,278 @@ +# add-on + +addon.homeconnectdirect.name = Home Connect Direct Binding +addon.homeconnectdirect.description = The binding integrates the Home Connect devices into openHAB. It connects to household appliances from brands such as Bosch and Siemens. The connection is established locally with the devices. + +# add-on config + +addon.config.homeconnectdirect.loginEnabled.label = Enforce Password Protection +addon.config.homeconnectdirect.loginEnabled.description = This configuration parameter enables you to activate or deactivate password protection for the console interface. When enabled, users must provide a password to access the console. +addon.config.homeconnectdirect.loginPassword.label = Password +addon.config.homeconnectdirect.loginPassword.description = Console password +addon.config.homeconnectdirect.messageQueueSize.label = Message Queue Size +addon.config.homeconnectdirect.messageQueueSize.description = This is the maximum size of the message queue per home appliance. It specifies the maximum number of messages that can be stored. The higher this value is, the more memory is used. + +# thing types + +thing-type.homeconnectdirect.coffeemaker.label = Coffee Maker +thing-type.homeconnectdirect.coffeemaker.description = Coffee maker which supports Home Connect. +thing-type.homeconnectdirect.cookprocessor.label = Cook Processor +thing-type.homeconnectdirect.cookprocessor.description = Cook processor which supports Home Connect. +thing-type.homeconnectdirect.cooktop.label = Cooktop +thing-type.homeconnectdirect.cooktop.description = Cooktop which supports Home Connect. +thing-type.homeconnectdirect.dishwasher.label = Dishwasher +thing-type.homeconnectdirect.dishwasher.description = Dishwasher which supports Home Connect. +thing-type.homeconnectdirect.dryer.label = Dryer +thing-type.homeconnectdirect.dryer.description = Dryer which supports Home Connect. +thing-type.homeconnectdirect.fridgefreezer.label = Refrigerator / Freezer +thing-type.homeconnectdirect.fridgefreezer.description = Refrigerator / Freezer which supports Home Connect. +thing-type.homeconnectdirect.generic.label = Generic Appliance +thing-type.homeconnectdirect.generic.description = Appliance which supports Home Connect. +thing-type.homeconnectdirect.hood.label = Hood +thing-type.homeconnectdirect.hood.description = Hood which supports Home Connect. +thing-type.homeconnectdirect.oven.label = Oven +thing-type.homeconnectdirect.oven.description = Oven which supports Home Connect. +thing-type.homeconnectdirect.warmingdrawer.label = Warming Drawer +thing-type.homeconnectdirect.warmingdrawer.description = Warming Drawer which supports Home Connect. +thing-type.homeconnectdirect.washer.label = Washer +thing-type.homeconnectdirect.washer.description = Washer which supports Home Connect. +thing-type.homeconnectdirect.washerdryer.label = Washer / Dryer combination +thing-type.homeconnectdirect.washerdryer.description = Washer / Dryer combination which supports Home Connect. + +# thing types config + +thing-type.config.homeconnectdirect.appliance.address.label = Address +thing-type.config.homeconnectdirect.appliance.address.description = Address of home appliance (hostname or IP) +thing-type.config.homeconnectdirect.appliance.connectionRetryDelay.label = Reconnect Delay +thing-type.config.homeconnectdirect.appliance.connectionRetryDelay.description = Specifies the amount of time (in minutes) to wait before attempting to reconnect after a disconnection occurs, whether due to an offline device or a connection error. +thing-type.config.homeconnectdirect.appliance.haId.label = Home Appliance ID +thing-type.config.homeconnectdirect.appliance.haId.description = Unique identifier representing a specific home appliance. + +# channel types + +channel-type.homeconnectdirect.active-program-channel.label = Active Program +channel-type.homeconnectdirect.active-program-channel.description = The currently active program. +channel-type.homeconnectdirect.bean-container-empty-channel.label = Bean Container Empty +channel-type.homeconnectdirect.bean-container-empty-channel.description = Indicates that the coffee machine's bean container is empty. +channel-type.homeconnectdirect.child-lock-channel.label = Child Lock +channel-type.homeconnectdirect.child-lock-channel.description = The child lock state. +channel-type.homeconnectdirect.coffee-maker-process-phase-channel.label = Process Phase +channel-type.homeconnectdirect.coffee-maker-process-phase-channel.description = The current process phase of the coffee maker. +channel-type.homeconnectdirect.coffeemaker-program-command-channel.label = Program Command +channel-type.homeconnectdirect.coffeemaker-program-command-channel.description = Controls the program execution. +channel-type.homeconnectdirect.coffeemaker-program-command-channel.command.option.start = Start Program +channel-type.homeconnectdirect.coffeemaker-program-command-channel.command.option.stop = Stop Program +channel-type.homeconnectdirect.command-channel.label = Command +channel-type.homeconnectdirect.command-channel.description = Allows sending specific operation commands to the home appliance. The available commands (options) are updated dynamically based on the current appliance state and supported features. +channel-type.homeconnectdirect.cooking-button-tones-channel.label = Button Tones +channel-type.homeconnectdirect.cooking-button-tones-channel.description = The button tones setting. +channel-type.homeconnectdirect.cooking-light-channel.label = Functional Light +channel-type.homeconnectdirect.cooking-light-channel.description = The functional light state. +channel-type.homeconnectdirect.device-description-number.label = Number (Device Description) +channel-type.homeconnectdirect.device-description-string.label = Text (Device Description) +channel-type.homeconnectdirect.device-description-switch.label = Switch (Device Description) +channel-type.homeconnectdirect.dishwasher-brilliance-dry-channel.label = Brilliance Dry +channel-type.homeconnectdirect.dishwasher-brilliance-dry-channel.description = Enhances the drying process and extends the drying phase to ensure spot-free, brilliant shine for glassware and plastic items. +channel-type.homeconnectdirect.dishwasher-intensiv-zone-channel.label = Intensiv Zone +channel-type.homeconnectdirect.dishwasher-intensiv-zone-channel.description = Increases spray pressure and temperature in the lower basket for heavily soiled pots and pans, while the upper basket remains gentle. +channel-type.homeconnectdirect.dishwasher-machine-care-reminder-channel.label = Machine Care Reminder +channel-type.homeconnectdirect.dishwasher-machine-care-reminder-channel.description = Indicates whether the dishwasher needs cleaning. +channel-type.homeconnectdirect.dishwasher-program-phase-channel.label = Program Phase +channel-type.homeconnectdirect.dishwasher-program-phase-channel.description = The program or process phase of the dishwasher. +channel-type.homeconnectdirect.dishwasher-rinse-aid-lack-channel.label = Lack of Rinse Aid +channel-type.homeconnectdirect.dishwasher-rinse-aid-lack-channel.description = Indicates if the dishwasher rinse aid is empty. +channel-type.homeconnectdirect.dishwasher-rinse-aid-nearly-empty-channel.label = Rinse Aid Nearly Empty +channel-type.homeconnectdirect.dishwasher-rinse-aid-nearly-empty-channel.description = Indicates when the dishwasher rinse aid is almost empty. +channel-type.homeconnectdirect.dishwasher-salt-lack-channel.label = Lack of Salt +channel-type.homeconnectdirect.dishwasher-salt-lack-channel.description = Indicates if the dishwasher salt is empty. +channel-type.homeconnectdirect.dishwasher-salt-nearly-empty-channel.label = Salt Nearly Empty +channel-type.homeconnectdirect.dishwasher-salt-nearly-empty-channel.description = Indicates when the dishwasher salt is almost empty. +channel-type.homeconnectdirect.dishwasher-vario-speed-plus-channel.label = SpeedPerfect+ / varioSpeed +channel-type.homeconnectdirect.dishwasher-vario-speed-plus-channel.description = SpeedPerfect+ is a high-speed cleaning option for dishwashers. +channel-type.homeconnectdirect.door-channel.label = Door +channel-type.homeconnectdirect.door-channel.description = The door state of the appliance. +channel-type.homeconnectdirect.drip-tray-full-channel.label = Drip Tray Full +channel-type.homeconnectdirect.drip-tray-full-channel.description = Indicates when the drip tray of the coffee machine is full. +channel-type.homeconnectdirect.drum-clean-reminder-channel.label = Drum Clean Reminder +channel-type.homeconnectdirect.drum-clean-reminder-channel.description = Indicates whether the washer drum needs cleaning. +channel-type.homeconnectdirect.empty-milk-tank-channel.label = Empty Milk Tank +channel-type.homeconnectdirect.empty-milk-tank-channel.description = Indicates when the coffee machine's milk tank is empty. +channel-type.homeconnectdirect.enum-switch.label = Switch (Enum Value) +channel-type.homeconnectdirect.fridgefreezer-chiller-preset-channel.label = Chiller Preset +channel-type.homeconnectdirect.fridgefreezer-chiller-preset-channel.description = Allows selecting a predefined cooling mode for the chiller compartment. +channel-type.homeconnectdirect.fridgefreezer-dispenser-enabled-channel.label = Dispenser Enabled +channel-type.homeconnectdirect.fridgefreezer-dispenser-enabled-channel.description = Enables or disables the dispenser. +channel-type.homeconnectdirect.fridgefreezer-dispenser-party-mode-channel.label = Dispenser Party Mode +channel-type.homeconnectdirect.fridgefreezer-dispenser-party-mode-channel.description = Enables or disables the dispenser party mode. +channel-type.homeconnectdirect.fridgefreezer-dispenser-water-filter-saturation-channel.label = Dispenser Water Filter Saturation +channel-type.homeconnectdirect.fridgefreezer-dispenser-water-filter-saturation-channel.description = Indicates the current saturation level of the water dispenser filter. A higher value suggests the filter is approaching the end of its service life and may need replacement soon. +channel-type.homeconnectdirect.fridgefreezer-setpoint-temperature-channel.label = Setpoint Temperature +channel-type.homeconnectdirect.fridgefreezer-setpoint-temperature-channel.description = Target temperature of the compartment. +channel-type.homeconnectdirect.fridgefreezer-super-mode-channel.label = Fridge / Freezer Super Mode +channel-type.homeconnectdirect.fridgefreezer-super-mode-channel.description = Enables the super cooling/freezing mode. +channel-type.homeconnectdirect.hood-intensive-level-channel.label = Intensive level +channel-type.homeconnectdirect.hood-intensive-level-channel.description = Intensive level of the hood. +channel-type.homeconnectdirect.hood-venting-level-channel.label = Venting Level +channel-type.homeconnectdirect.hood-venting-level-channel.description = Venting level of the hood. +channel-type.homeconnectdirect.laundry-care-process-phase-channel.label = Process Phase +channel-type.homeconnectdirect.laundry-care-process-phase-channel.description = The current process phase of the appliance. +channel-type.homeconnectdirect.laundry-care-wrinkle-guard-channel.label = Wrinkle Block +channel-type.homeconnectdirect.laundry-care-wrinkle-guard-channel.description = Prevents the laundry from creasing or wrinkling. The drum rotates at specific intervals. +channel-type.homeconnectdirect.laundry-drying-target-channel.label = Drying Target +channel-type.homeconnectdirect.laundry-drying-target-channel.description = Specifies the desired dryness setting. +channel-type.homeconnectdirect.laundry-load-information-channel.label = Load Information +channel-type.homeconnectdirect.laundry-load-information-channel.description = The current laundry load information. +channel-type.homeconnectdirect.laundry-load-recommendation-channel.label = Load Recommendation +channel-type.homeconnectdirect.laundry-load-recommendation-channel.description = The recommended load for the current program. +channel-type.homeconnectdirect.number.label = Number (Value) +channel-type.homeconnectdirect.operation-state-channel.label = Operation State +channel-type.homeconnectdirect.operation-state-channel.description = The operation state of the appliance. +channel-type.homeconnectdirect.oven-cavity-light-channel.label = Cavity Light +channel-type.homeconnectdirect.oven-cavity-light-channel.description = The cavity light state. +channel-type.homeconnectdirect.oven-current-cavity-temperature-channel.label = Cavity Temperature +channel-type.homeconnectdirect.oven-current-cavity-temperature-channel.description = The current cavity temperature. +channel-type.homeconnectdirect.oven-current-meat-probe-temperature-channel.label = Meat Probe Temperature +channel-type.homeconnectdirect.oven-current-meat-probe-temperature-channel.description = The current meat probe temperature. +channel-type.homeconnectdirect.oven-duration-channel.label = Selected Duration +channel-type.homeconnectdirect.oven-duration-channel.description = The duration of the oven program. +channel-type.homeconnectdirect.oven-meat-probe-plugged-channel.label = Meat Probe Plugged +channel-type.homeconnectdirect.oven-meat-probe-plugged-channel.description = Indicates if the meat probe is plugged in. +channel-type.homeconnectdirect.oven-program-command-channel.label = Program Command +channel-type.homeconnectdirect.oven-program-command-channel.description = Controls the program execution. +channel-type.homeconnectdirect.oven-program-command-channel.command.option.start = Start Program +channel-type.homeconnectdirect.oven-program-command-channel.command.option.pause = Pause Program +channel-type.homeconnectdirect.oven-program-command-channel.command.option.resume = Resume Program +channel-type.homeconnectdirect.oven-program-command-channel.command.option.stop = Stop Program +channel-type.homeconnectdirect.oven-setpoint-temperature-channel.label = Oven Program Temperature +channel-type.homeconnectdirect.oven-setpoint-temperature-channel.description = Program target temperature of the oven. +channel-type.homeconnectdirect.program-command-channel.label = Program Command +channel-type.homeconnectdirect.program-command-channel.description = Controls the program execution. +channel-type.homeconnectdirect.program-command-channel.command.option.start = Start Program +channel-type.homeconnectdirect.program-command-channel.command.option.pause = Pause Program +channel-type.homeconnectdirect.program-command-channel.command.option.resume = Resume Program +channel-type.homeconnectdirect.program-progress-channel.label = Program Progress +channel-type.homeconnectdirect.program-progress-channel.description = The program progress. +channel-type.homeconnectdirect.raw-message-channel.label = Raw Message +channel-type.homeconnectdirect.raw-message-channel.description = Allows sending a raw JSON payload directly to the home appliance. +channel-type.homeconnectdirect.remaining-channel.label = Remaining +channel-type.homeconnectdirect.remaining-channel.description = This display shows a countdown indicating the time remaining until a specific maintenance or cleaning task is due. The countdown updates dynamically, providing a clear and convenient way to monitor upcoming actions. +channel-type.homeconnectdirect.remaining-program-time-channel.label = Remaining Program Time +channel-type.homeconnectdirect.remaining-program-time-channel.description = The remaining program time. +channel-type.homeconnectdirect.remote-control-start-allowed-channel.label = Remote Start/Control Allowed +channel-type.homeconnectdirect.remote-control-start-allowed-channel.description = Indicates whether remote control or remote program start is enabled. +channel-type.homeconnectdirect.selected-program-channel.label = Selected Program +channel-type.homeconnectdirect.selected-program-channel.description = The currently selected program. +channel-type.homeconnectdirect.string.label = Text (Value) +channel-type.homeconnectdirect.switch.label = Switch (Value) +channel-type.homeconnectdirect.trigger.label = Trigger (Value) +channel-type.homeconnectdirect.washer-idos-active-channel.label = i-Dos Active +channel-type.homeconnectdirect.washer-idos-active-channel.description = Whether i-Dos is enabled for a washing program. +channel-type.homeconnectdirect.washer-idos-fill-level-poor-channel.label = i-Dos Fill Level Poor +channel-type.homeconnectdirect.washer-idos-fill-level-poor-channel.description = Indicates whether the i-Dos is almost empty. +channel-type.homeconnectdirect.washer-less-ironing-channel.label = Less Ironing +channel-type.homeconnectdirect.washer-less-ironing-channel.description = Reduces spin speed and tumbles laundry gently after the cycle to minimize creasing and simplify ironing. +channel-type.homeconnectdirect.washer-prewash-channel.label = Prewash +channel-type.homeconnectdirect.washer-prewash-channel.description = Adds a prewash cycle to the washing program to treat heavily soiled laundry before the main wash. +channel-type.homeconnectdirect.washer-rinse-hold-channel.label = Rinse Hold +channel-type.homeconnectdirect.washer-rinse-hold-channel.description = Stops the washing cycle before the final spin, keeping the laundry in the last rinse water to prevent creasing. +channel-type.homeconnectdirect.washer-rinse-plus-channel.label = Rinse Plus +channel-type.homeconnectdirect.washer-rinse-plus-channel.description = Allows the selection of additional rinse cycles at the end of the program to ensure the complete removal of detergent residues. +channel-type.homeconnectdirect.washer-silent-wash-channel.label = Silent Wash +channel-type.homeconnectdirect.washer-silent-wash-channel.description = Reduces noise by omitting intermediate spin cycles and lowering the final spin speed, ideal for operation during nighttime. +channel-type.homeconnectdirect.washer-soak-channel.label = Soak +channel-type.homeconnectdirect.washer-soak-channel.description = Adds an immersion phase before the wash cycle to loosen stubborn dirt and stains from heavily soiled laundry. +channel-type.homeconnectdirect.washer-speed-perfect-channel.label = SpeedPerfect / varioSpeed +channel-type.homeconnectdirect.washer-speed-perfect-channel.description = Reduces the program duration by increasing energy consumption and mechanical action. Does not affect the final spin speed. +channel-type.homeconnectdirect.washer-spin-speed-channel.label = Spin Speed +channel-type.homeconnectdirect.washer-spin-speed-channel.description = The spin speed of the washing program. +channel-type.homeconnectdirect.washer-stains-channel.label = Stains +channel-type.homeconnectdirect.washer-stains-channel.description = Optimizes the wash cycle and water temperature to target and remove specific types of stains like grass, oil, or red wine. +channel-type.homeconnectdirect.washer-temperature-channel.label = Washing Program Temperature +channel-type.homeconnectdirect.washer-temperature-channel.description = The temperature of the washing program. +channel-type.homeconnectdirect.washer-water-plus-channel.label = Water Plus +channel-type.homeconnectdirect.washer-water-plus-channel.description = Increases the water level and/or adds an additional rinse cycle to the washing program for improved rinsing results. +channel-type.homeconnectdirect.water-tank-empty-channel.label = Water Tank Empty +channel-type.homeconnectdirect.water-tank-empty-channel.description = Indicates when the coffee machine's water tank is empty. +channel-type.homeconnectdirect.water-tank-nearly-empty-channel.label = Water Tank Nearly Empty +channel-type.homeconnectdirect.water-tank-nearly-empty-channel.description = Indicates when the coffee machine's water tank is almost empty. + +# channel types config + +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.attribute.label = Attribute +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.attribute.description = The attribute of the description to map +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.attribute.option.available = Available +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.attribute.option.access = Access +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.attribute.option.min = Min +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.attribute.option.max = Max +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.attribute.option.stepSize = Step Size +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.attribute.option.enumerationType = Enumeration Type (UID) +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.attribute.option.enumerationTypeKey = Enumeration Type Key +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.descriptionKey.label = Description Key +thing-type.config.homeconnectdirect.dynamic-description-channel-generic.descriptionKey.description = Key to identify the device description (e.g. "BSH.Common.Option.ProgramProgress") +thing-type.config.homeconnectdirect.dynamic-value-channel-enum-switch.onValue.label = ON Values +thing-type.config.homeconnectdirect.dynamic-value-channel-enum-switch.onValue.description = Comma-separated list of enumeration value keys that map to the ON state (e.g. "Open" or "On,Present"). Any other value is mapped to OFF. This channel is read-only. +thing-type.config.homeconnectdirect.dynamic-value-channel-enum-switch.valueKey.label = Value Key +thing-type.config.homeconnectdirect.dynamic-value-channel-enum-switch.valueKey.description = Key to filter value messages (e.g. "BSH.Common.Status.DoorState") +thing-type.config.homeconnectdirect.dynamic-value-channel-generic.valueKey.label = Value Key +thing-type.config.homeconnectdirect.dynamic-value-channel-generic.valueKey.description = Key to filter value messages (e.g. "BSH.Common.Setting.AllowBackendConnection") +thing-type.config.homeconnectdirect.dynamic-value-channel-number.unit.label = Unit Of Measurement +thing-type.config.homeconnectdirect.dynamic-value-channel-number.unit.description = Unit of measurement (optional). The unit is used for representing the value in the GUI as well as for converting incoming values (like from '°F' to '°C'). Examples: "°C", "°F", "%%" (escaped percent) +thing-type.config.homeconnectdirect.dynamic-value-channel-number.valueKey.label = Value Key +thing-type.config.homeconnectdirect.dynamic-value-channel-number.valueKey.description = Key to filter value messages (e.g. "BSH.Common.Setting.AllowBackendConnection") + +# custom keys + +appliance.generic.label = {0} Generic Appliance +appliance.washer.label = {0} Washer +appliance.dryer.label = {0} Dryer +appliance.washerdryer.label = {0} Washer / Dryer combination +appliance.dishwasher.label = {0} Dishwasher +appliance.cookprocessor.label = {0} Cook Processor +appliance.coffeemaker.label = {0} Coffee Maker +appliance.oven.label = {0} Oven +appliance.warmingdrawer.label = {0} Warming Drawer +appliance.hood.label = {0} Hood +appliance.cooktop.label = {0} Cooktop +appliance.fridgefreezer.label = {0} Refrigerator / Freezer + +# thing status descriptions + +offline.config-error = The configuration contains an error. Please fill in all mandatory fields. +offline.profile-pending = Please fetch the appliance profiles from your Home Connect account at http(s)://[YOUROPENHAB]:[YOURPORT]/homeconnectdirect (e.g. http://192.168.178.100:8080/homeconnectdirect). +offline.profile-parse-error = Could not parse profile XML: {0} +offline.tls-unsupported-glibc = The running system ({0} {1}) does not support secure connections. glibc {2} or higher is required (verify with ''ldd --version''). Supported systems: Linux (X64, ARM64), Windows (X64), Mac OS (X64, ARM64). +offline.tls-unsupported = The running system ({0} {1}) does not support secure connections. Supported systems: Linux (X64, ARM64), Windows (X64), Mac OS (X64, ARM64). + +# general + +channel.door = Door + +# i-dos + +channel.idos.fill-level-poor = i-Dos {0} Fill Level Poor +channel.idos.active = i-Dos {0} Active + +# oven + +channel.oven.door = {0} Oven Door +channel.oven.current-temperature = {0} Current Temperature +channel.oven.meat-probe-temperature = {0} Meat Probe Temperature +channel.oven.cavity-light = {0} Cavity Light +channel.oven.meat-probe-plugged = {0} Meat Probe Plugged + +# fridge / freezer + +channel.fridgefreezer.setpoint = {0} Setpoint Temperature +channel.fridgefreezer.door = {0} Door +channel.fridgefreezer.super-mode = {0} Super Mode +channel.fridgefreezer.water-filter-saturation = Water Filter Saturation +channel.fridgefreezer.chiller-preset = Chiller Preset +channel.fridgefreezer.dispenser-enabled = Dispenser Enabled +channel.fridgefreezer.dispenser-party-mode = Dispenser Party Mode + +# program + +command.program.start = Start Program +command.program.stop = Stop Program +command.program.pause = Pause Program +command.program.resume = Resume Program diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000..10293b3bab --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,1195 @@ + + + + + + + Dishwasher which supports Home Connect. + Dishwasher + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + haId + + + + + + Cook processor which supports Home Connect. + FoodProcessor + + + + + + + + + + + + + + haId + + + + + + Washer which supports Home Connect. + WashingMachine + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + haId + + + + + + Washer / Dryer combination which supports Home Connect. + WashingMachine + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + haId + + + + + + Dryer which supports Home Connect. + Dryer + + + + + + + + + + + + + + + + + + + + + + + + + + + + haId + + + + + + Coffee maker which supports Home Connect. + CoffeeMaker + + + + + + + + + + + + + + + + + + + + + + + + + + + haId + + + + + + Oven which supports Home Connect. + Oven + + + + + + + + + + + + + + + + + + + + + + + + haId + + + + + + Warming Drawer which supports Home Connect. + Oven + + + + + + + + + + + + + + + + + + + + + + haId + + + + + + Hood which supports Home Connect. + KitchenHood + + + + + + + + + + + + + + + + + + + + + haId + + + + + + Cooktop which supports Home Connect. + Cooktop + + + + + + + + + + + + + + + haId + + + + + + Refrigerator / Freezer which supports Home Connect. + Refrigerator + + + + + + haId + + + + + + Appliance which supports Home Connect. + WhiteGood + + + + + + haId + + + + + + Contact + + The door state of the appliance. + + Status + OpenState + + + + + + String + + The operation state of the appliance. + + Status + Mode + + + + + + String + + The current process phase of the appliance. + + Status + Mode + + + + + + String + + The current process phase of the coffee maker. + + Status + Mode + + + + + + Switch + + Indicates whether remote control or remote program start is enabled. + + Status + Mode + + + + + + String + + The currently active program. + + Status + Mode + + + + + + String + + The currently selected program. + + Status + Mode + + veto + + + + Number:Time + + The remaining program time. + + Status + Duration + + + + + + Number:Dimensionless + + The program progress. + + Status + + + + + + String + + Controls the program execution. + + Control + + + + + + + + + veto + + + + String + + Controls the program execution. + + Control + + + + + + + + + + veto + + + + String + + Controls the program execution. + + Control + + + + + + + + veto + + + + Switch + + Indicates whether the dishwasher needs cleaning. + + Status + + + + + + Switch + + Indicates whether the i-Dos is almost empty. + + Status + + + + + + Switch + + Whether i-Dos is enabled for a washing program. + + Switch + Mode + + veto + + + + String + + The temperature of the washing program. + + Status + Temperature + + + veto + + + + String + + The spin speed of the washing program. + + Status + Speed + + + veto + + + + Switch + + Reduces the program duration by increasing energy consumption and mechanical action. Does not affect the + final spin speed. + + Switch + Mode + + + veto + + + + Switch + + SpeedPerfect+ is a high-speed cleaning option for dishwashers. + + Switch + Mode + + + veto + + + + Switch + + Increases spray pressure and temperature in the lower basket for heavily soiled pots and pans, while the + upper basket remains gentle. + + Switch + Mode + + + veto + + + + Switch + + Enhances the drying process and extends the drying phase to ensure spot-free, brilliant shine for + glassware and plastic items. + + Switch + Mode + + + veto + + + + Switch + + Increases the water level and/or adds an additional rinse cycle to the washing program for improved + rinsing results. + + Switch + Mode + + + veto + + + + Switch + + Adds a prewash cycle to the washing program to treat heavily soiled laundry before the main wash. + + Switch + Mode + + + veto + + + + Switch + + Stops the washing cycle before the final spin, keeping the laundry in the last rinse water to prevent + creasing. + + Switch + Mode + + + veto + + + + Switch + + Reduces spin speed and tumbles laundry gently after the cycle to minimize creasing and simplify ironing. + + Switch + Mode + + + veto + + + + Switch + + Reduces noise by omitting intermediate spin cycles and lowering the final spin speed, ideal for operation + during nighttime. + + Switch + Mode + + + veto + + + + Switch + + Adds an immersion phase before the wash cycle to loosen stubborn dirt and stains from heavily soiled + laundry. + + Switch + Mode + + + veto + + + + String + + Allows the selection of additional rinse cycles at the end of the program to ensure the complete removal + of detergent residues. + + Control + Mode + + + veto + + + + String + + Optimizes the wash cycle and water temperature to target and remove specific types of stains like grass, + oil, or red wine. + + Control + Mode + + + veto + + + + Number:Mass + + The current laundry load information. + + Measurement + Info + + + + + + Number:Mass + + The recommended load for the current program. + + Calculation + Info + + + + + + Switch + + Indicates whether the washer drum needs cleaning. + + Status + + + + + + String + + The program or process phase of the dishwasher. + + Status + Mode + + + + + + Switch + + Indicates if the dishwasher salt is empty. + + Status + + + + + + Switch + + Indicates if the dishwasher rinse aid is empty. + + Status + + + + + + Switch + + Indicates when the dishwasher salt is almost empty. + + Status + + + + + + Switch + + Indicates when the dishwasher rinse aid is almost empty. + + Status + + + + + + Number + + This display shows a countdown indicating the time remaining until a specific maintenance or cleaning + task is due. The countdown updates dynamically, providing a clear and convenient way to monitor upcoming actions. + + Status + + + + + + Switch + + Indicates when the coffee machine's water tank is empty. + + Alarm + Level + + + + + + Switch + + Indicates when the coffee machine's water tank is almost empty. + + Alarm + Level + + + + + + Switch + + Indicates when the drip tray of the coffee machine is full. + + Alarm + Level + + + + + + Switch + + Indicates when the coffee machine's milk tank is empty. + + Alarm + Level + + + + + + Switch + + Indicates that the coffee machine's bean container is empty. + + Alarm + Level + + + + + + String + + Specifies the desired dryness setting. + + Setpoint + Moisture + + + veto + + + + String + + Prevents the laundry from creasing or wrinkling. The drum rotates at specific intervals. + + Control + Mode + + + veto + + + + Number:Time + + The duration of the oven program. + + Setpoint + Duration + + + veto + + + + Switch + + The functional light state. + + Switch + Mode + + + veto + + + + Switch + + The button tones setting. + + Switch + Mode + + + veto + + + + String + + Venting level of the hood. + + Control + Speed + + + veto + + + + String + + Intensive level of the hood. + + Control + Speed + + + veto + + + + Switch + + The child lock state. + + Switch + LockState + + + veto + + + + Number:Temperature + + The current cavity temperature. + + Status + Temperature + + + + + + Switch + + The cavity light state. + + Switch + Light + + + veto + + + + Number:Temperature + + The current meat probe temperature. + + Status + Temperature + + + + + + Switch + + Indicates if the meat probe is plugged in. + + Status + + + + + + Number:Temperature + + Program target temperature of the oven. + + Control + Temperature + + + + + + Number:Temperature + + Target temperature of the compartment. + + Setpoint + Temperature + + + veto + + + + Switch + + Enables the super cooling/freezing mode. + + Switch + Mode + + + veto + + + + String + + Allows selecting a predefined cooling mode for the chiller compartment. + + Control + Mode + + + veto + + + + Number:Dimensionless + + Indicates the current saturation level of the water dispenser filter. A higher value suggests the filter + is approaching the end of its service life and may need replacement soon. + + Status + + + + + + Switch + + Enables or disables the dispenser. + + Switch + Mode + + + veto + + + + Switch + + Enables or disables the dispenser party mode. + + Switch + Mode + + + veto + + + + String + + Allows sending specific operation commands to the home appliance. The available commands (options) are + updated dynamically based on the current appliance state and supported features. + + Control + + + veto + + + + String + + Allows sending a raw JSON payload directly to the home appliance. + + Control + + + veto + + + + + Switch + + + veto + + + + + String + + + veto + + + + + Number + + + veto + + + + + Switch + + + + + + + trigger + + + + + + Switch + + + Status + Mode + + + + + + + String + + + Status + Mode + + + + + + + Number + + + Status + Mode + + + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/css/homeconnectdirect.css b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/css/homeconnectdirect.css new file mode 100644 index 0000000000..13fd2c1d02 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/css/homeconnectdirect.css @@ -0,0 +1,346 @@ +/* Variables */ +:root { + --pico-font-size: 0.9rem; + --hcd-success: #4cd964; + --hcd-danger: #ff1407; + --hcd-secondary: #8e8e93; + --hcd-warning: #f1c40f; + --hcd-description: #9b59b6; + --hcd-sh-key: #0451a5; + --hcd-sh-string: #a31515; + --hcd-sh-number: #098658; + --hcd-sh-bool: #0000ff; +} + +/* Global & Utilities */ +@media (max-width: 960px) { + .hide-on-mobile { display: none !important; } +} + +.hidden-by-default { display: none; } +.link-no-decoration { text-decoration: none !important; } +.empty-info { text-align: center; color: var(--pico-muted-color); } +.object-placeholder { font-style: italic; } +.separator { border-top: 1px solid var(--pico-muted-border-color); margin: 0.25rem 0; } +.small-margin-bottom { margin-bottom: 0.5rem; } +.help-icon { cursor: help; margin-left: 0.25rem; } +[data-tooltip]::after { white-space: pre-wrap !important; text-align: left !important; } + +/* Colors & Badges */ +.error { color: var(--hcd-danger); } +.blue-450 { color: #5C7EF8; } +.blue-400 { color: #748BF8; } +.blue-300 { color: #9CA7FA; } +.blue-150 { color: #D0D2FA; } + +.bg-success { background-color: var(--hcd-success); } +.bg-danger { background-color: var(--hcd-danger); } +.bg-secondary { background-color: var(--hcd-secondary); } +.bg-warning { background-color: var(--hcd-warning); color: black; } + +.badge { padding: 0.2em 0.4em; border-radius: 0.65rem; font-size: 0.75em; font-weight: bold; color: white; } + +.badge-description { + background-color: rgba(155, 89, 182, 0.1); + color: var(--hcd-description); + border: 1px solid rgba(155, 89, 182, 0.25); + border-radius: 4px; + margin-left: 0.3rem; + padding-top: 0; + padding-bottom: 0; +} + +.badge-value { + background-color: rgba(55, 93, 230, 0.1); + color: var(--pico-primary); + border: 1px solid rgba(55, 93, 230, 0.25); + border-radius: 4px; + margin-left: 0.3rem; + padding-top: 0; + padding-bottom: 0; +} + +.change-new-value { text-decoration: underline; } +mark { padding: 0; background-color: rgba(255, 224, 102, 0.6); color: inherit; } +mark.active-match { + background-color: var(--hcd-warning); + color: black; + outline: 1px solid var(--pico-primary); +} + +/* Layout */ +header.container { padding-top: 0.5rem; padding-bottom: 0; } +.sidebar-layout { display: flex; gap: 1rem; align-items: flex-start; } +.sidebar { width: 300px; flex-shrink: 0; } +.main-content { flex-grow: 1; min-width: 0; } + +/* Navigation & Header */ +header nav hgroup h3 { margin-bottom: 0; } +.binding-header { color: var(--pico-primary); } +.binding-subheader { color: var(--pico-muted-color); } +.header-actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; } + +/* Buttons */ +.close-button, +.btn-small, +.filter-datetime-preset-button { + padding: 0.2rem 0.5rem; + font-size: 0.8em; + margin-bottom: 0; + width: auto; +} +.close-button { float: right; } +.filter-apply-button { width: 100%; } +.filter-action-btn { + cursor: pointer; background: none; border: none; padding: 0; margin: 0; + width: 1.2rem; height: 1.2rem; display: flex; align-items: center; justify-content: center; + border-radius: 3px; font-weight: bold; line-height: 1; +} +.filter-action-btn:hover { background-color: var(--pico-muted-background-color); } +.filter-action-btn img { width: 0.9em; height: 0.9em; opacity: 0.5; } +.filter-action-btn:hover img { opacity: 1; } + +/* Forms */ +.checkbox-group { display: flex; flex-wrap: wrap; gap: 1rem; } +.checkbox-group label { width: auto; margin-bottom: 0; } +.filter-datetime-presets { display: flex; gap: 0.5rem; } +.login-container { max-width: 400px !important; margin: 10vh auto !important; } +.code-input { font-family: monospace; } + +/* Autocomplete */ +.autocomplete-container { position: relative; } +.autocomplete-dropdown { + position: absolute; top: 100%; left: 0; width: 100%; z-index: 1000; + background: var(--pico-dropdown-background-color); + border: var(--pico-border-width) solid var(--pico-dropdown-border-color); + border-radius: var(--pico-border-radius); + box-shadow: var(--pico-dropdown-box-shadow); + max-height: 200px; overflow-y: auto; display: none; margin-top: 0; padding: 0; list-style: none; +} +.autocomplete-dropdown.show { display: block; } +.autocomplete-dropdown li { + padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal); + cursor: pointer; border-bottom: 1px solid var(--pico-muted-border-color); margin-bottom: 0; +} +.autocomplete-dropdown li:last-child { border-bottom: none; } +.autocomplete-dropdown li:hover { background-color: var(--pico-dropdown-hover-background-color); } + +/* Tag Input */ +.tag-input-container { + display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem; + padding: var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal); + border: var(--pico-border-width) solid var(--pico-form-element-border-color); + border-radius: var(--pico-border-radius); + background-color: var(--pico-form-element-background-color); + cursor: text; transition: border-color var(--pico-transition), box-shadow var(--pico-transition); + margin-bottom: var(--pico-spacing); +} +.tag-input-container:focus-within { + border-color: var(--pico-form-element-active-border-color); + box-shadow: 0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color); +} +.tag { + background-color: var(--pico-primary-background); color: var(--pico-primary-inverse); + padding: 0.1rem 0.4rem; border-radius: var(--pico-border-radius); + font-size: 0.85em; display: flex; align-items: center; gap: 0.25rem; +} +.tag.tag-description { background-color: var(--hcd-description); color: white; } +.tag-close { cursor: pointer; opacity: 0.7; font-weight: bold; line-height: 1; user-select: none; outline: none; } +.tag-close:hover { opacity: 1; } +.tag-input-field { + flex-grow: 1; border: none !important; box-shadow: none !important; background: transparent !important; + padding: 0 !important; margin: 0 !important; min-width: 60px; height: auto !important; color: var(--pico-color); +} +.tag-text { max-width: 210px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; } + +/* Cards (Appliances) */ +.appliances-grid { grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); } +.appliance-card { height: 100%; display: flex; flex-direction: column; } +.appliance-card-header { display: flex; justify-content: space-between; align-items: center; } +.appliance-card-footer { margin-top: auto; display: flex; justify-content: flex-end; gap: 0.5rem; } +.card-info-label { display: inline-block; min-width: 140px; } +.profile-actions, .logs-actions { text-align: right; white-space: nowrap; } +.profile-card-header { display: flex; justify-content: end; align-items: end; } + +/* Tables (Logs/Values) */ +.value-table-container { max-height: 600px; overflow-y: auto; border: 1px solid var(--muted-border-color); border-radius: 5px; } +.value-log-row td { position: relative; z-index: 0; vertical-align: top; } +.value-log-row td::after { + content: none; +} +.value-log-row.new-message td::after { + content: ""; position: absolute; top: 0; left: 0; width: 100%; height: 100%; + background-color: rgba(55, 93, 230, 0.4); z-index: -1; pointer-events: none; + animation: flash-highlight 3s ease-out forwards; +} +.value-log-row.outgoing td::before { + content: ""; position: absolute; top: 0; left: 0; width: 100%; height: 100%; + background-color: rgba(0, 0, 0, 0.03); z-index: -1; pointer-events: none; +} +.value-log-row td.details-cell { vertical-align: middle; } +.column-no-wrap { white-space: nowrap; } +.io-column-header, .actions-column-header, .session-column-header, .action-column-header { width: 1%; } +.io-column-header, .action-column-header { text-align: center; } +.actions-column-header { text-align: right; } +.session-column-header { text-align: left; } +.time-column-header, .resource-column-header { width: 3%; text-align: left; } +.io-cell, .action-cell { text-align: center; } +.io-cell { cursor: help; } +.actions-cell { text-align: right; } +.time-cell, .session-cell, .resource-cell { text-align: left; } + +/* Pagination */ +.pagination-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + border-top: 1px solid var(--pico-muted-border-color); + margin-top: 0.5rem; +} +.pagination-controls { display: flex; gap: 0.5rem; align-items: center; } +.pagination-info { display: flex; align-items: center; gap: 1rem; font-size: 0.85em; color: var(--pico-muted-color); } +.page-size-select { + width: auto; + margin-bottom: 0; + padding-right: 2rem; + font-size: 1em; + padding-top: 0.2rem; + padding-bottom: 0.2rem; +} + +/* Details View */ +.detail-list { font-size: 0.85em; display: flex; flex-direction: column; gap: 0.25rem; } +.detail-entry { display: flex; align-items: baseline; gap: 0.5rem; max-width: 100%; position: relative; } +.detail-entry:hover .filter-toolbox { display: flex; } +.detail-key { font-weight: 600; white-space: nowrap; flex-shrink: 0; } +.detail-value { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--pico-muted-color); min-width: 0; flex-grow: 1; } +.type-value .detail-key { color: var(--pico-primary); } +.type-description { flex-direction: column; align-items: flex-start; gap: 0; margin-bottom: 0.5rem; } +.type-description .detail-key { color: var(--hcd-description); font-weight: 500; } +.type-description .detail-value { white-space: normal; overflow: visible; padding-left: 0; width: 100%; } +.description-inline-key { + font-weight: 600; background-color: rgba(128, 128, 128, 0.07); padding: 0.1em 0.4em; + border-radius: 4px; font-size: 0.9em; border: 1px solid var(--pico-muted-border-color); +} +.description-pair { display: inline-block; } +.detail-key-wrapper { display: inline-flex; align-items: center; position: relative; } +.filter-toolbox { + position: absolute; left: 100%; top: 50%; transform: translateY(-50%); margin-left: 0.25rem; + background-color: var(--pico-background-color); box-shadow: 0 2px 5px rgba(0,0,0,0.15); + border: 1px solid var(--pico-muted-border-color); border-radius: 4px; padding: 2px 4px; + display: none; gap: 4px; z-index: 10; +} +.registered-device-list { font-size: 0.85em; display: flex; flex-wrap: wrap; gap: 0.5rem; } +.registered-device { + display: flex; align-items: center; gap: 0.5rem; border: 1px solid var(--pico-muted-border-color); + border-radius: var(--pico-border-radius); padding: 0.25rem 0.5rem; min-width: 185px; +} +.registered-device-status { width: 8px; height: 8px; border-radius: 50%; background-color: var(--pico-muted-border-color); flex-shrink: 0; } +.registered-device-status.connected { background-color: var(--hcd-success); } +.registered-device-info { display: flex; flex-direction: column; } +.registered-device-name { font-weight: 600; } +.registered-device-id { font-size: 0.9em; color: var(--pico-muted-color); } +.current-device .registered-device-name { color: var(--pico-primary); } +.current-device .registered-device-id { color: var(--pico-primary-focus); } +.registered-device.device-online { border-color: var(--hcd-success); } +.services-header { font-weight: 600; color: var(--pico-muted-color); } + +/* Modals */ +.source-modal { min-width: 500px; min-height: 400px; } +.configuration-modal { min-width: 800px; } +.configuration-controls { display: flex; justify-content: flex-end; align-items: center; } +.source-modal-meta-label { display: inline-block; min-width: 120px; } +.source-code { max-height: 60vh; overflow: auto; } +.modal-close:focus { outline: none; box-shadow: none; } +.full-width-modal { max-width: 70vw; } +.modal-section-header { color: var(--pico-contrast); } +.modal-header-meta { margin-top: 0.5rem; } +.modal-header-key { color: var(--hcd-description); } +.modal-header-value-key { color: var(--pico-primary); } +.modal-search-bar { margin-top: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9em; display: flex; gap: 0.5rem; align-items: center; } +.modal-search-bar input { margin-bottom: 0; } +.search-counter { white-space: nowrap; font-size: 0.9em; color: var(--pico-muted-color); } + +.modal-tabs { display: flex; flex-wrap: wrap; justify-content: flex-start; margin-bottom: 1rem; margin-top: 0.5rem; gap: 1rem; } +.modal-tabs a { padding: 0.2rem 0.5rem; font-size: 0.85em; border-radius: var(--pico-border-radius); text-decoration: none; } +.modal-tabs a:hover { background-color: var(--pico-muted-background-color); } +.modal-tabs a[aria-current="page"] { background-color: var(--pico-primary); color: var(--pico-primary-inverse); } +.modal-tabs a[aria-current="page"]:hover { background-color: var(--pico-primary-hover); } + +/* Code Blocks & Syntax Highlighting */ +pre { background: #f0f0fb42; padding: 10px; border-radius: 5px; font-size: 0.85em; tab-size: 4; } +.code-container { + position: relative; +} + +.copy-overlay-btn { + position: absolute; + top: 0.5rem; + right: 1.1rem; + background-color: var(--pico-background-color); + border: 1px solid var(--pico-muted-border-color); + border-radius: 4px; + padding: 0.2rem 0.5rem; + font-size: 0.75em; + cursor: pointer; + display: none; /* Optimization: display none by default */ + z-index: 5; + color: var(--pico-color); + transition: background-color 0.2s, border-color 0.2s, color 0.2s; +} + +.code-container:hover .copy-overlay-btn { + display: block; +} + +.copy-overlay-btn:hover { + background-color: var(--pico-muted-background-color); + border-color: var(--pico-primary); + color: var(--pico-primary); +} + +/* JSON Syntax Highlighting */ +.json-key { color: var(--hcd-sh-key); } +.json-string { color: var(--hcd-sh-string); } +.json-number { color: var(--hcd-sh-number); } +.json-boolean, .json-null { color: var(--hcd-sh-bool); } + +/* YAML Syntax Highlighting */ +.yaml-key { color: var(--hcd-sh-key); } +.yaml-string { color: var(--hcd-sh-string); } +.yaml-number { color: var(--hcd-sh-number); } +.yaml-boolean { color: var(--hcd-sh-bool); } +.yaml-comment { color: var(--hcd-secondary); } +.yaml-preview { max-height: 200px; opacity: 0.8; font-size: 0.8em; } + +/* DSL Syntax Highlighting */ +.dsl-keyword { color: var(--hcd-sh-key); font-weight: bold; } +.dsl-string { color: var(--hcd-sh-string); } +.dsl-number { color: var(--hcd-sh-number); } +.dsl-comment { color: var(--hcd-secondary); } + +/* Toasts */ +.toast-container { + position: fixed; top: 1rem; right: 1rem; z-index: 9999; + display: flex; flex-direction: column; gap: 0.5rem; width: 300px; +} +.toast { + padding: 0.75rem 1rem; border-radius: var(--pico-border-radius); box-shadow: var(--pico-box-shadow); + color: white; cursor: pointer; opacity: 0.95; transition: opacity 0.3s; +} +.toast:hover { opacity: 1; } +.toast.toast-success { background-color: var(--hcd-success); } +.toast.toast-error { background-color: var(--hcd-danger); } +.toast.toast-info { background-color: var(--hcd-secondary); } + +/* Animations */ +@keyframes flash-highlight { 0% { opacity: 1; } 100% { opacity: 0; } } +@keyframes modal-is-opening { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } } +@keyframes modal-is-closing { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-20px); } } +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } +dialog.modal-is-opening { animation: modal-is-opening 0.2s ease-out forwards; } +dialog.modal-is-closing { animation: modal-is-closing 0.2s ease-in forwards; } +dialog.modal-is-opening::backdrop { animation: fade-in 0.2s ease-out forwards; } +dialog.modal-is-closing::backdrop { animation: fade-out 0.2s ease-in forwards; } \ No newline at end of file diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/css/pico.blue.min.css b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/css/pico.blue.min.css new file mode 100644 index 0000000000..cbbe2c361b --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/css/pico.blue.min.css @@ -0,0 +1,4 @@ +@charset "UTF-8";/*! + * Pico CSS ✨ v2.1.1 (https://picocss.com) + * Copyright 2019-2025 - Licensed under MIT + */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(116, 139, 248, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#2060df;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(32, 96, 223, 0.5);--pico-primary-hover:#184eb8;--pico-primary-hover-background:#1d59d0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(116, 139, 248, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/favicon/128x128.png b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/favicon/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..05d4ffb7aee733a712bb140f6eba1f1f7ed9674c GIT binary patch literal 3837 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 literal 0 HcmV?d00001 diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/favicon/favicon.svg b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/favicon/favicon.svg new file mode 100644 index 0000000000..135a5c6bd6 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/favicon/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/clipboard-document-list.svg b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/clipboard-document-list.svg new file mode 100644 index 0000000000..945fae347e --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/clipboard-document-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/eye.svg b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/eye.svg new file mode 100644 index 0000000000..a60148e303 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/eye.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/funnel.svg b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/funnel.svg new file mode 100644 index 0000000000..b67acb2e68 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/funnel.svg @@ -0,0 +1,3 @@ + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/minus-circle.svg b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/minus-circle.svg new file mode 100644 index 0000000000..4d10da3d7d --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/img/minus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/js/alpinejs.min.js b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/js/alpinejs.min.js new file mode 100644 index 0000000000..c3a2c7d578 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/assets/js/alpinejs.min.js @@ -0,0 +1,5 @@ +(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message} + +${r?'Expression: "'+r+`" + +`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + + + + + +

+ + + + + + +
+ +
+ + + + + + + + + +
+
+ + Upload Profile +
+

Select a .zip file containing the appliance profile.

+
+ + +
+ +
+
+
+
+ + +
+
+ + Send Message +
+
+ + + + + +
+ +
+
+
+
+ + +
+
+ + Delete Profile +
+

+ Are you sure you want to delete the profile with the Home Appliance ID: ? +

+ +
+
+ + +
+
+ + Import Log +
+

Select a .zip or .zip.log file containing the logs.

+
+ + +
+ +
+
+
+
+ + +
+
+ + Import Proxy Log +
+

+ Select the log files generated by HomeCap (recommended) or the Home Connect Appliance Proxy. +
+ Required files: *.json, *_DeviceDescription.xml, *_FeatureMapping.xml. +

+
+ + + + +
+ +
+
+
+
+ + +
+
+ + Delete Log File +
+

+ Are you sure you want to delete the log file ? +

+ +
+
+ + +
+
+ + Select Attribute +
+

Select the attribute you want to configure for :

+
+ +
+
+
+ + +
+
+ + openHAB Configuration Generator +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ Enum Value Mapping +

Select the enum values that should be mapped to the 'ON' state in openHAB.

+ + + + + + + + + + +
Enum ValueON
+
+
+ Current Thing Configuration +
+ +

+                
+
+
+ Current Thing Configuration +
+ +

+                
+
+ New Thing Channel Snippet +
+ +

+            
+
+
+ + +
+
+ + + + + + + + +
+ + + +
+
+ + +
+
+ + + + + + +
+ +
+
+ + +
+
+ + Message Details + +
+ +
+
+
+ +
+ +
+ + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/templates/login.html b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/templates/login.html new file mode 100644 index 0000000000..20dfbd010a --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/main/resources/templates/login.html @@ -0,0 +1,30 @@ + + + + + + Login - Home Connect Direct + + + + + + + + +
+ +
+ + \ No newline at end of file diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/common/utils/StringUtilsTest.java b/bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/common/utils/StringUtilsTest.java new file mode 100644 index 0000000000..cf0db773c2 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/common/utils/StringUtilsTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.common.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link StringUtils}. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +class StringUtilsTest { + + @Test + void mapKeyToLabel() { + // Test WiFi logic + assertEquals("Deactivate WiFi", StringUtils.mapKeyToLabel("BSH.Common.Command.DeactivateWiFi", null)); + assertEquals("WiFi Settings", StringUtils.mapKeyToLabel("bla.WiFiSettings", null)); + + assertEquals("X Ray", StringUtils.mapKeyToLabel("test.XRay", null)); + assertEquals("A String", StringUtils.mapKeyToLabel("test.AString", null)); + assertEquals("My TV", StringUtils.mapKeyToLabel("test.MyTV", null)); + assertEquals("Pre Wash", StringUtils.mapKeyToLabel("test.PreWash", null)); + assertEquals("Re Wash", StringUtils.mapKeyToLabel("test.ReWash", null)); + assertEquals("Pre Rinse", StringUtils.mapKeyToLabel("PreRinse", null)); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionServiceTest.java b/bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionServiceTest.java new file mode 100644 index 0000000000..9a6c01c454 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/service/description/DeviceDescriptionServiceTest.java @@ -0,0 +1,965 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.service.description; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +import java.net.URISyntaxException; +import java.nio.file.Paths; +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.openhab.binding.homeconnectdirect.internal.service.description.DeviceDescriptionService.FoundObject; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.CommandList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.DeviceDescriptionType; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.EventList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Option; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.OptionList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.Program; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ProgramGroup; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.ProgramOption; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.SettingList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.StatusList; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.change.DeviceDescriptionChange; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AccessProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.AvailableProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.EnumerationTypeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.description.model.provider.RangeProvider; +import org.openhab.binding.homeconnectdirect.internal.service.feature.FeatureMappingService; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.data.DescriptionChangeData; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.serializer.ResourceAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; +import com.google.gson.reflect.TypeToken; + +/** + * Tests for {@link DeviceDescriptionService}. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +class DeviceDescriptionServiceTest { + + private final Gson gson = new GsonBuilder().registerTypeAdapter(Resource.class, new ResourceAdapter()) + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create(); + private final Logger logger = LoggerFactory.getLogger(DeviceDescriptionServiceTest.class); + + @DisplayName("Test loading of initial device description state") + @ParameterizedTest(name = "[{index}] {0} ({1})") + @CsvSource({ + "SIEMENS WM16XE91,Washer,SIEMENS-WM16XE91-000000000000_FeatureMapping.xml,SIEMENS-WM16XE91-000000000000_DeviceDescription.xml", + "SIEMENS TI9555X1DE,CoffeeMaker,SIEMENS-TI9555X1DE-000000000000_FeatureMapping.xml,SIEMENS-TI9555X1DE-000000000000_DeviceDescription.xml", + "SIEMENS SN658X06TE,Dishwasher,SIEMENS-SN658X06TE-000000000000_FeatureMapping.xml,SIEMENS-SN658X06TE-000000000000_DeviceDescription.xml", + "BOSCH MCC9555DWC,CookProcessor,BOSCH-MCC9555DWC-000000000000_FeatureMapping.xml,BOSCH-MCC9555DWC-000000000000_DeviceDescription.xml" }) + void testInitialState(String device, String type, String featureMappingFileName, String descriptionFileName) + throws URISyntaxException { + logger.info("Loading device description state. device: {} type: {}", device, type); + + // given + var path = Paths.get(Objects + .requireNonNull(Objects.requireNonNull(getClass().getClassLoader()).getResource("userdata")).toURI()); + var featureMappingPath = path.resolve(featureMappingFileName); + var descriptionPath = path.resolve(descriptionFileName); + var featureMappingService = new FeatureMappingService(featureMappingPath); + + // then + var service = new DeviceDescriptionService("test", descriptionPath, featureMappingService.getFeatureMapping()); + var description = service.getDeviceDescription(); + + // when + assertNotNull(description); + } + + @Test + @DisplayName("Test updating the device description state") + void testApplyDescriptionChanges() throws URISyntaxException { + // given + var path = Paths.get(Objects + .requireNonNull(Objects.requireNonNull(getClass().getClassLoader()).getResource("userdata")).toURI()); + var featureMappingPath = path.resolve("SIEMENS-WM16XE91-000000000000_FeatureMapping.xml"); + var descriptionPath = path.resolve("SIEMENS-WM16XE91-000000000000_DeviceDescription.xml"); + var featureMappingService = new FeatureMappingService(featureMappingPath); + var roAllDescriptionChangesData = """ + [ + { + "uid": 5, + "parentUID": 258, + "available": true, + "access": "READ" + }, + { + "uid": 529, + "parentUID": 258, + "available": true, + "access": "READ" + }, + { + "uid": 557, + "parentUID": 258, + "available": true, + "access": "READ" + }, + { + "uid": 527, + "parentUID": 258, + "available": true, + "access": "READ" + }, + { + "uid": 568, + "parentUID": 258, + "available": true, + "access": "NONE" + }, + { + "uid": 535, + "parentUID": 258, + "available": true, + "access": "READ" + }, + { + "uid": 552, + "parentUID": 258, + "available": true, + "access": "READ" + }, + { + "uid": 523, + "parentUID": 258, + "available": true, + "access": "READ" + }, + { + "uid": 517, + "parentUID": 258, + "available": true, + "access": "READ" + }, + { + "uid": 591, + "parentUID": 258, + "available": false, + "access": "NONE" + }, + { + "uid": 27, + "parentUID": 258, + "available": true, + "access": "READ" + }, + { + "uid": 10754, + "parentUID": 258, + "available": true, + "access": "READ", + "min": 0, + "max": 65535, + "stepSize": 1000 + }, + { + "uid": 258, + "parentUID": 0, + "available": true, + "access": "READ" + }, + { + "uid": 512, + "parentUID": 261, + "available": true, + "access": "WRITEONLY" + }, + { + "uid": 6, + "parentUID": 261, + "available": true, + "access": "WRITEONLY" + }, + { + "uid": 576, + "parentUID": 261, + "available": true, + "access": "NONE" + }, + { + "uid": 553, + "parentUID": 261, + "available": false, + "access": "NONE" + }, + { + "uid": 554, + "parentUID": 261, + "available": true, + "access": "WRITEONLY" + }, + { + "uid": 555, + "parentUID": 5402, + "available": true, + "access": "WRITEONLY" + }, + { + "uid": 1, + "parentUID": 261, + "available": true, + "access": "WRITEONLY" + }, + { + "uid": 530, + "parentUID": 261, + "available": true, + "access": "NONE" + }, + { + "uid": 560, + "parentUID": 261, + "available": true, + "access": "NONE" + }, + { + "uid": 536, + "parentUID": 261, + "available": false, + "access": "WRITEONLY" + }, + { + "uid": 537, + "parentUID": 5402, + "available": true, + "access": "WRITEONLY" + }, + { + "uid": 16, + "parentUID": 261, + "available": true, + "access": "WRITEONLY" + }, + { + "uid": 546, + "parentUID": 5402, + "available": true, + "access": "NONE" + }, + { + "uid": 23041, + "parentUID": 261, + "available": false, + "access": "NONE" + }, + { + "uid": 261, + "parentUID": 0, + "available": true, + "access": "WRITEONLY" + }, + { + "uid": 5402, + "parentUID": 261, + "available": true, + "access": "NONE" + }, + { + "uid": 27141, + "parentUID": 31233, + "available": true, + "access": "READ", + "default": 0 + }, + { + "uid": 548, + "parentUID": 5661, + "available": false, + "access": "NONE", + "min": 300, + "max": 12600, + "stepSize": 300 + }, + { + "uid": 561, + "parentUID": 5658, + "available": true, + "access": "READ", + "stepSize": 20 + }, + { + "uid": 531, + "parentUID": 262, + "available": true, + "access": "READ", + "min": 60, + "stepSize": 60 + }, + { + "uid": 551, + "parentUID": 5660, + "available": true, + "access": "READ", + "min": 0, + "max": 86400, + "stepSize": 60 + }, + { + "uid": 586, + "parentUID": 5660, + "available": true, + "access": "NONE", + "min": 0, + "max": 86400, + "stepSize": 60 + }, + { + "uid": 542, + "parentUID": 5659, + "available": true, + "access": "READ", + "stepSize": 1 + }, + { + "uid": 544, + "parentUID": 5659, + "available": true, + "access": "READ", + "min": 0, + "max": 21600, + "stepSize": 60 + }, + { + "uid": 549, + "parentUID": 5659, + "available": true, + "access": "READ" + }, + { + "uid": 562, + "parentUID": 5658, + "available": true, + "access": "READ", + "stepSize": 20 + }, + { + "uid": 27138, + "parentUID": 5661, + "available": true, + "access": "READ", + "min": 0, + "max": 65535, + "stepSize": 100 + }, + { + "uid": 27142, + "parentUID": 5659, + "available": true, + "access": "READ" + }, + { + "uid": 27141, + "parentUID": 5661, + "available": true, + "access": "NONE", + "default": 0 + }, + { + "uid": 24833, + "parentUID": 5661, + "available": true, + "access": "READ", + "default": false + }, + { + "uid": 24834, + "parentUID": 5661, + "available": true, + "access": "READWRITE", + "default": false + }, + { + "uid": 24585, + "parentUID": 5661, + "available": true, + "access": "READWRITE", + "default": false + }, + { + "uid": 24582, + "parentUID": 5661, + "available": true, + "access": "READ", + "default": false + }, + { + "uid": 24584, + "parentUID": 5661, + "available": true, + "access": "READWRITE", + "default": false + }, + { + "uid": 24583, + "parentUID": 5661, + "available": true, + "access": "READWRITE", + "min": 0, + "max": 3, + "default": 0 + }, + { + "uid": 24586, + "parentUID": 5661, + "available": true, + "access": "READWRITE", + "default": false + }, + { + "uid": 24592, + "parentUID": 5661, + "available": true, + "access": "READ", + "default": false + }, + { + "uid": 24835, + "parentUID": 5661, + "available": true, + "access": "READ", + "default": false + }, + { + "uid": 24578, + "parentUID": 5661, + "available": true, + "access": "READ", + "enumType": 32802, + "min": 40, + "max": 160, + "default": 40 + }, + { + "uid": 24581, + "parentUID": 5661, + "available": true, + "access": "READ", + "enumType": 24581, + "default": 0 + }, + { + "uid": 24577, + "parentUID": 5661, + "available": true, + "access": "READ", + "enumType": 32801, + "min": 0, + "max": 5 + }, + { + "uid": 24579, + "parentUID": 5661, + "available": true, + "access": "READWRITE", + "default": false + }, + { + "uid": 262, + "parentUID": 0, + "available": true, + "access": "READWRITE" + }, + { + "uid": 5659, + "parentUID": 262, + "available": true, + "access": "READ" + }, + { + "uid": 5661, + "parentUID": 262, + "available": true, + "access": "READWRITE" + }, + { + "uid": 5660, + "parentUID": 262, + "available": true, + "access": "READ" + }, + { + "uid": 5658, + "parentUID": 262, + "available": true, + "access": "NONE" + }, + { + "uid": 3, + "parentUID": 259, + "available": true, + "access": "READWRITE" + }, + { + "uid": 515, + "parentUID": 259, + "available": true, + "access": "READ" + }, + { + "uid": 518, + "parentUID": 259, + "available": true, + "access": "NONE" + }, + { + "uid": 521, + "parentUID": 259, + "available": false, + "access": "NONE" + }, + { + "uid": 524, + "parentUID": 259, + "available": true, + "access": "READWRITE" + }, + { + "uid": 533, + "parentUID": 259, + "available": true, + "access": "READ", + "enumType": 514 + }, + { + "uid": 539, + "parentUID": 259, + "available": true, + "access": "READWRITE", + "min": 0, + "max": 2 + }, + { + "uid": 14849, + "parentUID": 259, + "available": true, + "access": "READWRITE" + }, + { + "uid": 14850, + "parentUID": 259, + "available": true, + "access": "READWRITE", + "min": 0, + "max": 4 + }, + { + "uid": 12292, + "parentUID": 259, + "available": true, + "access": "READWRITE" + }, + { + "uid": 12290, + "parentUID": 259, + "available": true, + "access": "READWRITE", + "min": 5, + "max": 200, + "stepSize": 1 + }, + { + "uid": 12291, + "parentUID": 259, + "available": true, + "access": "READWRITE", + "min": 5, + "max": 200, + "stepSize": 1 + }, + { + "uid": 12289, + "parentUID": 259, + "available": true, + "access": "READWRITE" + }, + { + "uid": 259, + "parentUID": 0, + "available": true, + "access": "READWRITE" + }, + { + "uid": 263, + "parentUID": 0, + "available": true + }, + { + "uid": 5904, + "parentUID": 263, + "available": true + }, + { + "uid": 5906, + "parentUID": 263, + "available": true + }, + { + "uid": 5907, + "parentUID": 5906, + "available": true + }, + { + "uid": 256, + "parentUID": 0, + "access": "READ" + }, + { + "uid": 257, + "parentUID": 0, + "access": "READ" + }, + { + "uid": 31233, + "parentUID": 5904, + "available": true, + "execution": "SELECTONLY" + }, + { + "uid": 28718, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28673, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28719, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28674, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28675, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28676, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28677, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28684, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28696, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28756, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28681, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28680, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28678, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28679, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28690, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28692, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28693, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + }, + { + "uid": 28694, + "parentUID": 5907, + "available": true, + "execution": "SELECTANDSTART" + } + ] + """; + List changes = gson.fromJson(roAllDescriptionChangesData, + TypeToken.getParameterized(List.class, DescriptionChangeData.class).getType()); + + // then + var service = new DeviceDescriptionService("test", descriptionPath, featureMappingService.getFeatureMapping()); + var initialDeviceDescription = service.getDeviceDescription(); + var deviceDescriptionChanges = service.applyDescriptionChanges(changes); + var changedDeviceDescription = service.getDeviceDescription(); + + // when + assertNotNull(deviceDescriptionChanges); + assertNotNull(changes); + assertEquals(changes.size(), deviceDescriptionChanges.size()); + + // check if changed value from deviceDescriptionChanges is reflected in the DeviceDescriptionService state + deviceDescriptionChanges.stream().filter(deviceDescriptionChange -> deviceDescriptionChange.changes() != null) + .forEach(deviceDescriptionChange -> Objects.requireNonNull(deviceDescriptionChange.changes()) + .forEach((type, change) -> { + switch (type) { + case "available": { + var foundObject = getDeviceDescriptionObject(service, deviceDescriptionChange); + assertNotNull(foundObject); + assertNotNull(foundObject.object()); + assertInstanceOf(AvailableProvider.class, foundObject.object()); + AvailableProvider availableProvider = (AvailableProvider) foundObject.object(); + assertEquals(change.to(), availableProvider.available()); + break; + } + case "min": { + var foundObject = getDeviceDescriptionObject(service, deviceDescriptionChange); + assertNotNull(foundObject); + assertNotNull(foundObject.object()); + assertInstanceOf(RangeProvider.class, foundObject.object()); + RangeProvider rangeProvider = (RangeProvider) foundObject.object(); + assertEquals(String.valueOf(change.to()), String.valueOf(rangeProvider.min())); + break; + } + case "max": { + var foundObject = getDeviceDescriptionObject(service, deviceDescriptionChange); + assertNotNull(foundObject); + assertNotNull(foundObject.object()); + assertInstanceOf(RangeProvider.class, foundObject.object()); + RangeProvider rangeProvider = (RangeProvider) foundObject.object(); + assertEquals(String.valueOf(change.to()), String.valueOf(rangeProvider.max())); + break; + } + case "stepSize": { + var foundObject = getDeviceDescriptionObject(service, deviceDescriptionChange); + assertNotNull(foundObject); + assertNotNull(foundObject.object()); + assertInstanceOf(RangeProvider.class, foundObject.object()); + RangeProvider rangeProvider = (RangeProvider) foundObject.object(); + assertEquals(String.valueOf(change.to()), String.valueOf(rangeProvider.stepSize())); + break; + } + case "enumerationType": { + var foundObject = getDeviceDescriptionObject(service, deviceDescriptionChange); + assertNotNull(foundObject); + assertNotNull(foundObject.object()); + assertInstanceOf(EnumerationTypeProvider.class, foundObject.object()); + EnumerationTypeProvider enumerationTypeProvider = (EnumerationTypeProvider) foundObject + .object(); + assertEquals(String.valueOf(change.to()), + String.valueOf(enumerationTypeProvider.enumerationType())); + break; + } + case "defaultValue": { + var foundObject = getDeviceDescriptionObject(service, deviceDescriptionChange); + assertNotNull(foundObject); + assertNotNull(foundObject.object()); + if (foundObject.object() instanceof Option option) { + assertEquals(String.valueOf(change.to()), + String.valueOf(option.defaultValue())); + } else if (foundObject.object() instanceof ProgramOption programOption) { + assertEquals(String.valueOf(change.to()), programOption.defaultValue()); + } else { + fail("Unknown object type: " + foundObject.object()); + } + break; + } + case "access": { + var foundObject = getDeviceDescriptionObject(service, deviceDescriptionChange); + assertNotNull(foundObject); + assertNotNull(foundObject.object()); + assertInstanceOf(AccessProvider.class, foundObject.object()); + AccessProvider accessProvider = (AccessProvider) foundObject.object(); + assertEquals(String.valueOf(change.to()), String.valueOf(accessProvider.access())); + break; + } + default: + throw new IllegalStateException("Unexpected value: " + type); + } + })); + + assertEquals(countEvents(initialDeviceDescription.eventList()), + countEvents(changedDeviceDescription.eventList())); + assertEquals(countCommands(initialDeviceDescription.commandList()), + countCommands(changedDeviceDescription.commandList())); + assertEquals(countOptions(initialDeviceDescription.optionList()), + countOptions(changedDeviceDescription.optionList())); + assertEquals(countSettings(initialDeviceDescription.settingList()), + countSettings(changedDeviceDescription.settingList())); + assertEquals(countStatuses(initialDeviceDescription.statusList()), + countStatuses(changedDeviceDescription.statusList())); + assertEquals(countPrograms(initialDeviceDescription.programGroup()), + countPrograms(changedDeviceDescription.programGroup())); + assertEquals(countProgramOptions(initialDeviceDescription.programGroup()), + countProgramOptions(changedDeviceDescription.programGroup())); + assertEquals(Objects.isNull(initialDeviceDescription.activeProgram()), + Objects.isNull(changedDeviceDescription.activeProgram())); + assertEquals(Objects.isNull(initialDeviceDescription.selectedProgram()), + Objects.isNull(changedDeviceDescription.selectedProgram())); + assertEquals(Objects.isNull(initialDeviceDescription.protectionPort()), + Objects.isNull(changedDeviceDescription.protectionPort())); + } + + private @Nullable FoundObject getDeviceDescriptionObject(DeviceDescriptionService service, + DeviceDescriptionChange change) { + var parentType = change.parentType(); + var parentUid = change.parentUid(); + if (DeviceDescriptionType.PROGRAM.equals(parentType) && parentUid != null) { + var programOption = service.findProgramOption(parentUid, change.uid()); + if (programOption != null) { + return new FoundObject(programOption, DeviceDescriptionType.PROGRAM_OPTION); + } + return null; + } else { + return service.getDeviceDescriptionObject(change.uid()); + } + } + + private int countProgramOptions(@Nullable ProgramGroup programGroup) { + if (programGroup == null) { + return 0; + } + int count = 0; + for (Program program : programGroup.programs().values()) { + count += program.options().size(); + } + for (ProgramGroup subGroup : programGroup.programGroups().values()) { + count += countProgramOptions(subGroup); + } + return count; + } + + private int countStatuses(@Nullable StatusList statusList) { + if (statusList == null) { + return 0; + } + int count = statusList.statuses().size(); + for (StatusList subList : statusList.statusLists().values()) { + count += countStatuses(subList); + } + return count; + } + + private int countSettings(@Nullable SettingList settingList) { + if (settingList == null) { + return 0; + } + int count = settingList.settings().size(); + for (SettingList subList : settingList.settingLists().values()) { + count += countSettings(subList); + } + return count; + } + + private int countEvents(@Nullable EventList eventList) { + if (eventList == null) { + return 0; + } + int count = eventList.events().size(); + for (EventList subList : eventList.eventLists().values()) { + count += countEvents(subList); + } + return count; + } + + private int countCommands(@Nullable CommandList commandList) { + if (commandList == null) { + return 0; + } + int count = commandList.commands().size(); + for (CommandList subList : commandList.commandLists().values()) { + count += countCommands(subList); + } + return count; + } + + private int countOptions(@Nullable OptionList optionList) { + if (optionList == null) { + return 0; + } + int count = optionList.options().size(); + for (OptionList subList : optionList.optionLists().values()) { + count += countOptions(subList); + } + return count; + } + + private int countPrograms(@Nullable ProgramGroup programGroup) { + if (programGroup == null) { + return 0; + } + int count = programGroup.programs().size(); + for (ProgramGroup subGroup : programGroup.programGroups().values()) { + count += countPrograms(subGroup); + } + return count; + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/LogRequestHandlerTest.java b/bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/LogRequestHandlerTest.java new file mode 100644 index 0000000000..ac9bb228ae --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/java/org/openhab/binding/homeconnectdirect/internal/servlet/handler/api/LogRequestHandlerTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homeconnectdirect.internal.servlet.handler.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.OffsetDateTime; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.openhab.binding.homeconnectdirect.internal.common.utils.ConfigurationUtils; +import org.openhab.binding.homeconnectdirect.internal.handler.model.ApplianceMessage; +import org.openhab.binding.homeconnectdirect.internal.handler.model.MessageType; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Action; +import org.openhab.binding.homeconnectdirect.internal.service.websocket.model.Resource; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonNull; + +/** + * Tests for the messages json validation of {@link LogRequestHandler}. + * + * @author Jonas Brüstel - Initial contribution + */ +@NonNullByDefault +class LogRequestHandlerTest { + + private final Gson gson = ConfigurationUtils.createGson(); + + @Test + @DisplayName("A serialized appliance message passes validation (guards against field name drift)") + void testValidSerializedMessage() { + assertTrue(LogRequestHandler.isValidMessagesJson(validMessagesJson(), gson)); + } + + @Test + @DisplayName("An empty array is valid") + void testEmptyArray() { + assertTrue(LogRequestHandler.isValidMessagesJson("[]", gson)); + } + + @ParameterizedTest(name = "missing ''{0}'' is rejected") + @ValueSource(strings = { "dateTime", "type", "resource", "action" }) + @DisplayName("A message missing a mandatory field is rejected") + void testMissingMandatoryField(String field) { + var array = parseArray(validMessagesJson()); + array.get(0).getAsJsonObject().remove(field); + assertFalse(LogRequestHandler.isValidMessagesJson(gson.toJson(array), gson)); + } + + @ParameterizedTest(name = "null ''{0}'' is rejected") + @ValueSource(strings = { "dateTime", "type", "resource", "action" }) + @DisplayName("A message with a null mandatory field is rejected") + void testNullMandatoryField(String field) { + var array = parseArray(validMessagesJson()); + var object = array.get(0).getAsJsonObject(); + object.add(field, JsonNull.INSTANCE); + assertFalse(LogRequestHandler.isValidMessagesJson(gson.toJson(array), gson)); + } + + @Test + @DisplayName("A json object instead of an array is rejected") + void testNonArray() { + assertFalse(LogRequestHandler.isValidMessagesJson("{}", gson)); + } + + @Test + @DisplayName("A non-object array element is rejected") + void testNonObjectElement() { + assertFalse(LogRequestHandler.isValidMessagesJson("[\"foo\"]", gson)); + } + + @Test + @DisplayName("A malformed date is rejected by the typed parser") + void testMalformedDate() { + var array = parseArray(validMessagesJson()); + array.get(0).getAsJsonObject().addProperty("dateTime", "not-a-date"); + assertFalse(LogRequestHandler.isValidMessagesJson(gson.toJson(array), gson)); + } + + @Test + @DisplayName("Invalid json is rejected") + void testInvalidJson() { + assertFalse(LogRequestHandler.isValidMessagesJson("not json", gson)); + } + + private String validMessagesJson() { + var message = new ApplianceMessage(OffsetDateTime.now(), 1L, MessageType.INCOMING, Resource.RO_VALUES, 1, 1L, + 1L, Action.GET, null, null, null, null); + return gson.toJson(new ApplianceMessage[] { message }); + } + + private JsonArray parseArray(String json) { + return Objects.requireNonNull(gson.fromJson(json, JsonArray.class)); + } +} diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/BOSCH-MCC9555DWC-000000000000_DeviceDescription.xml b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/BOSCH-MCC9555DWC-000000000000_DeviceDescription.xml new file mode 100644 index 0000000000..2db6bb8229 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/BOSCH-MCC9555DWC-000000000000_DeviceDescription.xml @@ -0,0 +1 @@ +xinclude error: file "HC_INT_BSH_CTD.xml" not foundxinclude error: file "HC_INT_BSH_DTD.xml" not foundxinclude error: file "HC_INT_BSH_DEV.xml" not foundCookProcessorBOSCHMCC9555DWC340AllHomeAppliancesApplication \ No newline at end of file diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/BOSCH-MCC9555DWC-000000000000_FeatureMapping.xml b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/BOSCH-MCC9555DWC-000000000000_FeatureMapping.xml new file mode 100644 index 0000000000..d6c2588238 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/BOSCH-MCC9555DWC-000000000000_FeatureMapping.xml @@ -0,0 +1,306 @@ + + + + + xinclude error: file "HC_INT_PCP_MCC9555DWC_34-0_DeviceDescription.xml" not found + + + + BSH.Common.Command.AbortProgram + BSH.Common.Command.AcknowledgeEvent + BSH.Common.Command.AllowSoftwareDownload + BSH.Common.Command.AllowSoftwareUpdate + BSH.Common.Command.AllowSoftwareUpdateLocalWiFi + BSH.Common.Command.ApplyFactoryReset + BSH.Common.Command.Next + BSH.Common.Command.PauseProgram + BSH.Common.Command.Previous + BSH.Common.Command.RejectEvent + BSH.Common.Command.ResumeProgram + BSH.Common.Event.Content.DownloadError + BSH.Common.Event.ProgramAborted + BSH.Common.Event.ProgramFinished + BSH.Common.Event.SoftwareDownloadAvailable + BSH.Common.Event.SoftwareUpdateAvailable + BSH.Common.Event.SoftwareUpdateSuccessful + BSH.Common.Option.Content.SourceId + BSH.Common.Option.Content.VariantId + BSH.Common.Option.CurrentStepNumber + BSH.Common.Option.CurrentStepProgress + BSH.Common.Option.CurrentStepRemainingTime + BSH.Common.Option.CurrentStepRemainingTime.AutoCounting + BSH.Common.Option.HeatupProgress + BSH.Common.Option.ProgramProgress + BSH.Common.Option.ReadyToStart + BSH.Common.Option.RemainingProgramTime + BSH.Common.Option.TotalNumberOfSteps + BSH.Common.Program.AutomaticProgram + BSH.Common.Program.GuidedProgram + BSH.Common.ProgramGroup.BuildingBlock + BSH.Common.Root.ActiveProgram + BSH.Common.Root.CommandList + BSH.Common.Root.EventList + BSH.Common.Root.OptionList + BSH.Common.Root.ProgramGroup + BSH.Common.Root.SelectedProgram + BSH.Common.Root.SettingList + BSH.Common.Root.StatusList + BSH.Common.Setting.AllowBackendConnection + BSH.Common.Setting.AutoStandbyTime + BSH.Common.Setting.PowerState + BSH.Common.Setting.Sound.Button.Power + BSH.Common.Setting.Sound.Button.Volume + BSH.Common.Status.BackendConnected + BSH.Common.Status.OperationState + BSH.Common.Status.SoftwareUpdateTransactionID + ConsumerProducts.CookProcessor.Event.ResumingRecipe + ConsumerProducts.CookProcessor.Event.StepFinished + ConsumerProducts.CookProcessor.Option.BuildingBlock.Action + ConsumerProducts.CookProcessor.Option.BuildingBlock.ConversionFactor + ConsumerProducts.CookProcessor.Option.BuildingBlock.Duration + ConsumerProducts.CookProcessor.Option.BuildingBlock.ExitCriteria + ConsumerProducts.CookProcessor.Option.BuildingBlock.HeaderText + ConsumerProducts.CookProcessor.Option.BuildingBlock.HintPicture + ConsumerProducts.CookProcessor.Option.BuildingBlock.HintText1 + ConsumerProducts.CookProcessor.Option.BuildingBlock.HintText2 + ConsumerProducts.CookProcessor.Option.BuildingBlock.LidType + ConsumerProducts.CookProcessor.Option.BuildingBlock.NeededLidState + ConsumerProducts.CookProcessor.Option.BuildingBlock.NeededTool + ConsumerProducts.CookProcessor.Option.BuildingBlock.PresentedStirrerSpeed + ConsumerProducts.CookProcessor.Option.BuildingBlock.RotationOffTime + ConsumerProducts.CookProcessor.Option.BuildingBlock.RotationOnTime + ConsumerProducts.CookProcessor.Option.BuildingBlock.RotationSetPoint + ConsumerProducts.CookProcessor.Option.BuildingBlock.StepPicture + ConsumerProducts.CookProcessor.Option.BuildingBlock.TargetTemperature + ConsumerProducts.CookProcessor.Option.BuildingBlock.TargetVolume + ConsumerProducts.CookProcessor.Option.BuildingBlock.TargetWeight + ConsumerProducts.CookProcessor.Option.BuildingBlock.UseAdditionalTemperatureSensor + ConsumerProducts.CookProcessor.Option.BuildingBlock.UserInstruction + ConsumerProducts.CookProcessor.Option.BuildingBlock.UserInstructionDuringOperation + ConsumerProducts.CookProcessor.OptionList.BuildingBlock + ConsumerProducts.CookProcessor.Program.BuildingBlock.AutomaticWarmingUpViscousDishes + ConsumerProducts.CookProcessor.Program.BuildingBlock.Beating + ConsumerProducts.CookProcessor.Program.BuildingBlock.Boiling + ConsumerProducts.CookProcessor.Program.BuildingBlock.Braising + ConsumerProducts.CookProcessor.Program.BuildingBlock.BraisingBigParts + ConsumerProducts.CookProcessor.Program.BuildingBlock.Browning + ConsumerProducts.CookProcessor.Program.BuildingBlock.Caramelizing + ConsumerProducts.CookProcessor.Program.BuildingBlock.Chopping + ConsumerProducts.CookProcessor.Program.BuildingBlock.ColdPreCleaning + ConsumerProducts.CookProcessor.Program.BuildingBlock.CookingFoamingFoods + ConsumerProducts.CookProcessor.Program.BuildingBlock.CookingSugarSirup + ConsumerProducts.CookProcessor.Program.BuildingBlock.Defrosting + ConsumerProducts.CookProcessor.Program.BuildingBlock.DoughProving + ConsumerProducts.CookProcessor.Program.BuildingBlock.FreetextInstruction + ConsumerProducts.CookProcessor.Program.BuildingBlock.FryingRoastingSearing + ConsumerProducts.CookProcessor.Program.BuildingBlock.Grating + ConsumerProducts.CookProcessor.Program.BuildingBlock.HeatingManualMode1 + ConsumerProducts.CookProcessor.Program.BuildingBlock.HeatingManualMode2 + ConsumerProducts.CookProcessor.Program.BuildingBlock.HeatingManualMode3 + ConsumerProducts.CookProcessor.Program.BuildingBlock.HotFoodPureeing + ConsumerProducts.CookProcessor.Program.BuildingBlock.KeepWarmHighViscous + ConsumerProducts.CookProcessor.Program.BuildingBlock.KeepWarmLowViscous + ConsumerProducts.CookProcessor.Program.BuildingBlock.KneadingHeavyDough + ConsumerProducts.CookProcessor.Program.BuildingBlock.KneadingSoftDough + ConsumerProducts.CookProcessor.Program.BuildingBlock.LeaveToCulture + ConsumerProducts.CookProcessor.Program.BuildingBlock.LidChange + ConsumerProducts.CookProcessor.Program.BuildingBlock.ManualCookingParameter + ConsumerProducts.CookProcessor.Program.BuildingBlock.ManualCookingParameterWithSeaLevelCompensation + ConsumerProducts.CookProcessor.Program.BuildingBlock.Melting + ConsumerProducts.CookProcessor.Program.BuildingBlock.Mixing + ConsumerProducts.CookProcessor.Program.BuildingBlock.MixingBatter + ConsumerProducts.CookProcessor.Program.BuildingBlock.Poaching + ConsumerProducts.CookProcessor.Program.BuildingBlock.Pureeing + ConsumerProducts.CookProcessor.Program.BuildingBlock.Serving + ConsumerProducts.CookProcessor.Program.BuildingBlock.ServingOrKeepWarmHighViscous + ConsumerProducts.CookProcessor.Program.BuildingBlock.ServingOrKeepWarmLowViscous + ConsumerProducts.CookProcessor.Program.BuildingBlock.SimmeringFruitSpread + ConsumerProducts.CookProcessor.Program.BuildingBlock.SimmeringInstantSoup + ConsumerProducts.CookProcessor.Program.BuildingBlock.SimmeringLiquidDishes + ConsumerProducts.CookProcessor.Program.BuildingBlock.Slicing + ConsumerProducts.CookProcessor.Program.BuildingBlock.Soaking + ConsumerProducts.CookProcessor.Program.BuildingBlock.SousVide + ConsumerProducts.CookProcessor.Program.BuildingBlock.SteamingHigh + ConsumerProducts.CookProcessor.Program.BuildingBlock.SteamingHighReduced + ConsumerProducts.CookProcessor.Program.BuildingBlock.SteamingLow + ConsumerProducts.CookProcessor.Program.BuildingBlock.SteamingMiddle + ConsumerProducts.CookProcessor.Program.BuildingBlock.SteamingSpecial + ConsumerProducts.CookProcessor.Program.BuildingBlock.SteamingTowerCooking + ConsumerProducts.CookProcessor.Program.BuildingBlock.Stewing + ConsumerProducts.CookProcessor.Program.BuildingBlock.StewingSensibleDishes + ConsumerProducts.CookProcessor.Program.BuildingBlock.Stirring + ConsumerProducts.CookProcessor.Program.BuildingBlock.Sweating + ConsumerProducts.CookProcessor.Program.BuildingBlock.ToolChange + ConsumerProducts.CookProcessor.Program.BuildingBlock.Waiting + ConsumerProducts.CookProcessor.Program.BuildingBlock.WarmWhipping + ConsumerProducts.CookProcessor.Program.BuildingBlock.WarmingMilk + ConsumerProducts.CookProcessor.Program.BuildingBlock.WarmingMilkBasedDishes + ConsumerProducts.CookProcessor.Program.BuildingBlock.WarmingUpLiquidDishes + ConsumerProducts.CookProcessor.Program.BuildingBlock.WarmingUpViscousDishes + ConsumerProducts.CookProcessor.Program.BuildingBlock.WaterFastBoiling + ConsumerProducts.CookProcessor.Program.BuildingBlock.Weighing + ConsumerProducts.CookProcessor.Program.BuildingBlock.WeighingVolume + ConsumerProducts.CookProcessor.Program.BuildingBlock.Whipping + ConsumerProducts.CookProcessor.Program.ManualProgram + ConsumerProducts.CookProcessor.Program.Turbo + ConsumerProducts.CookProcessor.ProgramGroup.Automatic + ConsumerProducts.CookProcessor.ProgramGroup.Guided + ConsumerProducts.CookProcessor.ProgramGroup.Manual + ConsumerProducts.CookProcessor.Setting.BoilingPointAdjustment + ConsumerProducts.CookProcessor.Setting.BrightnessDisplay + + + BSH.Common.Error.200.OK + BSH.Common.Error.202.Accepted + BSH.Common.Error.400.BadRequest + BSH.Common.Error.403.Forbidden + BSH.Common.Error.404.NotFound + BSH.Common.Error.405.MethodNotAllowed + BSH.Common.Error.413.RequestEntityTooLong + BSH.Common.Error.414.RequestUriTooLong + BSH.Common.Error.429.TooManyRequests + BSH.Common.Error.500.InternalServerError + BSH.Common.Error.501.NotImplemented + BSH.Common.Error.502.BadGateway + BSH.Common.Error.503.ServiceUnavailable + BSH.Common.Error.504.GatewayTimeout + BSH.Common.Error.507.InsufficientMemory + BSH.Common.Error.BackendNotConnected + BSH.Common.Error.Busy + BSH.Common.Error.CmdViolation + BSH.Common.Error.DeviceStateNotCompliant + BSH.Common.Error.EnergyManagementNotConnected + BSH.Common.Error.Incomplete + BSH.Common.Error.Inconsistent + BSH.Common.Error.InvalidFormat + BSH.Common.Error.InvalidUIDValue + BSH.Common.Error.LockedByLocalControl + BSH.Common.Error.NoAccess + BSH.Common.Error.NoAccessByList + BSH.Common.Error.NoExecution + BSH.Common.Error.NotAvailable + BSH.Common.Error.NotAvailableByList + BSH.Common.Error.NotInLocalWiFi + BSH.Common.Error.ProcessStateNotCompliant + BSH.Common.Error.ReadRequest.Busy + BSH.Common.Error.ReadRequest.NoAccess + BSH.Common.Error.ReadRequest.NoAccessByList + BSH.Common.Error.ReadRequest.NotAvailable + BSH.Common.Error.ReadRequest.NotAvailableByList + BSH.Common.Error.ReadRequest.UnknownUID + BSH.Common.Error.RemoteControlNotActive + BSH.Common.Error.RemoteStartNotActive + BSH.Common.Error.UnknownUID + BSH.Common.Error.ValueOutOfRange + BSH.Common.Error.WriteRequest.Busy + BSH.Common.Error.WriteRequest.NoAccess + BSH.Common.Error.WriteRequest.NoAccessByList + BSH.Common.Error.WriteRequest.NotAvailable + BSH.Common.Error.WriteRequest.NotAvailableByList + BSH.Common.Error.WriteRequest.UnknownUID + + + + 00Hours05Minutes + 00Hours10Minutes + 00Hours15Minutes + 00Hours20Minutes + 00Hours25Minutes + 00Hours30Minutes + 00Hours35Minutes + 00Hours40Minutes + 00Hours45Minutes + 00Hours50Minutes + 00Hours55Minutes + 01Hours00Minutes + + + Off + Present + Confirmed + + + Inactive + Ready + Run + Pause + ActionRequired + Finished + Error + Aborting + + + On + Standby + + + Above0m + Above50m + Above300m + Above600m + Above900m + Above1200m + Above1500m + Above1800m + + + Add + Remove + + + TimeExpired + TimeExpiredAfterTemperatureReached + UserConfirmation + TemperatureReached + + + RaspingLid + CookingLidWithoutInsert + CookingLidWithMeasuringCup + + + DontCare + Closed + Open + + + GratingDiscFine + AsianVegetableDisc + SupercutDiscRaw + SupercutDiscFine + FrenchFriesDisc + DiscRaspingRaw + DiscRaspingFine + DiscCuttingRaw + DiscCuttingFine + None + Knife + KnifeIce + Stirrer + Whisk + Disc + ToolSelectionNeeded + + + Level0 + Level10 + Level20 + Level30 + Level40 + Level50 + Level60 + Level70 + Level80 + Level90 + Level100 + Level110 + Level120 + Level130 + Level140 + Level150 + Level160 + Level170 + Level180 + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-SN658X06TE-000000000000_DeviceDescription.xml b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-SN658X06TE-000000000000_DeviceDescription.xml new file mode 100644 index 0000000000..a4057fefde --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-SN658X06TE-000000000000_DeviceDescription.xml @@ -0,0 +1 @@ +xinclude error: file "HC_INT_BSH_CTD.xml" not foundxinclude error: file "HC_INT_BSH_DTD.xml" not foundxinclude error: file "HC_INT_BSH_DEV.xml" not foundDishwasherSIEMENSSN658X06TE10Application \ No newline at end of file diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-SN658X06TE-000000000000_FeatureMapping.xml b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-SN658X06TE-000000000000_FeatureMapping.xml new file mode 100644 index 0000000000..1b152b7404 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-SN658X06TE-000000000000_FeatureMapping.xml @@ -0,0 +1,302 @@ + + + + + + + xinclude error: file "HC_INT_PDC_SN658X06TE_1-0_DeviceDescription.xml" not found + + + + + BSH.Common.Command.AbortProgram +BSH.Common.Command.AcknowledgeEvent +BSH.Common.Command.DeactivateRemoteControlStart +BSH.Common.Command.DeactivateWiFi +BSH.Common.Command.DepairCustomerEnergyManager +BSH.Common.Command.DisallowCustomerServiceConnection +BSH.Common.Command.RejectEvent +BSH.Common.Command.SetWaterHardness +BSH.Common.Event.AquaStopOccured +BSH.Common.Event.CustomerServiceRequest +BSH.Common.Event.LowWaterPressure +BSH.Common.Event.ProgramAborted +BSH.Common.Event.ProgramFinished +BSH.Common.Event.SoftwareUpdateAvailable +BSH.Common.Option.EnergyForecast +BSH.Common.Option.FlexStartInRelative +BSH.Common.Option.ProgramProgress +BSH.Common.Option.RemainingProgramTime +BSH.Common.Option.RemainingProgramTimeIsEstimated +BSH.Common.Option.StartInRelative +BSH.Common.Option.WaterForecast +BSH.Common.Root.ActiveProgram +BSH.Common.Root.CommandList +BSH.Common.Root.EventList +BSH.Common.Root.OptionList +BSH.Common.Root.ProgramGroup +BSH.Common.Root.SelectedProgram +BSH.Common.Root.SettingList +BSH.Common.Root.StatusList +BSH.Common.Setting.AllowBackendConnection +BSH.Common.Setting.AllowFlexStart +BSH.Common.Setting.ApplianceTime +BSH.Common.Setting.Language +BSH.Common.Setting.PowerState +BSH.Common.Setting.SynchronizeWithTimeServer +BSH.Common.Status.BackendConnected +BSH.Common.Status.CustomerEnergyManagerPaired +BSH.Common.Status.CustomerServiceConnectionAllowed +BSH.Common.Status.DoorState +BSH.Common.Status.FlexStart +BSH.Common.Status.OperationState +BSH.Common.Status.RemoteControlActive +BSH.Common.Status.RemoteControlStartAllowed +BSH.Common.Status.WiFiSignalStrength +Dishcare.Dishwasher.Event.CheckFilterSystem +Dishcare.Dishwasher.Event.DrainPumpBlocked +Dishcare.Dishwasher.Event.DrainingNotPossible +Dishcare.Dishwasher.Event.InternalError +Dishcare.Dishwasher.Event.LowVoltage +Dishcare.Dishwasher.Event.MachineCareReminder +Dishcare.Dishwasher.Event.RinseAidLack +Dishcare.Dishwasher.Event.RinseAidNearlyEmpty +Dishcare.Dishwasher.Event.SaltLack +Dishcare.Dishwasher.Event.SaltNearlyEmpty +Dishcare.Dishwasher.Event.WaterheaterCalcified +Dishcare.Dishwasher.Option.BrillianceDry +Dishcare.Dishwasher.Option.IntensivZone +Dishcare.Dishwasher.Option.VarioSpeedPlus +Dishcare.Dishwasher.OptionList.Options +Dishcare.Dishwasher.Program.Auto2 +Dishcare.Dishwasher.Program.Eco50 +Dishcare.Dishwasher.Program.Glas40 +Dishcare.Dishwasher.Program.Intensiv70 +Dishcare.Dishwasher.Program.Kurz60 +Dishcare.Dishwasher.Program.MachineCare +Dishcare.Dishwasher.Program.NightWash +Dishcare.Dishwasher.Program.PreRinse +Dishcare.Dishwasher.Program.Quick45 +Dishcare.Dishwasher.Setting.AutoPowerOff +Dishcare.Dishwasher.Setting.BrandLogo +Dishcare.Dishwasher.Setting.EcoAsDefault +Dishcare.Dishwasher.Setting.EcoPrognosis +Dishcare.Dishwasher.Setting.ExtraDry +Dishcare.Dishwasher.Setting.HotWater +Dishcare.Dishwasher.Setting.RelAbsOperatingTime +Dishcare.Dishwasher.Setting.RinseAid +Dishcare.Dishwasher.Setting.SensitivityTurbidity +Dishcare.Dishwasher.Setting.SoundLevelKey +Dishcare.Dishwasher.Setting.SoundLevelSignal +Dishcare.Dishwasher.Setting.TimeFormat +Dishcare.Dishwasher.Setting.TimeLight +Dishcare.Dishwasher.Setting.WaterHardness +Dishcare.Dishwasher.Status.ProgramPhase + + + + + BSH.Common.Error.200.OK +BSH.Common.Error.202.Accepted +BSH.Common.Error.400.BadRequest +BSH.Common.Error.403.Forbidden +BSH.Common.Error.404.NotFound +BSH.Common.Error.405.MethodNotAllowed +BSH.Common.Error.413.RequestEntityTooLong +BSH.Common.Error.414.RequestUriTooLong +BSH.Common.Error.429.TooManyRequests +BSH.Common.Error.500.InternalServerError +BSH.Common.Error.501.NotImplemented +BSH.Common.Error.502.BadGateway +BSH.Common.Error.503.ServiceUnavailable +BSH.Common.Error.504.GatewayTimeout +BSH.Common.Error.507.InsufficientMemory +BSH.Common.Error.BackendNotConnected +BSH.Common.Error.Busy +BSH.Common.Error.CmdViolation +BSH.Common.Error.DeviceStateNotCompliant +BSH.Common.Error.EnergyManagementNotConnected +BSH.Common.Error.Incomplete +BSH.Common.Error.Inconsistent +BSH.Common.Error.InvalidFormat +BSH.Common.Error.InvalidUIDValue +BSH.Common.Error.LockedByLocalControl +BSH.Common.Error.NoAccess +BSH.Common.Error.NoAccessByList +BSH.Common.Error.NoExecution +BSH.Common.Error.NotAvailable +BSH.Common.Error.NotAvailableByList +BSH.Common.Error.NotInLocalWiFi +BSH.Common.Error.ProcessStateNotCompliant +BSH.Common.Error.ReadRequest.Busy +BSH.Common.Error.ReadRequest.NoAccess +BSH.Common.Error.ReadRequest.NoAccessByList +BSH.Common.Error.ReadRequest.NotAvailable +BSH.Common.Error.ReadRequest.NotAvailableByList +BSH.Common.Error.ReadRequest.UnknownUID +BSH.Common.Error.RemoteControlNotActive +BSH.Common.Error.RemoteStartNotActive +BSH.Common.Error.UnknownUID +BSH.Common.Error.ValueOutOfRange +BSH.Common.Error.WriteRequest.Busy +BSH.Common.Error.WriteRequest.NoAccess +BSH.Common.Error.WriteRequest.NoAccessByList +BSH.Common.Error.WriteRequest.NotAvailable +BSH.Common.Error.WriteRequest.NotAvailableByList +BSH.Common.Error.WriteRequest.UnknownUID + + + + + +Open +Closed + + + +Off +Present +Confirmed + + + +Disabled +Enabled +Pending +Scheduled +Started +Finished + + + +Cs +Da +De +El +En +EnUs +Es +EsMx +Fi +Fr +FrCa +Hr +Hu +It +Nl +No +Pl +Pt +Ro +Ru +Sk +Sl +Sv +Tr +Uk +Zh + + + +Inactive +Ready +DelayedStart +Run +Pause +Finished +Aborting + + + +Off +On + + + +Off +OneMinute +120Minutes + + + +LastProgram +EcoAsDefault + + + +ColdWater +HotWater + + + +Off +Low +Medium +High + + + +None +PreRinse +MainWash +FinalRinse +Drying + + + +Relative +Absolute + + + +Off +R01 +R02 +R03 +R04 +R05 +R06 + + + +Standard +Sensitive +VerySensitive + + + +Off +Low +Medium +High + + + +24h +12h + + + +Off +On + + + +H00 +H01 +H02 +H03 +H04 +H05 +H06 +H07 + + + + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-TI9555X1DE-000000000000_DeviceDescription.xml b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-TI9555X1DE-000000000000_DeviceDescription.xml new file mode 100644 index 0000000000..9788b3c939 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-TI9555X1DE-000000000000_DeviceDescription.xml @@ -0,0 +1 @@ +xinclude error: file "HC_INT_BSH_CTD.xml" not foundxinclude error: file "HC_INT_BSH_DTD.xml" not foundxinclude error: file "HC_INT_BSH_DEV.xml" not foundCoffeeMakerSIEMENSTI9555X1DE891Application \ No newline at end of file diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-TI9555X1DE-000000000000_FeatureMapping.xml b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-TI9555X1DE-000000000000_FeatureMapping.xml new file mode 100644 index 0000000000..57320fa73b --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-TI9555X1DE-000000000000_FeatureMapping.xml @@ -0,0 +1,405 @@ + + + + + xinclude error: file "HC_INT_PCP_TI9555X1DE_89-1_DeviceDescription.xml" not found + + + + BSH.Common.Command.AbortProgram + BSH.Common.Command.AcknowledgeEvent + BSH.Common.Command.AllowCustomerServiceConnectionLocalWiFi + BSH.Common.Command.ApplyFactoryReset + BSH.Common.Command.ApplyNetworkReset + BSH.Common.Command.DeactivateRemoteControlStart + BSH.Common.Command.DeactivateWiFi + BSH.Common.Command.DisallowCustomerServiceConnection + BSH.Common.Command.PauseProgram + BSH.Common.Command.RejectEvent + BSH.Common.Command.ResumeProgram + BSH.Common.Command.SetWaterHardness + BSH.Common.Event.CustomerServiceRequest + BSH.Common.Event.ProgramFinished + BSH.Common.Event.SoftwareUpdateAvailable + BSH.Common.Event.SoftwareUpdateSuccessful + BSH.Common.Option.CurrentStepRemainingTime + BSH.Common.Option.ProgramProgress + BSH.Common.Root.ActiveProgram + BSH.Common.Root.CommandList + BSH.Common.Root.EventList + BSH.Common.Root.OptionList + BSH.Common.Root.ProgramGroup + BSH.Common.Root.SelectedProgram + BSH.Common.Root.SettingList + BSH.Common.Root.StatusList + BSH.Common.Setting.AllowBackendConnection + BSH.Common.Setting.AllowConsumerInsights + BSH.Common.Setting.ChildLock + BSH.Common.Setting.Language + BSH.Common.Setting.PowerState + BSH.Common.Status.BackendConnected + BSH.Common.Status.CustomerServiceConnectionAllowed + BSH.Common.Status.DoorState + BSH.Common.Status.InteriorIlluminationActive + BSH.Common.Status.LocalControlActive + BSH.Common.Status.OperationState + BSH.Common.Status.RemoteControlStartAllowed + BSH.Common.Status.WiFiSignalStrength + ConsumerProducts.CoffeeMaker.Command.BeverageInfo + ConsumerProducts.CoffeeMaker.Command.DeleteAllProfiles + ConsumerProducts.CoffeeMaker.Event.AdjustGrindSetting + ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty + ConsumerProducts.CoffeeMaker.Event.BrewingUnitIsMissing + ConsumerProducts.CoffeeMaker.Event.CallHotline + ConsumerProducts.CoffeeMaker.Event.CleanBrewingUnit + ConsumerProducts.CoffeeMaker.Event.CleanFillWaterTank + ConsumerProducts.CoffeeMaker.Event.CleanMilkTank + ConsumerProducts.CoffeeMaker.Event.CloseDoor + ConsumerProducts.CoffeeMaker.Event.CoffeeOutletMissing + ConsumerProducts.CoffeeMaker.Event.DeactivatePlaylist + ConsumerProducts.CoffeeMaker.Event.DeviceDescalingBlockage + ConsumerProducts.CoffeeMaker.Event.DeviceDescalingOverdue + ConsumerProducts.CoffeeMaker.Event.DeviceIsToCold4C + ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCalcNCleaned + ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeCleaned + ConsumerProducts.CoffeeMaker.Event.DeviceShouldBeDescaled + ConsumerProducts.CoffeeMaker.Event.DripTrayFull + ConsumerProducts.CoffeeMaker.Event.DripTrayNotInserted + ConsumerProducts.CoffeeMaker.Event.EmptyDripTray + ConsumerProducts.CoffeeMaker.Event.EmptyDripTrayRemoveContainer + ConsumerProducts.CoffeeMaker.Event.EmptyMilkTank + ConsumerProducts.CoffeeMaker.Event.EmptyWaterTank + ConsumerProducts.CoffeeMaker.Event.Enjoy + ConsumerProducts.CoffeeMaker.Event.FillDescaler + ConsumerProducts.CoffeeMaker.Event.InsertWaterFilter + ConsumerProducts.CoffeeMaker.Event.MilkContainerConnected + ConsumerProducts.CoffeeMaker.Event.MilkContainerRemoved + ConsumerProducts.CoffeeMaker.Event.MilkReminder + ConsumerProducts.CoffeeMaker.Event.MilkStillOK + ConsumerProducts.CoffeeMaker.Event.MilkTubeRemoved + ConsumerProducts.CoffeeMaker.Event.NotEnoughPomaceCapacityForThisKindOfBeverage + ConsumerProducts.CoffeeMaker.Event.NotEnoughWaterForThisKindOfBeverage + ConsumerProducts.CoffeeMaker.Event.PlaceContainerUnderOutlet + ConsumerProducts.CoffeeMaker.Event.PlaceEmptyGlassUnderOutlet + ConsumerProducts.CoffeeMaker.Event.PowerSupplyError + ConsumerProducts.CoffeeMaker.Event.PressOnOff + ConsumerProducts.CoffeeMaker.Event.RefillEmptyBeanContainer + ConsumerProducts.CoffeeMaker.Event.RefillEmptyWaterTank + ConsumerProducts.CoffeeMaker.Event.RefillRemovedMilkContainer + ConsumerProducts.CoffeeMaker.Event.RemoveContainerUnderOutlet + ConsumerProducts.CoffeeMaker.Event.RemoveMilkContainer + ConsumerProducts.CoffeeMaker.Event.RemoveWaterFilter + ConsumerProducts.CoffeeMaker.Event.RestartAppliance + ConsumerProducts.CoffeeMaker.Event.ServiceProgramFinished + ConsumerProducts.CoffeeMaker.Event.SpecialRinsing + ConsumerProducts.CoffeeMaker.Event.SwitchOffPower30sekBackOn + ConsumerProducts.CoffeeMaker.Event.SystemHasRunDry + ConsumerProducts.CoffeeMaker.Event.ThrowCleaningDiscInTheDrawer + ConsumerProducts.CoffeeMaker.Event.UnderOverVoltage + ConsumerProducts.CoffeeMaker.Event.WaterFilterShouldBeChanged + ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty + ConsumerProducts.CoffeeMaker.Event.WaterTankNearlyEmpty + ConsumerProducts.CoffeeMaker.Event.WaterTankNotInserted + ConsumerProducts.CoffeeMaker.Option.BeanAmount + ConsumerProducts.CoffeeMaker.Option.BeveragesRemaining + ConsumerProducts.CoffeeMaker.Option.CoffeeMilkRatio + ConsumerProducts.CoffeeMaker.Option.CoffeeTemperature + ConsumerProducts.CoffeeMaker.Option.Cups + ConsumerProducts.CoffeeMaker.Option.DisplayName + ConsumerProducts.CoffeeMaker.Option.FillQuantity + ConsumerProducts.CoffeeMaker.Option.FlowRate + ConsumerProducts.CoffeeMaker.Option.HotWaterTemperature + ConsumerProducts.CoffeeMaker.Option.MultipleBeverages + ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte + ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino + ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee + ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso + ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio + ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato + ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater + ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato + ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth + ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto + ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk + ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee + ConsumerProducts.CoffeeMaker.Program.CleaningModes.ApplianceOffRinsing + ConsumerProducts.CoffeeMaker.Program.CleaningModes.ApplianceOnRinsing + ConsumerProducts.CoffeeMaker.Program.CleaningModes.CalcNClean + ConsumerProducts.CoffeeMaker.Program.CleaningModes.Clean + ConsumerProducts.CoffeeMaker.Program.CleaningModes.Descale + ConsumerProducts.CoffeeMaker.Program.CleaningModes.RinseMilkSystem + ConsumerProducts.CoffeeMaker.Program.CleaningModes.RinseWaterFilter + ConsumerProducts.CoffeeMaker.Program.CleaningModes.SpecialRinsing + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun + ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange + ConsumerProducts.CoffeeMaker.ProgramGroup.Beverages + ConsumerProducts.CoffeeMaker.ProgramGroup.CleaningModes + ConsumerProducts.CoffeeMaker.ProgramGroup.CoffeeWorld + ConsumerProducts.CoffeeMaker.Setting.CoffeeMilkOrder + ConsumerProducts.CoffeeMaker.Setting.CoffeeMilkOrderMac + ConsumerProducts.CoffeeMaker.Setting.CupWarmer + ConsumerProducts.CoffeeMaker.Setting.LeaveProfilesAutomatically + ConsumerProducts.CoffeeMaker.Setting.MacchiatoPause + ConsumerProducts.CoffeeMaker.Setting.PlaylistMode + ConsumerProducts.CoffeeMaker.Setting.PlaylistRunning + ConsumerProducts.CoffeeMaker.Setting.RemindForMilkAfter + ConsumerProducts.CoffeeMaker.Setting.SwitchOffAfter + ConsumerProducts.CoffeeMaker.Setting.UserMode + ConsumerProducts.CoffeeMaker.Setting.WaterFilter + ConsumerProducts.CoffeeMaker.Setting.WaterHardness + ConsumerProducts.CoffeeMaker.Status.BeverageCountdownCalcNClean + ConsumerProducts.CoffeeMaker.Status.BeverageCountdownCleaning + ConsumerProducts.CoffeeMaker.Status.BeverageCountdownDescaling + ConsumerProducts.CoffeeMaker.Status.BeverageCountdownWaterfilter + ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffee + ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffeeAndMilk + ConsumerProducts.CoffeeMaker.Status.BeverageCounterFrothyMilk + ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotMilk + ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWater + ConsumerProducts.CoffeeMaker.Status.BeverageCounterPowderCoffee + ConsumerProducts.CoffeeMaker.Status.BeverageCounterRistrettoEspresso + ConsumerProducts.CoffeeMaker.Status.DemoModeActive + ConsumerProducts.CoffeeMaker.Status.LastSelectedBeverage + ConsumerProducts.CoffeeMaker.Status.LastSelectedCoffeeWorldBeverage + ConsumerProducts.CoffeeMaker.Status.ProcessPhase + + + BSH.Common.Error.200.OK + BSH.Common.Error.202.Accepted + BSH.Common.Error.400.BadRequest + BSH.Common.Error.403.Forbidden + BSH.Common.Error.404.NotFound + BSH.Common.Error.405.MethodNotAllowed + BSH.Common.Error.413.RequestEntityTooLong + BSH.Common.Error.414.RequestUriTooLong + BSH.Common.Error.429.TooManyRequests + BSH.Common.Error.500.InternalServerError + BSH.Common.Error.501.NotImplemented + BSH.Common.Error.502.BadGateway + BSH.Common.Error.503.ServiceUnavailable + BSH.Common.Error.504.GatewayTimeout + BSH.Common.Error.507.InsufficientMemory + BSH.Common.Error.BackendNotConnected + BSH.Common.Error.Busy + BSH.Common.Error.CmdViolation + BSH.Common.Error.DeviceStateNotCompliant + BSH.Common.Error.EnergyManagementNotConnected + BSH.Common.Error.Incomplete + BSH.Common.Error.Inconsistent + BSH.Common.Error.InvalidFormat + BSH.Common.Error.InvalidUIDValue + BSH.Common.Error.LockedByLocalControl + BSH.Common.Error.NoAccess + BSH.Common.Error.NoAccessByList + BSH.Common.Error.NoExecution + BSH.Common.Error.NotAvailable + BSH.Common.Error.NotAvailableByList + BSH.Common.Error.NotInLocalWiFi + BSH.Common.Error.ProcessStateNotCompliant + BSH.Common.Error.ReadRequest.Busy + BSH.Common.Error.ReadRequest.NoAccess + BSH.Common.Error.ReadRequest.NoAccessByList + BSH.Common.Error.ReadRequest.NotAvailable + BSH.Common.Error.ReadRequest.NotAvailableByList + BSH.Common.Error.ReadRequest.UnknownUID + BSH.Common.Error.RemoteControlNotActive + BSH.Common.Error.RemoteStartNotActive + BSH.Common.Error.UnknownUID + BSH.Common.Error.ValueOutOfRange + BSH.Common.Error.WriteRequest.Busy + BSH.Common.Error.WriteRequest.NoAccess + BSH.Common.Error.WriteRequest.NoAccessByList + BSH.Common.Error.WriteRequest.NotAvailable + BSH.Common.Error.WriteRequest.NotAvailableByList + BSH.Common.Error.WriteRequest.UnknownUID + + + + Open + Closed + + + Off + Present + Confirmed + + + De + En + Fr + It + + + Inactive + Ready + Run + Pause + ActionRequired + Finished + Error + Aborting + + + On + Standby + + + VeryMild + Mild + MildPlus + Normal + NormalPlus + Strong + StrongPlus + VeryStrong + VeryStrongPlus + ExtraStrong + DoubleShot + DoubleShotPlus + DoubleShotPlusPlus + TripleShot + TripleShotPlus + CoffeeGround + + + FirstMilk + FirstCoffee + + + FirstMilk + FirstCoffee + + + 10Percent + 20Percent + 25Percent + 30Percent + 40Percent + 50Percent + 60Percent + 67Percent + 70Percent + 80Percent + 90Percent + + + 88C + 90C + 92C + 94C + 95C + 96C + + + Two + Four + Six + + + Normal + Intense + IntensePlus + + + 60C + 70C + 80C + 85C + 90C + 97C + + + Min + 5Sec + 10Sec + + + None + BeverageAborting + Grinding + BrewingUnitMovesUp + CoffeeBrewing + BrewingUnitMovesDown + MilkFroth + CleaningMilkSystem + HotWaterDispensing + CleaningMode + DescalePhase + RinsePhase + CleanPhase + + + Off + 00Hours05Minutes + 00Hours10Minutes + 00Hours15Minutes + 00Hours20Minutes + 00Hours25Minutes + 00Hours30Minutes + 00Hours35Minutes + 00Hours40Minutes + 00Hours45Minutes + 00Hours50Minutes + 00Hours55Minutes + 01Hours00Minutes + 01Hours15Minutes + 01Hours30Minutes + 01Hours45Minutes + 02Hours00Minutes + 02Hours15Minutes + 02Hours30Minutes + 02Hours45Minutes + 03Hours00Minutes + + + 00Hours05Minutes + 00Hours10Minutes + 00Hours15Minutes + 00Hours20Minutes + 00Hours25Minutes + 00Hours30Minutes + 00Hours35Minutes + 00Hours40Minutes + 00Hours45Minutes + 00Hours50Minutes + 00Hours55Minutes + 01Hours00Minutes + 01Hours15Minutes + 01Hours30Minutes + 01Hours45Minutes + 02Hours00Minutes + 02Hours15Minutes + 02Hours30Minutes + 02Hours45Minutes + 03Hours00Minutes + + + Standard + Barista + + + Soft + Medium + Hard + VeryHard + SofteningDevice + + + diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-WM16XE91-000000000000_DeviceDescription.xml b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-WM16XE91-000000000000_DeviceDescription.xml new file mode 100644 index 0000000000..750c1c96d8 --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-WM16XE91-000000000000_DeviceDescription.xml @@ -0,0 +1 @@ +xinclude error: file "HC_INT_BSH_CTD.xml" not foundxinclude error: file "HC_INT_BSH_DTD.xml" not foundxinclude error: file "HC_INT_BSH_DEV.xml" not foundWasherSIEMENSWM16XE9120Application \ No newline at end of file diff --git a/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-WM16XE91-000000000000_FeatureMapping.xml b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-WM16XE91-000000000000_FeatureMapping.xml new file mode 100644 index 0000000000..11e57b4ded --- /dev/null +++ b/bundles/org.openhab.binding.homeconnectdirect/src/test/resources/userdata/SIEMENS-WM16XE91-000000000000_FeatureMapping.xml @@ -0,0 +1,498 @@ + + + + + xinclude error: file "HC_INT_PLC_WM16XE91_2-0_DeviceDescription.xml" not found + + + + BSH.Common.Command.AbortProgram + BSH.Common.Command.AcknowledgeEvent + BSH.Common.Command.AllowCustomerServiceConnectionLocalWiFi + BSH.Common.Command.ApplyFactoryReset + BSH.Common.Command.ApplyNetworkReset + BSH.Common.Command.DeactivateRemoteControlStart + BSH.Common.Command.DeactivateWiFi + BSH.Common.Command.DepairCustomerEnergyManager + BSH.Common.Command.DisallowCustomerServiceConnection + BSH.Common.Command.MainsPowerOff + BSH.Common.Command.PauseProgram + BSH.Common.Command.RejectEvent + BSH.Common.Command.ResumeProgram + BSH.Common.Event.AquaStopOccured + BSH.Common.Event.CustomerServiceRequest + BSH.Common.Event.HomeConnectApplianceDataMissing + BSH.Common.Event.LowWaterPressure + BSH.Common.Event.ProgramAborted + BSH.Common.Event.ProgramFinished + BSH.Common.Event.SoftwareUpdateAvailable + BSH.Common.Option.Duration + BSH.Common.Option.EnergyForecast + BSH.Common.Option.EstimatedTotalProgramTime + BSH.Common.Option.FinishInRelative + BSH.Common.Option.FlexFinishInRelative + BSH.Common.Option.ProgramProgress + BSH.Common.Option.RemainingProgramTime + BSH.Common.Option.RemainingProgramTimeIsEstimated + BSH.Common.Option.WaterForecast + BSH.Common.Root.ActiveProgram + BSH.Common.Root.CommandList + BSH.Common.Root.EventList + BSH.Common.Root.OptionList + BSH.Common.Root.ProgramGroup + BSH.Common.Root.SelectedProgram + BSH.Common.Root.SettingList + BSH.Common.Root.StatusList + BSH.Common.Setting.AllowBackendConnection + BSH.Common.Setting.AllowEnergyManagement + BSH.Common.Setting.AllowFlexStart + BSH.Common.Setting.ApplianceTime + BSH.Common.Setting.ChildLock + BSH.Common.Setting.Language + BSH.Common.Setting.PowerState + BSH.Common.Setting.SynchronizeWithTimeServer + BSH.Common.Setting.Time.DisplayMode + BSH.Common.Setting.TimeFormat + BSH.Common.Status.BackendConnected + BSH.Common.Status.CustomerEnergyManagerPaired + BSH.Common.Status.CustomerServiceConnectionAllowed + BSH.Common.Status.DoorState + BSH.Common.Status.FlexStart + BSH.Common.Status.LocalControlActive + BSH.Common.Status.OperationState + BSH.Common.Status.RemoteControlActive + BSH.Common.Status.RemoteControlStartAllowed + BSH.Common.Status.RemoteControlStartAllowedSince + BSH.Common.Status.WiFiSignalStrength + LaundryCare.Common.Command.SelectProgramGroup + LaundryCare.Common.CommandList.RemoteStart + LaundryCare.Common.Event.DelayedShutdown + LaundryCare.Common.Event.DelayedShutdownCanceled + LaundryCare.Common.Event.DoorLock.WaterLevelTooHigh + LaundryCare.Common.Event.DoorNotLockable + LaundryCare.Common.Event.DoorNotUnlockable + LaundryCare.Common.Event.DoorOpen + LaundryCare.Common.Event.FatalErrorOccured + LaundryCare.Common.Event.FoamDetection + LaundryCare.Common.Event.SupplyPower.SupplyVoltageTooLow + LaundryCare.Common.Option.LoadRecommendation + LaundryCare.Common.Option.LowTemperatureHygiene + LaundryCare.Common.Option.ProcessPhase + LaundryCare.Common.Option.ReferToProgram + LaundryCare.Common.Option.VarioPerfect + LaundryCare.Common.OptionList.ActiveStatus + LaundryCare.Common.OptionList.Options + LaundryCare.Common.OptionList.RemoteStart + LaundryCare.Common.OptionList.SelectedStatus + LaundryCare.Common.Program.Memory1 + LaundryCare.Common.Program.Memory2 + LaundryCare.Common.Program.Memory3 + LaundryCare.Common.Program.Memory4 + LaundryCare.Common.Program.Memory5 + LaundryCare.Common.Program.Memory6 + LaundryCare.Common.Program.Memory7 + LaundryCare.Common.Program.Memory8 + LaundryCare.Common.ProgramGroup.Favorites + LaundryCare.Common.ProgramGroup.Regular + LaundryCare.Common.ProgramGroup.Washer + LaundryCare.Common.Setting.AutoPowerOff + LaundryCare.Common.Setting.Brightness + LaundryCare.Common.Setting.BrightnessLevel + LaundryCare.Common.Setting.DoorLightRing.Active + LaundryCare.Common.Setting.DoorLightRing.ActiveMode + LaundryCare.Common.Setting.DoorLightRing.Brightness + LaundryCare.Common.Setting.DoorLightRing.BrightnessLevel + LaundryCare.Common.Setting.EndSignal + LaundryCare.Common.Setting.EndSignalVolume + LaundryCare.Common.Setting.KeySignalVolume + LaundryCare.Common.Setting.Sound.Mute + LaundryCare.Common.Setting.TimeLight.Active + LaundryCare.Common.Status.LoadInformation + LaundryCare.Common.Status.SelectedProgramGroup + LaundryCare.Common.StatusList.ProcessingData + LaundryCare.Common.StatusList.WasherData + LaundryCare.Washer.Event.DrumCleanReminder + LaundryCare.Washer.Event.IDos.IDosOpenTray + LaundryCare.Washer.Event.IDos1FillLevelPoor + LaundryCare.Washer.Event.IDos2FillLevelPoor + LaundryCare.Washer.Event.IDosUnitDefect + LaundryCare.Washer.Event.PumpError + LaundryCare.Washer.Event.ReleaseRinseHoldPending + LaundryCare.Washer.Event.Spin.SpinAbort + LaundryCare.Washer.Option.Disinfectant + LaundryCare.Washer.Option.HygienicSteam + LaundryCare.Washer.Option.HygienicSteamIntensity + LaundryCare.Washer.Option.IDos1.Active + LaundryCare.Washer.Option.IDos1DosingLevel + LaundryCare.Washer.Option.IDos2.Active + LaundryCare.Washer.Option.IDos2DosingLevel + LaundryCare.Washer.Option.IntensivePlus + LaundryCare.Washer.Option.LessIroning + LaundryCare.Washer.Option.MultipleSoak + LaundryCare.Washer.Option.Prewash + LaundryCare.Washer.Option.RinseHold + LaundryCare.Washer.Option.RinsePlus + LaundryCare.Washer.Option.RinsePlus1 + LaundryCare.Washer.Option.RinsePlus3 + LaundryCare.Washer.Option.SilentWash + LaundryCare.Washer.Option.Soak + LaundryCare.Washer.Option.SpeedPerfect + LaundryCare.Washer.Option.SpinSpeed + LaundryCare.Washer.Option.Stains + LaundryCare.Washer.Option.Temperature + LaundryCare.Washer.Option.WaterAndRinsePlus + LaundryCare.Washer.Option.WaterAndRinsePlus1 + LaundryCare.Washer.Option.WaterPlus + LaundryCare.Washer.Program.Auto30 + LaundryCare.Washer.Program.Auto40 + LaundryCare.Washer.Program.Cotton + LaundryCare.Washer.Program.Cotton.Colour + LaundryCare.Washer.Program.Cotton.Eco4060 + LaundryCare.Washer.Program.DarkWash + LaundryCare.Washer.Program.DelicatesSilk + LaundryCare.Washer.Program.Drain + LaundryCare.Washer.Program.DrumClean + LaundryCare.Washer.Program.EasyCare + LaundryCare.Washer.Program.Mix + LaundryCare.Washer.Program.PowerSpeed59 + LaundryCare.Washer.Program.Rinse + LaundryCare.Washer.Program.ShirtsBlouses + LaundryCare.Washer.Program.Spin + LaundryCare.Washer.Program.Super153045.Super1530 + LaundryCare.Washer.Program.WaterProof + LaundryCare.Washer.Program.Wool + LaundryCare.Washer.Setting.EnableDrumCleanReminder + LaundryCare.Washer.Setting.IDos1.ContentName + LaundryCare.Washer.Setting.IDos1BaseLevel + LaundryCare.Washer.Setting.IDos2.ContentName + LaundryCare.Washer.Setting.IDos2BaseLevel + LaundryCare.Washer.Setting.IDos2Content + LaundryCare.Washer.Status.IDos1FillLevel + LaundryCare.Washer.Status.IDos2FillLevel + LaundryCare.Washer.Status.ProcessingData.LaundryLoad + LaundryCare.Washer.Status.ProcessingData.PeakFinalSpinSpeed + LaundryCare.Washer.Status.ProcessingData.PeakFinalSpinSpeedDuration + LaundryCare.Washer.Status.ProcessingData.TextileType + + + BSH.Common.Error.200.OK + BSH.Common.Error.202.Accepted + BSH.Common.Error.400.BadRequest + BSH.Common.Error.403.Forbidden + BSH.Common.Error.404.NotFound + BSH.Common.Error.405.MethodNotAllowed + BSH.Common.Error.413.RequestEntityTooLong + BSH.Common.Error.414.RequestUriTooLong + BSH.Common.Error.429.TooManyRequests + BSH.Common.Error.500.InternalServerError + BSH.Common.Error.501.NotImplemented + BSH.Common.Error.502.BadGateway + BSH.Common.Error.503.ServiceUnavailable + BSH.Common.Error.504.GatewayTimeout + BSH.Common.Error.507.InsufficientMemory + BSH.Common.Error.BackendNotConnected + BSH.Common.Error.Busy + BSH.Common.Error.CmdViolation + BSH.Common.Error.DeviceStateNotCompliant + BSH.Common.Error.EnergyManagementNotConnected + BSH.Common.Error.Incomplete + BSH.Common.Error.Inconsistent + BSH.Common.Error.InvalidFormat + BSH.Common.Error.InvalidUIDValue + BSH.Common.Error.LockedByLocalControl + BSH.Common.Error.NoAccess + BSH.Common.Error.NoAccessByList + BSH.Common.Error.NoExecution + BSH.Common.Error.NotAvailable + BSH.Common.Error.NotAvailableByList + BSH.Common.Error.NotInLocalWiFi + BSH.Common.Error.ProcessStateNotCompliant + BSH.Common.Error.ReadRequest.Busy + BSH.Common.Error.ReadRequest.NoAccess + BSH.Common.Error.ReadRequest.NoAccessByList + BSH.Common.Error.ReadRequest.NotAvailable + BSH.Common.Error.ReadRequest.NotAvailableByList + BSH.Common.Error.ReadRequest.UnknownUID + BSH.Common.Error.RemoteControlNotActive + BSH.Common.Error.RemoteStartNotActive + BSH.Common.Error.UnknownUID + BSH.Common.Error.ValueOutOfRange + BSH.Common.Error.WriteRequest.Busy + BSH.Common.Error.WriteRequest.NoAccess + BSH.Common.Error.WriteRequest.NoAccessByList + BSH.Common.Error.WriteRequest.NotAvailable + BSH.Common.Error.WriteRequest.NotAvailableByList + BSH.Common.Error.WriteRequest.UnknownUID + + + + Open + Closed + Locked + Ajar + + + Off + Present + Confirmed + + + Disabled + Enabled + Pending + Scheduled + Started + Finished + + + Ar + Az + Bg + Bs + Cs + Da + De + El + En + EnUs + Es + EsMx + Et + Fi + Fr + FrCa + He + Hi + Hr + Hu + Id + Is + It + Ja + Kk + Ko + Lt + Lv + Mk + Mo + Ms + Nl + No + Pl + Pt + PtBr + Ro + Ru + Sk + Sl + Sq + Sr + Sv + Th + Tr + Uk + Vi + Zh + ZhHk + ZhTw + Fa + + + Inactive + Ready + DelayedStart + Run + Pause + ActionRequired + Finished + Error + Aborting + + + MainsOff + On + Standby + + + Relative + Absolute + + + 24Hours + 12Hours + + + Off + On + Auto + + + Off + 15min + 30min + 60min + + + Low + Medium + High + VeryHigh + + + NoPhase + Prewash + IntermediateSpin + FillingDetergent + DetectingLoad + Heating + Washing + Cooldown + RinsingFoam + Rinsing + RinsingAquaSensor + RinseOnly + WaterProofing + RinsingSoftener + HoldAfterRinse + Pumping + SpinningFinal + LessIroning + Fluffing + Soaking + HygienicSteamFogging + GuardingOxy + GeneratingOzone + DegradingOzone + Drying + CoolingDown + AdditionalCoolingDown + GuardingWrinkle + DetectingTextile + DetectingSoil + Disinfecting + LowTemperatureHygiene + SteamingActive + IronDryReached + CupboardDryReached + CupboardDryPlusReached + CleaningHeatExchanger + SlightlyDampReached + ExtraDryReached + GentlyDryReached + QuickCare + DepthCarePhase1 + DepthCarePhase2 + ColdRefreshing + AnyPhase + + + Mixed + Cotton + Synthetic + + + Off + Low + Medium + Loud + VeryLoud + + + Off + EcoPerfect + SpeedPerfect + + + Middle + High + + + Softener + Detergent + + + Poor + Filled + + + Off + Light + Normal + Strong + + + Off + Plus1 + Plus2 + Plus3 + + + Off + Plus1 + Plus2 + Plus3 + + + Off + UlOff + UlLow + RPM400 + RPM600 + RPM700 + UlMedium + RPM800 + RPM900 + RPM1000 + UlHigh + RPM1200 + RPM1400 + RPM1500 + RPM1600 + Auto + Max + + + Off + BabyFood + Blood + Egg + Mud + Grass + Coffee + Cosmetics + RedWine + Chocolate + Perspiration + Socks + ButterOil + Tea + TomatoSauce + Strawberry + Orange + + + Cold + GC20 + GC30 + GC40 + GC50 + GC60 + GC70 + GC80 + GC90 + UlCold + UlWarm + UlHot + UlExtraHot + Auto + Max + + + Off + Plus1 + Plus2 + Plus3 + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index eaae93afb7..d5c468f816 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -205,6 +205,7 @@ org.openhab.binding.herzborg org.openhab.binding.homeassistant org.openhab.binding.homeconnect + org.openhab.binding.homeconnectdirect org.openhab.binding.homekit org.openhab.binding.homie org.openhab.binding.homematic