diff --git a/CODEOWNERS b/CODEOWNERS
index 7ca2d5d362f02..da38d0ee73337 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -442,6 +442,7 @@
/bundles/org.openhab.binding.wlanthermo/ @CSchlipp
/bundles/org.openhab.binding.wled/ @Skinah
/bundles/org.openhab.binding.wolfsmartset/ @BoBiene
+/bundles/org.openhab.binding.worxlandroid/ @clinique
/bundles/org.openhab.binding.wundergroundupdatereceiver/ @danieldemus
/bundles/org.openhab.binding.x/ @computergeek1507
/bundles/org.openhab.binding.xmltv/ @clinique
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 2aa407838123e..e954b8c268d8d 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -2186,6 +2186,11 @@
org.openhab.binding.wolfsmartset
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.worxlandroid
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.wundergroundupdatereceiver
diff --git a/bundles/org.openhab.binding.worxlandroid/NOTICE b/bundles/org.openhab.binding.worxlandroid/NOTICE
new file mode 100644
index 0000000000000..38d625e349232
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/NOTICE
@@ -0,0 +1,13 @@
+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
diff --git a/bundles/org.openhab.binding.worxlandroid/README.md b/bundles/org.openhab.binding.worxlandroid/README.md
new file mode 100644
index 0000000000000..c2c3bc95d26cb
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/README.md
@@ -0,0 +1,526 @@
+
+# WorxLandroid Binding
+
+This is the binding for Worx Landroid robotic lawn mowers.
+It connects openHAB with your WorxLandroid mower using the API and MQTT.
+This binding allows you to integrate, view and control supported Worx lawn mowers with openHAB.
+
+## Supported Things
+
+Currently following Things are supported:
+
+- `bridge`: **Bridge Worx Landroid API** Thing representing the handler for Worx API
+- `mower`: One or many Things for supported **Landroid Mower**'s
+
+## Discovery
+
+A Bridge is required to connect to the Worx API.
+Here you can provide your credentials for your WorxLandroid account.
+Once the Bridge has been added Worx Landroid mowers will be discovered automatically.
+
+## Things Configuration
+
+The following options can be set for the `bridge`:
+
+| Property | Description |
+|----------|------------------------------------------|
+| username | Username to access the WorxLandroid API. |
+| password | Password to access the WorxLandroid API. |
+
+The following options can be set for the `mower`:
+
+| Property | Description | Default | Advanced |
+|-----------------------|----------------------------------------------------------------------------------------------|---------|----------|
+| serialNumber | Serial number of the mower | - | No |
+| refreshStatusInterval | Interval for refreshing mower status (ONLINE/OFFLINE) and channel 'common#online' in seconds | 3600 | Yes |
+| pollingInterval | Interval for polling in seconds (min="30" max="7200"). | 1200 | Yes |
+
+
+Default values for `refreshStatusInterval` and `pollingInterval` are the recommended settings in order to prevent a 24h ban from Worx.
+Lower polling and refresh values will likely result in a 24h ban for your account.
+
+## Channels
+
+Currently following **Channels** are supported on the **Landroid Mower**:
+
+### Common
+
+| Channel | Type | ChannelName | Values |
+|------------------|----------|-------------------------|-------------------|
+| status | String | common#status | *1 (see below) |
+| error | String | common#error | *2 (see below) |
+| online | Switch | common#online | |
+| online-timestamp | DateTime | common#online-timestamp | |
+| action | String | common#action | START, STOP, HOME |
+| enable | Switch | common#enable | |
+| lock | Switch | common#lock | |
+
+*1: Values for **error** Channel:
+
+UNKNOWN, NO_ERR, TRAPPED, LIFTED, WIRE_MISSING, OUTSIDE_WIRE, RAINING, CLOSE_DOOR_TO_MOW, CLOSE_DOOR_TO_GO_HOME, BLADE_MOTOR_BLOCKED, WHEEL_MOTOR_BLOCKED, TRAPPED_TIMEOUT, UPSIDE_DOWN, BATTERY_LOW, REVERSE_WIRE, CHARGE_ERROR, TIMEOUT_FINDING_HOME, MOWER_LOCKED, BATTERY_OVER_TEMPERATURE, MOWER_OUTSIDE_WIRE
+
+*2: Values for **status** Channel:
+
+UNKNOWN, IDLE, HOME, START_SEQUENCE, LEAVING_HOME, FOLLOW_WIRE, SEARCHING_HOME, SEARCHING_WIRE, MOWING, LIFTED, TRAPPED, BLADE_BLOCKED, DEBUG, REMOTE_CONTROL, GOING_HOME, ZONE_TRAINING, BORDER_CUT, SEARCHING_ZONE, PAUSE, MANUAL_STOP
+
+### Config
+
+| Channel | Type | ChannelName |
+|-----------|----------|------------------|
+| timestamp | DateTime | config#timestamp |
+| command | Number | config#command |
+
+### Multi-Zones
+
+If Multi Zones are supported, you are able to define 4 separate zones and split working times by 10 to those.
+
+To ease zone configuration, you are able to set distance in meters where a specific zone starts. Bearing in mind that you roughly shall know how many meters of cable have been used (without buffer).
+
+As second step you are able to set time in percent and split in parts of 10 between allocation zones.
+
+| Channel | Type | ChannelName |
+|--------------|---------------|--------------------------|
+| enable | Switch | multi-zones#enable |
+| last-zone | Number | multi-zones#last-zone |
+| zone-1 | Number:Length | multi-zones#zone-1 |
+| zone-2 | Number:Length | multi-zones#zone-2 |
+| zone-3 | Number:Length | multi-zones#zone-3 |
+| zone-4 | Number:Length | multi-zones#zone-4 |
+| allocation-0 | Number | multi-zones#allocation-0 |
+| allocation-1 | Number | multi-zones#allocation-1 |
+| allocation-2 | Number | multi-zones#allocation-2 |
+| allocation-3 | Number | multi-zones#allocation-3 |
+| allocation-4 | Number | multi-zones#allocation-4 |
+| allocation-5 | Number | multi-zones#allocation-5 |
+| allocation-6 | Number | multi-zones#allocation-6 |
+| allocation-7 | Number | multi-zones#allocation-7 |
+| allocation-8 | Number | multi-zones#allocation-8 |
+| allocation-9 | Number | multi-zones#allocation-9 |
+
+### Schedule
+
+| Channel | Type | ChannelName | |
+|----------------|----------|-------------------------|-------------------|
+| mode | String | schedule#mode | ONLY IF SUPPORTED |
+| time-extension | Number | schedule#time-extension | |
+| next-start | DateTime | schedule#next-start | |
+| next-stop | DateTime | schedule#next-stop | |
+
+### Aws
+
+| Channel | Type | ChannelName |
+|-----------|--------|---------------|
+| poll | Switch | aws#poll |
+| connected | Switch | aws#connected |
+
+### Sunday (Slot 1)
+
+| Channel | Type | ChannelName |
+|----------|-------------|-----------------|
+| enable | Switch | sunday#enable |
+| time | DateTime | sunday#time |
+| duration | Number:Time | sunday#duration |
+| edgecut | Switch | sunday#edgecut |
+
+### Sunday2 (Slot 2, ONLY IF SUPPORTED)
+
+| Channel | Type | ChannelName |
+|----------|-------------|------------------|
+| enable | Switch | sunday2#enable |
+| time | DateTime | sunday2#time |
+| duration | Number:Time | sunday2#duration |
+| edgecut | Switch | sunday2#edgecut |
+
+And so on for each day of the week along with the Slot 2 when supported.
+
+### One-Time
+
+| Channel | Type | ChannelName |
+|----------|--------|-------------------|
+| edgecut | Switch | one-time#edgecut |
+| duration | Switch | one-time#duration |
+
+### Battery
+
+| Channel | Type | ChannelName |
+|---------------------|--------------------------|-----------------------------|
+| temperature | Number:Temperature | battery#temperature |
+| voltage | Number:ElectricPotential | battery#voltage |
+| level | Number | battery#level |
+| charge-cycles | Number | battery#charge-cycles |
+| charge-cycles-total | Number | battery#charge-cycles-total |
+| charging | Switch | battery#charging |
+
+### Orientation
+
+| Channel | Type | ChannelName |
+|---------|--------------|-------------------|
+| pitch | Number:Angle | orientation#pitch |
+| roll | Number:Angle | orientation#roll |
+| yaw | Number:Angle | orientation#yaw |
+
+### Metrics
+
+| Channel | Type | ChannelName |
+|------------------|---------------|--------------------------|
+| blade-time | Number:Time | metrics#blade-time |
+| blade-time-total | Number:Time | metrics#blade-time-total |
+| distance | Number:Length | metrics#distance |
+| total-time | Number:Time | metrics#total-time |
+
+### Rain (if supported)
+
+| Channel | Type | ChannelName |
+|---------|-------------|--------------|
+| state | Switch | rain#state |
+| counter | Number:Time | rain#counter |
+| delay | Number:Time | rain#delay |
+
+### Wifi
+
+| Channel | Type | ChannelName |
+|--------------|--------------|-------------------|
+| rssi | Number:Power | wifi#rssi |
+| wifi-quality | Number | wifi#wifi-quality |
+
+## Examples
+
+### $OPENHAB_CONF/items/landroid.things
+
+```java
+Bridge worxlandroid:bridge:api "Worx Api" [ username="xxxxYYYxxxx", password="dldkssdjldj" ] {
+ Thing mower lanmower "Worx M600" [ serialNumber="sdmldksmdskmlsd" ]
+}
+
+```
+
+
+### $OPENHAB_CONF/items/landroid.items
+
+```java
+String MyMower "MyMower [%s]"
+String LandroidMowerCommonStatus "Status code" {channel="worxlandroid:mower:MyWorxBridge:mymower:common#status"}
+String LandroidMowerCommonError "Error code" {channel="worxlandroid:mower:MyWorxBridge:mymower:common#error"}
+Switch LandroidMowerCommonOnline "Online" {channel="worxlandroid:mower:MyWorxBridge:mymower:common#online"}
+DateTime LandroidMowerCommonOnlineTimestamp "Online status timestamp" {channel="worxlandroid:mower:MyWorxBridge:mymower:common#online-timestamp"}
+String LandroidMowerCommonAction "Action" {channel="worxlandroid:mower:MyWorxBridge:mymower:common#action"}
+Switch LandroidMowerCommonEnable "Mowing enabled" {channel="worxlandroid:mower:MyWorxBridge:mymower:common#enable"}
+Switch LandroidMowerCommonLock "Lock mower wifi" {channel="worxlandroid:mower:MyWorxBridge:mymower:common#lock"}
+DateTime LandroidMowerConfigTimestamp "Last update" {channel="worxlandroid:mower:MyWorxBridge:mymower:config#timestamp"}
+Number LandroidMowerConfigCommand "Command" {channel="worxlandroid:mower:MyWorxBridge:mymower:config#command"}
+Switch LandroidMowerMultiZonesEnable "Multizone enabled" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#enable"}
+Number LandroidMowerMultiZonesLastZone "Last zone" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#last-zone"}
+Number:Length LandroidMowerMultiZonesZone1 "Meters zone 1" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#zone-1"}
+Number:Length LandroidMowerMultiZonesZone2 "Meters zone 2" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#zone-2"}
+Number:Length LandroidMowerMultiZonesZone3 "Meters zone 3" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#zone-3"}
+Number:Length LandroidMowerMultiZonesZone4 "Meters zone 4" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#zone-4"}
+Number LandroidMowerMultiZonesAllocation0 "Zone allocation 1" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-0"}
+Number LandroidMowerMultiZonesAllocation1 "Zone allocation 2" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-1"}
+Number LandroidMowerMultiZonesAllocation2 "Zone allocation 3" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-2"}
+Number LandroidMowerMultiZonesAllocation3 "Zone allocation 4" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-3"}
+Number LandroidMowerMultiZonesAllocation4 "Zone allocation 5" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-4"}
+Number LandroidMowerMultiZonesAllocation5 "Zone allocation 6" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-5"}
+Number LandroidMowerMultiZonesAllocation6 "Zone allocation 7" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-6"}
+Number LandroidMowerMultiZonesAllocation7 "Zone allocation 8" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-7"}
+Number LandroidMowerMultiZonesAllocation8 "Zone allocation 9" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-8"}
+Number LandroidMowerMultiZonesAllocation9 "Zone allocation 10" {channel="worxlandroid:mower:MyWorxBridge:mymower:multi-zones#allocation-9"}
+String LandroidMowerScheduleMode "Schedule mode" {channel="worxlandroid:mower:MyWorxBridge:mymower:schedule#mode"}
+Number:Dimensionless LandroidMowerScheduleTimeExtension "Schedule time extension" {channel="worxlandroid:mower:MyWorxBridge:mymower:schedule#time-extension"}
+DateTime LandroidMowerScheduleNextStart "Next start" {channel="worxlandroid:mower:MyWorxBridge:mymower:schedule#next-start"}
+DateTime LandroidMowerScheduleNextStop "Next stop" {channel="worxlandroid:mower:MyWorxBridge:mymower:schedule#next-stop"}
+Switch LandroidMowerAwsPoll "Poll AWS" {channel="worxlandroid:mower:MyWorxBridge:mymower:aws#poll"}
+Switch LandroidMowerAwsConnected "AWS connected" {channel="worxlandroid:mower:MyWorxBridge:mymower:aws#connected"}
+Switch LandroidMowerSundayEnable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:sunday#enable"}
+DateTime LandroidMowerSundayTime "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:sunday#time"}
+Number:Time LandroidMowerSundayDuration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:sunday#duration", unit="min"}
+Switch LandroidMowerSundayEdgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:sunday#edgecut"}
+Switch LandroidMowerSunday2Enable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:sunday2#enable"}
+DateTime LandroidMowerSunday2Time "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:sunday2#time"}
+Number:Time LandroidMowerSunday2Duration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:sunday2#duration", unit="min"}
+Switch LandroidMowerSunday2Edgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:sunday2#edgecut"}
+Switch LandroidMowerMondayEnable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:monday#enable"}
+DateTime LandroidMowerMondayTime "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:monday#time"}
+Number:Time LandroidMowerMondayDuration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:monday#duration", unit="min"}
+Switch LandroidMowerMondayEdgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:monday#edgecut"}
+Switch LandroidMowerMonday2Enable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:monday2#enable"}
+DateTime LandroidMowerMonday2Time "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:monday2#time"}
+Number:Time LandroidMowerMonday2Duration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:monday2#duration", unit="min"}
+Switch LandroidMowerMonday2Edgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:monday2#edgecut"}
+Switch LandroidMowerTuesdayEnable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:tuesday#enable"}
+DateTime LandroidMowerTuesdayTime "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:tuesday#time"}
+Number:Time LandroidMowerTuesdayDuration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:tuesday#duration", unit="min"}
+Switch LandroidMowerTuesdayEdgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:tuesday#edgecut"}
+Switch LandroidMowerTuesday2Enable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:tuesday2#enable"}
+DateTime LandroidMowerTuesday2Time "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:tuesday2#time"}
+Number:Time LandroidMowerTuesday2Duration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:tuesday2#duration", unit="min"}
+Switch LandroidMowerTuesday2Edgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:tuesday2#edgecut"}
+Switch LandroidMowerWednesdayEnable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:wednesday#enable"}
+DateTime LandroidMowerWednesdayTime "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:wednesday#time"}
+Number:Time LandroidMowerWednesdayDuration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:wednesday#duration", unit="min"}
+Switch LandroidMowerWednesdayEdgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:wednesday#edgecut"}
+Switch LandroidMowerWednesday2Enable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:wednesday2#enable"}
+DateTime LandroidMowerWednesday2Time "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:wednesday2#time"}
+Number:Time LandroidMowerWednesday2Duration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:wednesday2#duration", unit="min"}
+Switch LandroidMowerWednesday2Edgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:wednesday2#edgecut"}
+Switch LandroidMowerThursdayEnable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:thursday#enable"}
+DateTime LandroidMowerThursdayTime "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:thursday#time"}
+Number:Time LandroidMowerThursdayDuration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:thursday#duration", unit="min"}
+Switch LandroidMowerThursdayEdgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:thursday#edgecut"}
+Switch LandroidMowerThursday2Enable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:thursday2#enable"}
+DateTime LandroidMowerThursday2Time "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:thursday2#time"}
+Number:Time LandroidMowerThursday2Duration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:thursday2#duration", unit="min"}
+Switch LandroidMowerThursday2Edgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:thursday2#edgecut"}
+Switch LandroidMowerFridayEnable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:friday#enable"}
+DateTime LandroidMowerFridayTime "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:friday#time"}
+Number:Time LandroidMowerFridayDuration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:friday#duration", unit="min"}
+Switch LandroidMowerFridayEdgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:friday#edgecut"}
+Switch LandroidMowerFriday2Enable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:friday2#enable"}
+DateTime LandroidMowerFriday2Time "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:friday2#time"}
+Number:Time LandroidMowerFriday2Duration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:friday2#duration", unit="min"}
+Switch LandroidMowerFriday2Edgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:friday2#edgecut"}
+Switch LandroidMowerSaturdayEnable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:saturday#enable"}
+DateTime LandroidMowerSaturdayTime "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:saturday#time"}
+Number:Time LandroidMowerSaturdayDuration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:saturday#duration", unit="min"}
+Switch LandroidMowerSaturdayEdgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:saturday#edgecut"}
+Switch LandroidMowerSaturday2Enable "Active" {channel="worxlandroid:mower:MyWorxBridge:mymower:saturday2#enable"}
+DateTime LandroidMowerSaturday2Time "Start time" {channel="worxlandroid:mower:MyWorxBridge:mymower:saturday2#time"}
+Number:Time LandroidMowerSaturday2Duration "Duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:saturday2#duration", unit="min"}
+Switch LandroidMowerSaturday2Edgecut "Edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:saturday2#edgecut"}
+Switch LandroidMowerOneTimeEdgecut "Schedule edgecut" {channel="worxlandroid:mower:MyWorxBridge:mymower:one-time#edgecut"}
+Number:Time LandroidMowerOneTimeDuration "Edgecut duration" {channel="worxlandroid:mower:MyWorxBridge:mymower:one-time#duration", unit="min"}
+Number:Temperature LandroidMowerBatteryTemperature "Battery temperature" {channel="worxlandroid:mower:MyWorxBridge:mymower:battery#temperature"}
+Number:ElectricPotential LandroidMowerBatteryVoltage "Battery voltage" {channel="worxlandroid:mower:MyWorxBridge:mymower:battery#voltage"}
+Number LandroidMowerBatteryLevel "Battery level" {channel="worxlandroid:mower:MyWorxBridge:mymower:battery#level"}
+Number LandroidMowerBatteryChargeCycles "Current charge cycles" {channel="worxlandroid:mower:MyWorxBridge:mymower:battery#charge-cycles"}
+Number LandroidMowerBatteryChargeCyclesTotal "Total charge cycles" {channel="worxlandroid:mower:MyWorxBridge:mymower:battery#charge-cycles-total"}
+Switch LandroidMowerBatteryCharging "Battery charging" {channel="worxlandroid:mower:MyWorxBridge:mymower:battery#charging"}
+Number:Angle LandroidMowerOrientationPitch "Pitch" {channel="worxlandroid:mower:MyWorxBridge:mymower:orientation#pitch"}
+Number:Angle LandroidMowerOrientationRoll "Roll" {channel="worxlandroid:mower:MyWorxBridge:mymower:orientation#roll"}
+Number:Angle LandroidMowerOrientationYaw "Yaw" {channel="worxlandroid:mower:MyWorxBridge:mymower:orientation#yaw"}
+Number:Time LandroidMowerMetricsBladeTime "Current blade time" {channel="worxlandroid:mower:MyWorxBridge:mymower:metrics#blade-time", unit="h"}
+Number:Time LandroidMowerMetricsBladeTimeTotal "Total blade time" {channel="worxlandroid:mower:MyWorxBridge:mymower:metrics#blade-time-total", unit="h"}
+Number:Length LandroidMowerMetricsDistance "Total distance" {channel="worxlandroid:mower:MyWorxBridge:mymower:metrics#distance", unit="km"}
+Number:Time LandroidMowerMetricsTotalTime "Total time" {channel="worxlandroid:mower:MyWorxBridge:mymower:metrics#total-time", unit="h"}
+Switch LandroidMowerRainState "State" {channel="worxlandroid:mower:MyWorxBridge:mymower:rain#state"}
+Number:Time LandroidMowerRainCounter "Counter" {channel="worxlandroid:mower:MyWorxBridge:mymower:rain#counter", unit="min"}
+Number:Time LandroidMowerRainDelay "Delay" {channel="worxlandroid:mower:MyWorxBridge:mymower:rain#delay", unit="min"}
+Number:Power LandroidMowerWifiRssi "Rssi" {channel="worxlandroid:mower:MyWorxBridge:mymower:wifi#rssi", unit="dBm"}
+Number LandroidMowerWifiWifiQuality "Wifi quality" {channel="worxlandroid:mower:MyWorxBridge:mymower:wifi#wifi-quality"}
+
+```
+
+### $OPENHAB_CONF/sitemaps/landroid.sitemap
+
+```perl
+sitemap landroid label="Landroid"
+{
+ Frame label="Worx Landroid Mower" {
+ Text label="Status" item=MyMower icon=none {
+ Default item=LandroidMowerCommonStatus
+ Default item=LandroidMowerCommonError
+ Default item=LandroidMowerCommonOnline
+ Default item=LandroidMowerCommonOnlineTimestamp
+ Default item=LandroidMowerConfigTimestamp
+ Default item=LandroidMowerConfigCommand
+ Default item=LandroidMowerAwsPoll
+ Text item=LandroidMowerAwsConnected label="AWS connected [%s]"
+ Default item=LandroidMowerOrientationPitch
+ Default item=LandroidMowerOrientationRoll
+ Default item=LandroidMowerOrientationYaw
+ Default item=LandroidMowerMetricsBladeTime
+ Default item=LandroidMowerMetricsBladeTimeTotal
+ Default item=LandroidMowerMetricsDistance
+ Default item=LandroidMowerMetricsTotalTime
+ Default item=LandroidMowerWifiRssi
+ Default item=LandroidMowerWifiWifiQuality
+ }
+ Text label="Control" icon=none {
+ Default item=LandroidMowerCommonAction
+ Default item=LandroidMowerCommonEnable
+ Default item=LandroidMowerCommonLock
+ Default item=LandroidMowerOneTimeEdgecut
+ Setpoint item=LandroidMowerOneTimeDuration minValue=30 maxValue=300 step=30
+ }
+ Text label="Multi zones" icon=none {
+ Default item=LandroidMowerMultiZonesEnable
+ Default item=LandroidMowerMultiZonesLastZone
+ Default item=LandroidMowerMultiZonesZone1
+ Default item=LandroidMowerMultiZonesZone2
+ Default item=LandroidMowerMultiZonesZone3
+ Default item=LandroidMowerMultiZonesZone4
+ Default item=LandroidMowerMultiZonesAllocation0
+ Default item=LandroidMowerMultiZonesAllocation1
+ Default item=LandroidMowerMultiZonesAllocation2
+ Default item=LandroidMowerMultiZonesAllocation3
+ Default item=LandroidMowerMultiZonesAllocation4
+ Default item=LandroidMowerMultiZonesAllocation5
+ Default item=LandroidMowerMultiZonesAllocation6
+ Default item=LandroidMowerMultiZonesAllocation7
+ Default item=LandroidMowerMultiZonesAllocation8
+ Default item=LandroidMowerMultiZonesAllocation9
+ }
+ Text label="Schedule" icon=none {
+ Default item=LandroidMowerScheduleMode
+ Setpoint item=LandroidMowerScheduleTimeExtension minValue=-100 maxValue=100 step=10
+ Default item=LandroidMowerScheduleNextStart
+ Default item=LandroidMowerScheduleNextStop
+ Text label="Sunday" icon=none {
+ Default item=LandroidMowerSundayEnable
+ Default item=LandroidMowerSundayTime
+ Default item=LandroidMowerSundayDuration
+ Default item=LandroidMowerSundayEdgecut
+ Default item=LandroidMowerSunday2Enable
+ Default item=LandroidMowerSunday2Time
+ Default item=LandroidMowerSunday2Duration
+ Default item=LandroidMowerSunday2Edgecut
+ }
+ Text label="Monday" icon=none {
+ Default item=LandroidMowerMondayEnable
+ Default item=LandroidMowerMondayTime
+ Default item=LandroidMowerMondayDuration
+ Default item=LandroidMowerMondayEdgecut
+ Default item=LandroidMowerMonday2Enable
+ Default item=LandroidMowerMonday2Time
+ Default item=LandroidMowerMonday2Duration
+ Default item=LandroidMowerMonday2Edgecut
+ }
+ Text label="Tuesday" icon=none {
+ Default item=LandroidMowerTuesdayEnable
+ Default item=LandroidMowerTuesdayTime
+ Default item=LandroidMowerTuesdayDuration
+ Default item=LandroidMowerTuesdayEdgecut
+ Default item=LandroidMowerTuesday2Enable
+ Default item=LandroidMowerTuesday2Time
+ Default item=LandroidMowerTuesday2Duration
+ Default item=LandroidMowerTuesday2Edgecut
+ }
+ Text label="Wednesday" icon=none {
+ Default item=LandroidMowerWednesdayEnable
+ Default item=LandroidMowerWednesdayTime
+ Default item=LandroidMowerWednesdayDuration
+ Default item=LandroidMowerWednesdayEdgecut
+ Default item=LandroidMowerWednesday2Enable
+ Default item=LandroidMowerWednesday2Time
+ Default item=LandroidMowerWednesday2Duration
+ Default item=LandroidMowerWednesday2Edgecut
+ }
+ Text label="Thursday" icon=none {
+ Default item=LandroidMowerThursdayEnable
+ Default item=LandroidMowerThursdayTime
+ Default item=LandroidMowerThursdayDuration
+ Default item=LandroidMowerThursdayEdgecut
+ Default item=LandroidMowerThursday2Enable
+ Default item=LandroidMowerThursday2Time
+ Default item=LandroidMowerThursday2Duration
+ Default item=LandroidMowerThursday2Edgecut
+ }
+ Text label="Friday" icon=none {
+ Default item=LandroidMowerFridayEnable
+ Default item=LandroidMowerFridayTime
+ Default item=LandroidMowerFridayDuration
+ Default item=LandroidMowerFridayEdgecut
+ Default item=LandroidMowerFriday2Enable
+ Default item=LandroidMowerFriday2Time
+ Default item=LandroidMowerFriday2Duration
+ Default item=LandroidMowerFriday2Edgecut
+ }
+ Text label="Saturday" icon=none {
+ Default item=LandroidMowerSaturdayEnable
+ Default item=LandroidMowerSaturdayTime
+ Default item=LandroidMowerSaturdayDuration
+ Default item=LandroidMowerSaturdayEdgecut
+ Default item=LandroidMowerSaturday2Enable
+ Default item=LandroidMowerSaturday2Time
+ Default item=LandroidMowerSaturday2Duration
+ Default item=LandroidMowerSaturday2Edgecut
+ }
+ }
+ Text label="Battery" icon=none {
+ Default item=LandroidMowerBatteryTemperature
+ Default item=LandroidMowerBatteryVoltage
+ Default item=LandroidMowerBatteryLevel
+ Default item=LandroidMowerBatteryChargeCycles
+ Default item=LandroidMowerBatteryChargeCyclesTotal
+ Text item=LandroidMowerBatteryCharging label="Battery charging [%s]"
+ }
+ Text label="Rainsensor" icon=none {
+ Text item=LandroidMowerRainState label="State [%s]"
+ Default item=LandroidMowerRainCounter
+ Setpoint item=LandroidMowerRainDelay minValue=30 maxValue=600 step=15
+ }
+ }
+}
+
+```
+
+### $OPENHAB_CONF/rules/landroid.rules
+
+```java
+
+rule "Landroid mower status"
+when
+ Item LandroidMowerCommonError changed or
+ Item LandroidMowerCommonStatus changed
+then
+ if (LandroidMowerCommonError.state != "NO_ERR") {
+ MyMower.postUpdate(transform("MAP", "landroid_error_de.map", LandroidMowerCommonError.state.toString))
+ } else {
+ MyMower.postUpdate(transform("MAP", "landroid_status_de.map", LandroidMowerCommonStatus.state.toString))
+ }
+end
+
+```
+
+### $OPENHAB_CONF/transform/landroid_error_en.map
+
+```text
+
+UNKNOWN=unknown
+NO_ERR=no error
+TRAPPED=trapped
+LIFTED=lifted
+WIRE_MISSING=wire missing
+OUTSIDE_WIRE=outside wire
+RAINING=raining
+CLOSE_DOOR_TO_MOW=close door to mow
+CLOSE_DOOR_TO_GO_HOME=close door to go home
+BLADE_MOTOR_BLOCKED=blade motor blocked
+WHEEL_MOTOR_BLOKED=wheel motor blocked
+TRAPPED_TIMEOUT=trapped timeout
+UPSIDE_DOWN=upside down
+BATTERY_LOW=battery low
+REVERSE_WIRE=reverse wire
+CHARGE_ERROR=charge error
+TIMEOUT_FINDING_HOME=timeout finding home
+MOWER_LOCKED=mower locked
+BATTERY_OVER_TEMPERATURE=battery over temperature
+MOWER_OUTSIDE_WIRE=mower outside wire
+
+```
+
+### $OPENHAB_CONF/transform/landroid_status_en.map
+
+```text
+
+UNKNOWN=unknown
+IDLE=idle
+HOME=home
+START_SEQUENCE=start sequence
+LEAVING_HOME=leaving home
+FOLLOW_WIRE=follow wire
+SEARCHING_HOME=searching home
+SEARCHING_WIRE=searching wire
+MOWING=mowing
+LIFTED=lifted
+TRAPPED=trapped
+BLADE_BLOCKED=blade blocked
+DEBUG=debug
+REMOTE_CONTROL=remote control
+GOING_HOME=going home
+ZONE_TRAINING=zone training
+BORDER_CUT=border cut
+SEARCHING_ZONE=searching zone
+PAUSE=pause
+MANUEL_STOP=manuel stop
+
+```
diff --git a/bundles/org.openhab.binding.worxlandroid/images/landroid.svg b/bundles/org.openhab.binding.worxlandroid/images/landroid.svg
new file mode 100644
index 0000000000000..e7af19e961ae9
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/images/landroid.svg
@@ -0,0 +1,24 @@
+
+
diff --git a/bundles/org.openhab.binding.worxlandroid/pom.xml b/bundles/org.openhab.binding.worxlandroid/pom.xml
new file mode 100644
index 0000000000000..b36395c7171a6
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/pom.xml
@@ -0,0 +1,32 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 5.1.0-SNAPSHOT
+
+
+ org.openhab.binding.worxlandroid
+
+ openHAB Add-ons :: Bundles :: Worx Landroid Binding
+
+
+
+ software.amazon.awssdk.iotdevicesdk
+ aws-iot-device-sdk
+ 1.27.4
+ provided
+
+
+ software.amazon.awssdk.crt
+ aws-crt
+ 0.38.13
+ provided
+
+
+
+
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/feature/feature.xml b/bundles/org.openhab.binding.worxlandroid/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..0b3f5adcfcd6f
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/feature/feature.xml
@@ -0,0 +1,10 @@
+
+
+ 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.worxlandroid/${project.version}
+ mvn:org.openhab.osgiify/software.amazon.awssdk.iotdevicesdk.aws-iot-device-sdk/1.27.4
+
+
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/WorxLandroidBindingConstants.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/WorxLandroidBindingConstants.java
new file mode 100644
index 0000000000000..5bcd1fea13ac7
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/WorxLandroidBindingConstants.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link WorxLandroidBindingConstants} class defines datCommon constants, which are
+ * used across the whole binding.
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+public class WorxLandroidBindingConstants {
+
+ public static final String BINDING_ID = "worxlandroid";
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge");
+ public static final ThingTypeUID THING_TYPE_MOWER = new ThingTypeUID(BINDING_ID, "mower");
+
+ public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_MOWER);
+
+ // Channel group ids
+ public static final String GROUP_COMMON = "common";
+ public static final String GROUP_CONFIG = "config";
+ public static final String GROUP_MULTI_ZONES = "multi-zones";
+ public static final String GROUP_SCHEDULE = "schedule";
+ public static final String GROUP_ONE_TIME = "one-time";
+ public static final String GROUP_BATTERY = "battery";
+ public static final String GROUP_ORIENTATION = "orientation";
+ public static final String GROUP_METRICS = "metrics";
+ public static final String GROUP_RAIN = "rain";
+ public static final String GROUP_WIFI = "wifi";
+ public static final String GROUP_AWS = "aws";
+
+ // List channel ids
+ // common
+ public static final String CHANNEL_ONLINE_TIMESTAMP = "online-timestamp";
+ public static final String CHANNEL_ACTION = "action";
+ public static final String CHANNEL_ENABLE = "enable";
+ public static final String CHANNEL_ONLINE = "online";
+ public static final String CHANNEL_LOCK = "lock";
+ public static final String CHANNEL_RSSI = "rssi";
+
+ // AWS
+ public static final String CHANNEL_POLL = "poll";
+ public static final String CHANNEL_CONNECTED = "connected";
+
+ // cfgCommon
+ public static final String CHANNEL_TIMESTAMP = "timestamp";
+ public static final String CHANNEL_COMMAND = "command";
+ public static final String CHANNEL_DELAY = "delay";
+
+ // cfgSc
+ public static final String CHANNEL_TIME_EXTENSION = "time-extension";
+ public static final String CHANNEL_MODE = "mode";
+ public static final String CHANNEL_START = "next-start";
+ public static final String CHANNEL_STOP = "next-stop";
+
+ // cfgScXXXday
+ public static final String CHANNEL_DURATION = "duration";
+ public static final String CHANNEL_EDGECUT = "edgecut";
+ public static final String CHANNEL_TIME = "time";
+
+ // datCommon
+ public static final String CHANNEL_WIFI_QUALITY = "wifi-quality";
+ public static final String CHANNEL_LAST_ZONE = "last-zone";
+ public static final String CHANNEL_STATUS_CODE = "status";
+ public static final String CHANNEL_ERROR_CODE = "error";
+
+ // datBattery
+ public static final String CHANNEL_TEMPERATURE = "temperature";
+ public static final String CHANNEL_VOLTAGE = "voltage";
+ public static final String CHANNEL_LEVEL = "level";
+ public static final String CHANNEL_CHARGE_CYCLES = "charge-cycles";
+ public static final String CHANNEL_CHARGE_CYCLES_TOTAL = "charge-cycles-total";
+ public static final String CHANNEL_CHARGING = "charging";
+
+ // datDmp
+ public static final String CHANNEL_PITCH = "pitch";
+ public static final String CHANNEL_ROLL = "roll";
+ public static final String CHANNEL_YAW = "yaw";
+
+ // datSt
+ public static final String CHANNEL_BLADE_TIME = "blade-time";
+ public static final String CHANNEL_BLADE_TIME_TOTAL = "blade-time-total";
+ public static final String CHANNEL_DISTANCE = "distance";
+ public static final String CHANNEL_TOTAL_TIME = "total-time";
+
+ // datRain
+ public static final String CHANNEL_RAIN_STATE = "state";
+ public static final String CHANNEL_RAIN_COUNTER = "counter";
+
+ public static final String CHANNEL_PREFIX_ALLOCATION = "allocation-%d";
+ public static final String CHANNEL_PREFIX_ZONE = "zone-%d";
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/WorxLandroidHandlerFactory.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/WorxLandroidHandlerFactory.java
new file mode 100644
index 0000000000000..7e75f1bf3c654
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/WorxLandroidHandlerFactory.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal;
+
+import static org.openhab.binding.worxlandroid.internal.WorxLandroidBindingConstants.*;
+
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.worxlandroid.internal.api.WorxApiHandler;
+import org.openhab.binding.worxlandroid.internal.discovery.MowerDiscoveryService;
+import org.openhab.binding.worxlandroid.internal.handler.WorxLandroidBridgeHandler;
+import org.openhab.binding.worxlandroid.internal.handler.WorxLandroidMowerHandler;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link WorxLandroidHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Nils Billing - Initial contribution
+ * @author Gaël L'hopital - Added oAuthFactory
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.worxlandroid", service = ThingHandlerFactory.class)
+public class WorxLandroidHandlerFactory extends BaseThingHandlerFactory {
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_MOWER, THING_TYPE_BRIDGE);
+
+ private final Map> discoveryServiceRegs = new HashMap<>();
+ private final OAuthFactory oAuthFactory;
+ private final WorxApiHandler worxApiHandler;
+
+ @Activate
+ public WorxLandroidHandlerFactory(final @Reference OAuthFactory oAuthFactory,
+ final @Reference WorxApiHandler worxApiHandler) {
+ this.oAuthFactory = oAuthFactory;
+ this.worxApiHandler = worxApiHandler;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (THING_TYPE_BRIDGE.equals(thingTypeUID)) {
+ WorxLandroidBridgeHandler bridgeHandler = new WorxLandroidBridgeHandler((Bridge) thing, worxApiHandler,
+ oAuthFactory);
+ MowerDiscoveryService discoveryService = new MowerDiscoveryService(bridgeHandler);
+ discoveryServiceRegs.put(thing.getUID(), bundleContext.registerService(DiscoveryService.class.getName(),
+ discoveryService, new Hashtable<>()));
+
+ return bridgeHandler;
+ } else if (THING_TYPE_MOWER.equals(thingTypeUID)) {
+ return new WorxLandroidMowerHandler(thing, worxApiHandler.getDeserializer());
+ }
+ return null;
+ }
+
+ @Override
+ protected void removeHandler(ThingHandler handler) {
+ if (handler instanceof WorxLandroidBridgeHandler) {
+ ServiceRegistration> serviceReg = discoveryServiceRegs.remove(handler.getThing().getUID());
+ if (serviceReg != null) {
+ serviceReg.unregister();
+ }
+ }
+ super.removeHandler(handler);
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/WorxLandroidIconProvider.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/WorxLandroidIconProvider.java
new file mode 100644
index 0000000000000..41f40f8702b6f
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/WorxLandroidIconProvider.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal;
+
+import static org.openhab.binding.worxlandroid.internal.WorxLandroidBindingConstants.BINDING_ID;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.ui.icon.IconProvider;
+import org.openhab.core.ui.icon.IconSet;
+import org.openhab.core.ui.icon.IconSet.Format;
+import org.osgi.framework.BundleContext;
+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 WorxLandroidIconProvider} is the class providing binding related icons.
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+@Component(service = { IconProvider.class, WorxLandroidIconProvider.class })
+@NonNullByDefault
+public class WorxLandroidIconProvider implements IconProvider {
+ private static final String DEFAULT_LABEL = "Worx Landroid Icons";
+ private static final String DEFAULT_DESCRIPTION = "Icons illustrating channels provided by Worx Landroid binding.";
+
+ private final Logger logger = LoggerFactory.getLogger(WorxLandroidIconProvider.class);
+ private final BundleContext context;
+ private final TranslationProvider i18nProvider;
+
+ @Activate
+ public WorxLandroidIconProvider(final BundleContext context, final @Reference TranslationProvider i18nProvider) {
+ this.context = context;
+ this.i18nProvider = i18nProvider;
+ }
+
+ @Override
+ public Set getIconSets() {
+ return getIconSets(null);
+ }
+
+ @Override
+ public Set getIconSets(@Nullable Locale locale) {
+ String label = getText("label", DEFAULT_LABEL, locale);
+ String description = getText("decription", DEFAULT_DESCRIPTION, locale);
+
+ return Set.of(new IconSet(BINDING_ID, label, description, Set.of(Format.SVG)));
+ }
+
+ private String getText(String entry, String defaultValue, @Nullable Locale locale) {
+ String text = defaultValue;
+ if (locale != null) {
+ text = i18nProvider.getText(context.getBundle(), "iconset." + entry, defaultValue, locale);
+ text = text == null ? defaultValue : text;
+ }
+ return text;
+ }
+
+ @Override
+ public @Nullable Integer hasIcon(String category, String iconSetId, Format format) {
+ return iconSetId.equals(BINDING_ID) && format == Format.SVG ? 0 : null;
+ }
+
+ public @Nullable InputStream getIcon(String category, String state) {
+ return getIcon(category, BINDING_ID, state, Format.SVG);
+ }
+
+ @Override
+ public @Nullable InputStream getIcon(String category, String iconSetId, @Nullable String state, Format format) {
+ String icon = getResource(category, true);
+ if (icon.isEmpty()) {
+ return null;
+ }
+
+ if (state != null) {
+ String withState = "%s-%s".formatted(category, state.toString().toLowerCase());
+ String iconWithState = getResource(withState, false);
+ if (!iconWithState.isEmpty()) {
+ icon = iconWithState;
+ }
+ }
+
+ return new ByteArrayInputStream(icon.getBytes());
+ }
+
+ private String getResource(String iconName, boolean shouldExist) {
+ String result = "";
+
+ URL iconResource = context.getBundle().getEntry("icon/%s.svg".formatted(iconName));
+ if (iconResource != null) {
+ try (InputStream stream = iconResource.openStream()) {
+ result = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ logger.warn("Unable to load resource '{}': {}", iconResource.getPath(), e.getMessage());
+ }
+ } else if (shouldExist) {
+ logger.warn("Unable to find icon '{}'", iconName);
+ }
+ return result;
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/WebApiException.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/WebApiException.java
new file mode 100644
index 0000000000000..eda61ede762cd
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/WebApiException.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.api;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link WebApiException} is a class for handling the Worx Landroid API exceptions
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+public class WebApiException extends Exception {
+ private static final long serialVersionUID = 1L;
+ private static final int UNKNOWN = 0;
+
+ private final int errorCode;
+
+ public WebApiException(int errorCode, String errorMsg) {
+ super(errorMsg);
+ this.errorCode = errorCode;
+ }
+
+ public WebApiException(String errorMsg, Throwable cause) {
+ super(errorMsg);
+ this.errorCode = UNKNOWN;
+ }
+
+ public WebApiException(String errorMsg) {
+ super(errorMsg);
+ this.errorCode = UNKNOWN;
+ }
+
+ public WebApiException(Throwable cause) {
+ super(cause.getMessage(), cause);
+ this.errorCode = UNKNOWN;
+ }
+
+ public int getErrorCode() {
+ return errorCode;
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/WorxApiDeserializer.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/WorxApiDeserializer.java
new file mode 100644
index 0000000000000..4557e317f5124
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/WorxApiDeserializer.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.api;
+
+import java.lang.reflect.Type;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * The {@link WorxApiDeserializer} is responsible to instantiate suitable Gson (de)serializer
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = WorxApiDeserializer.class)
+public class WorxApiDeserializer {
+ private static final DateTimeFormatter WORX_FORMATTER = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ssX");
+
+ private final Gson gson;
+
+ @Activate
+ public WorxApiDeserializer(@Reference TimeZoneProvider timeZoneProvider) {
+ gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .registerTypeAdapter(ZoneId.class,
+ (JsonDeserializer) (json, type, context) -> ZoneId
+ .of(json.getAsJsonPrimitive().getAsString()))
+ .registerTypeAdapter(ZonedDateTime.class,
+ (JsonDeserializer) (json, type, context) -> ZonedDateTime
+ .parse(json.getAsJsonPrimitive().getAsString() + "Z", WORX_FORMATTER)
+ .withZoneSameInstant(timeZoneProvider.getTimeZone()))
+ .registerTypeAdapter(Boolean.class, (JsonDeserializer) (json, type, context) -> {
+ String value = json.getAsJsonPrimitive().getAsString().toUpperCase();
+ return "1".equals(value);
+ }).create();
+ }
+
+ public String toJson(Object object) {
+ return gson.toJson(object);
+ }
+
+ public Map toMap(Object object) {
+ Map fromObject = gson.fromJson(toJson(object), new TypeToken>() {
+ }.getType());
+ return fromObject != null ? Map.copyOf(fromObject) : Map.of();
+ }
+
+ public T deserialize(Type typeToken, String json) throws WebApiException {
+ try {
+ @Nullable
+ T result = gson.fromJson(json, typeToken);
+ if (result != null) {
+ return result;
+ }
+ throw new WebApiException("Deserialization of '%s' resulted in null value".formatted(json));
+ } catch (JsonSyntaxException e) {
+ throw new WebApiException("Unexpected error deserializing '%s' : %s".formatted(json, e.getMessage()));
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/WorxApiHandler.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/WorxApiHandler.java
new file mode 100644
index 0000000000000..c7f9c4b225683
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/WorxApiHandler.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.api;
+
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.openhab.binding.worxlandroid.internal.api.dto.ProductItemStatus;
+import org.openhab.binding.worxlandroid.internal.api.dto.UsersMeResponse;
+import org.openhab.core.io.net.http.HttpClientFactory;
+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;
+
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * {@link WorxApiHandler} is a API request
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = WorxApiHandler.class)
+public class WorxApiHandler {
+ private static final String URL_BASE = "https://api.worxlandroid.com/api/v2/";
+ private static final String URL_PRODUCT_ITEMS = URL_BASE + "product-items";
+ private static final String URL_USERS_ME = URL_BASE + "users/me";
+
+ private static final Type PRODUCT_ITEM_STATUS_LIST = new TypeToken>() {
+ }.getType();
+ private static final Type PRODUCT_ITEM_STATUS = new TypeToken() {
+ }.getType();
+ private static final Type USERS_ME = new TypeToken() {
+ }.getType();
+
+ private final Logger logger = LoggerFactory.getLogger(WorxApiHandler.class);
+ private final HttpClient httpClient;
+ private final WorxApiDeserializer deserializer;
+
+ @Activate
+ public WorxApiHandler(final @Reference HttpClientFactory httpClientFactory,
+ final @Reference WorxApiDeserializer deserializer) {
+ this.httpClient = httpClientFactory.getCommonHttpClient();
+ this.deserializer = deserializer;
+ }
+
+ private Request buildRequest(String url, String accessToken, HttpMethod method) {
+ Request request = httpClient.newRequest(url).method(method);
+ request.header(HttpHeader.AUTHORIZATION, "Bearer %s".formatted(accessToken));
+ request.header(HttpHeader.CONTENT_TYPE, "application/json; utf-8");
+ request.timeout(15, TimeUnit.SECONDS);
+ return request;
+ }
+
+ private T apiGet(String url, String accessToken, Type type) throws WebApiException {
+ Request request = buildRequest(url, accessToken, HttpMethod.GET);
+
+ logger.debug("URI: {}", request.getURI().toString());
+ try {
+ ContentResponse response = request.send();
+ if (response.getStatus() == 200) {
+ String result = response.getContentAsString();
+ logger.trace("Worx Landroid Api Response: {}", result);
+ return deserializer.deserialize(type, result);
+ }
+ throw new WebApiException(
+ "Error calling Worx Landroid Api! HTTP Status = %d".formatted(response.getStatus()));
+ } catch (InterruptedException | TimeoutException | ExecutionException e) {
+ throw new WebApiException(e);
+ }
+ }
+
+ private boolean apiPost(String url, String accessToken) {
+ Request request = buildRequest(url, accessToken, HttpMethod.POST);
+
+ logger.debug("URI: {}", request.getURI().toString());
+ try {
+ return request.send().getStatus() == 200;
+ } catch (InterruptedException | TimeoutException | ExecutionException e) {
+ logger.error("Error posting at {}: {}", request.getURI().toString(), e.getMessage());
+ }
+ return false;
+ }
+
+ public WorxApiDeserializer getDeserializer() {
+ return deserializer;
+ }
+
+ public List retrieveDeviceStatus(String token) throws WebApiException {
+ return apiGet("%s?status=1".formatted(URL_PRODUCT_ITEMS), token, PRODUCT_ITEM_STATUS_LIST);
+ }
+
+ public ProductItemStatus retrieveDeviceStatus(String token, String serialNumber) throws WebApiException {
+ return apiGet("%s/%s?status=1".formatted(URL_PRODUCT_ITEMS, serialNumber), token, PRODUCT_ITEM_STATUS);
+ }
+
+ public UsersMeResponse retrieveMe(String token) throws WebApiException {
+ return apiGet(URL_USERS_ME, token, USERS_ME);
+ }
+
+ public boolean resetBladeTime(String token, String serialNumber) {
+ return apiPost("%s/%s/counters/blade/reset".formatted(URL_PRODUCT_ITEMS, serialNumber), token);
+ }
+
+ public boolean resetBatteryCycles(String token, String serialNumber) {
+ return apiPost("%s/%s/counters/battery/reset".formatted(URL_PRODUCT_ITEMS, serialNumber), token);
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/Commands.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/Commands.java
new file mode 100644
index 0000000000000..7a826565ba60c
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/Commands.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.api.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.worxlandroid.internal.codes.WorxLandroidActionCodes;
+
+/**
+ * The {@link Commands} class hold record definition of Commands send to API
+ *
+ * @author Gaël L'hopital - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class Commands {
+ private record OTS(OTSCommand ots) {
+ }
+
+ private record OTSCommand( //
+ int bc, // bordercut
+ int wtm // work time minutes
+ ) {
+ }
+
+ public record OneTimeCommand(OTS sc) {
+ public OneTimeCommand(int bc, int wtm) {
+ this(new OTS(new OTSCommand(bc, wtm)));
+ }
+ }
+
+ private record ScheduleDaysP(int p, Object d, @Nullable Object dd) {
+ }
+
+ public record ScheduleDaysCommand(ScheduleDaysP sc) {
+ public ScheduleDaysCommand(int p, Object[] d, Object[] dd) {
+ this(new ScheduleDaysP(p, d, dd));
+ }
+
+ public ScheduleDaysCommand(int p, Object[] d) {
+ this(new ScheduleDaysP(p, d, null));
+ }
+ }
+
+ private record ScheduleCommandMode(int m) {
+ }
+
+ public record ScheduleCommand(ScheduleCommandMode sc) {
+ public ScheduleCommand(int m) {
+ this(new ScheduleCommandMode(m));
+ }
+ }
+
+ public record MowerCommand(int cmd) {
+ public MowerCommand(WorxLandroidActionCodes actionCode) {
+ this(actionCode.code);
+ }
+ }
+
+ public record ZoneMeterCommand(int[] mz) {
+ }
+
+ public record ZoneMeterAlloc(int[] mzv) {
+ }
+
+ public record SetRainDelay(int rd) {
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/LastStatus.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/LastStatus.java
new file mode 100644
index 0000000000000..116fcb3864c9f
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/LastStatus.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.api.dto;
+
+import java.time.ZonedDateTime;
+
+/**
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+public class LastStatus {
+ public ZonedDateTime timestamp;
+ public Payload payload;
+
+ public LastStatus(Payload payload) {
+ this.payload = payload;
+ this.timestamp = ZonedDateTime.now();
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/Payload.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/Payload.java
new file mode 100644
index 0000000000000..db2610ee390d1
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/Payload.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.api.dto;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+import org.openhab.binding.worxlandroid.internal.codes.WorxLandroidErrorCodes;
+import org.openhab.binding.worxlandroid.internal.codes.WorxLandroidStatusCodes;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+public class Payload {
+ public class US {
+ public int enabled;
+ public String stat;
+ }
+
+ public class Ots {
+ @SerializedName("wtm")
+ public int duration = -1;
+ private int bc = -1;
+
+ public boolean getEdgeCut() {
+ return bc == 1;
+ }
+ }
+
+ public class Al {
+ public int lvl;
+ public int t;
+ }
+
+ public class Modules {
+ @SerializedName("US")
+ public US uS;
+ }
+
+ public class Rain {
+ @SerializedName("s")
+ public Boolean raining;
+ @SerializedName("cnt")
+ public int counter = -1;
+ }
+
+ public class Schedule {
+ public static enum Mode {
+ @SerializedName("1")
+ NORMAL,
+ @SerializedName("2")
+ PARTY,
+ UNKNOWN
+ }
+
+ @SerializedName("m")
+ public Mode scheduleMode = Mode.UNKNOWN;
+ @SerializedName("p")
+ public int timeExtension = -1;
+ public int distm;
+ public Ots ots;
+ public List> d;
+ public List> dd;
+ }
+
+ public class Battery {
+ @SerializedName("t")
+ public double temp = -1;
+ @SerializedName("v")
+ public double voltage = -1;
+ @SerializedName("p")
+ public int level = -1;
+ @SerializedName("nr")
+ public int chargeCycle = -1;
+ @SerializedName("c")
+ public Boolean charging;
+ public int m;
+ }
+
+ public class Stat {
+ @SerializedName("b")
+ public int bladeWorkTime = -1;
+ @SerializedName("d")
+ public int distanceCovered = -1;
+ @SerializedName("wt")
+ public int mowerWorkTime = -1;
+ @SerializedName("bl")
+ public int lawnPerimeter;
+ }
+
+ public class Cfg {
+ private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");
+
+ private String dt = ""; // "dt": "13/03/2020",
+ private String tm = ""; // "tm": "17:09:34"
+
+ public int id = -1;
+ public String lg = ""; // en, fr...
+ public int cmd = -1;
+ public Schedule sc;
+ @SerializedName("mz")
+ public List multiZones = List.of();
+ @SerializedName("mzv")
+ public List multizoneAllocations = List.of();
+ @SerializedName("rd")
+ public int rainDelay = -1;
+ @SerializedName("sn")
+ public String serialNumber = "";
+ public int mzk;
+ public Al al;
+ public int tq;
+ public Modules modules;
+
+ public ZonedDateTime getDateTime(ZoneId zoneId) {
+ return dt.isEmpty() || tm.isEmpty() ? null
+ : ZonedDateTime.of(LocalDateTime.parse("%s %s".formatted(dt, tm), FORMATTER), zoneId);
+ }
+ }
+
+ public class Dat {
+ public static enum Axis {
+ // Don't change order - ordinal is used
+ PITCH,
+ ROLL,
+ YAW;
+ }
+
+ private int lk = -1;
+ @SerializedName("dmp")
+ private double[] dataMotionProcessor = { -1, -1, -1 }; // pitch, roll, yaw
+
+ public String mac = "";
+ public String fw = "";
+ @SerializedName("bt")
+ public Battery battery;
+ public Stat st;
+ @SerializedName("ls")
+ public WorxLandroidStatusCodes statusCode = WorxLandroidStatusCodes.UNKNOWN;
+ @SerializedName("le")
+ public WorxLandroidErrorCodes errorCode = WorxLandroidErrorCodes.UNKNOWN;
+ @SerializedName("lz")
+ public int lastZone = -1;
+ @SerializedName("rsi")
+ public int wifiQuality;
+ public int fwb;
+ public String conn;
+ public int act;
+ public int tr;
+ public Rain rain;
+ public Modules modules;
+
+ public boolean isLocked() {
+ return lk == 1;
+ }
+
+ public double getAngle(Axis axis) {
+ return dataMotionProcessor[axis.ordinal()];
+ }
+ }
+
+ public Cfg cfg;
+ public Dat dat;
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/ProductItemStatus.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/ProductItemStatus.java
new file mode 100644
index 0000000000000..c7185baa6471f
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/ProductItemStatus.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.api.dto;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The {@link ProductItemStatus} class
+ *
+ * @author Gaël L'hopital - Initial contribution
+ *
+ */
+public class ProductItemStatus {
+
+ public class Accessories {
+ public boolean ultrasonic;
+ }
+
+ public class MqttTopics {
+ public String commandIn;
+ public String commandOut;
+ }
+
+ public class SetupLocation {
+ public double latitude;
+ public double longitude;
+ }
+
+ public class AppSettings {
+ boolean cellularSetupCompleted;
+ }
+
+ public class City {
+ public int id;
+ public int countryId;
+ public String name;
+ public double latitude;
+ public double longitude;
+ public String createdAt;
+ public String updatedAt;
+ }
+
+ public class Sim {
+ public int id;
+ public String iccid;
+ public String simStatus;
+ public boolean pendingActivation;
+ public ZonedDateTime contractStartsAt;
+ public ZonedDateTime contractEndsAt;
+ public ZonedDateTime createdAt;
+ public ZonedDateTime updatedAt;
+ }
+
+ public class AutoSchedule {
+ public int boost;
+ public String grassType;
+ public boolean irrigation;
+ public Map nutrition;
+ public String soilType;
+ }
+
+ public String id;
+ public String uuid;
+ public int productId;
+ public String userId;
+ public String serialNumber;
+ public String macAddress;
+ public String name;
+ public boolean locked;
+ public String firmwareVersion;
+ public boolean firmwareAutoUpgrade;
+ public boolean pushNotifications;
+ public Sim sim;
+ public String pushNotificationsLevel;
+ public boolean test;
+ public boolean iotRegistered;
+ public boolean mqttRegistered;
+ public String pinCode;
+ public String registeredAt;
+ public boolean online;
+ public String mqttEndpoint;
+ public AppSettings appSettings;
+ public int protocol;
+ public String pendingRadioLinkValidation;
+ public List capabilities;
+ public List capabilitiesAvailable;
+ public Accessories accessories;
+ public MqttTopics mqttTopics;
+ public boolean warrantyRegistered;
+ public String purchasedAt;
+ public String warrantyExpiresAt;
+ public SetupLocation setupLocation;
+ public City city;
+ public ZoneId timeZone;
+ public double lawnSize;
+ public double lawnPerimeter;
+ public AutoSchedule autoScheduleSettings;
+ public boolean autoSchedule;
+ public boolean improvement;
+ public boolean diagnostic;
+ public long distanceCovered;
+ public long mowerWorkTime;
+
+ public long bladeWorkTime;
+ public long bladeWorkTimeReset;
+ public ZonedDateTime bladeWorkTimeResetAt;
+
+ public int batteryChargeCycles;
+ public int batteryChargeCyclesReset;
+ public ZonedDateTime batteryChargeCyclesResetAt;
+
+ public ZonedDateTime createdAt;
+ public ZonedDateTime updatedAt;
+ public LastStatus lastStatus;
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/UsersMeResponse.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/UsersMeResponse.java
new file mode 100644
index 0000000000000..5dac523b9addd
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/api/dto/UsersMeResponse.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.api.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link UsersMeResponse} class
+ *
+ * @author Nils Billing - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class UsersMeResponse {
+ public String id = "";
+ public String userType = "";
+ public boolean pushNotifications;
+ public String location = "";
+ public String actionsOnGooglePinCode = "";
+ public String createdAt = "";
+ public String updatedAt = "";
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidActionCodes.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidActionCodes.java
new file mode 100644
index 0000000000000..a62b4b4f21e3d
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidActionCodes.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.codes;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link WorxLandroidActionCodes} hosts action codes
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+public enum WorxLandroidActionCodes {
+ START(1, "start"),
+ STOP(2, "stop"),
+ HOME(3, "home"),
+ ZONETRAINING(4, "zonetraining"),
+ LOCK(5, "lock"),
+ UNLOCK(6, "unlock");
+
+ public final int code;
+ public final String description;
+
+ WorxLandroidActionCodes(int code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidDayCodes.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidDayCodes.java
new file mode 100644
index 0000000000000..e344cc0fd67b7
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidDayCodes.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.codes;
+
+import java.time.DayOfWeek;
+import java.time.format.TextStyle;
+import java.util.Locale;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link WorxLandroidDayCodes} hosts Landroid days of week
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+public enum WorxLandroidDayCodes {
+ SUNDAY(0, DayOfWeek.SUNDAY),
+ MONDAY(1, DayOfWeek.MONDAY),
+ TUESDAY(2, DayOfWeek.TUESDAY),
+ WEDNESDAY(3, DayOfWeek.WEDNESDAY),
+ THURSDAY(4, DayOfWeek.THURSDAY),
+ FRIDAY(5, DayOfWeek.FRIDAY),
+ SATURDAY(6, DayOfWeek.SATURDAY);
+
+ public final int code;
+ public final DayOfWeek dayOfWeek;
+
+ WorxLandroidDayCodes(int code, DayOfWeek dayOfWeek) {
+ this.code = code;
+ this.dayOfWeek = dayOfWeek;
+ }
+
+ public String getDescription() {
+ return dayOfWeek.getDisplayName(TextStyle.FULL, Locale.US);
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidErrorCodes.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidErrorCodes.java
new file mode 100644
index 0000000000000..243c1b64b3c41
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidErrorCodes.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.codes;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link WorxLandroidErrorCodes} hosts error codes
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+public enum WorxLandroidErrorCodes {
+ @SerializedName("-1")
+ UNKNOWN,
+ @SerializedName("0")
+ NO_ERR,
+ @SerializedName("1")
+ TRAPPED,
+ @SerializedName("2")
+ LIFTED,
+ @SerializedName("3")
+ WIRE_MISSING,
+ @SerializedName("4")
+ OUTSIDE_WIRE,
+ @SerializedName("5")
+ RAINING,
+ @SerializedName("6")
+ CLOSE_DOOR_TO_MOW,
+ @SerializedName("7")
+ CLOSE_DOOR_TO_GO_HOME,
+ @SerializedName("8")
+ BLADE_MOTOR_BLOCKED,
+ @SerializedName("9")
+ WHEEL_MOTOR_BLOCKED,
+ @SerializedName("10")
+ TRAPPED_TIMEOUT,
+ @SerializedName("11")
+ UPSIDE_DOWN,
+ @SerializedName("12")
+ BATTERY_LOW,
+ @SerializedName("13")
+ REVERSE_WIRE,
+ @SerializedName("14")
+ CHARGE_ERROR,
+ @SerializedName("15")
+ TIMEOUT_FINDING_HOME,
+ @SerializedName("16")
+ MOWER_LOCKED,
+ @SerializedName("17")
+ BATTERY_OVER_TEMPERATURE,
+ @SerializedName("20")
+ MOWER_OUTSIDE_WIRE;
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidStatusCodes.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidStatusCodes.java
new file mode 100644
index 0000000000000..6a1f077ef97e8
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/codes/WorxLandroidStatusCodes.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.codes;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link WorxLandroidStatusCodes} hosts status codes
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+public enum WorxLandroidStatusCodes {
+ @SerializedName("-1")
+ UNKNOWN,
+ @SerializedName("0")
+ IDLE,
+ @SerializedName("1")
+ HOME,
+ @SerializedName("2")
+ START_SEQUENCE,
+ @SerializedName("3")
+ LEAVING_HOME,
+ @SerializedName("4")
+ FOLLOW_WIRE,
+ @SerializedName("5")
+ SEARCHING_HOME,
+ @SerializedName("6")
+ SEARCHING_WIRE,
+ @SerializedName("7")
+ MOWING,
+ @SerializedName("8")
+ LIFTED,
+ @SerializedName("9")
+ TRAPPED,
+ @SerializedName("10")
+ BLADE_BLOCKED,
+ @SerializedName("11")
+ DEBUG,
+ @SerializedName("12")
+ REMOTE_CONTROL,
+ @SerializedName("13")
+ ESCAPE_FROM_OLM,
+ @SerializedName("30")
+ GOING_HOME,
+ @SerializedName("31")
+ ZONE_TRAINING,
+ @SerializedName("32")
+ BORDER_CUT,
+ @SerializedName("33")
+ SEARCHING_ZONE,
+ @SerializedName("34")
+ PAUSE,
+ @SerializedName("99")
+ MANUAL_STOP;
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/config/MowerConfiguration.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/config/MowerConfiguration.java
new file mode 100644
index 0000000000000..cf1bf0f0e7e43
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/config/MowerConfiguration.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link MowerConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Nils Billing - Initial contribution
+ * @author Gaël L'hopital - Added serialNumber configuration element
+ */
+@NonNullByDefault
+public class MowerConfiguration {
+ public static final String SERIAL_NUMBER = "serialNumber";
+
+ public String serialNumber = "";
+ public int refreshStatusInterval = 600;
+ public int pollingInterval = 3600;
+
+ @Override
+ public String toString() {
+ return "MowerConfiguration [serialNumber='%s', pollingInterval='%d', refreshStatusInterval='%d']"
+ .formatted(serialNumber, pollingInterval, refreshStatusInterval);
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/config/WebApiConfiguration.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/config/WebApiConfiguration.java
new file mode 100644
index 0000000000000..74fb15d11b44d
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/config/WebApiConfiguration.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link WebApiConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Nils Billing - Initial contribution
+ * @author Gaël L'hopital - Added NonNullByDefault, removed setters, removed reconnectInterval
+ */
+@NonNullByDefault
+public class WebApiConfiguration {
+ public String username = "";
+ public String password = "";
+
+ @Override
+ public String toString() {
+ return "WebApiConfiguration [username='%s', password='*****']".formatted(username);
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/discovery/MowerDiscoveryService.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/discovery/MowerDiscoveryService.java
new file mode 100644
index 0000000000000..d396e72edc4b8
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/discovery/MowerDiscoveryService.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.discovery;
+
+import static org.openhab.binding.worxlandroid.internal.WorxLandroidBindingConstants.THING_TYPE_MOWER;
+
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.worxlandroid.internal.WorxLandroidBindingConstants;
+import org.openhab.binding.worxlandroid.internal.api.WebApiException;
+import org.openhab.binding.worxlandroid.internal.api.dto.ProductItemStatus;
+import org.openhab.binding.worxlandroid.internal.config.MowerConfiguration;
+import org.openhab.binding.worxlandroid.internal.handler.WorxLandroidBridgeHandler;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link MowerDiscoveryService} is a service for discovering your mowers through Worx Landroid API
+ *
+ * @author Nils Billing - Initial contribution
+ * @author Gaël L'hopital - Added representation property and serialNumber configuration element
+ */
+@NonNullByDefault
+public class MowerDiscoveryService extends AbstractDiscoveryService {
+ /**
+ * Maximum time to search for devices in seconds.
+ */
+ private static final int SEARCH_TIME_SEC = 20;
+
+ private final Logger logger = LoggerFactory.getLogger(MowerDiscoveryService.class);
+ private final WorxLandroidBridgeHandler bridgeHandler;
+
+ public MowerDiscoveryService(WorxLandroidBridgeHandler bridgeHandler) {
+ super(WorxLandroidBindingConstants.SUPPORTED_THING_TYPES, SEARCH_TIME_SEC);
+ this.bridgeHandler = bridgeHandler;
+ }
+
+ @Override
+ public Set getSupportedThingTypes() {
+ return WorxLandroidBindingConstants.SUPPORTED_THING_TYPES;
+ }
+
+ @Override
+ protected void startScan() {
+ try {
+ List productItemsStatusResponse = bridgeHandler.retrieveAllDevices();
+ productItemsStatusResponse.forEach(mower -> {
+
+ DiscoveryResult discoveryResult = DiscoveryResultBuilder
+ .create(new ThingUID(THING_TYPE_MOWER, bridgeHandler.getThing().getUID(), mower.id))
+ .withRepresentationProperty(MowerConfiguration.SERIAL_NUMBER).withLabel(mower.name)
+ .withProperty(MowerConfiguration.SERIAL_NUMBER, mower.serialNumber)
+ .withBridge(bridgeHandler.getThing().getUID()).build();
+
+ thingDiscovered(discoveryResult);
+ logger.debug("Discovered a mower thing with ID '{}'", mower.serialNumber);
+ });
+ } catch (WebApiException exception) {
+ logger.warn("Error in WebApiException : {}", exception.getMessage());
+ }
+ }
+
+ @Override
+ protected void startBackgroundDiscovery() {
+ startScan();
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/AWSClientThingHandler.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/AWSClientThingHandler.java
new file mode 100644
index 0000000000000..09baef7a84c7b
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/AWSClientThingHandler.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.handler;
+
+import static org.openhab.binding.worxlandroid.internal.WorxLandroidBindingConstants.*;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.worxlandroid.internal.api.WebApiException;
+import org.openhab.binding.worxlandroid.internal.api.WorxApiDeserializer;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload;
+import org.openhab.binding.worxlandroid.internal.mqtt.AWSClient;
+import org.openhab.binding.worxlandroid.internal.mqtt.AWSClientCallbackI;
+import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import software.amazon.awssdk.crt.mqtt.MqttMessage;
+
+/**
+ * The{@link AWSClientThingHandler} is handles outside communications (AWS,API) parts
+ *
+ * @author Gaël L'hopital - Initial contribution
+ *
+ */
+@NonNullByDefault
+public abstract class AWSClientThingHandler extends BaseThingHandler
+ implements AWSClientCallbackI, ThingHandlerHelper, AccessTokenRefreshListener {
+ private static final Duration MIN_PUBLISH_DELAY_S = Duration.ofSeconds(2);
+
+ private final Logger logger = LoggerFactory.getLogger(AWSClientThingHandler.class);
+ private final AWSClient awsClient;
+ protected final WorxApiDeserializer deserializer;
+
+ protected String endpoint = "";
+ protected String uuid = "";
+ protected String userId = "";
+ protected String topic = "";
+ protected String token = "";
+
+ private Instant lastPublishTS = Instant.MIN;
+ private int lastReqHash = 0;
+
+ public AWSClientThingHandler(Thing thing, WorxApiDeserializer deserializer) {
+ super(thing);
+ this.deserializer = deserializer;
+ this.awsClient = new AWSClient(this);
+ }
+
+ @Override
+ public void initialize() {
+ checkInitBridgeOAuth();
+ }
+
+ @Override
+ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
+ checkInitBridgeOAuth();
+ }
+
+ private void checkInitBridgeOAuth() {
+ WorxLandroidBridgeHandler bridgeHandler = getBridgeHandler(getBridge(), WorxLandroidBridgeHandler.class);
+ if (bridgeHandler != null) {
+ setAccessToken(bridgeHandler.getAccessToken());
+ bridgeHandler.oAuthClientService.addAccessTokenRefreshListener(this);
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+ }
+ }
+
+ @Override
+ public void dispose() {
+ if (!topic.isEmpty()) {
+ awsClient.unsubscribe(topic);
+ }
+ awsClient.dispose();
+
+ WorxLandroidBridgeHandler bridgeHandler = getBridgeHandler(getBridge(), WorxLandroidBridgeHandler.class);
+ if (bridgeHandler != null) {
+ bridgeHandler.oAuthClientService.removeAccessTokenRefreshListener(this);
+ }
+
+ super.dispose();
+ }
+
+ @Override
+ public void onAWSConnectionSuccess() {
+ logger.debug("AWS connection is available");
+ if (!topic.isEmpty()) {
+ awsClient.subscribe(topic, this::onMqttMessage);
+ logger.debug("subscribed to topic: {}", topic);
+ } else {
+ logger.warn("Connected but no topic to subscribe to");
+ }
+
+ if (getThing().getStatus() != ThingStatus.ONLINE) {
+ updateStatus(ThingStatus.ONLINE);
+ }
+ updateChannelOnOff(GROUP_AWS, CHANNEL_CONNECTED, awsClient.isConnected());
+ }
+
+ @Override
+ public void onAWSConnectionClosed() {
+ // Don't try to reconnect if the connection is closed by the thing being disable
+ if (thing.getStatus() == ThingStatus.ONLINE) {
+ updateChannelOnOff(GROUP_AWS, CHANNEL_CONNECTED, awsClient.isConnected());
+
+ WorxLandroidBridgeHandler bridgeHandler = getBridgeHandler(getBridge(), WorxLandroidBridgeHandler.class);
+ if (bridgeHandler != null) {
+ bridgeHandler.requestTokenRefresh();
+ }
+ }
+ }
+
+ @Override
+ public void onAWSConnectionFailed(@Nullable String message) {
+ updateChannelOnOff(GROUP_AWS, CHANNEL_CONNECTED, awsClient.isConnected());
+ updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "No AWS Connection");
+ }
+
+ @Override
+ public boolean isLinked(ChannelUID channelUID) {
+ return super.isLinked(channelUID);
+ }
+
+ @Override
+ public void updateState(ChannelUID channelUID, State state) {
+ super.updateState(channelUID, state);
+ }
+
+ public void publishMessage(String topic, String cmd) {
+ Instant now = Instant.now();
+ int requestHash = topic.hashCode() + cmd.hashCode();
+ if (requestHash == lastReqHash) {
+ if (now.isBefore(lastPublishTS.plus(MIN_PUBLISH_DELAY_S))) {
+ logger.debug("Won't post again too soon");
+ return;
+ }
+ }
+ lastPublishTS = now;
+ lastReqHash = requestHash;
+ logger.debug("publish on topic: '{}' - message: '{}'", topic, cmd);
+ awsClient.publish(topic, cmd);
+ }
+
+ public void onMqttMessage(MqttMessage mqttMessage) {
+ String messagePayload = new String(mqttMessage.getPayload(), StandardCharsets.UTF_8);
+ logger.debug("onMessage: {}", messagePayload);
+ try {
+ Payload payload = deserializer.deserialize(Payload.class, messagePayload);
+ internalHandlePayload(payload);
+ } catch (WebApiException e) {
+ logger.warn("Error processing incoming AWS message: {}", e.getMessage());
+ }
+ }
+
+ protected abstract void internalHandlePayload(Payload payload);
+
+ @Override
+ public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
+ setAccessToken(tokenResponse.getAccessToken());
+ }
+
+ private void setAccessToken(String token) {
+ this.token = token;
+ connectAWS();
+ }
+
+ public void connectAws(String mqttEndpoint, String uuid, String userId, String commandOut) {
+ this.endpoint = mqttEndpoint;
+ this.uuid = uuid;
+ this.userId = userId;
+ this.topic = commandOut;
+ connectAWS();
+ }
+
+ private void connectAWS() {
+ awsClient.disconnect();
+ if (endpoint.isEmpty() || userId.isEmpty() || uuid.isEmpty() || token.isEmpty()) {
+ logger.debug("Some data missing to initiate AWS connection");
+ return;
+ }
+ awsClient.connect(endpoint, userId, uuid, token);
+ updateStatus(ThingStatus.ONLINE);
+ updateChannelOnOff(GROUP_AWS, CHANNEL_CONNECTED, awsClient.isConnected());
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ return;
+ }
+ if (!isOnline()) {
+ logger.warn("handleCommand mower: {} is offline!", getThing().getUID());
+ return;
+ }
+ WorxLandroidBridgeHandler bridgeHandler = getBridgeHandler(getBridge(), WorxLandroidBridgeHandler.class);
+ if (bridgeHandler == null) {
+ logger.error("no bridgeHandler");
+ return;
+ }
+ String groupId = channelUID.getGroupId();
+ String channelId = channelUID.getIdWithoutGroup();
+ internalHandleCommand(groupId, channelId, command);
+ }
+
+ protected abstract void internalHandleCommand(@Nullable String groupId, String channelId, Command command);
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/ThingHandlerHelper.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/ThingHandlerHelper.java
new file mode 100644
index 0000000000000..5896163eecda0
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/ThingHandlerHelper.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.handler;
+
+import java.time.ZonedDateTime;
+import java.util.HashSet;
+import java.util.Set;
+
+import javax.measure.Unit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelGroupUID;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.BridgeHandler;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * {@link ThingHandlerHelper} provides utility function for thing handlers
+ *
+ * @author Gaël L'hopital - Initial contribution
+ */
+@NonNullByDefault
+public interface ThingHandlerHelper {
+ public boolean isLinked(ChannelUID channelUID);
+
+ public Thing getThing();
+
+ public void updateState(ChannelUID channelUID, State state);
+
+ public default @Nullable T getBridgeHandler(@Nullable Bridge bridge,
+ Class expected) {
+ if (bridge != null) {
+ BridgeHandler handler = bridge.getHandler();
+ if (handler != null) {
+ try {
+ T expectedBridge = expected.cast(handler);
+ if (expectedBridge.getThing().getStatus() == ThingStatus.ONLINE) {
+ return expectedBridge;
+ }
+ } catch (ClassCastException exc) {
+ }
+ }
+ }
+ return null;
+ }
+
+ public default boolean isOnline() {
+ return getThing().getStatus() == ThingStatus.ONLINE;
+ }
+
+ public default ChannelGroupUID getGroupUID(String group) {
+ return new ChannelGroupUID(getThing().getUID(), group);
+ }
+
+ public default ChannelUID getChannelUID(String group, String channelId) {
+ return new ChannelUID(getThing().getUID(), group, channelId);
+ }
+
+ public default Set getChannelUIDs(String groupName, Set channelIds) {
+ Set result = new HashSet<>();
+ ChannelGroupUID groupUID = getGroupUID(groupName);
+ channelIds.forEach(id -> result.add(new ChannelUID(groupUID, id)));
+ return result;
+ }
+
+ public default void updateIfActive(String group, String channelId, State state) {
+ ChannelUID id = getChannelUID(group, channelId);
+ if (isLinked(id)) {
+ updateState(id, state);
+ }
+ }
+
+ public default void updateChannelOnOff(String group, String channelId, boolean value) {
+ updateIfActive(group, channelId, OnOffType.from(value));
+ }
+
+ public default void updateChannelDateTime(String group, String channelId, @Nullable ZonedDateTime timestamp) {
+ updateIfActive(group, channelId, timestamp == null ? UnDefType.NULL : new DateTimeType(timestamp));
+ }
+
+ public default void updateChannelString(String group, String channelId, @Nullable String value) {
+ updateIfActive(group, channelId, value == null || value.isEmpty() ? UnDefType.NULL : new StringType(value));
+ }
+
+ public default void updateChannelEnum(String group, String channelId, @Nullable Enum> value) {
+ String name = value != null ? value.name() : null;
+ updateChannelString(group, channelId, name == null || "UNKNOWN".equals(name) ? null : name);
+ }
+
+ public default void updateChannelDecimal(String group, String channelId, @Nullable Number value) {
+ updateIfActive(group, channelId, value == null || value.equals(-1) ? UnDefType.NULL : new DecimalType(value));
+ }
+
+ public default void updateChannelQuantity(String group, String channelId, @Nullable QuantityType> quantity) {
+ updateIfActive(group, channelId, quantity != null ? quantity : UnDefType.NULL);
+ }
+
+ public default void updateChannelQuantity(String group, String channelId, @Nullable Number d, Unit> unit) {
+ if (d == null) {
+ updateIfActive(group, channelId, UnDefType.NULL);
+ } else {
+ updateChannelQuantity(group, channelId, new QuantityType<>(d, unit));
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/WorxLandroidBridgeHandler.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/WorxLandroidBridgeHandler.java
new file mode 100644
index 0000000000000..2356f20b95f0b
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/WorxLandroidBridgeHandler.java
@@ -0,0 +1,209 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.handler;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.worxlandroid.internal.api.WebApiException;
+import org.openhab.binding.worxlandroid.internal.api.WorxApiHandler;
+import org.openhab.binding.worxlandroid.internal.api.dto.ProductItemStatus;
+import org.openhab.binding.worxlandroid.internal.api.dto.UsersMeResponse;
+import org.openhab.binding.worxlandroid.internal.config.WebApiConfiguration;
+import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+import org.openhab.core.auth.client.oauth2.OAuthClientService;
+import org.openhab.core.auth.client.oauth2.OAuthException;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.auth.client.oauth2.OAuthResponseException;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WorxLandroidBridgeHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Nils Billing - Initial contribution
+ * @author Gaël L'hopital - Refactored with oAuthFactory, removed AWSClient
+ */
+@NonNullByDefault
+public class WorxLandroidBridgeHandler extends BaseBridgeHandler
+ implements AccessTokenRefreshListener, ThingHandlerHelper {
+ private static final String URL_OAUTH_TOKEN = "https://id.worx.com/oauth/token";
+ private static final String CLIENT_ID = "013132A8-DB34-4101-B993-3C8348EA0EBC";
+
+ private final Logger logger = LoggerFactory.getLogger(WorxLandroidBridgeHandler.class);
+ private final WorxApiHandler apiHandler;
+ private final OAuthFactory oAuthFactory;
+
+ public final OAuthClientService oAuthClientService;
+
+ private String accessToken = "";
+ private int retryCount = 3;
+ private int retryDelayS = 1;
+ private Optional> tokenRefreshJob = Optional.empty();
+ private Optional> connectionJob = Optional.empty();
+
+ public WorxLandroidBridgeHandler(Bridge bridge, WorxApiHandler apiHandler, OAuthFactory oAuthFactory) {
+ super(bridge);
+ this.apiHandler = apiHandler;
+ this.oAuthFactory = oAuthFactory;
+ this.oAuthClientService = oAuthFactory.createOAuthClientService(getThing().getUID().getAsString(),
+ URL_OAUTH_TOKEN, null, CLIENT_ID, null, "*", true);
+ oAuthClientService.addAccessTokenRefreshListener(this);
+ }
+
+ @Override
+ public void initialize() {
+ logger.debug("Initializing Landroid API bridge handler.");
+ WebApiConfiguration config = getConfigAs(WebApiConfiguration.class);
+
+ if (config.username.isBlank()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/conf-error-no-username");
+ return;
+ }
+
+ if (config.password.isBlank()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/conf-error-no-password");
+ return;
+ }
+
+ updateStatus(ThingStatus.UNKNOWN);
+ scheduler.execute(() -> initiateConnection(config.username, config.password));
+ }
+
+ private void initiateConnection(String username, String password) {
+ stopConnectionJob();
+ try {
+ accessToken = oAuthClientService.getAccessTokenByResourceOwnerPasswordCredentials(username, password, "*")
+ .getAccessToken();
+
+ UsersMeResponse user = apiHandler.retrieveMe(accessToken);
+ updateProperties(apiHandler.getDeserializer().toMap(user));
+
+ updateStatus(ThingStatus.ONLINE);
+ } catch (IOException | WebApiException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ } catch (OAuthResponseException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/oauth-connection-error");
+ } catch (OAuthException e) {
+ Throwable cause = e.getCause();
+ if (cause != null) {
+ String message = cause.getMessage();
+ if (message != null && message.contains("http code 403")) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/oauth-connection-delayed");
+ connectionJob = Optional
+ .of(scheduler.schedule(() -> initiateConnection(username, password), 1, TimeUnit.HOURS));
+ return;
+ }
+ }
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/oauth-connection-error");
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ logger.debug("Landroid Bridge is read-only and does not handle commands");
+ }
+
+ @Override
+ public void dispose() {
+ stopConnectionJob();
+ stopTokenRefreshJob();
+
+ oAuthClientService.removeAccessTokenRefreshListener(this);
+ oAuthFactory.ungetOAuthService(getThing().getUID().getAsString());
+ super.dispose();
+ }
+
+ private void stopTokenRefreshJob() {
+ tokenRefreshJob.ifPresent(job -> job.cancel(true));
+ tokenRefreshJob = Optional.empty();
+ }
+
+ private void stopConnectionJob() {
+ connectionJob.ifPresent(job -> job.cancel(true));
+ connectionJob = Optional.empty();
+ }
+
+ @Override
+ public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
+ accessToken = tokenResponse.getAccessToken();
+ }
+
+ public void requestTokenRefresh() {
+ if (tokenRefreshJob.isPresent()) {
+ return;
+ }
+
+ try {
+ oAuthClientService.refreshToken();
+ retryCount = 3;
+ stopTokenRefreshJob();
+ } catch (IOException | OAuthResponseException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ } catch (OAuthException e) {
+ if (retryCount > 0) {
+ tokenRefreshJob = Optional.of(scheduler.schedule(() -> {
+ retryCount--;
+ requestTokenRefresh();
+ }, retryDelayS, TimeUnit.MINUTES));
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/oauth-refresh-error");
+ }
+ }
+ }
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public @Nullable ProductItemStatus retrieveDeviceStatus(String serialNumber) throws WebApiException {
+ return apiHandler.retrieveDeviceStatus(accessToken, serialNumber);
+ }
+
+ public List retrieveAllDevices() throws WebApiException {
+ return apiHandler.retrieveDeviceStatus(accessToken);
+ }
+
+ public boolean resetBladeTime(String serialNumber) {
+ return apiHandler.resetBladeTime(accessToken, serialNumber);
+ }
+
+ public boolean resetBatteryCycles(String serialNumber) {
+ return apiHandler.resetBatteryCycles(accessToken, serialNumber);
+ }
+
+ @Override
+ public boolean isLinked(ChannelUID channelUID) {
+ return super.isLinked(channelUID);
+ }
+
+ @Override
+ public void updateState(ChannelUID channelUID, State state) {
+ super.updateState(channelUID, state);
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/WorxLandroidMowerHandler.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/WorxLandroidMowerHandler.java
new file mode 100644
index 0000000000000..4eb4b6342d659
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/handler/WorxLandroidMowerHandler.java
@@ -0,0 +1,574 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.handler;
+
+import static org.openhab.binding.worxlandroid.internal.WorxLandroidBindingConstants.*;
+
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.IntStream;
+
+import javax.measure.Unit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.worxlandroid.internal.api.WebApiException;
+import org.openhab.binding.worxlandroid.internal.api.WorxApiDeserializer;
+import org.openhab.binding.worxlandroid.internal.api.dto.Commands.MowerCommand;
+import org.openhab.binding.worxlandroid.internal.api.dto.Commands.OneTimeCommand;
+import org.openhab.binding.worxlandroid.internal.api.dto.Commands.ScheduleCommand;
+import org.openhab.binding.worxlandroid.internal.api.dto.Commands.ScheduleDaysCommand;
+import org.openhab.binding.worxlandroid.internal.api.dto.Commands.SetRainDelay;
+import org.openhab.binding.worxlandroid.internal.api.dto.Commands.ZoneMeterAlloc;
+import org.openhab.binding.worxlandroid.internal.api.dto.Commands.ZoneMeterCommand;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload.Dat.Axis;
+import org.openhab.binding.worxlandroid.internal.api.dto.ProductItemStatus;
+import org.openhab.binding.worxlandroid.internal.codes.WorxLandroidActionCodes;
+import org.openhab.binding.worxlandroid.internal.codes.WorxLandroidDayCodes;
+import org.openhab.binding.worxlandroid.internal.codes.WorxLandroidStatusCodes;
+import org.openhab.binding.worxlandroid.internal.config.MowerConfiguration;
+import org.openhab.binding.worxlandroid.internal.vo.Mower;
+import org.openhab.binding.worxlandroid.internal.vo.ScheduledDay;
+import org.openhab.core.library.types.DateTimeType;
+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.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The{@link WorxLandroidMowerHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Nils Billing - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class WorxLandroidMowerHandler extends AWSClientThingHandler {
+ private static final String EMPTY_PAYLOAD = "{}";
+
+ private final Logger logger = LoggerFactory.getLogger(WorxLandroidMowerHandler.class);
+ private Optional> refreshJob = Optional.empty();
+ private Optional> pollingJob = Optional.empty();
+
+ private Optional mower = Optional.empty();
+
+ public WorxLandroidMowerHandler(Thing thing, WorxApiDeserializer deserializer) {
+ super(thing, deserializer);
+ }
+
+ @Override
+ public void initialize() {
+ super.initialize();
+ MowerConfiguration config = getConfigAs(MowerConfiguration.class);
+
+ if (config.serialNumber.isBlank()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/conf-error-no-serial");
+ return;
+ }
+
+ WorxLandroidBridgeHandler bridgeHandler = getBridgeHandler(getBridge(), WorxLandroidBridgeHandler.class);
+ if (bridgeHandler != null) {
+ initializeData(bridgeHandler);
+ }
+ }
+
+ @Override
+ public void dispose() {
+ refreshJob.ifPresent(job -> job.cancel(true));
+ refreshJob = Optional.empty();
+
+ pollingJob.ifPresent(job -> job.cancel(true));
+ pollingJob = Optional.empty();
+
+ super.dispose();
+ }
+
+ private void initializeData(WorxLandroidBridgeHandler bridgeHandler) {
+ MowerConfiguration config = getConfigAs(MowerConfiguration.class);
+ logger.debug("Initializing WorxLandroidMowerHandler for serial number '{}'", config.serialNumber);
+ try {
+ ProductItemStatus product = bridgeHandler.retrieveDeviceStatus(config.serialNumber);
+ if (product != null) {
+ connectAws(product.mqttEndpoint, product.uuid, product.userId, product.mqttTopics.commandOut);
+ mower = Optional.of(new Mower(this, product));
+ setChannelsAndProperties(mower.get());
+ processStatusMessage(mower.get());
+
+ updateStatus(product.online ? ThingStatus.ONLINE : ThingStatus.OFFLINE);
+ startScheduledJobs(bridgeHandler, mower.get(), config);
+ }
+ } catch (WebApiException e) {
+ logger.warn("initialize mower: id {} - {}::{}", config.serialNumber, getThing().getLabel(),
+ getThing().getUID());
+ }
+ }
+
+ private void setChannelsAndProperties(Mower mower) {
+ ThingBuilder thingBuilder = editThing();
+ Set toRemove = new HashSet<>();
+
+ if (!mower.lockSupported()) { // lock channel only when supported
+ toRemove.add(getChannelUID(GROUP_COMMON, CHANNEL_LOCK));
+ }
+
+ if (!mower.rainDelaySupported()) { // rainDelay channel only when supported
+ toRemove.add(getChannelUID(GROUP_RAIN, CHANNEL_DELAY));
+ }
+
+ if (!mower.rainDelayStartSupported()) { // // rainDelayStart channel only when supported
+ toRemove.addAll(getChannelUIDs(GROUP_RAIN, Set.of(CHANNEL_RAIN_STATE, CHANNEL_RAIN_COUNTER)));
+ }
+
+ if (!mower.multiZoneSupported()) { // multizone channels only when supported
+ toRemove.add(getChannelUID(GROUP_MULTI_ZONES, CHANNEL_LAST_ZONE));
+
+ // remove zone meter channels
+ IntStream.range(0, mower.getMultiZoneCount())
+ .forEach(index -> toRemove.add(getChannelUID(GROUP_MULTI_ZONES, "zone-%d".formatted(index + 1))));
+ // remove allocation channels
+ IntStream.range(0, 10).forEach(index -> toRemove
+ .add(getChannelUID(GROUP_MULTI_ZONES, "%s-%d".formatted(CHANNEL_PREFIX_ALLOCATION, index))));
+ }
+
+ if (!mower.oneTimeSchedulerSupported()) { // oneTimeScheduler channel only when supported
+ toRemove.addAll(getChannelUIDs(GROUP_ONE_TIME, Set.of(CHANNEL_DURATION, CHANNEL_EDGECUT, CHANNEL_MODE)));
+ }
+
+ if (!mower.scheduler2Supported()) { // Scheduler 2 channels only when supported version
+ EnumSet.allOf(WorxLandroidDayCodes.class).stream()
+ .map(dayCode -> "%s2".formatted(dayCode.getDescription().toLowerCase()))
+ .forEach(groupName -> toRemove.addAll(getChannelUIDs(groupName,
+ Set.of(CHANNEL_ENABLE, CHANNEL_DURATION, CHANNEL_EDGECUT, CHANNEL_TIME))));
+ }
+
+ toRemove.stream().forEach(thingBuilder::withoutChannel);
+ updateThing(thingBuilder.build());
+
+ updateProperties(Map.of(Thing.PROPERTY_MAC_ADDRESS, mower.getMacAddress(), Thing.PROPERTY_VENDOR, "Worx",
+ "productId", mower.getId(), "language", mower.getLanguage(), "mqtt_endpoint", endpoint));
+ }
+
+ private void processStatusMessage(Mower mower) {
+ updateStateCfg(mower);
+ updateStateDat(mower);
+ thing.setProperty(Thing.PROPERTY_FIRMWARE_VERSION, mower.getFirmwareVersion());
+ }
+
+ /**
+ * Start scheduled jobs.
+ * Jobs are only started if interval > 0
+ */
+ private void startScheduledJobs(WorxLandroidBridgeHandler bridgeHandler, Mower theMower,
+ MowerConfiguration config) {
+ if (config.refreshStatusInterval > 0) {
+ refreshJob = Optional.of(scheduler.scheduleWithFixedDelay(() -> {
+ try {
+ ProductItemStatus product = bridgeHandler.retrieveDeviceStatus(config.serialNumber);
+ updateChannelDateTime(GROUP_COMMON, CHANNEL_ONLINE_TIMESTAMP, ZonedDateTime.now());
+ updateChannelOnOff(GROUP_COMMON, CHANNEL_ONLINE, product != null && product.online);
+ updateStatus(product != null ? ThingStatus.ONLINE : ThingStatus.OFFLINE);
+ } catch (WebApiException e) {
+ logger.debug("Refreshing Thing {} failed, handler might be OFFLINE", config.serialNumber);
+ }
+ }, 3, config.refreshStatusInterval, TimeUnit.SECONDS));
+ }
+
+ if (config.pollingInterval > 0) {
+ pollingJob = Optional.of(scheduler.scheduleWithFixedDelay(() -> sendCommand(theMower, EMPTY_PAYLOAD), 5,
+ config.pollingInterval, TimeUnit.SECONDS));
+ }
+ }
+
+ @Override
+ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
+ super.bridgeStatusChanged(bridgeStatusInfo);
+ WorxLandroidBridgeHandler bridgeHandler = getBridgeHandler(getBridge(), WorxLandroidBridgeHandler.class);
+ if (bridgeHandler != null) {
+ initializeData(bridgeHandler);
+ }
+ }
+
+ @Override
+ protected void internalHandleCommand(@Nullable String groupId, String channelId, Command command) {
+ mower.ifPresent(theMower -> {
+ if (GROUP_MULTI_ZONES.equals(groupId)) {
+ handleMultiZonesCommand(theMower, channelId, command);
+ } else if (GROUP_AWS.equals(groupId)) {
+ handleAWSCommand(theMower, channelId);
+ } else if (GROUP_SCHEDULE.equals(groupId)) {
+ handleScheduleCommand(theMower, channelId, Integer.parseInt(command.toString()));
+ } else if (GROUP_ONE_TIME.equals(groupId)) {
+ handleOneTimeSchedule(theMower, channelId, command);
+ } else if (GROUP_COMMON.equals(groupId)) {
+ handleCommonGroup(theMower, channelId, command);
+ } else if (groupId != null && groupId.contains("day")) {
+ setScheduledDays(theMower, groupId, channelId, command);
+ sendCommand(theMower,
+ theMower.scheduler2Supported()
+ ? new ScheduleDaysCommand(theMower.getTimeExtension(), theMower.getScheduleArray1(),
+ theMower.getScheduleArray2())
+ : new ScheduleDaysCommand(theMower.getTimeExtension(), theMower.getScheduleArray1()));
+ } else if (CHANNEL_DELAY.equals(channelId)) {
+ int delaySec = commandToInt(command, Units.SECOND);
+ sendCommand(theMower, new SetRainDelay(delaySec));
+ } else if (CHANNEL_BLADE_TIME.equals(channelId) || CHANNEL_CHARGE_CYCLES.equals(channelId)) {
+ resetStat(channelId, theMower.getSerialNumber());
+ } else {
+ logger.debug("command for channel {} not supported: {}", channelId, command);
+ }
+ });
+ }
+
+ private void handleAWSCommand(Mower theMower, String channel) {
+ if (CHANNEL_POLL.equals(channel)) {
+ sendCommand(theMower, EMPTY_PAYLOAD);
+ updateState(CHANNEL_POLL, OnOffType.OFF);
+ } else {
+ logger.warn("No action identified on channel {}", channel);
+ }
+ }
+
+ private void handleCommonGroup(Mower theMower, String channel, Command command) {
+ if (CHANNEL_ACTION.equals(channel)) {
+ WorxLandroidActionCodes actionCode = WorxLandroidActionCodes.valueOf(command.toString());
+ sendCommand(theMower, new MowerCommand(actionCode));
+ } else if (CHANNEL_LOCK.equals(channel)) {
+ WorxLandroidActionCodes lockCode = OnOffType.ON.equals(command) ? WorxLandroidActionCodes.LOCK
+ : WorxLandroidActionCodes.UNLOCK;
+ sendCommand(theMower, new MowerCommand(lockCode));
+ } else if (CHANNEL_ENABLE.equals(channel)) {
+ theMower.setEnable(OnOffType.ON.equals(command));
+ sendCommand(theMower,
+ theMower.scheduler2Supported()
+ ? new ScheduleDaysCommand(theMower.getTimeExtension(), theMower.getScheduleArray1(),
+ theMower.getScheduleArray2())
+ : new ScheduleDaysCommand(theMower.getTimeExtension(), theMower.getScheduleArray1()));
+ } else {
+ logger.warn("No action identified for command {} on channel {}", command, channel);
+ }
+ }
+
+ private void handleOneTimeSchedule(Mower theMower, String channel, Command command) {
+ if (CHANNEL_DURATION.equals(channel)) {
+ sendCommand(theMower, new OneTimeCommand(0, Integer.parseInt(command.toString())));
+ } else if (CHANNEL_EDGECUT.equals(channel)) {
+ sendCommand(theMower, new OneTimeCommand(OnOffType.ON.equals(command) ? 1 : 0, 0));
+ } else {
+ logger.warn("No action identified for command {} on channel {}", command, channel);
+ }
+ }
+
+ private void handleScheduleCommand(Mower theMower, String channel, int command) {
+ if (CHANNEL_MODE.equals(channel)) {
+ sendCommand(theMower, new ScheduleCommand(command));
+ } else if (CHANNEL_TIME_EXTENSION.equals(channel)) {
+ theMower.setTimeExtension(command);
+ sendCommand(theMower,
+ theMower.scheduler2Supported()
+ ? new ScheduleDaysCommand(theMower.getTimeExtension(), theMower.getScheduleArray1(),
+ theMower.getScheduleArray2())
+ : new ScheduleDaysCommand(theMower.getTimeExtension(), theMower.getScheduleArray1()));
+ } else {
+ logger.warn("No action identified for command {} on channel {}", command, channel);
+ }
+ }
+
+ private void handleMultiZonesCommand(Mower theMower, String channel, Command command) {
+ if (CHANNEL_ENABLE.equals(channel)) {
+ theMower.setMultiZoneEnable(OnOffType.ON.equals(command));
+ sendCommand(theMower, new ZoneMeterCommand(theMower.getZoneMeters()));
+ } else if (CHANNEL_LAST_ZONE.equals(channel)) {
+ if (!WorxLandroidStatusCodes.HOME.equals(theMower.getStatusCode())) {
+ logger.warn("Cannot start zone because mower must be at HOME!");
+ return;
+ }
+
+ theMower.setZoneTo(Integer.parseInt(command.toString()));
+ sendCommand(theMower, new ZoneMeterCommand(theMower.getZoneMeters()));
+ scheduler.schedule(() -> sendCommand(theMower, new MowerCommand(WorxLandroidActionCodes.START)), 2000,
+ TimeUnit.MILLISECONDS);
+ } else {
+ String[] names = channel.split("-");
+ int index = Integer.valueOf(names[1]);
+
+ if (CHANNEL_PREFIX_ZONE.startsWith(names[0])) {
+ int meterValue = commandToInt(command, SIUnits.METRE);
+ theMower.setZoneMeter(index - 1, meterValue);
+ sendCommand(theMower, new ZoneMeterCommand(theMower.getZoneMeters()));
+ } else if (CHANNEL_PREFIX_ALLOCATION.startsWith(names[0])) {
+ theMower.setAllocation(index, Integer.parseInt(command.toString()));
+ sendCommand(theMower, new ZoneMeterAlloc(theMower.getAllocations()));
+ } else {
+ logger.warn("No action identified for command {} on channel {}", command, channel);
+ }
+ }
+ }
+
+ private int commandToInt(Command command, @Nullable Unit> targetUnit) {
+ if (command instanceof QuantityType> qtty && targetUnit != null) {
+ QuantityType> inTarget = qtty.toUnit(targetUnit);
+ if (inTarget != null) {
+ return inTarget.intValue();
+ }
+ }
+ return Integer.parseInt(command.toString());
+ }
+
+ /**
+ * Set scheduled days
+ *
+ * @param theMower
+ *
+ * @param scDaysIndex 1 or 2
+ * @param channelUID
+ * @param command
+ */
+ private void setScheduledDays(Mower theMower, String groupId, String channelId, Command command) {
+ int scDaysSlot = groupId.endsWith("2") ? 2 : 1;
+ WorxLandroidDayCodes dayCodeUpdated = WorxLandroidDayCodes.valueOf(groupId.replace("2", "").toUpperCase());
+
+ ScheduledDay scheduledDayUpdated = theMower.getScheduledDay(scDaysSlot, dayCodeUpdated);
+ if (scheduledDayUpdated == null) {
+ return;
+ }
+
+ if (CHANNEL_ENABLE.equals(channelId)) {
+ scheduledDayUpdated.setEnable(OnOffType.ON.equals(command));
+ } else if (CHANNEL_TIME.equals(channelId)) {
+ if (command instanceof DateTimeType dateTime) {
+ ZonedDateTime zdt = dateTime.getZonedDateTime();
+ scheduledDayUpdated.setStartTime(zdt);
+ } else if (command instanceof StringType stringType) {
+ scheduledDayUpdated.setStartTime(stringType.toString());
+ } else {
+ logger.warn("Incorrect command {} on channel {}:{} ", command, groupId, channelId);
+ }
+ } else if (CHANNEL_DURATION.equals(channelId)) {
+ scheduledDayUpdated.setDuration(Integer.parseInt(command.toString()));
+ } else if (CHANNEL_EDGECUT.equals(channelId)) {
+ scheduledDayUpdated.setEdgecut(OnOffType.ON.equals(command));
+ }
+ }
+
+ private void sendCommand(Mower theMower, Object command) {
+ logger.debug("send command: {}", command);
+ publishMessage(theMower.getMqttCommandIn(), command);
+ }
+
+ /**
+ * Update states for data values
+ *
+ * @param theMower
+ *
+ * @param dat
+ */
+ private void updateStateDat(Mower theMower) {
+ updateChannelQuantity(GROUP_ORIENTATION, CHANNEL_PITCH, theMower.getAngle(Axis.PITCH), Units.DEGREE_ANGLE);
+ updateChannelQuantity(GROUP_ORIENTATION, CHANNEL_ROLL, theMower.getAngle(Axis.ROLL), Units.DEGREE_ANGLE);
+ updateChannelQuantity(GROUP_ORIENTATION, CHANNEL_YAW, theMower.getAngle(Axis.YAW), Units.DEGREE_ANGLE);
+ updateChannelEnum(GROUP_COMMON, CHANNEL_STATUS_CODE, theMower.getPayloadDat().statusCode);
+ updateChannelEnum(GROUP_COMMON, CHANNEL_ERROR_CODE, theMower.getPayloadDat().errorCode);
+ updateChannelDecimal(GROUP_MULTI_ZONES, CHANNEL_LAST_ZONE, theMower.getLastZone());
+
+ updateChannelDecimal(GROUP_BATTERY, CHANNEL_CHARGE_CYCLES, theMower.getCurrentChargeCycles());
+ updateChannelDecimal(GROUP_BATTERY, CHANNEL_CHARGE_CYCLES_TOTAL, theMower.getTotalChargeCycles());
+ updateChannelQuantity(GROUP_METRICS, CHANNEL_BLADE_TIME, theMower.getCurrentBladeTime(), Units.MINUTE);
+ updateChannelQuantity(GROUP_METRICS, CHANNEL_BLADE_TIME_TOTAL, theMower.getTotalBladeTime(), Units.MINUTE);
+
+ theMower.getBattery().ifPresent(battery -> {
+ updateChannelQuantity(GROUP_BATTERY, CHANNEL_TEMPERATURE, battery.temp != -1 ? battery.temp : null,
+ SIUnits.CELSIUS);
+ updateChannelQuantity(GROUP_BATTERY, CHANNEL_VOLTAGE, battery.voltage != -1 ? battery.voltage : null,
+ Units.VOLT);
+ updateChannelDecimal(GROUP_BATTERY, CHANNEL_LEVEL, battery.level);
+ updateChannelOnOff(GROUP_BATTERY, CHANNEL_CHARGING, battery.charging);
+ });
+
+ theMower.getStats().ifPresent(stats -> {
+ updateChannelQuantity(GROUP_METRICS, CHANNEL_DISTANCE,
+ stats.distanceCovered != -1 ? stats.distanceCovered : null, SIUnits.METRE);
+ updateChannelQuantity(GROUP_METRICS, CHANNEL_TOTAL_TIME,
+ stats.mowerWorkTime != -1 ? stats.mowerWorkTime : null, Units.MINUTE);
+ });
+
+ int rssi = theMower.getPayloadDat().wifiQuality;
+ updateChannelDecimal(GROUP_WIFI, CHANNEL_WIFI_QUALITY, rssi <= 0 ? toQoS(rssi) : null);
+ updateChannelQuantity(GROUP_WIFI, CHANNEL_RSSI,
+ rssi <= 0 ? new QuantityType<>(rssi, Units.DECIBEL_MILLIWATTS) : null);
+
+ if (theMower.lockSupported()) {
+ updateChannelOnOff(GROUP_COMMON, CHANNEL_LOCK, theMower.getPayloadDat().isLocked());
+ }
+
+ theMower.getRain().ifPresent(rain -> {
+ if (theMower.rainDelayStartSupported()) {
+ updateChannelOnOff(GROUP_RAIN, CHANNEL_RAIN_STATE, rain.raining);
+ updateChannelQuantity(GROUP_RAIN, CHANNEL_RAIN_COUNTER, rain.counter, Units.MINUTE);
+ }
+ });
+ }
+
+ /**
+ * Update states for cfg values
+ *
+ * @param theMower
+ *
+ * @param cfg
+ * @param zoneId
+ */
+ private void updateStateCfg(Mower theMower) {
+ updateChannelDateTime(GROUP_CONFIG, CHANNEL_TIMESTAMP, theMower.getLastUpdate());
+
+ theMower.getOneTimeSchedule().ifPresent(ots -> {
+ updateChannelOnOff(GROUP_ONE_TIME, CHANNEL_EDGECUT, ots.getEdgeCut());
+ updateChannelQuantity(GROUP_ONE_TIME, CHANNEL_DURATION, ots.duration != -1 ? ots.duration : null,
+ Units.MINUTE);
+ });
+
+ theMower.getSchedule().ifPresent(schedule -> {
+ if (theMower.oneTimeSchedulerSupported()) {
+ updateChannelEnum(GROUP_SCHEDULE, CHANNEL_MODE, schedule.scheduleMode);
+ }
+
+ if (schedule.timeExtension != -1) {
+ updateChannelQuantity(GROUP_SCHEDULE, CHANNEL_TIME_EXTENSION, schedule.timeExtension, Units.PERCENT);
+ updateChannelOnOff(GROUP_COMMON, CHANNEL_ENABLE, theMower.isEnable());
+ }
+
+ if (schedule.d != null) {
+ updateStateCfgScDays(theMower, 1, schedule.d);
+ if (schedule.dd != null) {
+ updateStateCfgScDays(theMower, 2, schedule.dd);
+ }
+ }
+ });
+
+ int command = theMower.getPayloadCfg().cmd;
+ updateChannelDecimal(GROUP_CONFIG, CHANNEL_COMMAND, command != -1 ? command : null);
+
+ if (theMower.multiZoneSupported()) {
+ for (int zoneIndex = 0; zoneIndex < theMower.getZonesSize(); zoneIndex++) {
+ updateChannelQuantity(GROUP_MULTI_ZONES, CHANNEL_PREFIX_ZONE.formatted(zoneIndex + 1),
+ theMower.getZoneMeter(zoneIndex), SIUnits.METRE);
+ }
+
+ for (int allocationIndex = 0; allocationIndex < theMower.getAllocationsSize(); allocationIndex++) {
+ updateChannelDecimal(GROUP_MULTI_ZONES, CHANNEL_PREFIX_ALLOCATION.formatted(allocationIndex),
+ theMower.getAllocation(allocationIndex));
+ }
+ updateChannelOnOff(GROUP_MULTI_ZONES, CHANNEL_ENABLE, theMower.isMultiZoneEnable());
+ }
+
+ int rainDelay = theMower.getPayloadCfg().rainDelay;
+ updateChannelQuantity(GROUP_RAIN, CHANNEL_DELAY,
+ theMower.rainDelaySupported() && rainDelay != -1 ? rainDelay : null, Units.MINUTE);
+ }
+
+ /**
+ * @param theMower
+ * @param scDSlot scheduled day slot
+ * @param d scheduled day
+ */
+ private void updateStateCfgScDays(Mower theMower, int scDSlot, List> d) {
+ List nextStarts = new ArrayList<>();
+ List nextEnds = new ArrayList<>();
+
+ for (WorxLandroidDayCodes dayCode : WorxLandroidDayCodes.values()) {
+ ScheduledDay scheduledDay = theMower.getScheduledDay(scDSlot, dayCode);
+ if (scheduledDay == null) {
+ return;
+ }
+
+ String groupName = "%s%s".formatted(dayCode.getDescription().toLowerCase(),
+ scDSlot == 1 ? "" : String.valueOf(scDSlot));
+
+ updateChannelOnOff(groupName, CHANNEL_ENABLE, scheduledDay.isEnabled());
+ updateChannelOnOff(groupName, CHANNEL_EDGECUT, scheduledDay.isEdgecut());
+ updateChannelQuantity(groupName, CHANNEL_DURATION, scheduledDay.getDuration(), Units.MINUTE);
+
+ if (scheduledDay.isEnabled()) {
+ ZonedDateTime scheduleStart = ZonedDateTime.now().truncatedTo(ChronoUnit.MINUTES)
+ .with(scheduledDay.getStartTime());
+ scheduleStart = ZonedDateTime.from(dayCode.dayOfWeek.adjustInto(scheduleStart));
+ updateChannelDateTime(groupName, CHANNEL_TIME, scheduleStart);
+ ZonedDateTime scheduleEnd = scheduleStart.plusMinutes(scheduledDay.getDuration());
+ if (scheduleStart.isBefore(ZonedDateTime.now())) {
+ scheduleStart = scheduleStart.plusDays(7);
+ }
+ if (scheduleEnd.isBefore(ZonedDateTime.now())) {
+ scheduleEnd = scheduleEnd.plusDays(7);
+ }
+
+ nextStarts.add(scheduleStart);
+ nextEnds.add(scheduleEnd);
+ }
+ }
+ if (!nextStarts.isEmpty()) {
+ Collections.sort(nextStarts);
+ Collections.sort(nextEnds);
+ updateChannelDateTime(GROUP_SCHEDULE, CHANNEL_START, nextStarts.get(0));
+ updateChannelDateTime(GROUP_SCHEDULE, CHANNEL_STOP, nextEnds.get(0));
+ }
+ }
+
+ private int toQoS(int rssi) {
+ return rssi > -50 ? 4 : rssi > -60 ? 3 : rssi > -70 ? 2 : rssi > -85 ? 1 : 0;
+ }
+
+ public void publishMessage(String topic, Object command) {
+ publishMessage(topic, deserializer.toJson(command));
+ }
+
+ @Override
+ protected void internalHandlePayload(Payload payload) {
+ mower.ifPresent(theMower -> {
+ theMower.setStatus(payload);
+ updateStateCfg(theMower);
+ updateStateDat(theMower);
+ });
+ }
+
+ private boolean resetStat(String channelId, String serialNumber) {
+ WorxLandroidBridgeHandler bridgeHandler = getBridgeHandler(getBridge(), WorxLandroidBridgeHandler.class);
+ if (bridgeHandler != null) {
+ logger.debug("Resetting {}", channelId);
+ if (CHANNEL_BLADE_TIME.equals(channelId)) {
+ bridgeHandler.resetBladeTime(serialNumber);
+ } else {
+ bridgeHandler.resetBatteryCycles(serialNumber);
+ }
+ }
+ return true;
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/mqtt/AWSClient.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/mqtt/AWSClient.java
new file mode 100644
index 0000000000000..cb698808cf0b2
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/mqtt/AWSClient.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.mqtt;
+
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.common.ThreadPoolManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import software.amazon.awssdk.crt.CRT;
+import software.amazon.awssdk.crt.http.HttpRequest;
+import software.amazon.awssdk.crt.mqtt.MqttClientConnection;
+import software.amazon.awssdk.crt.mqtt.MqttClientConnectionEvents;
+import software.amazon.awssdk.crt.mqtt.MqttException;
+import software.amazon.awssdk.crt.mqtt.MqttMessage;
+import software.amazon.awssdk.crt.mqtt.OnConnectionFailureReturn;
+import software.amazon.awssdk.crt.mqtt.OnConnectionSuccessReturn;
+import software.amazon.awssdk.crt.mqtt.QualityOfService;
+import software.amazon.awssdk.iot.AwsIotMqttConnectionBuilder;
+
+/**
+ * {@link AWSClient} AWS client
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+public class AWSClient implements MqttClientConnectionEvents {
+ private static final QualityOfService QOS = QualityOfService.AT_MOST_ONCE;
+ private static final String AUTHORIZER_NAME = "com-worxlandroid-customer";
+ private static final String MQTT_USERNAME = "openhab";
+
+ private final ScheduledExecutorService scheduler = ThreadPoolManager.getScheduledPool("AWSClient");
+ private final Map> subscriptions = new HashMap<>();
+ private final Logger logger = LoggerFactory.getLogger(AWSClient.class);
+ private final AWSClientCallbackI clientCallback;
+
+ private @Nullable MqttClientConnection mqttClient;
+ private LocalDateTime lastResumed = LocalDateTime.MIN;
+ private boolean connected;
+
+ public AWSClient(AWSClientCallbackI clientCallback) {
+ this.clientCallback = clientCallback;
+ }
+
+ public void connect(String endpoint, String userId, String productUuid, String token) {
+ String[] tok = token.replaceAll("_", "/").replaceAll("-", "+").split("\\.");
+
+ try {
+ MqttClientConnection connection = AwsIotMqttConnectionBuilder.newDefaultBuilder()
+ // .withCustomAuthorizer(MQTT_USERNAME, AUTHORIZER_NAME, tok[2], null, MQTT_USERNAME, token)
+ .withClientId("WX/USER/%s/%s/%s".formatted(userId, MQTT_USERNAME, productUuid))
+ .withEndpoint(endpoint).withUsername(MQTT_USERNAME).withCleanSession(false).withKeepAliveSecs(300)
+ .withConnectionEventCallbacks(this).withWebsockets(true)
+ .withWebsocketHandshakeTransform(handshakeArgs -> {
+ HttpRequest httpRequest = handshakeArgs.getHttpRequest();
+ httpRequest.addHeader("x-amz-customauthorizer-name", AUTHORIZER_NAME);
+ httpRequest.addHeader("x-amz-customauthorizer-signature", tok[2]);
+ httpRequest.addHeader("jwt", tok[0] + "." + tok[1]);
+ handshakeArgs.complete(httpRequest);
+ }).build();
+ connection.connect().get();
+ this.mqttClient = connection;
+ } catch (MqttException | UnsupportedEncodingException | InterruptedException | ExecutionException e) {
+ clientCallback.onAWSConnectionFailed(e.getMessage());
+ }
+ }
+
+ public void dispose() {
+ disconnect();
+ subscriptions.clear();
+ }
+
+ @Override
+ public void onConnectionSuccess(@NonNullByDefault({}) OnConnectionSuccessReturn data) {
+ onConnectionResumed(data.getSessionPresent());
+ }
+
+ @Override
+ public void onConnectionResumed(boolean sessionPresent) {
+ connected = sessionPresent;
+ if (sessionPresent) {
+ lastResumed = LocalDateTime.now();
+ logger.debug("last connection resume {}", lastResumed);
+ subscriptions.forEach(this::subscribe);
+ clientCallback.onAWSConnectionSuccess();
+ } else {
+ clientCallback.onAWSConnectionClosed();
+ }
+ }
+
+ @Override
+ public void onConnectionInterrupted(int errorCode) {
+ LocalDateTime interrupted = LocalDateTime.now();
+ connected = false;
+ String error = CRT.awsErrorString(errorCode);
+ logger.debug("connection interrupted errorcode: {} : {}", errorCode, error);
+
+ scheduler.schedule(() -> {
+ /**
+ * workaround -> after 20 minutes the connection is interrupted but immediately resumed (~0,5sec).
+ * ConnectionBuilder with ".withKeepAliveSecs(300)" doesn't work
+ */
+ boolean isBetween = lastResumed.isAfter(interrupted) && lastResumed.isBefore(LocalDateTime.now());
+ logger.debug("lastResumed: {} interrupted: {} in: {}", lastResumed, interrupted, isBetween);
+ if (!isBetween) {
+ clientCallback.onAWSConnectionClosed();
+ }
+ }, 5, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void onConnectionFailure(@NonNullByDefault({}) OnConnectionFailureReturn data) {
+ connected = false;
+ if (data.getErrorCode() == 5134) {
+ clientCallback.onAWSConnectionFailed("Error code 5134: banned 24h");
+ } else {
+ logger.debug("{}", data.toString());
+ clientCallback.onAWSConnectionClosed();
+ }
+ };
+
+ public void disconnect() {
+ MqttClientConnection connection = mqttClient;
+ if (connection != null) {
+ connection.disconnect();
+ connection.close();
+ mqttClient = null;
+ }
+ connected = false;
+ }
+
+ public void subscribe(String topic, Consumer handler) {
+ MqttClientConnection connection = mqttClient;
+ if (connection != null) {
+ subscriptions.put(topic, handler);
+ connection.subscribe(topic, QOS, handler);
+ } else {
+ logger.warn("Tried to subscribe on {} when connection is closed", topic);
+ }
+ }
+
+ public void unsubscribe(String topic) {
+ MqttClientConnection connection = mqttClient;
+ if (connection != null) {
+ subscriptions.remove(topic);
+ connection.unsubscribe(topic);
+ } else {
+ logger.warn("Tried to unsubscribe from {} when connection is closed", topic);
+ }
+ }
+
+ public void publish(String topic, String payload) {
+ MqttClientConnection connection = mqttClient;
+ if (connection != null) {
+ connection.publish(new MqttMessage(topic, payload.getBytes(StandardCharsets.UTF_8), QOS));
+ } else {
+ logger.warn("Tried to publish on {} when connection is closed", topic);
+ }
+ }
+
+ public boolean isConnected() {
+ return connected;
+ }
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/mqtt/AWSClientCallbackI.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/mqtt/AWSClientCallbackI.java
new file mode 100644
index 0000000000000..a5274ee2fa70c
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/mqtt/AWSClientCallbackI.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.mqtt;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * {@link AWSClientCallbackI} Callback for AWS connection events
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+public interface AWSClientCallbackI {
+
+ /**
+ * callback method on connection success
+ */
+ public void onAWSConnectionSuccess();
+
+ /**
+ * callback method on connection closed
+ */
+ public void onAWSConnectionClosed();
+
+ /**
+ * callback method on connection failed
+ */
+ public void onAWSConnectionFailed(@Nullable String message);
+}
diff --git a/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/vo/Mower.java b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/vo/Mower.java
new file mode 100644
index 0000000000000..45750dc7d9302
--- /dev/null
+++ b/bundles/org.openhab.binding.worxlandroid/src/main/java/org/openhab/binding/worxlandroid/internal/vo/Mower.java
@@ -0,0 +1,413 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.worxlandroid.internal.vo;
+
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.worxlandroid.internal.api.dto.Commands.ZoneMeterCommand;
+import org.openhab.binding.worxlandroid.internal.api.dto.LastStatus;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload.Battery;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload.Cfg;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload.Dat;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload.Dat.Axis;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload.Ots;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload.Rain;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload.Schedule;
+import org.openhab.binding.worxlandroid.internal.api.dto.Payload.Stat;
+import org.openhab.binding.worxlandroid.internal.api.dto.ProductItemStatus;
+import org.openhab.binding.worxlandroid.internal.codes.WorxLandroidDayCodes;
+import org.openhab.binding.worxlandroid.internal.codes.WorxLandroidStatusCodes;
+import org.openhab.binding.worxlandroid.internal.handler.WorxLandroidMowerHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * {@link Mower}
+ *
+ * @author Nils Billing - Initial contribution
+ */
+@NonNullByDefault
+public class Mower {
+ private static final int[] MULTI_ZONE_METER_DISABLE = { 0, 0, 0, 0 };
+ private static final int[] MULTI_ZONE_METER_ENABLE = { 1, 0, 0, 0 };
+ private static final int TIME_EXTENSION_DISABLE = -100;
+
+ private final Logger logger = LoggerFactory.getLogger(Mower.class);
+ private final WorxLandroidMowerHandler mowerHandler;
+ private final ProductItemStatus product;
+
+ private final int[] zoneMeter;
+ private final int[] zoneMeterRestore;
+ private final int[] allocations = new int[10];
+ private final List