diff --git a/bundles/org.openhab.binding.automower/README.md b/bundles/org.openhab.binding.automower/README.md index 094fe0e050..e134c70121 100644 --- a/bundles/org.openhab.binding.automower/README.md +++ b/bundles/org.openhab.binding.automower/README.md @@ -1,7 +1,7 @@ # Automower Binding This is the binding for [Husqvarna Automower® robotic lawn mowers](https://www.husqvarna.com/uk/products/robotic-lawn-mowers/). -This binding allows you to integrate, view and control Automower® lawn mowers in the openHAB environment. +This binding allows you to integrate, view and control Husqvarna Automower® lawn mowers in the openHAB environment. ## Supported Things @@ -21,62 +21,73 @@ Once the bridge is created and configured, openHAB will automatically discover a - appKey (mandatory): The Application Key is required to communicate with the Automower® Connect API. It can be obtained by registering an Application on [the Husqvarna Website](https://developer.husqvarnagroup.cloud/). This application also needs to be connected to the ["Authentication API" and the "Automower® Connect API"](https://developer.husqvarnagroup.cloud/docs/getting-started) - appSecret (mandatory): The Application Secret is required to communicate with the Automower® Connect API. It can be obtained by registering an Application on [the Husqvarna Website](https://developer.husqvarnagroup.cloud/). -- pollingInterval (optional): How often the bridge state should be queried in seconds. Default is 1h (3600s) +- pollingInterval (optional): How often the current Automower® states should be polled in seconds via REST API. Default is 5min (300s) -Keep in mind that the status of the bridge should not be queried too often. -According to the Husqvarna documentation not more than 10000 requests per month / 1 request per second and application key are allowed. -With the default value of 1h this would mean ~720 requests per month for the bridge state +Keep in mind that the REST API should not be queried too frequently. +According to Husqvarna's guidelines, each application key is limited to 10.000 requests per month and 1 request per second. + +With the default polling interval of 5min, the bridge will make approximately 8.640 requests per month. +As the states are polled from the `bridge`, the number does not scale with the number of `automower`. + +In addition to periodic polling, the binding also receives event-triggered notifications whenever there are changes to the Automower®'s status, position, settings, or messages. `automower:` -- mowerId (mandatory): The Id of an Automower® as used by the Automower® Connect Api to identify a mower. This is automatically filled when the thing is discovered -- pollingInterval (optional): How often the current Automower® state should be polled in seconds. Default is 10min (600s) - -Keep in mind that the status of the Automower® should not be queried too often. -According to the Husqvarna documentation not more than 10000 requests per month / 1 request per second and application key are allowed. -With the default value of 10min this would mean ~4300 requests per month per single Automower® +- mowerId (mandatory): The Id of an Automower® as used by the Automower® Connect API to identify a Automower®. This is automatically filled when the thing is discovered +- mowerZoneId (optional): Time zone of the Automower® (e.g. Europe/Berlin). Default is the time zone of the system ## Channels ### Status Channels +These channels represent the Automower® status. + | channel | type | access mode | description | advanced | |---------------------------------------|----------------------|-----|----------------------------------------------------------------------------------------------------------------|-------| -| status#name | String | R | The name of the Automower® | false | +| status#name | String | R | The name of the mower | false | | status#mode | String | R | The current mode (MAIN_AREA, SECONDARY_AREA, HOME, DEMO, UNKNOWN) | false | | status#activity | String | R | The current activity (UNKNOWN, NOT_APPLICABLE, MOWING, GOING_HOME, CHARGING, LEAVING, PARKED_IN_CS, STOPPED_IN_GARDEN) | false | | status#inactive-reason | String | R | The current reason for being inactive (NONE, PLANNING, SEARCHING_FOR_SATELLITES) | false | | status#state | String | R | The current state (UNKNOWN, NOT_APPLICABLE, PAUSED, IN_OPERATION, WAIT_UPDATING, WAIT_POWER_UP, RESTRICTED_NONE, RESTRICTED_WEEK_SCHEDULE, RESTRICTED_PARK_OVERRIDE, RESTRICTED_SENSOR, RESTRICTED_DAILY_LIMIT, RESTRICTED_FOTA, RESTRICTED_FROST, RESTRICTED_ALL_WORK_AREAS_COMPLETED, RESTRICTED_EXTERNAL, OFF, STOPPED, ERROR, FATAL_ERROR, ERROR_AT_POWER_UP) | false | -| status#work-area-id[1](#f1) | Number | R | Id of the active work area | true | -| status#work-area[1](#f1) | String | R | Name of the active work area | false | -| status#last-update | DateTime | R | The time when the Automower® updated its states | false | +| status#work-area-id[1](#f1) | Number | R | Id of the active Work Area | true | +| status#work-area[1](#f1) | String | R | Name of the active Work Area | false | +| status#last-update | DateTime | R | The time when the mower sent the last update | false | | status#last-poll-update | DateTime | R | The time when the binding polled the last update from the cloud | true | -| status#poll-update | Switch | R | Poll Automower® status update from the cloud | true | +| status#poll-update | Switch | R/W | Poll mower status update from the cloud (`sendCommand(ON)`) | true | | status#battery | Number:Dimensionless | R | The battery state of charge in percent | false | | status#error-code | Number | R/W | The current error code. `sendCommand(0)` to confirm current non fatal error | true | | status#error-message | String | R | The current error message | false | | status#error-timestamp | DateTime | R | The timestamp when the current error occurred | false | -| status#error-confirmable[1](#f1) | Switch | R | If the mower has an Error Code this attribute states if the error is confirmable | true | -| status#next-start | DateTime | R | The time for the next auto start. If the mower is charging then the value is the estimated time when it will be leaving the charging station. If the mower is about to start now, the value is NULL | false | -| status#override-action | String | R | The action that overrides current planner operation | true | -| status#restricted-reason | String | R | A reason that restrics current planner operation (NONE, WEEK_SCHEDULE, PARK_OVERRIDE, SENSOR, DAILY_LIMIT, FOTA, FROST, ALL_WORK_AREAS_COMPLETED, EXTERNAL) | false | -| status#external-reason | String | R | An external reason set by i.e. IFTTT, Google Assistant or Amazon Alexa that restrics current planner operation | true | +| status#error-confirmable[1](#f1) | Switch | R | If the mower has an error, this attribute states if the error is confirmable | true | +| status#next-start | DateTime | R | The time for the next scheduled start. If the mower is charging then the value is the estimated time when it will leave the charging station. If the mower is about to start now, the value is NULL | false | +| status#override-action | String | R | The action that overrides the current planner operation | true | +| status#restricted-reason | String | R | The reason that restrics the current planner operation (NONE, WEEK_SCHEDULE, PARK_OVERRIDE, SENSOR, DAILY_LIMIT, FOTA, FROST, ALL_WORK_AREAS_COMPLETED, EXTERNAL) | false | +| status#external-reason | String | R | An external reason set by i.e. Google Assistant or Amazon Alexa that restrics the current planner operation | true | +| status#position | Location | R | Last GPS Position of the mower | false | ### Settings Channels +These channels hold Automower® settings. + | channel | type | access mode | description | advanced | |---------------------------------------------------|--------|-------------|-------------------------------------------------------------------------|----------| | setting#cutting-height | Number | R/W | Prescaled cutting height, Range: 1-9 | false | | setting#headlight-mode[1](#f1) | String | R/W | Headlight Mode (ALWAYS_ON, ALWAYS_OFF, EVENING_ONLY, EVENING_AND_NIGHT) | false | +The absolute cutting height can be calculated from the prescaled cutting height using the following formula: + +`cuttingHeightInCM = round((minCuttingHeight + ((maxCuttingHeight - minCuttingHeight) * (setting#cutting-height - 1) / 8)) * 2) / 2` + ### Statistics Channels +These channels hold different Automower® statistics. + | channel | type | access mode | description | advanced | |-------------------------------------|----------------------|-------------|---------------------------------------------------------------------------------------------|----------| | statistic#cutting-blade-usage-time | Number:Time | R/W | The time since the last reset of the cutting blade usage counter. `sendCommand(0)` to reset | false | | statistic#down-time | Number:Time | R | The time the mower has been disconnected from the cloud | true | | statistic#number-of-charging-cycles | Number | R | Number of charging cycles | false | -| statistic#number-of-collisions | Number | R | The total number of collisions | false | +| statistic#number-of-collisions | Number | R | Total number of collisions | false | | statistic#total-charging-time | Number:Time | R | Total charging time | false | | statistic#total-cutting-time | Number:Time | R | Total Cutting Time | false | | statistic#total-cutting-percent | Number:Dimensionless | R | Total cutting time in percent | false | @@ -106,19 +117,6 @@ These channels hold the different Calendar Task configurations. \ ... 01-#calendartasks -### Position Channels - -These channels hold the last 50 GPS positions recorded by the Automower®, thus describing the path it followed. -Position 01 is the latest recorded position, the other positions are pushed back, thus removing the previous position 50 from the list because it is replaced by the previous position 49. -Channel `last-position` is always identical with channel `position01` and thus provides more convenient access if only the latest GPS information is required by the user. - -| channel | type | access mode | description | advanced | -|-----------------------------------------------|----------|-------------|---------------------------------------------------------|----------| -| position#last[1](#f1) | Location | R | Last GPS Position (identical with positions#position01) | false | -| position#\-pos[1](#f1) | Location | R | GPS Position \ | true | - -\ ... 01-50 - ### Stayout Zones Channels These channels hold the different Stayout Zone configurations. @@ -126,9 +124,9 @@ These channels hold the different Stayout Zone configurations. | channel | type | access mode | description | advanced | |-----------------------------------------------------------|--------|-------------|------------------------------------------------------------------------------------------------------------------------------------|----------| | stayoutzone#dirty[1](#f1) | Switch | R | If the stay-out zones are synchronized with the Husqvarna cloud. If the map is dirty you can not enable or disable a stay-out zone | true | -| stayoutzone#\-zone-id[1](#f1) | String | R | Id of the Stayout zone | true | -| stayoutzone#\-zone-name[1](#f1) | String | R | The name of the Stayout zone | true | -| stayoutzone#\-zone-enabled[1](#f1) | Switch | R/W | If the Stayout zone is enabled, the Automower® will not access the zone | true | +| stayoutzone#\-zone-id[1](#f1) | String | R | Id of the stay-out zone | true | +| stayoutzone#\-zone-name[1](#f1) | String | R | The name of the stay-out zone | true | +| stayoutzone#\-zone-enabled[1](#f1) | Switch | R/W | If the stay-out zone is enabled, the mower will not access the zone | true | \ ... 01-#stayoutzones @@ -139,69 +137,68 @@ These channels hold the different Work Area configurations. | channel | type | access mode | description | advanced | |-----------------------------------------------------------------------------------------------------------|-----------------------|-------------|-----------------------------------------------------|----------| | workarea#\-area-id[1](#f1) | Number | R | Id of the Work Area | false | -| workarea#\-area-name[1](#f1) | String | R | Name of the work area | false | -| workarea#\-area-cutting-height[1](#f1) | Number:Dimensionless | R/W | Cutting height in percent. 0-100 | false | -| workarea#\-area-enabled[1](#f1) | Switch | R/W | If the work area is enabled or disabled | false | -| workarea#\-area-progress[1](#f1),[2](#f2) | Number | R | The progress on a work area | true | -| workarea#\-area-last-time-completed[1](#f1),[2](#f2) | DateTime | R | Timestamp when the work area was last completed | true | +| workarea#\-area-name[1](#f1) | String | R | Name of the Work Area | false | +| workarea#\-area-cutting-height[1](#f1) | Number:Dimensionless | R/W | Cutting height of the Work Area in percent. 0-100 | false | +| workarea#\-area-enabled[1](#f1) | Switch | R/W | If the Work Area is enabled or disabled | false | +| workarea#\-area-progress[1](#f1),[2](#f2) | Number | R | The progress on a Work Area | true | +| workarea#\-area-last-time-completed[1](#f1),[2](#f2) | DateTime | R | Timestamp when the Work Area was last completed | true | \ ... 01-#workareas ### Messages -These channels hold the last 50 messages recorded by the Automower®. -Message 01 is the latest recorded message, the other messages are pushed back, thus removing the previous message 50 from the list because it is replaced by the previous message 49. +These channels hold the last message recorded by the Automower®. -| channel | type | access mode | description | advanced | -|-------------------------|----------|-------------|------------------------------------------|----------| -| message#\-time | DateTime | R | Timestamp when the event occurred | true | -| message#\-code | Number | R | (Error) code of the event | true | -| message#\-text | String | R | The message | true | -| message#\-severity | String | R | The severity of the event | true | -| message#\-position | Location | R | GPS Position of the event (if available) | true | - -\ ... 01-50 +| channel | type | access mode | description | advanced | +|-----------------------|----------|-------------|-----------------------------------------------|----------| +| message#msg-timestamp | DateTime | R | The time when the last error occurred | true | +| message#msg-code | Number | R | The last error code | true | +| message#msg-text | String | R | The last error message | true | +| message#msg-severity | String | R | The severity of the last error | true | +| message#msg-position | Location | R | GPS position of the last event (if available) | true | ### Command Channels Command channels that trigger actions. -| channel | type | access mode | description | advanced | -|-----------------------------------|----------|-------------|------------------------------------------|----------| -| command#start | Number | W | Start the Automower® for a duration | false | -| command#resume_schedule | Switch | W | Resume the Automower® schedule | false | -| command#pause | Switch | W | Pause the Automower® | false | -| command#park | Number | W | Park the Automower® for a duration | false | -| command#park_until_next_schedule | Switch | W | Park the Automower® until next schedule | false | -| command#park_until_further_notice | Switch | W | Park the Automower® until further notice | false | +| channel | type | access mode | description | advanced | +|-----------------------------------|----------|-------------|--------------------------------------------------------------------------------------------------------|----------| +| command#start | Number | W | Start the mower for the given duration, overriding the schedule | false | +| command#start_in_workarea | Number | W | Start the mower in the given Work Area, overriding the schedule. The mower will continue forever | false | +| command#resume_schedule | Switch | W | Resume the schedule of the mower | false | +| command#pause | Switch | W | Pause the mower at the current location until manual resume | false | +| command#park | Number | W | Park the mower for the given duration, overriding the schedule | false | +| command#park_until_next_schedule | Switch | W | Park the mower, fully charge it and start afterwards according to the schedule | false | +| command#park_until_further_notice | Switch | W | Park the mower until it is started again by the start action/command or the schedule gets resumed | false | ## Actions The following actions are available for `automower` things: -| action name | arguments | description | -|----------------------------|------------------|------------------------------------------------------------------------------------------------| -| start | `duration (int)` | Start the Automower® for the given duration (minutes), overriding the schedule | -| pause | - | Pause the Automower® wherever it is currently located | -| parkUntilNextSchedule | - | Park the Automower®, fully charges it and starts afterwards according to the schedule | -| parkUntilFurtherNotice | - | Park the Automower® until it is started again by the start action or the schedule gets resumed | -| park | `duration (int)` | Park the Automower® for the given duration (minutes), overriding the schedule | -| resumeSchedule | - | Resume the schedule for the Automower® | -| confirmError | - | Confirm current non fatal error | -| resetCuttingBladeUsageTime | - | Reset the cutting blade usage time | -| setSettings | `byte cuttingHeight`
`String headlightMode` | Update Automower® settings | -| setWorkArea | `long workAreaId`
`boolean enable`
`byte cuttingHeight` | Update work area settings | -| setStayOutZone | `String zoneId`
`boolean enable` | Enable or disable stay-out zone | -| setCalendarTask | `Long workAreaId` (optional, set to `null` if the mower doesn't support work areas)
`short[] start`
`short[] duration`
`boolean[] monday`
`boolean[] tuesday`
`boolean[] wednesday`
`boolean[] thursday`
`boolean[] friday`
`boolean[] saturday`
`boolean[] sunday` | Update calendar task settings. Parameter are an array for all calendar tasks (per work area) | -| poll | - | Poll Automower® status update from the cloud | +| action name | arguments | description | +|----------------------------|-------------------|--------------------------------------------------------------------------------------------------------| +| start | `duration (long)` | Start the mower for the given duration (minutes), overriding the schedule | +| startInWorkArea | `workAreaId (long)`
`duration (long)` | Start the mower in the given Work Area for the given duration (minutes), overriding the schedule. If duration is skipped the mower will continue forever | +| pause | - | Pause the mower at the current location until manual resume | +| park | `duration (long)` | Park the mower for the given duration (minutes), overriding the schedule | +| parkUntilNextSchedule | - | Park the mower, fully charge it and start afterwards according to the schedule | +| parkUntilFurtherNotice | - | Park the mower until it is started again by the start action/command or the schedule gets resumed | +| resumeSchedule | - | Resume the schedule of the mower | +| confirmError | - | Confirm current non fatal error | +| resetCuttingBladeUsageTime | - | Reset the cutting blade usage time | +| setSettings | `byte cuttingHeight`
`String headlightMode` | Update mower settings | +| setWorkArea | `long workAreaId`
`boolean enable`
`byte cuttingHeight` | Update Work Area settings | +| setStayOutZone | `String zoneId`
`boolean enable` | Enable or disable stay-out zone | +| setCalendarTask | `Long workAreaId` (optional, set to `null` if the mower doesn't support Work Areas)
`short[] start`
`short[] duration`
`boolean[] monday`
`boolean[] tuesday`
`boolean[] wednesday`
`boolean[] thursday`
`boolean[] friday`
`boolean[] saturday`
`boolean[] sunday` | Update calendar task settings. Parameter are an array for all calendar tasks (per Work Area) | +| poll | - | Poll mower status update from the cloud | ## Full Example ### automower.thing ```java -Bridge automower:bridge:mybridge [ appKey="", userName="", password="" ] { - Thing automower myAutomower [ mowerId="", pollingInterval=3600 ] { +Bridge automower:bridge:mybridge [ appKey="", appSecret="", pollingInterval=300 ] { + Thing automower 12345678-1234-12ab-1234-123456abcdef [ mowerId="12345678-1234-12ab-1234-123456abcdef" ] { // 1234... is an example of the id recieved via discovery } } ``` @@ -209,24 +206,23 @@ Bridge automower:bridge:mybridge [ appKey="", user ### automower.items ```java -String Automower_Mode "Mode [%s]" { channel="automower:automower:mybridge:myAutomower:status#mode" } -String Automower_Activity "Activity [%s]" { channel="automower:automower:mybridge:myAutomower:status#activity" } -String Automower_State "State [%s]" { channel="automower:automower:mybridge:myAutomower:status#state" } -DateTime Automower_Last_Update "Last Update" { channel="automower:automower:mybridge:myAutomower:status#last-update" } -Number Automower_Battery "Battery [%d %%]" { channel="automower:automower:mybridge:myAutomower:status#battery" } -Number Automower_Error_Code "Error Code [%d]" { channel="automower:automower:mybridge:myAutomower:status#error-code" } -DateTime Automower_Error_Time "Error Time" { channel="automower:automower:mybridge:myAutomower:status#error-timestamp" } -String Automower_Override_Action "Override Action [%s]" { channel="automower:automower:mybridge:myAutomower:status#override-action" } -DateTime Automower_Next_Start_Time "Next Start Time" { channel="automower:automower:mybridge:myAutomower:status#next-start" } +String Automower_Mode "Mode [%s]" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#mode" } +String Automower_Activity "Activity [%s]" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#activity" } +String Automower_State "State [%s]" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#state" } +DateTime Automower_Last_Update "Last Update" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#last-update" } +Number Automower_Battery "Battery [%d %%]" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#battery" } +Number Automower_Error_Code "Error Code [%d]" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#error-code" } +DateTime Automower_Error_Time "Error Time" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#error-timestamp" } +String Automower_Override_Action "Override Action [%s]" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#override-action" } +DateTime Automower_Next_Start_Time "Next Start Time" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#next-start" } +Location Automower_Position "Last Position" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:status#position" } -Number Automower_Command_Start "Start mowing for duration [%d min]" { channel="automower:automower:mybridge:myAutomower:command#start" } -Switch Automower_Command_Resume "Resume the schedule" { channel="automower:automower:mybridge:myAutomower:command#resume_schedule" } -Switch Automower_Command_Pause "Pause the automower" { channel="automower:automower:mybridge:myAutomower:command#pause" } -Number Automower_Command_Park "Park for duration [%d min]" { channel="automower:automower:mybridge:myAutomower:command#park" } -Switch Automower_Command_Park_Next_Schedule "Park until next schedule" { channel="automower:automower:mybridge:myAutomower:command#park_until_next_schedule" } -Switch Automower_Command_Park_Notice "Park until further notice" { channel="automower:automower:mybridge:myAutomower:command#park_until_further_notice" } - -Location Automower_Last_Position "Last Position" { channel="automower:automower:mybridge:myAutomower:position#last-position" } +Number Automower_Command_Start "Start mowing for duration [%d min]" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:command#start" } +Switch Automower_Command_Resume "Resume the schedule" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:command#resume_schedule" } +Switch Automower_Command_Pause "Pause the automower" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:command#pause" } +Number Automower_Command_Park "Park for duration [%d min]" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:command#park" } +Switch Automower_Command_Park_Next_Schedule "Park until next schedule" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:command#park_until_next_schedule" } +Switch Automower_Command_Park_Notice "Park until further notice" { channel="automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef:command#park_until_further_notice" } ``` ### automower.sitemap @@ -244,7 +240,7 @@ sitemap demo label="Automower" Text item=Automower_Error_Time Text item=Automower_Override_Action Text item=Automower_Next_Start_Time - Text item=Automower_Calendar_Tasks + Text item=Automower_Position } } ``` @@ -262,7 +258,7 @@ then Automower_Command_Park_Notice.sendCommand(ON) // alternative via actions - val mowerActions = getActions("automower", "automower:automower:mybridge:myAutomower") + val mowerActions = getActions("automower", "automower:automower:mybridge:12345678-1234-12ab-1234-123456abcdef") mowerActions.parkUntilFurtherNotice() end ``` @@ -270,4 +266,4 @@ end ## Footnotes - 1) ... Channel availability depends on Automower® capabilities [↩](#a1) -- 2) ... Channel available for EPOS Automower® and systematic mowing work areas only [↩](#a2) +- 2) ... Channel available for EPOS Automower® and systematic mowing Work Area only [↩](#a2) diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/AutomowerBindingConstants.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/AutomowerBindingConstants.java index d64b79f393..f8fc161b79 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/AutomowerBindingConstants.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/AutomowerBindingConstants.java @@ -59,6 +59,7 @@ public class AutomowerBindingConstants { public static final String CHANNEL_STATUS_OVERRIDE_ACTION = GROUP_STATUS + "override-action"; public static final String CHANNEL_STATUS_RESTRICTED_REASON = GROUP_STATUS + "restricted-reason"; public static final String CHANNEL_STATUS_EXTERNAL_REASON = GROUP_STATUS + "external-reason"; + public static final String CHANNEL_STATUS_POSITION = GROUP_STATUS + "position"; // List of all setting Channel ids public static final String GROUP_SETTING = "setting#"; @@ -118,12 +119,6 @@ public class AutomowerBindingConstants { new ChannelTypeUID(BINDING_ID, "calendarTaskWorkAreaIdType"), new ChannelTypeUID(BINDING_ID, "workareaNameType"))); - // Position Channel ids - public static final String GROUP_POSITION = "position#"; - - public static final String CHANNEL_POSITION_LAST = GROUP_POSITION + "last"; - public static final String CHANNEL_POSITION = "pos"; - // Stayout Zones Channel ids public static final String GROUP_STAYOUTZONE = "stayoutzone#"; @@ -154,18 +149,17 @@ public class AutomowerBindingConstants { // Messages Channel ids public static final String GROUP_MESSAGE = "message#"; - public static final ArrayList CHANNEL_MESSAGE = new ArrayList<>( - List.of("msg-timestamp", "msg-code", "msg-text", "msg-severity", "msg-gps-position")); - - public static final ArrayList CHANNEL_TYPE_MESSAGE = new ArrayList<>(List.of( - new ChannelTypeUID(BINDING_ID, "messageTimeType"), new ChannelTypeUID(BINDING_ID, "messageCodeType"), - new ChannelTypeUID(BINDING_ID, "messageType"), new ChannelTypeUID(BINDING_ID, "messageSeverityType"), - new ChannelTypeUID(BINDING_ID, "messagePositionType"))); + public static final String CHANNEL_MESSAGE_TIMESTAMP = GROUP_MESSAGE + "msg-timestamp"; + public static final String CHANNEL_MESSAGE_CODE = GROUP_MESSAGE + "msg-code"; + public static final String CHANNEL_MESSAGE_TEXT = GROUP_MESSAGE + "msg-text"; + public static final String CHANNEL_MESSAGE_SEVERITY = GROUP_MESSAGE + "msg-severity"; + public static final String CHANNEL_MESSAGE_GPS_POSITION = GROUP_MESSAGE + "msg-gps-position"; // Command Channel ids public static final String GROUP_COMMAND = "command#"; public static final String CHANNEL_COMMAND_START = GROUP_COMMAND + "start"; + public static final String CHANNEL_COMMAND_START_IN_WORK_AREA = GROUP_COMMAND + "start_in_work_area"; public static final String CHANNEL_COMMAND_RESUME_SCHEDULE = GROUP_COMMAND + "resume_schedule"; public static final String CHANNEL_COMMAND_PAUSE = GROUP_COMMAND + "pause"; public static final String CHANNEL_COMMAND_PARK = GROUP_COMMAND + "park"; @@ -189,15 +183,15 @@ public class AutomowerBindingConstants { public static final ChannelTypeUID CHANNEL_TYPE_STATUS_WORK_AREA = new ChannelTypeUID(BINDING_ID, "workAreaType"); public static final ChannelTypeUID CHANNEL_TYPE_STATUS_ERROR_CONFIRMABLE = new ChannelTypeUID(BINDING_ID, "errorConfirmableType"); + public static final ChannelTypeUID CHANNEL_TYPE_STATUS_POSITION = new ChannelTypeUID(BINDING_ID, "positionType"); public static final ChannelTypeUID CHANNEL_TYPE_SETTING_HEADLIGHT_MODE = new ChannelTypeUID(BINDING_ID, "settingHeadlightModeType"); - public static final ChannelTypeUID CHANNEL_TYPE_POSITION_LAST = new ChannelTypeUID(BINDING_ID, "lastPositionType"); - public static final ChannelTypeUID CHANNEL_TYPE_POSITION = new ChannelTypeUID(BINDING_ID, "positionType"); public static final ChannelTypeUID CHANNEL_TYPE_STAYOUTZONES_DIRTY = new ChannelTypeUID(BINDING_ID, "zoneDirtyType"); public static final Map ERROR = new HashMap<>() { + private static final long serialVersionUID = 1L; { put(0, "No message"); put(1, "Outside working area"); @@ -278,7 +272,7 @@ public class AutomowerBindingConstants { put(76, "Connection NOT changed"); put(77, "Com board not available"); put(78, "Slipped - Mower has Slipped. Situation not solved with moving pattern"); - put(79, "Invalid battery combination - Invalid combination of different battery types."); + put(79, "Invalid battery combination - Invalid combination of different battery types"); put(80, "Cutting system imbalance Warning"); put(81, "Safety function faulty"); put(82, "Wheel motor blocked, rear right"); diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/AutomowerHandlerFactory.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/AutomowerHandlerFactory.java index ef92bf758b..47eceb195d 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/AutomowerHandlerFactory.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/AutomowerHandlerFactory.java @@ -29,6 +29,7 @@ import org.openhab.core.auth.client.oauth2.OAuthFactory; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.i18n.TimeZoneProvider; import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.io.net.http.WebSocketFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; @@ -57,13 +58,15 @@ public class AutomowerHandlerFactory extends BaseThingHandlerFactory { protected final @NonNullByDefault({}) HttpClient httpClient; private @Nullable ServiceRegistration automowerDiscoveryServiceRegistration; private final TimeZoneProvider timeZoneProvider; + private final WebSocketFactory webSocketFactory; @Activate public AutomowerHandlerFactory(@Reference OAuthFactory oAuthFactory, @Reference HttpClientFactory httpClientFactory, - @Reference TimeZoneProvider timeZoneProvider) { + @Reference TimeZoneProvider timeZoneProvider, @Reference WebSocketFactory webSocketFactory) { this.oAuthFactory = oAuthFactory; this.httpClient = httpClientFactory.getCommonHttpClient(); this.timeZoneProvider = timeZoneProvider; + this.webSocketFactory = webSocketFactory; } @Override @@ -74,7 +77,8 @@ public class AutomowerHandlerFactory extends BaseThingHandlerFactory { @Override protected @Nullable ThingHandler createHandler(Thing thing) { if (AutomowerBridgeHandler.SUPPORTED_THING_TYPES.contains(thing.getThingTypeUID())) { - AutomowerBridgeHandler handler = new AutomowerBridgeHandler((Bridge) thing, oAuthFactory, httpClient); + AutomowerBridgeHandler handler = new AutomowerBridgeHandler((Bridge) thing, oAuthFactory, httpClient, + webSocketFactory.getCommonWebSocketClient()); registerAutomowerDiscoveryService(handler); return handler; } @@ -89,8 +93,8 @@ public class AutomowerHandlerFactory extends BaseThingHandlerFactory { @Override protected synchronized void removeHandler(ThingHandler thingHandler) { if (thingHandler instanceof AutomowerBridgeHandler) { + ServiceRegistration automowerDiscoveryServiceRegistration = this.automowerDiscoveryServiceRegistration; if (automowerDiscoveryServiceRegistration != null) { - // remove discovery service, if bridge handler is removed automowerDiscoveryServiceRegistration.unregister(); } } diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/actions/AutomowerActions.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/actions/AutomowerActions.java index d95b67550a..5b4138a478 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/actions/AutomowerActions.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/actions/AutomowerActions.java @@ -49,7 +49,7 @@ public class AutomowerActions implements ThingActions { @RuleAction(label = "@text/action-start-label", description = "@text/action-start-desc") public void start( - @ActionInput(name = "duration", label = "@text/action-input-duration-label", description = "@text/action-input-duration-desc") int durationMin) { + @ActionInput(name = "duration-min", label = "@text/action-input-duration-min-label", description = "@text/action-input-duration-min-desc") long durationMin) { AutomowerHandler automowerHandler = handler; if (automowerHandler == null) { logger.warn("Automower Action service ThingHandler is null!"); @@ -58,10 +58,26 @@ public class AutomowerActions implements ThingActions { } } - public static void start(ThingActions actions, int durationMin) { + public static void start(ThingActions actions, long durationMin) { ((AutomowerActions) actions).start(durationMin); } + @RuleAction(label = "@text/action-start-in-work-area-label", description = "@text/action-start-in-work-area-desc") + public void startInWorkArea( + @ActionInput(name = "work-area-id", label = "@text/action-input-work-area-id-label", description = "@text/action-input-work-area-id-desc") long workAreaId, + @ActionInput(name = "duration-min-opt", label = "@text/action-input-duration-min-opt-label", description = "@text/action-input-duration-min-opt-desc") @Nullable Long durationMin) { + AutomowerHandler automowerHandler = handler; + if (automowerHandler == null) { + logger.warn("Automower Action service ThingHandler is null!"); + } else { + automowerHandler.sendAutomowerCommand(AutomowerCommand.START_IN_WORK_AREA, workAreaId, durationMin); + } + } + + public static void startInWorkArea(ThingActions actions, long workAreaId, @Nullable Long durationMin) { + ((AutomowerActions) actions).startInWorkArea(workAreaId, durationMin); + } + @RuleAction(label = "@text/action-pause-label", description = "@text/action-pause-desc") public void pause() { AutomowerHandler automowerHandler = handler; @@ -106,7 +122,7 @@ public class AutomowerActions implements ThingActions { @RuleAction(label = "@text/action-park-label", description = "@text/action-park-desc") public void park( - @ActionInput(name = "duration", label = "@text/action-input-duration-label", description = "@text/action-input-duration-desc") int durationMin) { + @ActionInput(name = "duration-min", label = "@text/action-input-duration-min-label", description = "@text/action-input-duration-min-desc") long durationMin) { AutomowerHandler automowerHandler = handler; if (automowerHandler == null) { logger.warn("Automower Action service ThingHandler is null!"); @@ -115,7 +131,7 @@ public class AutomowerActions implements ThingActions { } } - public static void park(ThingActions actions, int durationMin) { + public static void park(ThingActions actions, long durationMin) { ((AutomowerActions) actions).park(durationMin); } @@ -187,7 +203,7 @@ public class AutomowerActions implements ThingActions { @RuleAction(label = "@text/action-set-work-area-label", description = "@text/action-set-work-area-desc") public void setWorkArea( - @ActionInput(name = "workarea-id", label = "@text/action-input-workarea-id-label", description = "@text/action-input-workarea-id-desc") long workAreaId, + @ActionInput(name = "work-area-id", label = "@text/action-input-work-area-id-label", description = "@text/action-input-work-area-id-desc") long workAreaId, @ActionInput(name = "enable", label = "@text/action-input-enable-label", description = "@text/action-input-enable-desc") boolean enable, @ActionInput(name = "cutting-height", label = "@text/action-input-cutting-height-label", description = "@text/action-input-cutting-height-desc") byte cuttingHeight) { AutomowerHandler automowerHandler = handler; @@ -220,7 +236,7 @@ public class AutomowerActions implements ThingActions { @RuleAction(label = "@text/action-set-calendartask-label", description = "@text/action-set-calendartask-desc") public void setCalendarTask( - @ActionInput(name = "workarea-id", label = "@text/action-input-workarea-id-label", description = "@text/action-input-workarea-id-desc") @Nullable Long workAreaId, + @ActionInput(name = "work-area-id", label = "@text/action-input-work-area-id-label", description = "@text/action-input-work-area-id-desc") @Nullable Long workAreaId, @ActionInput(name = "start", label = "@text/action-input-start-label", description = "@text/action-input-start-desc") short[] start, @ActionInput(name = "duration", label = "@text/action-input-duration-label", description = "@text/action-input-duration-desc") short[] duration, @ActionInput(name = "monday", label = "@text/action-input-monday-label", description = "@text/action-input-monday-desc") boolean[] monday, diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerBridge.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerBridge.java index 41039c7fc6..9574fbc49a 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerBridge.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerBridge.java @@ -68,7 +68,7 @@ public class AutomowerBridge { this.automowerApi = new AutomowerConnectApi(httpClient); } - private AccessTokenResponse authenticate() throws AutomowerCommunicationException { + public synchronized AccessTokenResponse authenticate() throws AutomowerCommunicationException { try { AccessTokenResponse result = authService.getAccessTokenResponse(); if (result == null || result.isExpired(Instant.now(), 120)) { @@ -112,17 +112,27 @@ public class AutomowerBridge { * @param id The id of the mower * @param command The command that should be sent. Valid values are: "Start", "ResumeSchedule", "Pause", "Park", * "ParkUntilNextSchedule", "ParkUntilFurtherNotice" - * @param commandDuration The duration of the command. This is only evaluated for "Start" and "Park" commands + * @param commandDuration The duration of the command. This is only evaluated for "Start", "StartInWorkArea" and + * "Park" commands + * @param commandWorkAreaId The work area id to be used for the command. This is only evaluated for + * "StartInWorkArea" command * @throws AutomowerCommunicationException In case the query cannot be executed successfully */ - public void sendAutomowerCommand(String id, AutomowerCommand command, long commandDuration) - throws AutomowerCommunicationException { - MowerCommandAttributes attributes = new MowerCommandAttributes(); - attributes.setDuration(commandDuration); - + public void sendAutomowerCommand(String id, AutomowerCommand command, @Nullable Long commandWorkAreaId, + @Nullable Long commandDuration) throws AutomowerCommunicationException { MowerCommand mowerCommand = new MowerCommand(); mowerCommand.setType(command.getCommand()); - mowerCommand.setAttributes(attributes); + + if (commandDuration != null || commandWorkAreaId != null) { + MowerCommandAttributes attributes = new MowerCommandAttributes(); + if (commandDuration != null) { + attributes.setDuration(commandDuration); + } + if (commandWorkAreaId != null) { + attributes.setWorkAreaId(commandWorkAreaId); + } + mowerCommand.setAttributes(attributes); + } MowerCommandRequest request = new MowerCommandRequest(); request.setData(mowerCommand); diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerBridgeHandler.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerBridgeHandler.java index 4d65956b54..169adeb866 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerBridgeHandler.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerBridgeHandler.java @@ -14,16 +14,26 @@ package org.openhab.binding.automower.internal.bridge; import static org.openhab.binding.automower.internal.AutomowerBindingConstants.THING_TYPE_BRIDGE; +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.eclipse.jetty.websocket.common.WebSocketSession; +import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Mower; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.MowerListResult; import org.openhab.binding.automower.internal.rest.exceptions.AutomowerCommunicationException; +import org.openhab.binding.automower.internal.things.AutomowerHandler; import org.openhab.core.auth.client.oauth2.OAuthClientService; import org.openhab.core.auth.client.oauth2.OAuthFactory; import org.openhab.core.thing.Bridge; @@ -52,24 +62,86 @@ public class AutomowerBridgeHandler extends BaseBridgeHandler { private static final long DEFAULT_POLLING_INTERVAL_S = TimeUnit.HOURS.toSeconds(1); private final OAuthFactory oAuthFactory; + private @Nullable WebSocketSession webSocketSession; private @Nullable OAuthClientService oAuthService; private @Nullable ScheduledFuture automowerBridgePollingJob; private @Nullable AutomowerBridge bridge; private final HttpClient httpClient; + private final WebSocketClient webSocketClient; + private boolean closing; + private Map automowerHandlers = new HashMap<>(); - public AutomowerBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient) { + public AutomowerBridgeHandler(Bridge bridge, OAuthFactory oAuthFactory, HttpClient httpClient, + WebSocketClient webSocketClient) { super(bridge); this.oAuthFactory = oAuthFactory; this.httpClient = httpClient; + this.webSocketClient = webSocketClient; } - private void pollAutomowers(AutomowerBridge bridge) { + public void registerAutomowerHandler(String mowerId, AutomowerHandler handler) { + automowerHandlers.put(mowerId, handler); + logger.trace("Registered AutomowerHandler for mower with ID: {}", mowerId); + } + + public void unregisterAutomowerHandler(String mowerId) { + automowerHandlers.remove(mowerId); + logger.trace("Unregistered AutomowerHandler for mower with ID: {}", mowerId); + } + + public WebSocketClient getWebSocketClient() { + return webSocketClient; + } + + public ScheduledExecutorService getScheduler() { + return scheduler; + } + + public @Nullable WebSocketSession getWebSocketSession() { + return webSocketSession; + } + + public void setWebSocketSession(@Nullable WebSocketSession webSocketSession) { + this.webSocketSession = webSocketSession; + } + + public boolean isClosing() { + return closing; + } + + public void setClosing(boolean closing) { + this.closing = closing; + } + + public @Nullable AutomowerHandler getAutomowerHandlerByThingId(@Nullable String thingId) { + return automowerHandlers.get(thingId); + } + + public synchronized void pollAutomowers(AutomowerBridge bridge) { MowerListResult automowers; try { automowers = bridge.getAutomowers(); - updateStatus(ThingStatus.ONLINE); - logger.debug("Found {} automowers", automowers.getData().size()); + List mowers = automowers.getData(); + if (mowers == null || mowers.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/comm-error-no-mowers-found"); + logger.debug("No automowers found in the response from REST API"); + } else { + updateStatus(ThingStatus.ONLINE); + logger.debug("Found {} automowers in the response from REST API", mowers.size()); + // Update all known AutomowerHandlers with the data from the REST API + for (Mower mower : mowers) { + String id = mower.getId(); + AutomowerHandler automowerHandler = getAutomowerHandlerByThingId(id); + if (automowerHandler != null) { + logger.debug("Data from REST API for known AutomowerHandler with id: {}", id); + automowerHandler.updateAutomowerStateViaREST(mower); + } else { + logger.debug("Data from REST API for unknown AutomowerHandler with id: {}", id); + } + } + } } catch (AutomowerCommunicationException e) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/comm-error-query-mowers-failed"); @@ -79,16 +151,29 @@ public class AutomowerBridgeHandler extends BaseBridgeHandler { @Override public void dispose() { + closing = true; + AutomowerBridge currentBridge = bridge; if (currentBridge != null) { stopAutomowerBridgePolling(currentBridge); bridge = null; } + + WebSocketSession webSocketSession = this.webSocketSession; + if (webSocketSession != null) { + try { + webSocketSession.close(); + } catch (Exception e) { + logger.error("Failed to close WebSocket session: {}", e.getMessage()); + } + } + OAuthClientService oAuthService = this.oAuthService; if (oAuthService != null) { oAuthFactory.ungetOAuthService(thing.getUID().getAsString()); this.oAuthService = null; } + logger.debug("Bridge {} disposed", thing.getUID().getAsString()); } @Override @@ -111,9 +196,12 @@ public class AutomowerBridgeHandler extends BaseBridgeHandler { HUSQVARNA_API_TOKEN_URL, null, appKey, appSecret, null, null); this.oAuthService = oAuthService; - if (bridge == null) { + if (this.bridge == null) { AutomowerBridge currentBridge = new AutomowerBridge(oAuthService, appKey, httpClient, scheduler); - bridge = currentBridge; + this.bridge = currentBridge; + // connect WebSocket and poll automower state via REST API once after connection + connectWebSocket(new AutomowerWebSocketAdapter(this, currentBridge)); + // setup polling of automowers via REST API startAutomowerBridgePolling(currentBridge, pollingIntervalS); } updateStatus(ThingStatus.UNKNOWN); @@ -130,8 +218,8 @@ public class AutomowerBridgeHandler extends BaseBridgeHandler { ScheduledFuture currentPollingJob = automowerBridgePollingJob; if (currentPollingJob == null) { final long pollingIntervalToUse = pollingIntervalS == null ? DEFAULT_POLLING_INTERVAL_S : pollingIntervalS; - automowerBridgePollingJob = scheduler.scheduleWithFixedDelay(() -> pollAutomowers(bridge), 1, - pollingIntervalToUse, TimeUnit.SECONDS); + automowerBridgePollingJob = scheduler.scheduleWithFixedDelay(() -> pollAutomowers(bridge), + pollingIntervalToUse, pollingIntervalToUse, TimeUnit.SECONDS); } } @@ -164,4 +252,26 @@ public class AutomowerBridgeHandler extends BaseBridgeHandler { return Optional.empty(); } } + + public void connectWebSocket(AutomowerWebSocketAdapter webSocketAdapter) { + try { + AutomowerBridge bridge = this.bridge; + if (bridge != null) { + String accessToken = bridge.authenticate().getAccessToken(); + if (accessToken == null) { + logger.error("No OAuth2 access token available for WebSocket connection"); + return; + } + String wsUrl = "wss://ws.openapi.husqvarna.dev/v1"; + ClientUpgradeRequest request = new ClientUpgradeRequest(); + request.setHeader("Authorization", "Bearer " + accessToken); + webSocketSession = (WebSocketSession) webSocketClient + .connect(webSocketAdapter, URI.create(wsUrl), request).get(); + } else { + logger.error("Bridge is null, cannot connect WebSocket"); + } + } catch (Exception e) { + logger.error("Failed to start WebSocket client: {}", e.getMessage()); + } + } } diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerWebSocketAdapter.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerWebSocketAdapter.java new file mode 100644 index 0000000000..b51db94996 --- /dev/null +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/bridge/AutomowerWebSocketAdapter.java @@ -0,0 +1,187 @@ +/* + * 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.automower.internal.bridge; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketFrame; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.api.extensions.Frame; +import org.eclipse.jetty.websocket.common.WebSocketSession; +import org.eclipse.jetty.websocket.common.frames.PongFrame; +import org.openhab.binding.automower.internal.rest.exceptions.AutomowerCommunicationException; +import org.openhab.binding.automower.internal.things.AutomowerHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * The {@link AutomowerWebSocketAdapter} handles the WebSocket Connection of the Husqvarna web service. + * + * @author MikeTheTux - Initial contribution + */ +@NonNullByDefault +@WebSocket +public class AutomowerWebSocketAdapter { + private final AutomowerBridgeHandler handler; + private final AutomowerBridge bridge; + private final Logger logger = LoggerFactory.getLogger(AutomowerWebSocketAdapter.class); + private @Nullable ScheduledFuture connectionTracker; + private int unansweredPings = 0; + private static final int MAX_UNANSWERED_PINGS = 5; + + private ByteBuffer pingPayload = ByteBuffer.wrap("ping".getBytes(StandardCharsets.UTF_8)); + + public AutomowerWebSocketAdapter(AutomowerBridgeHandler handler, AutomowerBridge bridge) { + this.handler = handler; + this.bridge = bridge; + } + + @OnWebSocketConnect + public synchronized void onConnect(Session session) { + handler.setClosing(false); + unansweredPings = 0; + + logger.debug("Connected to Husqvarna WebSocket ({})", session.getRemoteAddress().getHostString()); + + // Initialize MowerStatus via polling the REST API + logger.debug("Polling Automowers for initial state / after reconnect of WebSocket"); + // Poll all automowers to get their initial state + // This is necessary because the WebSocket does not provide the initial state + // and we need to have the initial state before subscribing to the WebSocket messages + // This will also recover potential lost WebSocket messages during WebSocket reconnect + handler.pollAutomowers(bridge); + + // Subscribe to all messages after connecting + try { + String subscribeAllMessage = "{\"type\":\"subscribe\",\"topics\":[\"*\"]}"; + session.getRemote().sendString(subscribeAllMessage); + logger.debug("Sent subscription message to subscribe to all topics"); + } catch (Exception e) { + logger.error("Failed to send subscription message: {}", e.getMessage()); + } + + // Cancel previous ping task if it exists + ScheduledFuture connectionTracker = this.connectionTracker; + if (connectionTracker != null && !connectionTracker.isCancelled()) { + logger.trace("Cancelling previous connectionTracker (ping)"); + connectionTracker.cancel(true); + } + logger.trace("Starting connectionTracker (ping)"); + // start sending PING every minute + this.connectionTracker = handler.getScheduler().scheduleWithFixedDelay(this::sendKeepAlivePing, 1, 1, + TimeUnit.MINUTES); + } + + @OnWebSocketFrame + public synchronized void onFrame(Frame pong) { + if (pong instanceof PongFrame) { + unansweredPings = 0; + // logger.trace("Pong received"); + } + } + + @OnWebSocketMessage + public void onMessage(String message) { + try { + if (!message.isBlank()) { + JsonObject event = JsonParser.parseString(message).getAsJsonObject(); + if (event.has("id")) { + String id = event.get("id").getAsString(); + AutomowerHandler automowerHandler = handler.getAutomowerHandlerByThingId(id); + if (automowerHandler != null) { + logger.debug("Message from WebSocket for known AutomowerHandler: {}", message); + automowerHandler.processWebSocketMessage(event); + } else { + logger.debug("Message from WebSocket for unknown AutomowerHandler: {}", message); + } + } else { + logger.trace("Message from WebSocket without Id: {}", message); + } + } else { + logger.trace("Received empty message from WebSocket"); + } + } catch (Exception e) { + logger.error("Failed to process WebSocket message: {}", e.getMessage()); + } + } + + @OnWebSocketClose + public void onClose(int statusCode, String reason) { + logger.debug("WebSocket closed: {} - {}", statusCode, reason); + + // Cancel ping task on disconnect + final ScheduledFuture connectionTracker = this.connectionTracker; + if (connectionTracker != null) { + logger.trace("Cancelling connectionTracker (ping)"); + connectionTracker.cancel(true); + } + + if (!handler.isClosing()) { + try { + logger.debug("Reconnecting to Husqvarna Webservice ()"); + handler.connectWebSocket(this); + } catch (Exception e) { + logger.error("Failed to restart WebSocket client: {}", e.getMessage()); + } + } + } + + @OnWebSocketError + public void onError(Throwable cause) { + logger.error("WebSocket error: {}", cause.getMessage()); + } + + /** + * Sends a ping to tell the Husqvarna smart system that the client is alive. + */ + private synchronized void sendKeepAlivePing() { + try { + WebSocketSession webSocketSession = handler.getWebSocketSession(); + if (webSocketSession != null) { + String accessToken = bridge.authenticate().getAccessToken(); + if (unansweredPings > MAX_UNANSWERED_PINGS || accessToken == null) { + webSocketSession.close(1000, "Timeout: manually closing dead connection"); + } else { + if (webSocketSession.isOpen()) { + try { + // logger.trace("Sending ping ..."); + webSocketSession.getRemote().sendPing(pingPayload); + ++unansweredPings; + } catch (IOException ex) { + logger.warn("Error while sending ping: {}", ex.getMessage()); + } + } + } + } else { + logger.warn("WebSocket session is null, cannot send ping"); + } + } catch (AutomowerCommunicationException e) { + logger.error("Failed to authenticate while sending keep-alive ping: {}", e.getMessage()); + } + } +} diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/AutomowerConnectApi.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/AutomowerConnectApi.java index ccbc3574f2..56bd60c6ec 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/AutomowerConnectApi.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/AutomowerConnectApi.java @@ -64,7 +64,7 @@ public class AutomowerConnectApi extends HusqvarnaApi { request.method(HttpMethod.GET); ContentResponse response = executeRequest(appKey, token, request); - + logger.trace("getMowers: {}", response.getContentAsString()); return parseResponse(response, MowerListResult.class); } diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/dto/MowerCommandAttributes.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/dto/MowerCommandAttributes.java index f107ac1ac3..faea484401 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/dto/MowerCommandAttributes.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/dto/MowerCommandAttributes.java @@ -16,13 +16,22 @@ package org.openhab.binding.automower.internal.rest.api.automowerconnect.dto; * @author Markus Pfleger - Initial contribution */ public class MowerCommandAttributes { - private long duration; + private Long duration; + private Long workAreaId; - public long getDuration() { + public Long getDuration() { return duration; } - public void setDuration(long duration) { + public void setDuration(Long duration) { this.duration = duration; } + + public Long getWorkAreaId() { + return workAreaId; + } + + public void setWorkAreaId(Long workAreaId) { + this.workAreaId = workAreaId; + } } diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/dto/StayOutZone.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/dto/StayOutZone.java index f4a22a1aa4..2a18ff1354 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/dto/StayOutZone.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/rest/api/automowerconnect/dto/StayOutZone.java @@ -40,7 +40,7 @@ public class StayOutZone { return enabled; } - public void setEnabed(Boolean enabled) { + public void setEnabled(Boolean enabled) { this.enabled = enabled; } } diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerCommand.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerCommand.java index 361349e0b9..7c8bf87112 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerCommand.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerCommand.java @@ -27,6 +27,7 @@ import org.openhab.core.thing.ChannelUID; @NonNullByDefault public enum AutomowerCommand { START("Start", AutomowerBindingConstants.CHANNEL_COMMAND_START), + START_IN_WORK_AREA("StartInWorkArea", AutomowerBindingConstants.CHANNEL_COMMAND_START_IN_WORK_AREA), RESUME_SCHEDULE("ResumeSchedule", AutomowerBindingConstants.CHANNEL_COMMAND_RESUME_SCHEDULE), PAUSE("Pause", AutomowerBindingConstants.CHANNEL_COMMAND_PAUSE), PARK("Park", AutomowerBindingConstants.CHANNEL_COMMAND_PARK), diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerConfiguration.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerConfiguration.java index 4fae936358..e8a609755c 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerConfiguration.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerConfiguration.java @@ -23,7 +23,6 @@ import org.eclipse.jdt.annotation.Nullable; @NonNullByDefault public class AutomowerConfiguration { public @Nullable String mowerId; - public @Nullable Integer pollingInterval; public @Nullable String mowerZoneId; @Nullable @@ -35,14 +34,6 @@ public class AutomowerConfiguration { this.mowerId = mowerId; } - public @Nullable Integer getPollingInterval() { - return pollingInterval; - } - - public void setPollingInterval(Integer pollingInterval) { - this.pollingInterval = pollingInterval; - } - @Nullable public String getMowerZoneId() { return mowerZoneId; diff --git a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerHandler.java b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerHandler.java index 7b3d8e4ce5..cbe308fa75 100644 --- a/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerHandler.java +++ b/bundles/org.openhab.binding.automower/src/main/java/org/openhab/binding/automower/internal/things/AutomowerHandler.java @@ -24,7 +24,6 @@ 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.concurrent.atomic.AtomicReference; @@ -34,20 +33,27 @@ import org.openhab.binding.automower.internal.AutomowerBindingConstants; import org.openhab.binding.automower.internal.actions.AutomowerActions; import org.openhab.binding.automower.internal.bridge.AutomowerBridge; import org.openhab.binding.automower.internal.bridge.AutomowerBridgeHandler; +import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Action; +import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Activity; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.CalendarTask; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Capabilities; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Headlight; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.HeadlightMode; +import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.InactiveReason; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Message; +import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Metadata; +import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Mode; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Mower; +import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.MowerApp; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.MowerMessages; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.MowerStayOutZoneAttributes; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.MowerWorkAreaAttributes; -import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Position; +import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Planner; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.RestrictedReason; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Settings; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.State; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.StayOutZone; +import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.StayOutZones; import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.WorkArea; import org.openhab.binding.automower.internal.rest.exceptions.AutomowerCommunicationException; import org.openhab.core.i18n.TimeZoneProvider; @@ -78,6 +84,9 @@ import org.openhab.core.types.UnDefType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + /** * The {@link AutomowerHandler} is responsible for handling commands, which are * sent to one of the channels. @@ -89,32 +98,17 @@ import org.slf4j.LoggerFactory; public class AutomowerHandler extends BaseThingHandler { public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_AUTOMOWER); private static final String NO_ID = "NO_ID"; - private static final long DEFAULT_COMMAND_DURATION_MIN = 60; - private static final long DEFAULT_POLLING_INTERVAL_S = TimeUnit.MINUTES.toSeconds(10); private final Logger logger = LoggerFactory.getLogger(AutomowerHandler.class); private final TimeZoneProvider timeZoneProvider; private ZoneId mowerZoneId; private AtomicReference automowerId = new AtomicReference<>(NO_ID); - private long lastQueryTimeMs = 0L; - - private @Nullable ScheduledFuture automowerPollingJob; - // Max 1 request per second and appKey. - private long maxQueryFrequencyNanos = TimeUnit.SECONDS.toNanos(1); + private @Nullable ZonedDateTime lastQueryTime = null; private @Nullable Mower mowerState; private @Nullable MowerMessages mowerMessages; - private Runnable automowerPollingRunnable = () -> { - Bridge bridge = getBridge(); - if (bridge != null && bridge.getStatus() == ThingStatus.ONLINE) { - updateAutomowerState(); - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); - } - }; - public AutomowerHandler(Thing thing, TimeZoneProvider timeZoneProvider) { super(thing); this.timeZoneProvider = timeZoneProvider; @@ -122,13 +116,13 @@ public class AutomowerHandler extends BaseThingHandler { } @Override - public void handleCommand(ChannelUID channelUID, Command command) { - if (RefreshType.REFRESH == command) { - // not implemented as it would causes >100 channel updates in a row during setup (performance) - } else { + public synchronized void handleCommand(ChannelUID channelUID, Command command) { + // REFRESH is not implemented as it would causes >100 channel updates in a row during setup (performance, API + // rate limit) + if (RefreshType.REFRESH != command) { String groupId = channelUID.getGroupId(); String channelId = channelUID.getIdWithoutGroup(); - if ((groupId != null) && (channelId != null)) { + if (groupId != null) { if (GROUP_CALENDARTASK.startsWith(groupId)) { String[] channelIDSplit = channelId.split("-", 2); int index = Integer.parseInt(channelIDSplit[0]) - 1; @@ -201,9 +195,13 @@ public class AutomowerHandler extends BaseThingHandler { } else if (GROUP_COMMAND.startsWith(groupId)) { AutomowerCommand.fromChannelUID(channelUID).ifPresent(commandName -> { logger.debug("Sending command '{}'", commandName); - getCommandValue(command).ifPresentOrElse( - duration -> sendAutomowerCommand(commandName, duration), - () -> sendAutomowerCommand(commandName)); + getCommandValue(command).ifPresentOrElse(param -> { + if (commandName == AutomowerCommand.START_IN_WORK_AREA) { + sendAutomowerCommand(commandName, param, null); + } else { + sendAutomowerCommand(commandName, param); + } + }, () -> sendAutomowerCommand(commandName)); updateState(channelUID, OnOffType.OFF); }); @@ -212,17 +210,13 @@ public class AutomowerHandler extends BaseThingHandler { } } - private Optional getCommandValue(Type type) { + private Optional getCommandValue(Type type) { if (type instanceof DecimalType command) { - return Optional.of(command.intValue()); + return Optional.of(command.longValue()); } return Optional.empty(); } - private void refreshChannels(ChannelUID channelUID) { - updateAutomowerState(); - } - @Override public Collection> getServices() { return Set.of(AutomowerActions.class); @@ -234,9 +228,9 @@ public class AutomowerHandler extends BaseThingHandler { if (bridge != null) { AutomowerConfiguration currentConfig = getConfigAs(AutomowerConfiguration.class); final String configMowerId = currentConfig.getMowerId(); - final Integer pollingIntervalS = currentConfig.getPollingInterval(); + final String configMowerZoneId = currentConfig.getMowerZoneId(); - if ((configMowerZoneId != null) && !configMowerZoneId.isBlank()) { + if (configMowerZoneId != null && !configMowerZoneId.isBlank()) { try { mowerZoneId = ZoneId.of(configMowerZoneId); } catch (DateTimeException e) { @@ -250,18 +244,41 @@ public class AutomowerHandler extends BaseThingHandler { if (configMowerId == null) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/conf-error-no-mower-id"); - } else if (pollingIntervalS != null && pollingIntervalS < 1) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "@text/conf-error-invalid-polling-interval"); } else { automowerId.set(configMowerId); - startAutomowerPolling(pollingIntervalS); + // Adding handler to map of handlers + AutomowerBridgeHandler automowerBridgeHandler = getAutomowerBridgeHandler(); + if (automowerBridgeHandler != null) { + automowerBridgeHandler.registerAutomowerHandler(configMowerId, this); + } + + // initial poll to get the current state of the mower + poll(); + // update messages once via polling of REST API and later event based via WebSocket only + initializeMessages(configMowerId); } } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED); } } + private void initializeMessages(String mowerId) { + AutomowerBridge automowerBridge = getAutomowerBridge(); + try { + if (automowerBridge != null) { + logger.debug("Querying automower messages for: {}", mowerId); + mowerMessages = automowerBridge.getAutomowerMessages(mowerId); + updateMessagesChannelState(mowerMessages); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge"); + } + } catch (AutomowerCommunicationException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/comm-error-query-mower-failed"); + logger.warn("Unable to query automower messages for: {}. Error: {}", mowerId, e.getMessage()); + } + } + @Nullable private AutomowerBridge getAutomowerBridge() { Bridge bridge = getBridge(); @@ -274,38 +291,38 @@ public class AutomowerHandler extends BaseThingHandler { return null; } + @Nullable + private AutomowerBridgeHandler getAutomowerBridgeHandler() { + Bridge bridge = getBridge(); + if (bridge != null) { + ThingHandler handler = bridge.getHandler(); + if (handler instanceof AutomowerBridgeHandler bridgeHandler) { + return bridgeHandler; + } + } + return null; + } + @Override public void dispose() { + AutomowerBridgeHandler automowerBridgeHandler = getAutomowerBridgeHandler(); + if (automowerBridgeHandler != null) { + automowerBridgeHandler.unregisterAutomowerHandler(this.getThing().getUID().getId()); + } + if (!automowerId.get().equals(NO_ID)) { - stopAutomowerPolling(); automowerId.set(NO_ID); } } - private void startAutomowerPolling(@Nullable Integer pollingIntervalS) { - if (automowerPollingJob == null) { - final long pollingIntervalToUse = pollingIntervalS == null ? DEFAULT_POLLING_INTERVAL_S : pollingIntervalS; - automowerPollingJob = scheduler.scheduleWithFixedDelay(automowerPollingRunnable, 1, pollingIntervalToUse, - TimeUnit.SECONDS); - } - } - - private void stopAutomowerPolling() { - if (automowerPollingJob != null) { - automowerPollingJob.cancel(true); - automowerPollingJob = null; - } - } - private boolean isValidResult(@Nullable Mower mower) { - return ((mower != null && mower.getAttributes() != null) && (mower.getAttributes().getMetadata() != null) - && (mower.getAttributes().getBattery() != null) && (mower.getAttributes().getSystem() != null) - && (mower.getAttributes().getCalendar() != null) - && (mower.getAttributes().getCalendar().getTasks() != null) - && (mower.getAttributes().getCapabilities() != null) && (mower.getAttributes().getMower() != null) - && (mower.getAttributes().getPlanner() != null) - && (mower.getAttributes().getPlanner().getOverride() != null) - && (mower.getAttributes().getSettings() != null) && (mower.getAttributes().getStatistics() != null)); + return (mower != null && mower.getAttributes() != null && mower.getAttributes().getMetadata() != null + && mower.getAttributes().getBattery() != null && mower.getAttributes().getSystem() != null + && mower.getAttributes().getCalendar() != null && mower.getAttributes().getCalendar().getTasks() != null + && mower.getAttributes().getCapabilities() != null && mower.getAttributes().getMower() != null + && mower.getAttributes().getPlanner() != null + && mower.getAttributes().getPlanner().getOverride() != null + && mower.getAttributes().getSettings() != null && mower.getAttributes().getStatistics() != null); } private boolean isConnected(@Nullable Mower mower) { @@ -313,86 +330,92 @@ public class AutomowerHandler extends BaseThingHandler { && mower.getAttributes().getMetadata().isConnected(); } - public void poll() { - updateAutomowerState(); + public void updateAutomowerStateViaREST(Mower mower) { + this.lastQueryTime = ZonedDateTime.now(timeZoneProvider.getTimeZone()); + updateAutomowerState(mower); } - private synchronized void updateAutomowerState() { - String id = automowerId.get(); - try { - AutomowerBridge automowerBridge = getAutomowerBridge(); - if (automowerBridge != null) { - long timediff = System.nanoTime() - lastQueryTimeMs; - if ((mowerState == null) || (timediff > maxQueryFrequencyNanos)) { - logger.trace("Polling mower due to maxQueryFrequency: '{} > {}'", timediff / 1000000000.0, - maxQueryFrequencyNanos / 1000000000.0); - mowerState = automowerBridge.getAutomowerStatus(id); - Thread.sleep(maxQueryFrequencyNanos / 1000000L); - mowerMessages = automowerBridge.getAutomowerMessages(id); - lastQueryTimeMs = System.nanoTime(); - } else { - logger.trace("Skip mower polling due to maxQueryFrequency: '{} <= {}'", timediff / 1000000000.0, - maxQueryFrequencyNanos / 1000000000.0); - } - if (isValidResult(mowerState)) { - initializeProperties(mowerState); + private void updateAutomowerState() { + Mower mower = this.mowerState; + if (mower != null) { + updateAutomowerState(mower); + } + } - updateChannelState(mowerState, mowerMessages); - - if (isConnected(mowerState)) { - updateStatus(ThingStatus.ONLINE); - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "@text/comm-error-mower-not-connected-to-cloud"); - } - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "@text/comm-error-query-mower-failed"); - } + private void updateAutomowerState(Mower mower) { + this.mowerState = mower; + if (isValidResult(this.mowerState)) { + initializeProperties(mower); + updateMowerChannelState(mower); + if (isConnected(mower)) { + updateStatus(ThingStatus.ONLINE); } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/comm-error-mower-not-connected-to-cloud"); } - } catch (AutomowerCommunicationException e) { + } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/comm-error-query-mower-failed"); - logger.warn("Unable to query automower status for: {}. Error: {}", id, e.getMessage()); - } catch (InterruptedException e) { - logger.warn("An exception occurred while putting updateAutomowerState() to sleep: '{}'", e.getMessage()); + } + } + + public void poll() { + AutomowerBridge automowerBridge = getAutomowerBridge(); + AutomowerBridgeHandler automowerBridgeHandler = getAutomowerBridgeHandler(); + if (automowerBridgeHandler != null && automowerBridge != null) { + automowerBridgeHandler.pollAutomowers(automowerBridge); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge"); } } /** - * Sends a command to the automower with the default duration of 60min + * Sends a command to the automower that requires no parameter * - * @param command The command that should be sent. Valid values are: "Start", "ResumeSchedule", "Pause", "Park", - * "ParkUntilNextSchedule", "ParkUntilFurtherNotice" + * @param command The command that should be sent */ public void sendAutomowerCommand(AutomowerCommand command) { - sendAutomowerCommand(command, DEFAULT_COMMAND_DURATION_MIN); + sendAutomowerCommand(command, null, null); + } + + /** + * Sends a command to the automower that requires a duration + * + * @param command The command that should be sent + * @param commandDurationMinutes The duration of the command in minutes. This is only evaluated for "Start", + * "StartInWorkArea" and "Park" commands + */ + public void sendAutomowerCommand(AutomowerCommand command, long commandDurationMinutes) { + sendAutomowerCommand(command, null, commandDurationMinutes); } /** * Sends a command to the automower with the given duration * - * @param command The command that should be sent. Valid values are: "Start", "ResumeSchedule", "Pause", "Park", - * "ParkUntilNextSchedule", "ParkUntilFurtherNotice" - * @param commandDurationMinutes The duration of the command in minutes. This is only evaluated for "Start" and - * "Park" commands + * @param command The command that should be sent. Valid values are: "Start", "StartInWorkArea", "ResumeSchedule", + * "Pause", "Park", "ParkUntilNextSchedule", "ParkUntilFurtherNotice" + * @param commandWorkAreaId The work area id to be used for the command. This is only evaluated for + * "StartInWorkArea" command + * @param commandDurationMinutes The duration of the command in minutes. This is only evaluated for "Start", + * "StartInWorkArea" and "Park" commands */ - public void sendAutomowerCommand(AutomowerCommand command, long commandDurationMinutes) { - logger.debug("Sending command '{} {}'", command.getCommand(), commandDurationMinutes); + public void sendAutomowerCommand(AutomowerCommand command, @Nullable Long commandWorkAreaId, + @Nullable Long commandDurationMinutes) { + logger.debug("Sending command '{} {} {}'", command.getCommand(), commandWorkAreaId, commandDurationMinutes); String id = automowerId.get(); try { AutomowerBridge automowerBridge = getAutomowerBridge(); if (automowerBridge != null) { - automowerBridge.sendAutomowerCommand(id, command, commandDurationMinutes); + automowerBridge.sendAutomowerCommand(id, command, commandWorkAreaId, commandDurationMinutes); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge"); } } catch (AutomowerCommunicationException e) { logger.warn("Unable to send Command to automower: {}, Error: {}", id, e.getMessage()); } - updateAutomowerState(); + + // Update of the mower state after sending the command is not required as resulting state updates will be + // received via WebSocket events } /** @@ -401,10 +424,11 @@ public class AutomowerHandler extends BaseThingHandler { * @param command The command that should be sent. E.g. a duration in min for the Start channel * @param channelID The triggering channel */ - public void sendAutomowerCalendarTask(@Nullable Long workAreaId, short[] start, short[] duration, boolean[] monday, - boolean[] tuesday, boolean[] wednesday, boolean[] thursday, boolean[] friday, boolean[] saturday, - boolean[] sunday) { - if (isValidResult(mowerState)) { + public synchronized void sendAutomowerCalendarTask(@Nullable Long workAreaId, short[] start, short[] duration, + boolean[] monday, boolean[] tuesday, boolean[] wednesday, boolean[] thursday, boolean[] friday, + boolean[] saturday, boolean[] sunday) { + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower)) { List calendarTaskArray = new ArrayList<>(); for (int i = 0; (i < start.length) && (i < duration.length) && (i < monday.length) && (i < tuesday.length) @@ -426,12 +450,14 @@ public class AutomowerHandler extends BaseThingHandler { calendarTaskArray.add(calendarTask); } + mower.getAttributes().getCalendar().setTasks(calendarTaskArray); + String id = automowerId.get(); try { AutomowerBridge automowerBridge = getAutomowerBridge(); if (automowerBridge != null) { automowerBridge.sendAutomowerCalendarTask(id, - mowerState.getAttributes().getCapabilities().hasWorkAreas(), workAreaId, calendarTaskArray); + mower.getAttributes().getCapabilities().hasWorkAreas(), workAreaId, calendarTaskArray); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge"); @@ -439,6 +465,9 @@ public class AutomowerHandler extends BaseThingHandler { } catch (AutomowerCommunicationException e) { logger.warn("Unable to send CalendarTask to automower: {}, Error: {}", id, e.getMessage()); } + + // Update of the mower state after sending the update is not required as resulting state updates will be + // received via WebSocket events } } @@ -452,36 +481,22 @@ public class AutomowerHandler extends BaseThingHandler { public void sendAutomowerCalendarTask(Command command, int index, String param) { logger.debug("Sending CalendarTask: index '{}', param '{}', command '{}'", index, param, command.toString()); - if (isValidResult(mowerState)) { + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower)) { List calendarTasksFiltered; - List calendarTasksAll = mowerState.getAttributes().getCalendar().getTasks(); + List calendarTasksAll = mower.getAttributes().getCalendar().getTasks(); int indexFiltered = 0; - if (mowerState.getAttributes().getCapabilities().hasWorkAreas()) { + if (mower.getAttributes().getCapabilities().hasWorkAreas()) { // only set the Tasks of the current WorkArea calendarTasksFiltered = new ArrayList<>(); int i = 0; for (CalendarTask calendarTask : calendarTasksAll) { if (calendarTask.getWorkAreaId().equals(calendarTasksAll.get(index).getWorkAreaId())) { if (index == i) { - // remember index and create deep copy + // remember index indexFiltered = calendarTasksFiltered.size(); - - CalendarTask calendarTask2 = new CalendarTask(); - calendarTask2.setStart(calendarTask.getStart()); - calendarTask2.setDuration(calendarTask.getDuration()); - calendarTask2.setMonday(calendarTask.getMonday()); - calendarTask2.setTuesday(calendarTask.getTuesday()); - calendarTask2.setWednesday(calendarTask.getWednesday()); - calendarTask2.setThursday(calendarTask.getThursday()); - calendarTask2.setFriday(calendarTask.getFriday()); - calendarTask2.setSaturday(calendarTask.getSaturday()); - calendarTask2.setSunday(calendarTask.getSunday()); - calendarTask2.setWorkAreaId(calendarTask.getWorkAreaId()); - calendarTasksFiltered.add(calendarTask2); - } else { - // no deep copy required for the lines that are not updated - calendarTasksFiltered.add(calendarTask); } + calendarTasksFiltered.add(calendarTask); } i++; } @@ -532,7 +547,7 @@ public class AutomowerHandler extends BaseThingHandler { AutomowerBridge automowerBridge = getAutomowerBridge(); if (automowerBridge != null) { automowerBridge.sendAutomowerCalendarTask(id, - mowerState.getAttributes().getCapabilities().hasWorkAreas(), calendarTask.getWorkAreaId(), + mower.getAttributes().getCapabilities().hasWorkAreas(), calendarTask.getWorkAreaId(), calendarTasksFiltered); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, @@ -542,7 +557,9 @@ public class AutomowerHandler extends BaseThingHandler { logger.warn("Unable to send CalendarTask to automower: {}, Error: {}", id, e.getMessage()); } } - updateAutomowerState(); + + // Update of the mower state after sending the update is not required as resulting state updates will be + // received via WebSocket events } /** @@ -552,9 +569,10 @@ public class AutomowerHandler extends BaseThingHandler { * @param enable Zone enabled or disabled */ public void sendAutomowerStayOutZone(int index, boolean enable) { - if (isValidResult(mowerState)) { - sendAutomowerStayOutZone(mowerState.getAttributes().getStayOutZones().getZones().get(index).getId(), - enable); + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower)) { + StayOutZone stayOutZone = mower.getAttributes().getStayOutZones().getZones().get(index); + sendAutomowerStayOutZone(stayOutZone.getId(), enable); } } @@ -566,9 +584,14 @@ public class AutomowerHandler extends BaseThingHandler { */ public void sendAutomowerStayOutZone(String zoneId, boolean enable) { logger.debug("Sending StayOutZone: zoneId {}, enable {}", zoneId, enable); - if (isValidResult(mowerState)) { + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower)) { MowerStayOutZoneAttributes attributes = new MowerStayOutZoneAttributes(); attributes.setEnable(enable); + mower.getAttributes().getStayOutZones().getZones().stream().filter(zone -> zone.getId().equals(zoneId)) + .findFirst().ifPresent(zone -> { + zone.setEnabled(enable); + }); String id = automowerId.get(); try { @@ -583,6 +606,7 @@ public class AutomowerHandler extends BaseThingHandler { logger.warn("Unable to send StayOutZone to automower: {}, Error: {}", id, e.getMessage()); } + // Update the mower state as this part is not updated via WebSocket events updateAutomowerState(); } } @@ -595,9 +619,10 @@ public class AutomowerHandler extends BaseThingHandler { * */ public void sendAutomowerWorkAreaEnable(int index, boolean enable) { - if (isValidResult(mowerState)) { - sendAutomowerWorkArea(mowerState.getAttributes().getWorkAreas().get(index).getWorkAreaId(), enable, - mowerState.getAttributes().getWorkAreas().get(index).getCuttingHeight()); + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower)) { + WorkArea workArea = mower.getAttributes().getWorkAreas().get(index); + sendAutomowerWorkArea(workArea.getWorkAreaId(), enable, workArea.getCuttingHeight()); } } @@ -609,9 +634,10 @@ public class AutomowerHandler extends BaseThingHandler { * */ public void sendAutomowerWorkAreaCuttingHeight(int index, byte cuttingHeight) { - if (isValidResult(mowerState)) { - sendAutomowerWorkArea(mowerState.getAttributes().getWorkAreas().get(index).getWorkAreaId(), - mowerState.getAttributes().getWorkAreas().get(index).isEnabled(), cuttingHeight); + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower)) { + WorkArea workArea = mower.getAttributes().getWorkAreas().get(index); + sendAutomowerWorkArea(workArea.getWorkAreaId(), workArea.isEnabled(), cuttingHeight); } } @@ -624,10 +650,16 @@ public class AutomowerHandler extends BaseThingHandler { */ public void sendAutomowerWorkArea(long workAreaId, boolean enable, byte cuttingHeight) { logger.debug("Sending WorkArea: workAreaId {}, enable {}, cuttingHeight {}", workAreaId, enable, cuttingHeight); - if (isValidResult(mowerState)) { + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower)) { MowerWorkAreaAttributes workAreaAttributes = new MowerWorkAreaAttributes(); workAreaAttributes.setEnable(enable); workAreaAttributes.setCuttingHeight(cuttingHeight); + mower.getAttributes().getWorkAreas().stream().filter(workArea -> workArea.getWorkAreaId() == workAreaId) + .findFirst().ifPresent(workArea -> { + workArea.setEnabled(enable); + workArea.setCuttingHeight(cuttingHeight); + }); String id = automowerId.get(); try { @@ -642,6 +674,7 @@ public class AutomowerHandler extends BaseThingHandler { logger.warn("Unable to send WorkArea to automower: {}, Error: {}", id, e.getMessage()); } + // Update the mower state as this part is not updated via WebSocket events updateAutomowerState(); } } @@ -681,22 +714,29 @@ public class AutomowerHandler extends BaseThingHandler { public void sendAutomowerSettings(@Nullable Byte cuttingHeight, @Nullable HeadlightMode headlightMode) { logger.debug("Sending Settings: cuttingHeight {}, headlightMode {}", cuttingHeight, ((headlightMode != null) ? headlightMode.toString() : "null")); - if (isValidResult(mowerState)) { - Settings settings = new Settings(); + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower)) { + // Create a new Settings object and set the values + // that are not null. This allows to only update the values that are changed. + Settings settingsRequest = new Settings(); + // Update the settings object with the new values + Settings settings = mower.getAttributes().getSettings(); if (cuttingHeight != null) { + settingsRequest.setCuttingHeight(cuttingHeight); settings.setCuttingHeight(cuttingHeight); } if (headlightMode != null) { Headlight headlight = new Headlight(); headlight.setHeadlightMode(headlightMode); - settings.setHeadlight(headlight); + settingsRequest.setHeadlight(headlight); + settings.getHeadlight().setHeadlightMode(headlightMode); } String id = automowerId.get(); try { AutomowerBridge automowerBridge = getAutomowerBridge(); if (automowerBridge != null) { - automowerBridge.sendAutomowerSettings(id, settings); + automowerBridge.sendAutomowerSettings(id, settingsRequest); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge"); @@ -705,7 +745,8 @@ public class AutomowerHandler extends BaseThingHandler { logger.warn("Unable to send SettingCuttingHeight to automower: {}, Error: {}", id, e.getMessage()); } - updateAutomowerState(); + // Update of the mower state after sending the update is not required as resulting state updates will be + // received via WebSocket events } } @@ -714,8 +755,9 @@ public class AutomowerHandler extends BaseThingHandler { */ public void sendAutomowerConfirmError() { logger.debug("Sending ConfirmError"); - if (isValidResult(mowerState) && (mowerState.getAttributes().getCapabilities().canConfirmError()) - && (mowerState.getAttributes().getMower().getIsErrorConfirmable())) { + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower) && mower.getAttributes().getCapabilities().canConfirmError() + && mower.getAttributes().getMower().getIsErrorConfirmable()) { String id = automowerId.get(); try { AutomowerBridge automowerBridge = getAutomowerBridge(); @@ -728,7 +770,9 @@ public class AutomowerHandler extends BaseThingHandler { } catch (AutomowerCommunicationException e) { logger.warn("Unable to send ConfirmError to automower: {}, Error: {}", id, e.getMessage()); } - updateAutomowerState(); + + // Update of the mower state after sending the update is not required as resulting state updates will be + // received via WebSocket events } } @@ -737,22 +781,35 @@ public class AutomowerHandler extends BaseThingHandler { */ public void sendAutomowerResetCuttingBladeUsageTime() { logger.debug("Sending ResetCuttingBladeUsageTime"); - String id = automowerId.get(); - try { - AutomowerBridge automowerBridge = getAutomowerBridge(); - if (automowerBridge != null) { - automowerBridge.sendAutomowerResetCuttingBladeUsageTime(id); - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge"); + Mower mower = this.mowerState; + if (mower != null && isValidResult(mower)) { + mower.getAttributes().getStatistics().setCuttingBladeUsageTime(0); + + String id = automowerId.get(); + try { + AutomowerBridge automowerBridge = getAutomowerBridge(); + if (automowerBridge != null) { + automowerBridge.sendAutomowerResetCuttingBladeUsageTime(id); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/conf-error-no-bridge"); + } + } catch (AutomowerCommunicationException e) { + logger.warn("Unable to send ResetCuttingBladeUsageTime to automower: {}, Error: {}", id, + e.getMessage()); } - } catch (AutomowerCommunicationException e) { - logger.warn("Unable to send ResetCuttingBladeUsageTime to automower: {}, Error: {}", id, e.getMessage()); + + // Update the mower state as this part is not updated via WebSocket events + updateAutomowerState(); } - updateAutomowerState(); } - private String restrictedState(RestrictedReason reason) { - return "RESTRICTED_" + reason.name(); + private String restrictedState(@Nullable RestrictedReason reason) { + String restrictedReason = "RESTRICTED"; + if (reason != null) { + restrictedReason += "_" + reason.name(); + } + return restrictedReason; } private @Nullable String getErrorMessage(int errorCode) { @@ -771,442 +828,442 @@ public class AutomowerHandler extends BaseThingHandler { return null; } - private void updateChannelState(@Nullable Mower mower, @Nullable MowerMessages mowerMessages) { - if (isValidResult(mower)) { + private void updateMowerChannelState(@Nullable Mower mower) { + if (mower != null && isValidResult(mower)) { Capabilities capabilities = mower.getAttributes().getCapabilities(); + List calendarTasks = mower.getAttributes().getCalendar().getTasks(); /* * Update channels based on the received data */ - // create a copy of the present channels - channelAdd.clear(); - for (Channel channel : thing.getChannels()) { - channelAdd.add(channel); - } - channelRemove.clear(); - - if (capabilities.hasWorkAreas()) { - createChannel(CHANNEL_STATUS_WORK_AREA_ID, CHANNEL_TYPE_STATUS_WORK_AREA_ID, "Number"); - createChannel(CHANNEL_STATUS_WORK_AREA, CHANNEL_TYPE_STATUS_WORK_AREA, "String"); - } else { - removeChannel(CHANNEL_STATUS_WORK_AREA_ID); - removeChannel(CHANNEL_STATUS_WORK_AREA); - } - if (capabilities.canConfirmError()) { - createChannel(CHANNEL_STATUS_ERROR_CONFIRMABLE, CHANNEL_TYPE_STATUS_ERROR_CONFIRMABLE, "Switch"); - } else { - removeChannel(CHANNEL_STATUS_ERROR_CONFIRMABLE); - } - - if (capabilities.hasHeadlights()) { - createChannel(CHANNEL_SETTING_HEADLIGHT_MODE, CHANNEL_TYPE_SETTING_HEADLIGHT_MODE, "String"); - } else { - removeChannel(CHANNEL_SETTING_HEADLIGHT_MODE); - } - - if (capabilities.hasPosition()) { - createChannel(CHANNEL_POSITION_LAST, CHANNEL_TYPE_POSITION_LAST, "Location"); - for (int i = 0; i < 50; i++) { - createIndexedChannel(GROUP_POSITION, i + 1, CHANNEL_POSITION, CHANNEL_TYPE_POSITION, "Location"); - } - } else { - removeChannel(CHANNEL_POSITION_LAST); - removeConsecutiveIndexedChannels(GROUP_POSITION, 1, CHANNEL_POSITION); - } - - List calendarTasks = mower.getAttributes().getCalendar().getTasks(); - int i; - for (i = 0; i < calendarTasks.size(); i++) { - int j = 0; - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Number:Time"); - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Number:Time"); - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch"); - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch"); - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch"); - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch"); - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch"); - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch"); - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch"); - if (capabilities.hasWorkAreas()) { - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "Number"); - createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), - CHANNEL_TYPE_CALENDARTASK.get(j++), "String"); - } else { - removeIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++)); - removeIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++)); - } - } - // remove all consecutive channels that are no longer required - for (int j = 0; j < CHANNEL_CALENDARTASK.size(); j++) { - removeConsecutiveIndexedChannels(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j)); - } - - i = 0; - if (capabilities.hasStayOutZones()) { - createChannel(CHANNEL_STAYOUTZONE_DIRTY, CHANNEL_TYPE_STAYOUTZONES_DIRTY, "Switch"); - - if (mower.getAttributes().getStayOutZones() != null) { - List stayOutZones = mower.getAttributes().getStayOutZones().getZones(); - if (stayOutZones != null) { - for (; i < stayOutZones.size(); i++) { - int j = 0; - createIndexedChannel(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j), - CHANNEL_TYPE_STAYOUTZONE.get(j++), "String"); - createIndexedChannel(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j), - CHANNEL_TYPE_STAYOUTZONE.get(j++), "String"); - createIndexedChannel(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j), - CHANNEL_TYPE_STAYOUTZONE.get(j++), "Switch"); - } - } - } - } else { - removeChannel(CHANNEL_STAYOUTZONE_DIRTY); - } - // remove all consecutive channels that are no longer required - for (int j = 0; j < CHANNEL_STAYOUTZONE.size(); j++) { - removeConsecutiveIndexedChannels(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j)); - } - - i = 0; - if (capabilities.hasWorkAreas()) { - List workAreas = mower.getAttributes().getWorkAreas(); - if (workAreas != null) { - for (; i < workAreas.size(); i++) { - int j = 0; - createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), - CHANNEL_TYPE_WORKAREA.get(j++), "Number"); - createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), - CHANNEL_TYPE_WORKAREA.get(j++), "String"); - createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), - CHANNEL_TYPE_WORKAREA.get(j++), "Number:Dimensionless"); - createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), - CHANNEL_TYPE_WORKAREA.get(j++), "Switch"); - createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), - CHANNEL_TYPE_WORKAREA.get(j++), "Number:Dimensionless"); - createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), - CHANNEL_TYPE_WORKAREA.get(j++), "DateTime"); - } - } - } - // remove all consecutive channels that are no longer required - for (int j = 0; j < CHANNEL_WORKAREA.size(); j++) { - removeConsecutiveIndexedChannels(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j)); - } - - for (i = 0; i < 50; i++) { - int j = 0; - createIndexedChannel(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j), CHANNEL_TYPE_MESSAGE.get(j++), - "DateTime"); - createIndexedChannel(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j), CHANNEL_TYPE_MESSAGE.get(j++), - "Number"); - createIndexedChannel(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j), CHANNEL_TYPE_MESSAGE.get(j++), - "String"); - createIndexedChannel(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j), CHANNEL_TYPE_MESSAGE.get(j++), - "String"); - createIndexedChannel(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j), CHANNEL_TYPE_MESSAGE.get(j++), - "Location"); - } - - // remove channels that are now longer required and add new once - updateThing(editThing().withChannels(channelAdd).withoutChannels(channelRemove).build()); + addRemoveDynamicChannels(mower, capabilities, calendarTasks); /* * Now update the state of the channels */ - updateState(CHANNEL_STATUS_NAME, new StringType(mower.getAttributes().getSystem().getName())); - updateState(CHANNEL_STATUS_MODE, new StringType(mower.getAttributes().getMower().getMode().name())); - updateState(CHANNEL_STATUS_ACTIVITY, new StringType(mower.getAttributes().getMower().getActivity().name())); - updateState(CHANNEL_STATUS_INACTIVE_REASON, - new StringType(mower.getAttributes().getMower().getInactiveReason().name())); - if (mower.getAttributes().getMower().getState() != State.RESTRICTED) { - updateState(CHANNEL_STATUS_STATE, new StringType(mower.getAttributes().getMower().getState().name())); - } else { - updateState(CHANNEL_STATUS_STATE, - new StringType(restrictedState(mower.getAttributes().getPlanner().getRestrictedReason()))); - } + /* Update Status channels */ + updateStatusChannels(mower, capabilities); + /* Update Settings channels */ + updateSettingChannels(mower, capabilities); + /* Update Statistics channels */ + updateStatisticChannels(mower); + /* Update CalendarTasks channels */ + updateCalendarTaskChannels(mower, capabilities, calendarTasks); + /* Update StayOutZones channels */ + updateStayOutZonesChannels(mower, capabilities); + /* Update WorkAreas channels */ + updateWorkAreasChannels(mower, capabilities); + } + } + private void addRemoveDynamicChannels(Mower mower, Capabilities capabilities, List calendarTasks) { + // create a copy of the present channels + List channelAdd = new ArrayList<>(); + for (Channel channel : thing.getChannels()) { + channelAdd.add(channel); + } + List channelRemove = new ArrayList<>(); + + if (capabilities.hasWorkAreas()) { + createChannel(CHANNEL_STATUS_WORK_AREA_ID, CHANNEL_TYPE_STATUS_WORK_AREA_ID, "Number", channelAdd); + createChannel(CHANNEL_STATUS_WORK_AREA, CHANNEL_TYPE_STATUS_WORK_AREA, "String", channelAdd); + } else { + removeChannel(CHANNEL_STATUS_WORK_AREA_ID, channelRemove); + removeChannel(CHANNEL_STATUS_WORK_AREA, channelRemove); + } + if (capabilities.canConfirmError()) { + createChannel(CHANNEL_STATUS_ERROR_CONFIRMABLE, CHANNEL_TYPE_STATUS_ERROR_CONFIRMABLE, "Switch", + channelAdd); + } else { + removeChannel(CHANNEL_STATUS_ERROR_CONFIRMABLE, channelRemove); + } + if (capabilities.hasPosition()) { + createChannel(CHANNEL_STATUS_POSITION, CHANNEL_TYPE_STATUS_POSITION, "Location", channelAdd); + } else { + removeChannel(CHANNEL_STATUS_POSITION, channelRemove); + } + + if (capabilities.hasHeadlights()) { + createChannel(CHANNEL_SETTING_HEADLIGHT_MODE, CHANNEL_TYPE_SETTING_HEADLIGHT_MODE, "String", channelAdd); + } else { + removeChannel(CHANNEL_SETTING_HEADLIGHT_MODE, channelRemove); + } + + int i; + for (i = 0; i < calendarTasks.size(); i++) { + int j = 0; + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Number:Time", channelAdd); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Number:Time", channelAdd); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch", channelAdd); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch", channelAdd); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch", channelAdd); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch", channelAdd); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch", channelAdd); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch", channelAdd); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Switch", channelAdd); if (capabilities.hasWorkAreas()) { - Long workAreaId = mower.getAttributes().getMower().getWorkAreaId(); - if (workAreaId != null) { - updateState(CHANNEL_STATUS_WORK_AREA_ID, new DecimalType(workAreaId)); - WorkArea workArea = getWorkAreaById(mower, workAreaId); - if ((workArea != null) && (workArea.getName() != null)) { - if ((workAreaId.equals(0L)) && workArea.getName().isBlank()) { - updateState(CHANNEL_STATUS_WORK_AREA, new StringType("main area")); - } else { - updateState(CHANNEL_STATUS_WORK_AREA, new StringType(workArea.getName())); - } - } else { - updateState(CHANNEL_STATUS_WORK_AREA, UnDefType.NULL); - } - } else { - updateState(CHANNEL_STATUS_WORK_AREA_ID, UnDefType.NULL); - } - } - - updateState(CHANNEL_STATUS_LAST_UPDATE, new DateTimeType( - toZonedDateTime(mower.getAttributes().getMetadata().getStatusTimestamp(), ZoneId.of("UTC")))); - updateState(CHANNEL_STATUS_LAST_POLL_UPDATE, new DateTimeType()); - updateState(CHANNEL_STATUS_BATTERY, - new QuantityType<>(mower.getAttributes().getBattery().getBatteryPercent(), Units.PERCENT)); - - int errorCode = mower.getAttributes().getMower().getErrorCode(); - updateState(CHANNEL_STATUS_ERROR_CODE, new DecimalType(errorCode)); - String errorMessage = getErrorMessage(errorCode); - if (errorMessage != null) { - updateState(CHANNEL_STATUS_ERROR_MESSAGE, new StringType(errorMessage)); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "Number", channelAdd); + createIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), + CHANNEL_TYPE_CALENDARTASK.get(j++), "String", channelAdd); } else { - updateState(CHANNEL_STATUS_ERROR_MESSAGE, UnDefType.NULL); + removeIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), channelRemove); + removeIndexedChannel(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), channelRemove); } + } + // remove all consecutive channels that are no longer required + for (int j = 0; j < CHANNEL_CALENDARTASK.size(); j++) { + removeConsecutiveIndexedChannels(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j), channelRemove); + } - long errorCodeTimestamp = mower.getAttributes().getMower().getErrorCodeTimestamp(); - if (errorCodeTimestamp == 0L) { - updateState(CHANNEL_STATUS_ERROR_TIMESTAMP, UnDefType.NULL); - } else { - updateState(CHANNEL_STATUS_ERROR_TIMESTAMP, - new DateTimeType(toZonedDateTime(errorCodeTimestamp, mowerZoneId))); - } + i = 0; + if (capabilities.hasStayOutZones()) { + createChannel(CHANNEL_STAYOUTZONE_DIRTY, CHANNEL_TYPE_STAYOUTZONES_DIRTY, "Switch", channelAdd); - if (capabilities.canConfirmError()) { - updateState(CHANNEL_STATUS_ERROR_CONFIRMABLE, - OnOffType.from(mower.getAttributes().getMower().getIsErrorConfirmable())); - } - - long nextStartTimestamp = mower.getAttributes().getPlanner().getNextStartTimestamp(); - // If next start timestamp is 0 it means the mower should start now, so using current timestamp - if (nextStartTimestamp == 0L) { - updateState(CHANNEL_STATUS_NEXT_START, UnDefType.NULL); - } else { - updateState(CHANNEL_STATUS_NEXT_START, - new DateTimeType(toZonedDateTime(nextStartTimestamp, mowerZoneId))); - } - updateState(CHANNEL_STATUS_OVERRIDE_ACTION, - new StringType(mower.getAttributes().getPlanner().getOverride().getAction().name())); - RestrictedReason restrictedReason = mower.getAttributes().getPlanner().getRestrictedReason(); - if (restrictedReason != null) { - updateState(CHANNEL_STATUS_RESTRICTED_REASON, new StringType(restrictedReason.name())); - } else { - updateState(CHANNEL_STATUS_RESTRICTED_REASON, UnDefType.NULL); - } - - updateState(CHANNEL_STATUS_EXTERNAL_REASON, - new DecimalType(mower.getAttributes().getPlanner().getExternalReason())); - - updateState(CHANNEL_SETTING_CUTTING_HEIGHT, - new DecimalType(mower.getAttributes().getSettings().getCuttingHeight())); - - if (capabilities.hasHeadlights()) { - Headlight headlight = mower.getAttributes().getSettings().getHeadlight(); - if (headlight != null) { - updateState(CHANNEL_SETTING_HEADLIGHT_MODE, new StringType(headlight.getHeadlightMode().name())); - } else { - updateState(CHANNEL_SETTING_HEADLIGHT_MODE, UnDefType.NULL); - } - } - - updateState(CHANNEL_STATISTIC_CUTTING_BLADE_USAGE_TIME, - new QuantityType<>(mower.getAttributes().getStatistics().getCuttingBladeUsageTime(), Units.SECOND)); - updateState(CHANNEL_STATISTIC_DOWN_TIME, - new QuantityType<>(mower.getAttributes().getStatistics().getDownTime(), Units.SECOND)); - updateState(CHANNEL_STATISTIC_NUMBER_OF_CHARGING_CYCLES, - new DecimalType(mower.getAttributes().getStatistics().getNumberOfChargingCycles())); - updateState(CHANNEL_STATISTIC_NUMBER_OF_COLLISIONS, - new DecimalType(mower.getAttributes().getStatistics().getNumberOfCollisions())); - updateState(CHANNEL_STATISTIC_TOTAL_CHARGING_TIME, - new QuantityType<>(mower.getAttributes().getStatistics().getTotalChargingTime(), Units.SECOND)); - updateState(CHANNEL_STATISTIC_TOTAL_CUTTING_TIME, - new QuantityType<>(mower.getAttributes().getStatistics().getTotalCuttingTime(), Units.SECOND)); - updateState(CHANNEL_STATISTIC_TOTAL_DRIVE_DISTANCE, - new QuantityType<>(mower.getAttributes().getStatistics().getTotalDriveDistance(), SIUnits.METRE)); - updateState(CHANNEL_STATISTIC_TOTAL_RUNNING_TIME, - new QuantityType<>(mower.getAttributes().getStatistics().getTotalRunningTime(), Units.SECOND)); - updateState(CHANNEL_STATISTIC_TOTAL_SEARCHING_TIME, - new QuantityType<>(mower.getAttributes().getStatistics().getTotalSearchingTime(), Units.SECOND)); - - if (mower.getAttributes().getStatistics().getTotalRunningTime() != 0) { - updateState(CHANNEL_STATISTIC_TOTAL_CUTTING_PERCENT, - new QuantityType<>( - (float) mower.getAttributes().getStatistics().getTotalCuttingTime() - / (float) mower.getAttributes().getStatistics().getTotalRunningTime() * 100.0, - Units.PERCENT)); - updateState(CHANNEL_STATISTIC_TOTAL_SEARCHING_PERCENT, - new QuantityType<>( - (float) mower.getAttributes().getStatistics().getTotalSearchingTime() - / (float) mower.getAttributes().getStatistics().getTotalRunningTime() * 100.0, - Units.PERCENT)); - } else { - updateState(CHANNEL_STATISTIC_TOTAL_CUTTING_PERCENT, new QuantityType<>(0, Units.PERCENT)); - updateState(CHANNEL_STATISTIC_TOTAL_SEARCHING_PERCENT, new QuantityType<>(0, Units.PERCENT)); - } - updateState(CHANNEL_STATISTIC_UP_TIME, - new QuantityType<>(mower.getAttributes().getStatistics().getUpTime(), Units.SECOND)); - - if (capabilities.hasPosition()) { - updateState(CHANNEL_POSITION_LAST, - new PointType(new DecimalType(mower.getAttributes().getLastPosition().getLatitude()), - new DecimalType(mower.getAttributes().getLastPosition().getLongitude()))); - List positions = mower.getAttributes().getPositions(); - i = 0; - for (; i < positions.size(); i++) { - updateIndexedState(GROUP_POSITION, i + 1, CHANNEL_POSITION, - new PointType(new DecimalType(positions.get(i).getLatitude()), - new DecimalType(positions.get(i).getLongitude()))); - } - for (; i < 50; i++) { - updateIndexedState(GROUP_POSITION, i + 1, CHANNEL_POSITION, UnDefType.NULL); - } - } - - i = 0; - for (; i < calendarTasks.size(); i++) { - int j = 0; - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - new QuantityType<>(calendarTasks.get(i).getStart(), Units.MINUTE)); - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - new QuantityType<>(calendarTasks.get(i).getDuration(), Units.MINUTE)); - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - OnOffType.from(calendarTasks.get(i).getMonday())); - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - OnOffType.from(calendarTasks.get(i).getTuesday())); - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - OnOffType.from(calendarTasks.get(i).getWednesday())); - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - OnOffType.from(calendarTasks.get(i).getThursday())); - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - OnOffType.from(calendarTasks.get(i).getFriday())); - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - OnOffType.from(calendarTasks.get(i).getSaturday())); - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - OnOffType.from(calendarTasks.get(i).getSunday())); - if (capabilities.hasWorkAreas()) { - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - new DecimalType(calendarTasks.get(i).getWorkAreaId())); - WorkArea workArea = getWorkAreaById(mower, calendarTasks.get(i).getWorkAreaId()); - if (workArea != null) { - if ((calendarTasks.get(i).getWorkAreaId().equals(0L)) && workArea.getName().isBlank()) { - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - new StringType("main area")); - } else { - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), - new StringType(workArea.getName())); - } - } else { - updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), UnDefType.NULL); - } - } - } - - i = 0; - if (capabilities.hasStayOutZones()) { - updateState(CHANNEL_STAYOUTZONE_DIRTY, - OnOffType.from(mower.getAttributes().getStayOutZones().isDirty())); - - if (mower.getAttributes().getStayOutZones() != null) { - List stayOutZones = mower.getAttributes().getStayOutZones().getZones(); - if (stayOutZones != null) { - for (; i < stayOutZones.size(); i++) { - int j = 0; - updateIndexedState(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j++), - new StringType(stayOutZones.get(i).getId())); - updateIndexedState(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j++), - new StringType(stayOutZones.get(i).getName())); - updateIndexedState(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j++), - OnOffType.from(stayOutZones.get(i).isEnabled())); - } - } - } - } - - i = 0; - if (capabilities.hasWorkAreas()) { - List workAreas = mower.getAttributes().getWorkAreas(); - if (workAreas != null) { - for (; i < workAreas.size(); i++) { + if (mower.getAttributes().getStayOutZones() != null) { + List stayOutZones = mower.getAttributes().getStayOutZones().getZones(); + if (stayOutZones != null) { + for (; i < stayOutZones.size(); i++) { int j = 0; - WorkArea workArea = workAreas.get(i); - updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), - new DecimalType(workArea.getWorkAreaId())); - - if ((workArea.getWorkAreaId() == 0L) && workArea.getName().isBlank()) { - updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), - new StringType("main area")); - } else { - updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), - new StringType(workArea.getName())); - } - updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), - new QuantityType<>(workArea.getCuttingHeight(), Units.PERCENT)); - updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), - OnOffType.from(workArea.isEnabled())); - if (workArea.getProgress() != null) { - updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), - new QuantityType<>(workArea.getProgress(), Units.PERCENT)); - } else { - updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), UnDefType.NULL); - } - if ((workArea.getLastTimeCompleted() != null) && (workArea.getLastTimeCompleted() != 0)) { - updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), - new DateTimeType(toZonedDateTime(workArea.getLastTimeCompleted(), mowerZoneId))); - } else { - updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), UnDefType.NULL); - } + createIndexedChannel(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j), + CHANNEL_TYPE_STAYOUTZONE.get(j++), "String", channelAdd); + createIndexedChannel(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j), + CHANNEL_TYPE_STAYOUTZONE.get(j++), "String", channelAdd); + createIndexedChannel(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j), + CHANNEL_TYPE_STAYOUTZONE.get(j++), "Switch", channelAdd); } } } + } else { + removeChannel(CHANNEL_STAYOUTZONE_DIRTY, channelRemove); + } + // remove all consecutive channels that are no longer required + for (int j = 0; j < CHANNEL_STAYOUTZONE.size(); j++) { + removeConsecutiveIndexedChannels(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j), channelRemove); + } + + i = 0; + if (capabilities.hasWorkAreas()) { + List workAreas = mower.getAttributes().getWorkAreas(); + if (workAreas != null) { + for (; i < workAreas.size(); i++) { + int j = 0; + createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), CHANNEL_TYPE_WORKAREA.get(j++), + "Number", channelAdd); + createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), CHANNEL_TYPE_WORKAREA.get(j++), + "String", channelAdd); + createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), CHANNEL_TYPE_WORKAREA.get(j++), + "Number:Dimensionless", channelAdd); + createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), CHANNEL_TYPE_WORKAREA.get(j++), + "Switch", channelAdd); + createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), CHANNEL_TYPE_WORKAREA.get(j++), + "Number:Dimensionless", channelAdd); + createIndexedChannel(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), CHANNEL_TYPE_WORKAREA.get(j++), + "DateTime", channelAdd); + } + } + } + // remove all consecutive channels that are no longer required + for (int j = 0; j < CHANNEL_WORKAREA.size(); j++) { + removeConsecutiveIndexedChannels(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j), channelRemove); } - if (mowerMessages != null) { - List messages = mowerMessages.getAttributes().getMessages(); - if (messages != null) { - int i; - for (i = 0; i < messages.size(); i++) { - int j = 0; - Message message = messages.get(i); - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), - new DateTimeType(toZonedDateTime(message.getTime() * 1000, mowerZoneId))); - int code = message.getCode(); - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), new DecimalType(code)); - String errorMessage = getErrorMessage(code); - if (errorMessage != null) { - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), - new StringType(errorMessage)); + // remove channels that are now longer required and add new once + updateThing(editThing().withChannels(channelAdd).withoutChannels(channelRemove).build()); + } + + private void updateStatusChannels(Mower mower, Capabilities capabilities) { + updateState(CHANNEL_STATUS_NAME, new StringType(mower.getAttributes().getSystem().getName())); + updateState(CHANNEL_STATUS_MODE, new StringType(mower.getAttributes().getMower().getMode().name())); + updateState(CHANNEL_STATUS_ACTIVITY, new StringType(mower.getAttributes().getMower().getActivity().name())); + updateState(CHANNEL_STATUS_INACTIVE_REASON, + new StringType(mower.getAttributes().getMower().getInactiveReason().name())); + + if (mower.getAttributes().getMower().getState() != State.RESTRICTED) { + updateState(CHANNEL_STATUS_STATE, new StringType(mower.getAttributes().getMower().getState().name())); + } else { + updateState(CHANNEL_STATUS_STATE, + new StringType(restrictedState(mower.getAttributes().getPlanner().getRestrictedReason()))); + } + + if (capabilities.hasWorkAreas()) { + Long workAreaId = mower.getAttributes().getMower().getWorkAreaId(); + if (workAreaId != null) { + updateState(CHANNEL_STATUS_WORK_AREA_ID, new DecimalType(workAreaId)); + WorkArea workArea = getWorkAreaById(mower, workAreaId); + if (workArea != null && workArea.getName() != null) { + if (workAreaId.equals(0L) && workArea.getName().isBlank()) { + updateState(CHANNEL_STATUS_WORK_AREA, new StringType("main area")); } else { - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), UnDefType.NULL); - } - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), - new StringType(message.getSeverity())); - if ((message.getLatitude() != null) && (message.getLatitude() != null)) { - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), new PointType( - new DecimalType(message.getLatitude()), new DecimalType(message.getLongitude()))); - } else { - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), UnDefType.NULL); + updateState(CHANNEL_STATUS_WORK_AREA, new StringType(workArea.getName())); } + } else { + updateState(CHANNEL_STATUS_WORK_AREA, UnDefType.NULL); } - for (; i < 50; i++) { - int j = 0; - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), UnDefType.NULL); - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), UnDefType.NULL); - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), UnDefType.NULL); - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), UnDefType.NULL); - updateIndexedState(GROUP_MESSAGE, i + 1, CHANNEL_MESSAGE.get(j++), UnDefType.NULL); + } else { + updateState(CHANNEL_STATUS_WORK_AREA_ID, UnDefType.NULL); + } + } + + updateState(CHANNEL_STATUS_LAST_UPDATE, new DateTimeType( + toZonedDateTime(mower.getAttributes().getMetadata().getStatusTimestamp(), ZoneId.of("UTC")))); + ZonedDateTime lastQuery = this.lastQueryTime; + if (lastQuery != null) { + updateState(CHANNEL_STATUS_LAST_POLL_UPDATE, new DateTimeType(lastQuery)); + } else { + updateState(CHANNEL_STATUS_LAST_POLL_UPDATE, UnDefType.NULL); + } + updateState(CHANNEL_STATUS_BATTERY, + new QuantityType<>(mower.getAttributes().getBattery().getBatteryPercent(), Units.PERCENT)); + + int errorCode = mower.getAttributes().getMower().getErrorCode(); + updateState(CHANNEL_STATUS_ERROR_CODE, new DecimalType(errorCode)); + String errorMessage = getErrorMessage(errorCode); + if (errorMessage != null) { + updateState(CHANNEL_STATUS_ERROR_MESSAGE, new StringType(errorMessage)); + } else { + updateState(CHANNEL_STATUS_ERROR_MESSAGE, UnDefType.NULL); + } + + long errorCodeTimestamp = mower.getAttributes().getMower().getErrorCodeTimestamp(); + if (errorCodeTimestamp == 0L) { + updateState(CHANNEL_STATUS_ERROR_TIMESTAMP, UnDefType.NULL); + } else { + updateState(CHANNEL_STATUS_ERROR_TIMESTAMP, + new DateTimeType(toZonedDateTime(errorCodeTimestamp, mowerZoneId))); + } + + if (capabilities.canConfirmError()) { + updateState(CHANNEL_STATUS_ERROR_CONFIRMABLE, + OnOffType.from(mower.getAttributes().getMower().getIsErrorConfirmable())); + } + + long nextStartTimestamp = mower.getAttributes().getPlanner().getNextStartTimestamp(); + // If next start timestamp is 0 it means the mower should start now + if (nextStartTimestamp == 0L) { + updateState(CHANNEL_STATUS_NEXT_START, UnDefType.NULL); + } else { + updateState(CHANNEL_STATUS_NEXT_START, new DateTimeType(toZonedDateTime(nextStartTimestamp, mowerZoneId))); + } + updateState(CHANNEL_STATUS_OVERRIDE_ACTION, + new StringType(mower.getAttributes().getPlanner().getOverride().getAction().name())); + RestrictedReason restrictedReason = mower.getAttributes().getPlanner().getRestrictedReason(); + if (restrictedReason != null) { + updateState(CHANNEL_STATUS_RESTRICTED_REASON, new StringType(restrictedReason.name())); + } else { + updateState(CHANNEL_STATUS_RESTRICTED_REASON, UnDefType.NULL); + } + + updateState(CHANNEL_STATUS_EXTERNAL_REASON, + new DecimalType(mower.getAttributes().getPlanner().getExternalReason())); + + updateState(CHANNEL_SETTING_CUTTING_HEIGHT, + new DecimalType(mower.getAttributes().getSettings().getCuttingHeight())); + + if (capabilities.hasPosition()) { + updateState(CHANNEL_STATUS_POSITION, + new PointType(new DecimalType(mower.getAttributes().getLastPosition().getLatitude()), + new DecimalType(mower.getAttributes().getLastPosition().getLongitude()))); + } + } + + private void updateSettingChannels(Mower mower, Capabilities capabilities) { + if (capabilities.hasHeadlights()) { + Headlight headlight = mower.getAttributes().getSettings().getHeadlight(); + if (headlight != null) { + updateState(CHANNEL_SETTING_HEADLIGHT_MODE, new StringType(headlight.getHeadlightMode().name())); + } else { + updateState(CHANNEL_SETTING_HEADLIGHT_MODE, UnDefType.NULL); + } + } + } + + private void updateStatisticChannels(Mower mower) { + updateState(CHANNEL_STATISTIC_CUTTING_BLADE_USAGE_TIME, + new QuantityType<>(mower.getAttributes().getStatistics().getCuttingBladeUsageTime(), Units.SECOND)); + updateState(CHANNEL_STATISTIC_DOWN_TIME, + new QuantityType<>(mower.getAttributes().getStatistics().getDownTime(), Units.SECOND)); + updateState(CHANNEL_STATISTIC_NUMBER_OF_CHARGING_CYCLES, + new DecimalType(mower.getAttributes().getStatistics().getNumberOfChargingCycles())); + updateState(CHANNEL_STATISTIC_NUMBER_OF_COLLISIONS, + new DecimalType(mower.getAttributes().getStatistics().getNumberOfCollisions())); + updateState(CHANNEL_STATISTIC_TOTAL_CHARGING_TIME, + new QuantityType<>(mower.getAttributes().getStatistics().getTotalChargingTime(), Units.SECOND)); + updateState(CHANNEL_STATISTIC_TOTAL_CUTTING_TIME, + new QuantityType<>(mower.getAttributes().getStatistics().getTotalCuttingTime(), Units.SECOND)); + updateState(CHANNEL_STATISTIC_TOTAL_DRIVE_DISTANCE, + new QuantityType<>(mower.getAttributes().getStatistics().getTotalDriveDistance(), SIUnits.METRE)); + updateState(CHANNEL_STATISTIC_TOTAL_RUNNING_TIME, + new QuantityType<>(mower.getAttributes().getStatistics().getTotalRunningTime(), Units.SECOND)); + updateState(CHANNEL_STATISTIC_TOTAL_SEARCHING_TIME, + new QuantityType<>(mower.getAttributes().getStatistics().getTotalSearchingTime(), Units.SECOND)); + + if (mower.getAttributes().getStatistics().getTotalRunningTime() != 0) { + updateState(CHANNEL_STATISTIC_TOTAL_CUTTING_PERCENT, + new QuantityType<>( + (float) mower.getAttributes().getStatistics().getTotalCuttingTime() + / (float) mower.getAttributes().getStatistics().getTotalRunningTime() * 100.0, + Units.PERCENT)); + updateState(CHANNEL_STATISTIC_TOTAL_SEARCHING_PERCENT, + new QuantityType<>( + (float) mower.getAttributes().getStatistics().getTotalSearchingTime() + / (float) mower.getAttributes().getStatistics().getTotalRunningTime() * 100.0, + Units.PERCENT)); + } else { + updateState(CHANNEL_STATISTIC_TOTAL_CUTTING_PERCENT, new QuantityType<>(0, Units.PERCENT)); + updateState(CHANNEL_STATISTIC_TOTAL_SEARCHING_PERCENT, new QuantityType<>(0, Units.PERCENT)); + } + updateState(CHANNEL_STATISTIC_UP_TIME, + new QuantityType<>(mower.getAttributes().getStatistics().getUpTime(), Units.SECOND)); + } + + private void updateCalendarTaskChannels(Mower mower, Capabilities capabilities, List calendarTasks) { + int i = 0; + for (; i < calendarTasks.size(); i++) { + int j = 0; + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + new QuantityType<>(calendarTasks.get(i).getStart(), Units.MINUTE)); + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + new QuantityType<>(calendarTasks.get(i).getDuration(), Units.MINUTE)); + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + OnOffType.from(calendarTasks.get(i).getMonday())); + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + OnOffType.from(calendarTasks.get(i).getTuesday())); + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + OnOffType.from(calendarTasks.get(i).getWednesday())); + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + OnOffType.from(calendarTasks.get(i).getThursday())); + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + OnOffType.from(calendarTasks.get(i).getFriday())); + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + OnOffType.from(calendarTasks.get(i).getSaturday())); + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + OnOffType.from(calendarTasks.get(i).getSunday())); + if (capabilities.hasWorkAreas()) { + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + new DecimalType(calendarTasks.get(i).getWorkAreaId())); + WorkArea workArea = getWorkAreaById(mower, calendarTasks.get(i).getWorkAreaId()); + if (workArea != null) { + if (calendarTasks.get(i).getWorkAreaId().equals(0L) && workArea.getName().isBlank()) { + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + new StringType("main area")); + } else { + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), + new StringType(workArea.getName())); + } + } else { + updateIndexedState(GROUP_CALENDARTASK, i + 1, CHANNEL_CALENDARTASK.get(j++), UnDefType.NULL); } } } } + private void updateStayOutZonesChannels(Mower mower, Capabilities capabilities) { + int i = 0; + if (capabilities.hasStayOutZones()) { + StayOutZones stayOutZones = mower.getAttributes().getStayOutZones(); + if (stayOutZones != null) { + updateState(CHANNEL_STAYOUTZONE_DIRTY, OnOffType.from(stayOutZones.isDirty())); + + List stayOutZoneList = mower.getAttributes().getStayOutZones().getZones(); + if (stayOutZoneList != null) { + for (; i < stayOutZoneList.size(); i++) { + int j = 0; + updateIndexedState(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j++), + new StringType(stayOutZoneList.get(i).getId())); + updateIndexedState(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j++), + new StringType(stayOutZoneList.get(i).getName())); + updateIndexedState(GROUP_STAYOUTZONE, i + 1, CHANNEL_STAYOUTZONE.get(j++), + OnOffType.from(stayOutZoneList.get(i).isEnabled())); + } + } + } + } + } + + private void updateWorkAreasChannels(Mower mower, Capabilities capabilities) { + int i = 0; + if (capabilities.hasWorkAreas()) { + List workAreas = mower.getAttributes().getWorkAreas(); + if (workAreas != null) { + for (; i < workAreas.size(); i++) { + int j = 0; + WorkArea workArea = workAreas.get(i); + updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), + new DecimalType(workArea.getWorkAreaId())); + + if (workArea.getWorkAreaId() == 0L && workArea.getName().isBlank()) { + updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), + new StringType("main area")); + } else { + updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), + new StringType(workArea.getName())); + } + updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), + new QuantityType<>(workArea.getCuttingHeight(), Units.PERCENT)); + updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), + OnOffType.from(workArea.isEnabled())); + if (workArea.getProgress() != null) { + updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), + new QuantityType<>(workArea.getProgress(), Units.PERCENT)); + } else { + updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), UnDefType.NULL); + } + + // lastTimeCompleted is in seconds, convert it to milliseconds + Long lastTimeCompleted = workArea.getLastTimeCompleted(); + // If lastTimeCompleted is 0 it means the work area has never been completed + if (lastTimeCompleted != null && lastTimeCompleted != 0L) { + updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), new DateTimeType( + toZonedDateTime(TimeUnit.SECONDS.toMillis(lastTimeCompleted), mowerZoneId))); + } else { + updateIndexedState(GROUP_WORKAREA, i + 1, CHANNEL_WORKAREA.get(j++), UnDefType.NULL); + } + } + } + } + } + + private void updateMessagesChannelState(@Nullable MowerMessages mowerMessages) { + if (mowerMessages != null) { + Message message = mowerMessages.getAttributes().getMessages().get(0); + updateState(CHANNEL_MESSAGE_TIMESTAMP, + new DateTimeType(toZonedDateTime(message.getTime() * 1000, mowerZoneId))); + + int code = message.getCode(); + updateState(CHANNEL_MESSAGE_CODE, new DecimalType(code)); + + String errorMessage = getErrorMessage(code); + if (errorMessage != null) { + updateState(CHANNEL_MESSAGE_TEXT, new StringType(errorMessage)); + } else { + updateState(CHANNEL_MESSAGE_TEXT, UnDefType.NULL); + } + + updateState(CHANNEL_MESSAGE_SEVERITY, new StringType(message.getSeverity())); + Double latitude = message.getLatitude(); + Double longitude = message.getLongitude(); + if (latitude != null && longitude != null) { + updateState(CHANNEL_MESSAGE_GPS_POSITION, + new PointType(new DecimalType(latitude), new DecimalType(longitude))); + } else { + updateState(CHANNEL_MESSAGE_GPS_POSITION, UnDefType.NULL); + } + } + } + private void initializeProperties(@Nullable Mower mower) { - if (isValidResult(mower)) { + if (mower != null && isValidResult(mower)) { Map properties = editProperties(); properties.put(AutomowerBindingConstants.AUTOMOWER_ID, mower.getId()); @@ -1287,20 +1344,19 @@ public class AutomowerHandler extends BaseThingHandler { } private void createIndexedChannel(String ChannelGroup, int id, String channel, ChannelTypeUID channelTypeUID, - String itemType) { + String itemType, List channelAdd) { String indexedChannel = getIndexedChannel(ChannelGroup, id, channel); String indexedLabel = getIndexedLabel(id, channel); - createChannel(indexedLabel, indexedChannel, channelTypeUID, itemType); + createChannel(indexedLabel, indexedChannel, channelTypeUID, itemType, channelAdd); } - private void createChannel(String channel, ChannelTypeUID channelTypeUID, String itemType) { - createChannel(null, channel, channelTypeUID, itemType); + private void createChannel(String channel, ChannelTypeUID channelTypeUID, String itemType, + List channelAdd) { + createChannel(null, channel, channelTypeUID, itemType, channelAdd); } - private List channelAdd = new ArrayList<>(); - private List channelRemove = new ArrayList<>(); - - private void createChannel(@Nullable String label, String channel, ChannelTypeUID channelTypeUID, String itemType) { + private void createChannel(@Nullable String label, String channel, ChannelTypeUID channelTypeUID, String itemType, + List channelAdd) { ChannelUID channelUid = new ChannelUID(thing.getUID(), channel); if (thing.getChannel(channelUid) == null) { Channel newChannel; @@ -1314,20 +1370,21 @@ public class AutomowerHandler extends BaseThingHandler { } } - private void removeIndexedChannel(String ChannelGroup, int id, String channel) { + private void removeIndexedChannel(String ChannelGroup, int id, String channel, List channelRemove) { String indexedChannel = getIndexedChannel(ChannelGroup, id, channel); - removeChannel(indexedChannel); + removeChannel(indexedChannel, channelRemove); } - private void removeConsecutiveIndexedChannels(String ChannelGroup, int id, String channel) { + private void removeConsecutiveIndexedChannels(String ChannelGroup, int id, String channel, + List channelRemove) { String indexedChannel = getIndexedChannel(ChannelGroup, id, channel); - if (removeChannel(indexedChannel)) { + if (removeChannel(indexedChannel, channelRemove)) { // remove next channel recursively - removeConsecutiveIndexedChannels(ChannelGroup, id + 1, channel); + removeConsecutiveIndexedChannels(ChannelGroup, id + 1, channel, channelRemove); } } - private boolean removeChannel(String channel) { + private boolean removeChannel(String channel, List channelRemove) { ChannelUID channelUid = new ChannelUID(thing.getUID(), channel); Channel chn = thing.getChannel(channelUid); if (chn != null) { @@ -1337,4 +1394,285 @@ public class AutomowerHandler extends BaseThingHandler { return false; } } + + /* + * Process WebSocket messages according to + * https://developer.husqvarnagroup.cloud/apis/automower-connect-api?tab=websocket%20api + */ + public void processWebSocketMessage(JsonObject event) { + Mower mower = this.mowerState; + MowerMessages mowerMessages = this.mowerMessages; + if (mower != null && (mowerMessages != null)) { + try { + String type = event.has("type") ? event.get("type").getAsString() : null; + if (type != null && event.has("attributes") && event.get("attributes").isJsonObject()) { + JsonObject attributes = event.getAsJsonObject("attributes"); + Metadata metaData = mower.getAttributes().getMetadata(); + long nowMs = ZonedDateTime.now(mowerZoneId).toInstant().toEpochMilli(); + switch (type) { + case "battery-event-v2": + handleBatteryEventV2(attributes, mower, metaData, nowMs); + break; + case "calendar-event-v2": + handleCalendarEventV2(attributes, mower, metaData, nowMs); + break; + case "cuttingHeight-event-v2": + handleCuttingHeightEventV2(attributes, mower, metaData, nowMs); + break; + case "headlights-event-v2": + handleHeadlightsEventV2(attributes, mower, metaData, nowMs); + break; + case "message-event-v2": + handleMessageEventV2(attributes, mowerMessages); + break; + case "mower-event-v2": + handleMowerEventV2(attributes, mower, metaData, nowMs); + break; + case "planner-event-v2": + handlePlannerEventV2(attributes, mower, metaData, nowMs); + break; + case "position-event-v2": + handlePositionEventV2(attributes, mower, metaData, nowMs); + break; + default: + logger.debug("Unhandled WebSocket event type: {}", type); + break; + } + } else { + logger.warn("Received WebSocket event without type or attributes: {}", event); + } + } catch (Exception e) { + logger.error("Error processing WebSocket event: {}", e.getMessage()); + } + } else { + logger.debug("Channels not yet intialized via REST - ignoring WebSocket message: {}", event); + } + } + + private void handleBatteryEventV2(JsonObject attributes, Mower mower, Metadata metaData, long nowMs) { + try { + if (attributes.has("battery") && attributes.get("battery").isJsonObject()) { + JsonObject batteryObj = attributes.getAsJsonObject("battery"); + if (batteryObj.has("batteryPercent")) { + byte batteryPercent = batteryObj.get("batteryPercent").getAsByte(); + logger.debug("Received battery update: {}%", batteryPercent); + mower.getAttributes().getBattery().setBatteryPercent(batteryPercent); + metaData.setStatusTimestamp(nowMs); + updateMowerChannelState(mower); + } + } + } catch (Exception e) { + logger.error("Error processing battery-event-v2: {}", e.getMessage()); + } + } + + private void handleCalendarEventV2(JsonObject attributes, Mower mower, Metadata metaData, long nowMs) { + try { + if (attributes.has("calendar") && attributes.get("calendar").isJsonObject()) { + JsonObject calendarObj = attributes.getAsJsonObject("calendar"); + if (calendarObj.has("tasks")) { + List calendarTasks = mower.getAttributes().getCalendar().getTasks(); + calendarTasks.clear(); + JsonArray tasks = calendarObj.getAsJsonArray("tasks"); + for (int i = 0; i < tasks.size(); i++) { + JsonObject taskObj = tasks.get(i).getAsJsonObject(); + CalendarTask task = new CalendarTask(); + task.setStart(taskObj.get("start").getAsShort()); + task.setDuration(taskObj.get("duration").getAsShort()); + task.setMonday(taskObj.get("monday").getAsBoolean()); + task.setTuesday(taskObj.get("tuesday").getAsBoolean()); + task.setWednesday(taskObj.get("wednesday").getAsBoolean()); + task.setThursday(taskObj.get("thursday").getAsBoolean()); + task.setFriday(taskObj.get("friday").getAsBoolean()); + task.setSaturday(taskObj.get("saturday").getAsBoolean()); + task.setSunday(taskObj.get("sunday").getAsBoolean()); + if (taskObj.has("workAreaId")) { + task.setWorkAreaId(taskObj.get("workAreaId").getAsLong()); + } + logger.debug( + "Received calendar task: start={}, duration={}, monday={}, tuesday={}, wednesday={}, thursday={}, friday={}, saturday={}, sunday={}, workAreaId={}", + task.getStart(), task.getDuration(), task.getMonday(), task.getTuesday(), + task.getWednesday(), task.getThursday(), task.getFriday(), task.getSaturday(), + task.getSunday(), task.getWorkAreaId()); + calendarTasks.add(task); + } + metaData.setStatusTimestamp(nowMs); + updateMowerChannelState(mower); + } + } + } catch (Exception e) { + logger.error("Error processing calendar-event-v2: {}", e.getMessage()); + } + } + + private void handleCuttingHeightEventV2(JsonObject attributes, Mower mower, Metadata metaData, long nowMs) { + try { + if (attributes.has("cuttingHeight") && attributes.get("cuttingHeight").isJsonObject()) { + JsonObject cuttingHeightObj = attributes.getAsJsonObject("cuttingHeight"); + if (cuttingHeightObj.has("height")) { + mower.getAttributes().getSettings().setCuttingHeight(cuttingHeightObj.get("height").getAsByte()); + logger.debug("Received cutting height update: {}", cuttingHeightObj.get("height").getAsByte()); + metaData.setStatusTimestamp(nowMs); + updateMowerChannelState(mower); + } + } + } catch (Exception e) { + logger.error("Error processing cuttingHeight-event-v2: {}", e.getMessage()); + } + } + + private void handleHeadlightsEventV2(JsonObject attributes, Mower mower, Metadata metaData, long nowMs) { + try { + if (attributes.has("headlight") && attributes.get("headlight").isJsonObject()) { + JsonObject headlightObj = attributes.getAsJsonObject("headlight"); + if (headlightObj.has("mode")) { + mower.getAttributes().getSettings().getHeadlight() + .setHeadlightMode(HeadlightMode.valueOf(headlightObj.get("mode").getAsString())); + logger.debug("Received headlight mode update: {}", headlightObj.get("mode").getAsString()); + metaData.setStatusTimestamp(nowMs); + updateMowerChannelState(mower); + } + } + } catch (IllegalArgumentException e) { + logger.error("Invalid headlight mode received: {}", e.getMessage()); + } catch (Exception e) { + logger.error("Error processing headlight-event-v2: {}", e.getMessage()); + } + } + + private void handleMessageEventV2(JsonObject attributes, MowerMessages mowerMessages) { + try { + if (attributes.has("message") && attributes.get("message").isJsonObject()) { + JsonObject msgObj = attributes.getAsJsonObject("message"); + long time = msgObj.has("time") ? msgObj.get("time").getAsLong() : 0L; + int code = msgObj.has("code") ? msgObj.get("code").getAsInt() : 0; + String severity = msgObj.has("severity") ? msgObj.get("severity").getAsString() : ""; + Double latitude = msgObj.has("latitude") ? msgObj.get("latitude").getAsDouble() : null; + Double longitude = msgObj.has("longitude") ? msgObj.get("longitude").getAsDouble() : null; + logger.debug("Received mower message: time={}, code={}, severity={}, lat={}, lon={}", time, code, + severity, latitude, longitude); + + Message message = mowerMessages.getAttributes().getMessages().get(0); + if (message != null) { + message.setTime(time); + message.setCode(code); + message.setSeverity(severity); + message.setLatitude(latitude); + message.setLongitude(longitude); + updateMessagesChannelState(mowerMessages); + } + } + } catch (Exception e) { + logger.error("Error processing message-event-v2: {}", e.getMessage()); + } + } + + private void handleMowerEventV2(JsonObject attributes, Mower mower, Metadata metaData, long nowMs) { + try { + if (attributes.has("mower") && attributes.get("mower").isJsonObject()) { + JsonObject mowerObj = attributes.getAsJsonObject("mower"); + MowerApp mowerApp = mower.getAttributes().getMower(); + if (mowerObj.has("mode")) { + mowerApp.setMode(Mode.valueOf(mowerObj.get("mode").getAsString())); + } + if (mowerObj.has("activity")) { + mowerApp.setActivity(Activity.valueOf(mowerObj.get("activity").getAsString())); + } + if (mowerObj.has("inactiveReason")) { + mowerApp.setInactiveReason(InactiveReason.valueOf(mowerObj.get("inactiveReason").getAsString())); + } + if (mowerObj.has("state")) { + mowerApp.setState(State.valueOf(mowerObj.get("state").getAsString())); + } + if (mowerObj.has("errorCode")) { + mowerApp.setErrorCode(mowerObj.get("errorCode").getAsInt()); + } + if (mowerObj.has("isErrorConfirmable")) { + mowerApp.setIsErrorConfirmable(mowerObj.get("isErrorConfirmable").getAsBoolean()); + } + if (mowerObj.has("errorCodeTimestamp")) { + mowerApp.setErrorCodeTimestamp(mowerObj.get("errorCodeTimestamp").getAsLong()); + } + if (mowerObj.has("workAreaId")) { + mowerApp.setWorkAreaId(mowerObj.get("workAreaId").getAsLong()); + } + + logger.debug( + "Received mower event: mode={}, activity={}, inactiveReason={}, state={}, errorCode={}, isErrorConfirmable={}, errorCodeTimestamp={}, workAreaId={}", + mowerObj.has("mode") ? mowerObj.get("mode").getAsString() : null, + mowerObj.has("activity") ? mowerObj.get("activity").getAsString() : null, + mowerObj.has("inactiveReason") ? mowerObj.get("inactiveReason").getAsString() : null, + mowerObj.has("state") ? mowerObj.get("state").getAsString() : null, + mowerObj.has("errorCode") ? mowerObj.get("errorCode").getAsInt() : null, + mowerObj.has("isErrorConfirmable") ? mowerObj.get("isErrorConfirmable").getAsBoolean() : null, + mowerObj.has("errorCodeTimestamp") ? mowerObj.get("errorCodeTimestamp").getAsLong() : null, + mowerObj.has("workAreaId") ? mowerObj.get("workAreaId").getAsLong() : null); + metaData.setStatusTimestamp(nowMs); + updateMowerChannelState(mower); + } + } catch (IllegalArgumentException e) { + logger.error("Invalid value received in mower event: {}", e.getMessage()); + } catch (Exception e) { + logger.error("Error processing mower-event-v2: {}", e.getMessage()); + } + } + + private void handlePlannerEventV2(JsonObject attributes, Mower mower, Metadata metaData, long nowMs) { + try { + if (attributes.has("planner") && attributes.get("planner").isJsonObject()) { + JsonObject plannerObj = attributes.getAsJsonObject("planner"); + Planner planner = mower.getAttributes().getPlanner(); + if (plannerObj.has("nextStartTimestamp")) { + planner.setNextStartTimestamp(plannerObj.get("nextStartTimestamp").getAsLong()); + } + if (plannerObj.has("override") && plannerObj.get("override").isJsonObject()) { + JsonObject overrideObj = plannerObj.getAsJsonObject("override"); + if (overrideObj.has("action")) { + planner.getOverride().setAction(Action.valueOf(overrideObj.get("action").getAsString())); + } + } + if (plannerObj.has("restrictedReason")) { + planner.setRestrictedReason( + RestrictedReason.valueOf(plannerObj.get("restrictedReason").getAsString())); + } + if (plannerObj.has("externalReason")) { + planner.setExternalReason(plannerObj.get("externalReason").getAsInt()); + } + logger.debug( + "Received planner event: nextStartTimestamp={}, override.action={}, restrictedReason={}, externalReason={}", + plannerObj.has("nextStartTimestamp") ? plannerObj.get("nextStartTimestamp").getAsLong() : null, + plannerObj.has("override") && plannerObj.get("override").isJsonObject() + && plannerObj.getAsJsonObject("override").has("action") + ? plannerObj.getAsJsonObject("override").get("action").getAsString() + : null, + plannerObj.has("restrictedReason") ? plannerObj.get("restrictedReason").getAsString() : null, + plannerObj.has("externalReason") ? plannerObj.get("externalReason").getAsInt() : null); + metaData.setStatusTimestamp(nowMs); + updateMowerChannelState(mower); + } + } catch (IllegalArgumentException e) { + logger.error("Invalid value received in planner event: {}", e.getMessage()); + } catch (Exception e) { + logger.error("Error processing planner-event-v2: {}", e.getMessage()); + } + } + + private void handlePositionEventV2(JsonObject attributes, Mower mower, Metadata metaData, long nowMs) { + try { + if (attributes.has("position")) { + JsonObject position = attributes.getAsJsonObject("position"); + if (position.has("latitude") && position.has("longitude")) { + double latitude = position.get("latitude").getAsDouble(); + double longitude = position.get("longitude").getAsDouble(); + logger.debug("Received position update: lat={}, lon={}", latitude, longitude); + mower.getAttributes().getLastPosition().setLatitude(latitude); + mower.getAttributes().getLastPosition().setLongitude(longitude); + metaData.setStatusTimestamp(nowMs); + updateMowerChannelState(mower); + } + } + } catch (Exception e) { + logger.error("Error processing position-event-v2: {}", e.getMessage()); + } + } } diff --git a/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/addon/addon.xml index 14739ea7d1..9cbce72832 100644 --- a/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/addon/addon.xml +++ b/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/addon/addon.xml @@ -5,7 +5,7 @@ binding Automower Binding - Binding to interact with Husquvarna Automower robots. + Binding for Husqvarna Automower® robotic lawn mowers cloud diff --git a/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/i18n/automower.properties b/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/i18n/automower.properties index dca91f1ed4..01cdbd820c 100644 --- a/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/i18n/automower.properties +++ b/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/i18n/automower.properties @@ -1,36 +1,33 @@ # add-on addon.automower.name = Automower Binding -addon.automower.description = Binding to interact with Husquvarna Automower robots. +addon.automower.description = Binding for Husqvarna Automower® robotic lawn mowers # thing types -thing-type.automower.automower.label = Automower +thing-type.automower.automower.label = Automower® thing-type.automower.automower.description = An automatic lawn mower -thing-type.automower.bridge.label = Automower Connect Bridge -thing-type.automower.bridge.description = The bridge to communicate with the Automower Connect API +thing-type.automower.bridge.label = Automower® Connect Bridge +thing-type.automower.bridge.description = The bridge to communicate with the Automower® Connect API # thing types config -thing-type.config.automower.automower.mowerId.label = Automower Id -thing-type.config.automower.automower.mowerId.description = The Id of an automower as used by the Automower Connect Api to identify a mower +thing-type.config.automower.automower.mowerId.label = Automower® Id +thing-type.config.automower.automower.mowerId.description = The Id of an Automower® as used by the Automower® Connect API to identify a Automower®. This is automatically filled when the thing is discovered. thing-type.config.automower.automower.mowerZoneId.label = TimeZone -thing-type.config.automower.automower.mowerZoneId.description = Time zone of the mower (e.g. Europe/Berlin) -thing-type.config.automower.automower.pollingInterval.label = Polling Interval -thing-type.config.automower.automower.pollingInterval.description = How often the current automower state should be polled in seconds +thing-type.config.automower.automower.mowerZoneId.description = Time zone of the Automower® (e.g. Europe/Berlin). Default is the time zone configured in openHAB thing-type.config.automower.bridge.appKey.label = Application Key -thing-type.config.automower.bridge.appKey.description = The Application Key is required to communicate with the Automower Connect API at https://developer.husqvarnagroup.cloud/. It can be obtained by registering an Application on the Husqvarna Website. This application also needs to be connected to the "Authentication API" and the "Automower Connect API" +thing-type.config.automower.bridge.appKey.description = The Application Key is required to communicate with the Automower® Connect API. It can be obtained by registering an Application on https://developer.husqvarnagroup.cloud/. This application needs to be connected to the "Authentication API" and the "Automower® Connect API". https://developer.husqvarnagroup.cloud/docs/getting-started thing-type.config.automower.bridge.appSecret.label = Application Secret -thing-type.config.automower.bridge.appSecret.description = The Application Secret is required to communicate with the Automower Connect API at https://developer.husqvarnagroup.cloud/. It can be obtained by registering an Application on the Husqvarna Website. +thing-type.config.automower.bridge.appSecret.description = The Application Secret is required to communicate with the Automower® Connect API. See "Application Key" for further details thing-type.config.automower.bridge.pollingInterval.label = Polling Interval -thing-type.config.automower.bridge.pollingInterval.description = How often the available automowers should be queried in seconds +thing-type.config.automower.bridge.pollingInterval.description = How often the current Automower® states should be polled using REST API # channel group types channel-group-type.automower.calendarTasksType.label = Calendar Tasks channel-group-type.automower.commandsType.label = Commands channel-group-type.automower.messagesType.label = Messages -channel-group-type.automower.positionsType.label = Positions channel-group-type.automower.settingsType.label = Settings channel-group-type.automower.statisticsType.label = Statistics channel-group-type.automower.statusType.label = Status @@ -50,7 +47,7 @@ channel-type.automower.activityType.state.option.LEAVING = Leaving charging stat channel-type.automower.activityType.state.option.PARKED_IN_CS = Parked in charging station channel-type.automower.activityType.state.option.STOPPED_IN_GARDEN = Stopped in garden channel-type.automower.batteryType.label = Battery -channel-type.automower.batteryType.description = The battery level of the mower at the time of last update +channel-type.automower.batteryType.description = The battery state of charge in percent channel-type.automower.calendarTaskDurationType.label = Duration channel-type.automower.calendarTaskDurationType.description = Duration time channel-type.automower.calendarTaskDurationType.state.pattern = %1$tHh:%1$tMm @@ -74,36 +71,34 @@ channel-type.automower.calendarTaskWednesdayType.description = Enabled on Wednes channel-type.automower.calendarTaskWorkAreaIdType.label = Work Area Id of Calendar channel-type.automower.calendarTaskWorkAreaIdType.description = Work Area Id mapped to this calendar channel-type.automower.errorCodeType.label = Error Code -channel-type.automower.errorCodeType.description = The error code at the time of last update +channel-type.automower.errorCodeType.description = The current error code channel-type.automower.errorConfirmableType.label = Error Confirmable -channel-type.automower.errorConfirmableType.description = If the mower has an Error Code this attribute states if the error is confirmable +channel-type.automower.errorConfirmableType.description = If the Automower® has an error, this attribute states if the error is confirmable channel-type.automower.errorMessageType.label = Error Message -channel-type.automower.errorMessageType.description = The error message at the time of last update +channel-type.automower.errorMessageType.description = The current error message channel-type.automower.errorTimestampType.label = Error Time -channel-type.automower.errorTimestampType.description = The time when the error occurred +channel-type.automower.errorTimestampType.description = The time when the current error occurred channel-type.automower.externalReasonType.label = External Reason -channel-type.automower.externalReasonType.description = The channel providing an external reason that restrics current planner operation. +channel-type.automower.externalReasonType.description = An external reason set by i.e. Google Assistant or Amazon Alexa that restrics the current planner operation channel-type.automower.inactiveReasonType.label = Inactive Reason channel-type.automower.inactiveReasonType.description = The current reason for being inactive channel-type.automower.inactiveReasonType.state.option.NONE = No inactive reason -channel-type.automower.inactiveReasonType.state.option.PLANNING = The mower is planning a path or a work area +channel-type.automower.inactiveReasonType.state.option.PLANNING = The Automower® is planning a path or a Work Area channel-type.automower.inactiveReasonType.state.option.SEARCHING_FOR_SATELLITES = Waiting for fix when using EPOS channel-type.automower.lastPollUpdateType.label = Last Poll Update channel-type.automower.lastPollUpdateType.description = The time when the binding polled the last update from the cloud -channel-type.automower.lastPositionType.label = Last GPS Position -channel-type.automower.lastPositionType.description = Last GPS position of the mower channel-type.automower.lastUpdateType.label = Last Update -channel-type.automower.lastUpdateType.description = The time when the mower sent the last update -channel-type.automower.messageCodeType.label = Code -channel-type.automower.messageCodeType.description = (Error) code of the event -channel-type.automower.messagePositionType.label = GPS Position -channel-type.automower.messagePositionType.description = GPS Position of the event (if available) -channel-type.automower.messageSeverityType.label = Severity -channel-type.automower.messageSeverityType.description = The severity of the event -channel-type.automower.messageTimeType.label = Timestamp -channel-type.automower.messageTimeType.description = Timestamp when the event occurred -channel-type.automower.messageType.label = Message -channel-type.automower.messageType.description = The Message +channel-type.automower.lastUpdateType.description = The time when the Automower® sent the last update +channel-type.automower.messageCodeType.label = Last Error Code +channel-type.automower.messageCodeType.description = The last error code +channel-type.automower.messagePositionType.label = Message GPS Position +channel-type.automower.messagePositionType.description = GPS position of the last event (if available) +channel-type.automower.messageSeverityType.label = Message Severity +channel-type.automower.messageSeverityType.description = The severity of the last error +channel-type.automower.messageTimeType.label = Last Error Time +channel-type.automower.messageTimeType.description = The time when the last error occurred +channel-type.automower.messageType.label = Last Error Message +channel-type.automower.messageType.description = The last error message channel-type.automower.modeType.label = Mode channel-type.automower.modeType.description = The current mode channel-type.automower.modeType.state.option.MAIN_AREA = Main Area @@ -111,26 +106,26 @@ channel-type.automower.modeType.state.option.SECONDARY_AREA = Secondary Area channel-type.automower.modeType.state.option.HOME = Home channel-type.automower.modeType.state.option.DEMO = Demo channel-type.automower.modeType.state.option.UNKNOWN = Unknown -channel-type.automower.nameType.label = Automower Name -channel-type.automower.nameType.description = The channel providing the automower name -channel-type.automower.nextStartType.label = Next Auto Start -channel-type.automower.nextStartType.description = The channel providing the time for the next auto start. If the mower is charging then the value is the estimated time when it will be leaving the charging station. If the mower is about to start now, the value is NULL. +channel-type.automower.nameType.label = Automower® Name +channel-type.automower.nameType.description = The name of the Automower® +channel-type.automower.nextStartType.label = Next Start +channel-type.automower.nextStartType.description = The time for the next scheduled start. If the Automower® is charging then the value is the estimated time when it will be leaving the charging station. If the Automower® is about to start now, the value is NULL channel-type.automower.overrideActionType.label = Override Action -channel-type.automower.overrideActionType.description = The channel providing an action that overrides current planner operation. +channel-type.automower.overrideActionType.description = The action that overrides the current planner operation channel-type.automower.park.label = Park with Duration -channel-type.automower.park.description = Park for a duration in minutes +channel-type.automower.park.description = Park the Automower® for the given duration, overriding the schedule channel-type.automower.parkUntilFurtherNotice.label = Park and Pause the Schedule -channel-type.automower.parkUntilFurtherNotice.description = Park and pause the mower schedule until manual resume +channel-type.automower.parkUntilFurtherNotice.description = Park the Automower® until it is started again by the start action/command or the schedule gets resumed channel-type.automower.parkUntilNextSchedule.label = Park Until Next Schedule -channel-type.automower.parkUntilNextSchedule.description = Park until next schedule +channel-type.automower.parkUntilNextSchedule.description = Park the Automower®, fully charge it and start afterwards according to the schedule channel-type.automower.pause.label = Pause -channel-type.automower.pause.description = Pause the mower now until manual resume +channel-type.automower.pause.description = Pause the Automower® at the current location until manual resume channel-type.automower.pollUpdateType.label = Poll Update -channel-type.automower.pollUpdateType.description = Poll mower status update from the cloud -channel-type.automower.positionType.label = GPS Position -channel-type.automower.positionType.description = A waypoint of the mower's activity +channel-type.automower.pollUpdateType.description = Poll Automower® status update from the cloud +channel-type.automower.positionType.label = Last GPS Position +channel-type.automower.positionType.description = Last GPS position of the Automower® channel-type.automower.restrictedReasonType.label = Restricted Reason -channel-type.automower.restrictedReasonType.description = The channel providing a reason that restrics current planner operation. +channel-type.automower.restrictedReasonType.description = The reason that restrics the current planner operation channel-type.automower.restrictedReasonType.state.option.NONE = No restricted reason channel-type.automower.restrictedReasonType.state.option.WEEK_SCHEDULE = Restricted by week schedule channel-type.automower.restrictedReasonType.state.option.PARK_OVERRIDE = Forced to park @@ -138,25 +133,26 @@ channel-type.automower.restrictedReasonType.state.option.SENSOR = Restricted by channel-type.automower.restrictedReasonType.state.option.DAILY_LIMIT = Restricted by daily limit channel-type.automower.restrictedReasonType.state.option.FOTA = Restricted by FOTA transfer channel-type.automower.restrictedReasonType.state.option.FROST = Restricted by frost sensor -channel-type.automower.restrictedReasonType.state.option.ALL_WORK_AREAS_COMPLETED = Restricted: All work areas completed +channel-type.automower.restrictedReasonType.state.option.ALL_WORK_AREAS_COMPLETED = Restricted: All Work Areas completed channel-type.automower.restrictedReasonType.state.option.EXTERNAL = Restricted by external reason channel-type.automower.resumeSchedule.label = Resume Schedule -channel-type.automower.resumeSchedule.description = Resume schedule +channel-type.automower.resumeSchedule.description = Resume the schedule of the Automower® channel-type.automower.settingCuttingHeightType.label = Cutting Height channel-type.automower.settingCuttingHeightType.description = Prescaled cutting height, range: 1-9 channel-type.automower.settingHeadlightModeType.label = Headlight Mode -channel-type.automower.settingHeadlightModeType.description = Information about headlights channel-type.automower.start.label = Start with Duration -channel-type.automower.start.description = Start for a duration in minutes +channel-type.automower.start.description = Start the Automower® for the given duration, overriding the schedule +channel-type.automower.startInWorkArea.label = Start in Work Area +channel-type.automower.startInWorkArea.description = Start the Automower® in the given Work Area, overriding the schedule. The Automower® will continue forever channel-type.automower.statCuttingBladeUsageTimeType.label = Cutting Blade Usage Time channel-type.automower.statCuttingBladeUsageTimeType.description = The time since the last reset of the cutting blade usage counter channel-type.automower.statDownTimeType.label = Down Time -channel-type.automower.statDownTimeType.description = The time the mower has been disconnected from the cloud -channel-type.automower.statDownTimeType.state.pattern = %1$tHh:%1$tMm:%1$tSs +channel-type.automower.statDownTimeType.description = The time the Automower® has been disconnected from the cloud +channel-type.automower.statDownTimeType.state.pattern = %1$tdd:%1$tHh:%1$tMm:%1$tSs channel-type.automower.statNumberOfChargingCyclesType.label = Number of Charging Cycles channel-type.automower.statNumberOfChargingCyclesType.description = Number of charging cycles channel-type.automower.statNumberOfCollisionsType.label = Number of Collisions -channel-type.automower.statNumberOfCollisionsType.description = The total number of collisions +channel-type.automower.statNumberOfCollisionsType.description = Total number of collisions channel-type.automower.statTotalChargingTimeType.label = Total Charging Time channel-type.automower.statTotalChargingTimeType.description = Total charging time channel-type.automower.statTotalCuttingPercentType.label = Relative Total Cutting Time @@ -172,8 +168,8 @@ channel-type.automower.statTotalSearchingPercentType.description = The total sea channel-type.automower.statTotalSearchingTimeType.label = Total Searching Time channel-type.automower.statTotalSearchingTimeType.description = The total searching time channel-type.automower.statUpTimeType.label = Up Time -channel-type.automower.statUpTimeType.description = The time the mower has been connected to the cloud -channel-type.automower.statUpTimeType.state.pattern = %1$tHh:%1$tMm:%1$tSs +channel-type.automower.statUpTimeType.description = The time the Automower® has been connected to the cloud +channel-type.automower.statUpTimeType.state.pattern = %1$tdd:%1$tHh:%1$tMm:%1$tSs channel-type.automower.stateType.label = State channel-type.automower.stateType.description = The current state channel-type.automower.stateType.state.option.UNKNOWN = Unknown @@ -181,7 +177,7 @@ channel-type.automower.stateType.state.option.NOT_APPLICABLE = N/A channel-type.automower.stateType.state.option.PAUSED = Paused by user channel-type.automower.stateType.state.option.IN_OPERATION = Working channel-type.automower.stateType.state.option.WAIT_UPDATING = Downloading new firmware -channel-type.automower.stateType.state.option.WAIT_POWER_UP = Booting mower +channel-type.automower.stateType.state.option.WAIT_POWER_UP = Booting Automower® channel-type.automower.stateType.state.option.RESTRICTED_NONE = No restricted reason channel-type.automower.stateType.state.option.RESTRICTED_WEEK_SCHEDULE = Restricted by week schedule channel-type.automower.stateType.state.option.RESTRICTED_PARK_OVERRIDE = Forced to park @@ -189,7 +185,7 @@ channel-type.automower.stateType.state.option.RESTRICTED_SENSOR = Restricted by channel-type.automower.stateType.state.option.RESTRICTED_DAILY_LIMIT = Restricted by daily limit channel-type.automower.stateType.state.option.RESTRICTED_FOTA = Restricted by FOTA transfer channel-type.automower.stateType.state.option.RESTRICTED_FROST = Restricted by frost sensor -channel-type.automower.stateType.state.option.RESTRICTED_ALL_WORK_AREAS_COMPLETED = Restricted: All work areas completed +channel-type.automower.stateType.state.option.RESTRICTED_ALL_WORK_AREAS_COMPLETED = Restricted: All Work Areas completed channel-type.automower.stateType.state.option.RESTRICTED_EXTERNAL = Restricted by external reason channel-type.automower.stateType.state.option.OFF = Off channel-type.automower.stateType.state.option.STOPPED = Stopped: Manual intervention required @@ -197,29 +193,47 @@ channel-type.automower.stateType.state.option.ERROR = Error channel-type.automower.stateType.state.option.FATAL_ERROR = Fatal error channel-type.automower.stateType.state.option.ERROR_AT_POWER_UP = Boot error channel-type.automower.workAreaIdType.label = Work Area Id -channel-type.automower.workAreaIdType.description = Id of the active work area +channel-type.automower.workAreaIdType.description = Id of the active Work Area channel-type.automower.workAreaType.label = Work Area Name -channel-type.automower.workAreaType.description = Name of the active work area +channel-type.automower.workAreaType.description = Name of the active Work Area channel-type.automower.workareaCuttingHeightType.label = Cutting Height -channel-type.automower.workareaCuttingHeightType.description = Cutting height of the work area, range: 0-100 +channel-type.automower.workareaCuttingHeightType.description = Cutting height of the Work Area, range: 0-100 channel-type.automower.workareaEnabledType.label = Work Area Enabled -channel-type.automower.workareaEnabledType.description = If the work area is enabled or disabled +channel-type.automower.workareaEnabledType.description = If the Work Area is enabled or disabled channel-type.automower.workareaIdType.label = Work Area Id channel-type.automower.workareaIdType.description = Id of the Work Area channel-type.automower.workareaLastTimeCompletedType.label = Last Time Completed -channel-type.automower.workareaLastTimeCompletedType.description = Timestamp when the work area was last completed. EPOS mowers and systematic mowing work areas only. +channel-type.automower.workareaLastTimeCompletedType.description = Timestamp when the Work Area was last completed. EPOS Automower® and systematic mowing Work Areas only channel-type.automower.workareaNameType.label = Work Area Name -channel-type.automower.workareaNameType.description = Name of the work area +channel-type.automower.workareaNameType.description = Name of the Work Area channel-type.automower.workareaProgressType.label = Work Area Progress -channel-type.automower.workareaProgressType.description = The progress on a work area. EPOS mowers and systematic mowing work areas only. +channel-type.automower.workareaProgressType.description = The progress on a Work Area. EPOS Automower® and systematic mowing Work Areas only channel-type.automower.zoneDirtyType.label = Stayout Zones Dirty Flag -channel-type.automower.zoneDirtyType.description = If the stay-out zones are synchronized with the Husqvarna cloud. If the map is dirty you can not enable or disable a stay-out zone. +channel-type.automower.zoneDirtyType.description = If the stay-out zones are synchronized with the Husqvarna cloud. If the map is dirty you can not enable or disable a stay-out zone channel-type.automower.zoneEnabledType.label = Stayout Zone Enabled -channel-type.automower.zoneEnabledType.description = If the Stayout zone is enabled, the Automower® will not access the zone +channel-type.automower.zoneEnabledType.description = If the stay-out zone is enabled, the Automower® will not access the zone channel-type.automower.zoneIdType.label = Stayout Zone Id -channel-type.automower.zoneIdType.description = Id of the Stayout zone +channel-type.automower.zoneIdType.description = Id of the stay-out zone channel-type.automower.zoneNameType.label = Stayout Zone Name -channel-type.automower.zoneNameType.description = The name of the Stayout zone +channel-type.automower.zoneNameType.description = The name of the stay-out zone + +# channel types + +channel-type.automower.settingHeadlightModeType.description = Headlight Mode + +# thing types config + +thing-type.config.automower.automower.pollingInterval.label = Polling Interval +thing-type.config.automower.automower.pollingInterval.description = How often the current Automower® state should be polled + +# channel group types + +channel-group-type.automower.positionsType.label = Positions + +# channel types + +channel-type.automower.lastPositionType.label = Last GPS Position +channel-type.automower.lastPositionType.description = Last GPS position of the mower # channel types @@ -238,7 +252,7 @@ channel-type.automower.plannerRestrictedReasonType.state.option.SENSOR = Restric channel-type.automower.plannerRestrictedReasonType.state.option.DAILY_LIMIT = Restricted by daily limit channel-type.automower.plannerRestrictedReasonType.state.option.FOTA = Restricted by FOTA transfer channel-type.automower.plannerRestrictedReasonType.state.option.FROST = Restricted by frost sensor -channel-type.automower.plannerRestrictedReasonType.state.option.ALL_WORK_AREAS_COMPLETED = Restricted: All work areas completed +channel-type.automower.plannerRestrictedReasonType.state.option.ALL_WORK_AREAS_COMPLETED = Restricted: All Work Areas completed channel-type.automower.plannerRestrictedReasonType.state.option.EXTERNAL = Restricted by external reason # channel types @@ -277,78 +291,78 @@ channel-type.automower.calendarTasksType.description = The channel providing a J # actions -action-input-duration-label = duration -action-input-duration-desc = The duration of the automower command in minutes -action-park-label = park the automower -action-park-desc = Parks the automower for a defined amount of time, overriding its schedule. +action-input-duration-min-label = duration (min) +action-input-duration-min-desc = The duration of the Automower® command in minutes +action-input-duration-min-opt-label = duration (min) +action-input-duration-min-opt-desc = The duration of the Automower® command in minutes. If duration is skipped the Automower® will continue forever +action-input-work-area-id-label = work area id +action-input-work-area-id-desc = Id of Work Area +action-park-label = park +action-park-desc = Park the Automower® for the given duration (minutes), overriding the schedule action-parkuntilfurthernotice-label = park until further notice -action-parkuntilfurthernotice-desc = Parks the automower until the schedule is resumed. +action-parkuntilfurthernotice-desc = Park the Automower® until it is started again by the start action/command or the schedule gets resumed action-parkuntilnextschedule-label = park until the next schedule -action-parkuntilnextschedule-desc = Parks the automower until the next schedule. -action-pause-label = pause the automower -action-pause-desc = Pauses the automower wherever it currently is. +action-parkuntilnextschedule-desc = Park the Automower®, fully charge it and start afterwards according to the schedule +action-pause-label = pause +action-pause-desc = Pause the Automower® at the current location until manual resume action-resumeschedule-label = resume the schedule -action-resumeschedule-desc = Resumes the schedule for the automower. -action-start-label = start the automower -action-start-desc = Starts the automower for a defined amount of time, overriding its schedule. +action-resumeschedule-desc = Resume the schedule of the Automower® +action-start-label = start +action-start-desc = Start the Automower® for the given duration (minutes), overriding the schedule +action-start-in-work-area-label = start in work area +action-start-in-work-area-desc = Start the Automower® in the given Work Area for the given duration (minutes), overriding the schedule action-confirm-error-label = confirm error -action-confirm-error-desc = Confirm current non fatal error. +action-confirm-error-desc = Confirm current non fatal error action-reset-cutting-blade-usage-time-label = reset cutting blade usage time -action-reset-cutting-blade-usage-time-desc = Reset the cutting blade usage time. -action-set-settings-label = update automower settings -action-set-settings-desc = Sends a set of Settings to the automower. +action-reset-cutting-blade-usage-time-desc = Reset the cutting blade usage time +action-set-settings-label = update settings +action-set-settings-desc = Sends a set of Settings to the Automower® action-input-cutting-height-label = cutting height -action-input-cutting-height-desc = Prescaled cutting height, Range: 1-9. +action-input-cutting-height-desc = Prescaled cutting height, Range: 1-9 action-input-headlight-mode-label = headlight mode -action-input-headlight-mode-desc = Headlight Mode (ALWAYS_ON, ALWAYS_OFF, EVENING_ONLY, EVENING_AND_NIGHT). +action-input-headlight-mode-desc = Headlight Mode (ALWAYS_ON, ALWAYS_OFF, EVENING_ONLY, EVENING_AND_NIGHT) action-set-work-area-label = update work area configuration -action-set-work-area-desc = Update the work area configuration of the specified area. -action-input-workarea-id-label = work area id -action-input-workarea-id-desc = Id of WorkArea to be updated. +action-set-work-area-desc = Update the Work Area configuration action-input-enable-label = enable -action-input-enable-desc = Enable or disable WorkArea. +action-input-enable-desc = Enable or disable Work Area / stay-out zone action-input-cutting-height-label = cutting height -action-input-cutting-height-desc = Cutting height of the WorkArea. -action-set-stayoutzone-label = update stayout zone configuration -action-set-stayoutzone-desc = Update the stayout zone configuration of the specified zone. +action-input-cutting-height-desc = Cutting height of the Work Area, range: 0-100 +action-set-stayoutzone-label = update stay-out zone configuration +action-set-stayoutzone-desc = Update the stay-out zone configuration of the specified zone action-input-zone-id-label = zone id -action-input-zone-id-desc = Id of the stayout zone to be updated. -action-input-enable-label = enable -action-input-enable-desc = Enable or disable stayout zone. +action-input-zone-id-desc = Id of the stay-out zone to be updated action-set-calendartask-label = update calendar task configuration -action-set-calendartask-desc = Update the calendar task configuration of the mower or specified work area. -action-input-workarea-id-label = work area id -action-input-workarea-id-desc = Id of WorkArea where the calendar task configuration shall be updated (null if mower doesn't support work areas). +action-set-calendartask-desc = Update the calendar task configuration of the Automower® or specified Work Area action-input-start-label = start -action-input-start-desc = Start time relative to midnight (minutes). +action-input-start-desc = Start time relative to midnight (minutes) action-input-duration-label = duration -action-input-duration-desc = Duration time (minutes). +action-input-duration-desc = Duration time (minutes) action-input-monday-label = monday[] -action-input-monday-desc = Enabled on Mondays (array with the number of calendar tasks). +action-input-monday-desc = Enabled on Mondays (array with the number of calendar tasks) action-input-tuesday-label = tuesday[] -action-input-tuesday-desc = Enabled on Tuesdays (array with the number of calendar tasks). +action-input-tuesday-desc = Enabled on Tuesdays (array with the number of calendar tasks) action-input-wednesday-label = wednesday[] -action-input-wednesday-desc = Enabled on Wednesdays (array with the number of calendar tasks). +action-input-wednesday-desc = Enabled on Wednesdays (array with the number of calendar tasks) action-input-thursday-label = thursday[] -action-input-thursday-desc = Enabled on Thursdays (array with the number of calendar tasks). +action-input-thursday-desc = Enabled on Thursdays (array with the number of calendar tasks) action-input-friday-label = friday[] -action-input-friday-desc = Enabled on Fridays (array with the number of calendar tasks). +action-input-friday-desc = Enabled on Fridays (array with the number of calendar tasks) action-input-saturday-label = saturday -action-input-saturday-desc = Enabled on Saturdays (array with the number of calendar tasks). +action-input-saturday-desc = Enabled on Saturdays (array with the number of calendar tasks) action-input-sunday-label = sunday -action-input-sunday-desc = Enabled on Sunday (array with the number of calendar tasks). +action-input-sunday-desc = Enabled on Sunday (array with the number of calendar tasks) action-poll-label = poll -action-poll-desc = Poll mower status update from the cloud +action-poll-desc = Poll Automower® status update from the cloud # status messages comm-error-httpclient-init-failed = Unable to initialize http client -comm-error-query-mowers-failed = Unable to query registered mowers -comm-error-query-mower-failed = Unable to query the automower status -comm-error-send-mower-command-failed = Unable to send automower command -comm-error-mower-not-connected-to-cloud = Automower not connected to the cloud -conf-error-no-app-key = Cannot connect to Automower bridge as no app key is available in the configuration -conf-error-no-app-secret = Cannot connect to Automower bridge as no app secret is available in the configuration +comm-error-query-mowers-failed = Unable to query registered Automower® +comm-error-query-mower-failed = Unable to query the Automower® status +comm-error-send-mower-command-failed = Unable to send Automower® command +comm-error-mower-not-connected-to-cloud = Automower® not connected to the cloud +conf-error-no-app-key = Cannot connect to Automower® bridge as no app key is available in the configuration +conf-error-no-app-secret = Cannot connect to Automower® bridge as no app secret is available in the configuration conf-error-invalid-polling-interval = Invalid polling interval specified. The polling interval has to be >= 1 -conf-error-no-mower-id = No Automower ID specified. Unable to communicate with the mower without an ID -conf-error-no-bridge = No valid bridge for the automower is available +conf-error-no-mower-id = No Automower® ID specified. Unable to communicate with the Automower® without an ID +conf-error-no-bridge = No valid bridge for the Automower® is available diff --git a/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/thing/thing-types.xml index 6c5384afca..c96bd0f48b 100644 --- a/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/thing/thing-types.xml @@ -6,30 +6,31 @@ - - The bridge to communicate with the Automower Connect API + + The bridge to communicate with the Automower® Connect API WebService - The Application Key is required to communicate with the Automower Connect API at - https://developer.husqvarnagroup.cloud/. It can be obtained by - registering an Application on the Husqvarna Website. - This application also needs to be connected to the - "Authentication API" and the "Automower Connect API" + The Application Key is required to communicate with the Automower® Connect API. + It can be obtained by + registering an Application on https://developer.husqvarnagroup.cloud/. + This application needs to be connected to the + "Authentication API" and the "Automower® Connect + API". https://developer.husqvarnagroup.cloud/docs/getting-started + - The Application Secret is required to communicate with the Automower Connect API at - https://developer.husqvarnagroup.cloud/. It can be obtained by - registering an Application on the Husqvarna Website. + The Application Secret is required to communicate with the Automower® Connect API. See "Application + Key" for further details - 3600 - How often the available automowers should be queried in seconds + 300 + How often the current Automower® states should be polled using REST API @@ -41,7 +42,7 @@ - + An automatic lawn mower LawnMower @@ -50,7 +51,6 @@ - @@ -67,22 +67,18 @@ N/A N/A N/A - 1 + 2 - - The Id of an automower as used by the Automower Connect Api to identify a mower - - - - 600 - How often the current automower state should be polled in seconds + + The Id of an Automower® as used by the Automower® Connect API to identify a Automower®. This is + automatically filled when the thing is discovered. - Time zone of the mower (e.g. Europe/Berlin) + Time zone of the Automower® (e.g. Europe/Berlin). Default is the time zone configured in openHAB @@ -114,6 +110,9 @@ + @@ -168,18 +167,6 @@ --> - - - - - - @@ -213,16 +200,13 @@ - + + + + + + + @@ -230,6 +214,7 @@ + @@ -240,8 +225,8 @@ String - - The channel providing the automower name + + The name of the Automower® @@ -285,7 +270,7 @@ - + @@ -302,7 +287,7 @@ - + @@ -310,7 +295,7 @@ - + @@ -324,21 +309,21 @@ Number - Id of the active work area + Id of the active Work Area String - Name of the active work area + Name of the active Work Area DateTime - The time when the mower sent the last update + The time when the Automower® sent the last update @@ -352,13 +337,13 @@ Switch - Poll mower status update from the cloud + Poll Automower® status update from the cloud Number:Dimensionless - The battery level of the mower at the time of last update + The battery state of charge in percent Measurement Energy @@ -369,48 +354,50 @@ Number - The error code at the time of last update + The current error code String - The error message at the time of last update + The current error message DateTime - The time when the error occurred + The time when the current error occurred Switch - If the mower has an Error Code this attribute states if the error is confirmable + If the Automower® has an error, this attribute states if the error is confirmable DateTime - - The channel providing the time for the next auto start. If the mower is charging then the value is the - estimated time when it will be leaving the charging station. If the mower is about to start now, the value is NULL. + + The time for the next scheduled start. + If the Automower® is charging then the value is the estimated time + when it will be leaving the charging station. + If the Automower® is about to start now, the value is NULL String - The channel providing an action that overrides current planner operation. + The action that overrides the current planner operation String - The channel providing a reason that restrics current planner operation. + The reason that restrics the current planner operation @@ -420,7 +407,7 @@ - + @@ -429,7 +416,15 @@ Number - The channel providing an external reason that restrics current planner operation. + An external reason set by i.e. Google Assistant or Amazon Alexa that restrics the current planner + operation + + + + + Location + + Last GPS position of the Automower® @@ -442,7 +437,6 @@ String - Information about headlights @@ -455,8 +449,8 @@ Number:Time - The time the mower has been disconnected from the cloud - + The time the Automower® has been disconnected from the cloud + @@ -469,7 +463,7 @@ Number - The total number of collisions + Total number of collisions @@ -525,22 +519,8 @@ Number:Time - The time the mower has been connected to the cloud - - - - - Location - - Last GPS position of the mower - - - - - Location - - A waypoint of the mower's activity - + The time the Automower® has been connected to the cloud + @@ -603,28 +583,28 @@ Switch If the stay-out zones are synchronized with the Husqvarna cloud. If the map is dirty you can not enable - or disable a stay-out zone. + or disable a stay-out zone String - Id of the Stayout zone + Id of the stay-out zone String - The name of the Stayout zone + The name of the stay-out zone Switch - If the Stayout zone is enabled, the Automower® will not access the zone + If the stay-out zone is enabled, the Automower® will not access the zone @@ -638,70 +618,70 @@ String - Name of the work area + Name of the Work Area Number:Dimensionless - Cutting height of the work area, range: 0-100 + Cutting height of the Work Area, range: 0-100 Switch - If the work area is enabled or disabled + If the Work Area is enabled or disabled Number:Dimensionless - The progress on a work area. EPOS mowers and systematic mowing work areas only. + The progress on a Work Area. EPOS Automower® and systematic mowing Work Areas only DateTime - Timestamp when the work area was last completed. EPOS mowers and systematic mowing work areas only. + Timestamp when the Work Area was last completed. EPOS Automower® and systematic mowing Work Areas only DateTime - - Timestamp when the event occurred + + The time when the last error occurred Number - - (Error) code of the event + + The last error code String - - The Message + + The last error message String - - The severity of the event + + The severity of the last error Location - - GPS Position of the event (if available) + + GPS position of the last event (if available) @@ -709,38 +689,46 @@ Number - Start for a duration in minutes + Start the Automower® for the given duration, overriding the schedule + + Number + + Start the Automower® in the given Work Area, overriding the schedule. The Automower® will continue + forever + + + Switch - Resume schedule + Resume the schedule of the Automower® Switch - Pause the mower now until manual resume + Pause the Automower® at the current location until manual resume Number - Park for a duration in minutes + Park the Automower® for the given duration, overriding the schedule Switch - Park until next schedule + Park the Automower®, fully charge it and start afterwards according to the schedule Switch - Park and pause the mower schedule until manual resume + Park the Automower® until it is started again by the start action/command or the schedule gets resumed diff --git a/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/update/update.xml b/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/update/update.xml index 8939c5570b..045f7d3376 100644 --- a/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/update/update.xml +++ b/bundles/org.openhab.binding.automower/src/main/resources/OH-INF/update/update.xml @@ -158,24 +158,352 @@ - + automower:start - + automower:resumeSchedule - + automower:pause - + automower:park - + automower:parkUntilNextSchedule - + automower:parkUntilFurtherNotice + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + automower:messageTimeType + + + automower:messageCodeType + + + automower:messageType + + + automower:messageSeverityType + + + automower:messagePositionType + + + + + automower:startInWorkArea + +