Skip to content
 
 

Repository files navigation

Logo

ioBroker.husqvarna-automower-connect

NPM version Downloads

Current version in stable repository

NPM

Test and Release Donate Buy me a coffee

husqvarna-automower-connect adapter for ioBroker

This adapter fetches data from your Husqvarna lawn mower from https://developer.husqvarnagroup.cloud via the "new" WebSocket connection and works with the Automower Connect API v1.0.0/OAS 3.0.

This is a complete, actively maintained fork of ice987987/ioBroker.husqvarna-automower. It fixes several write commands (START, STARTINWORKAREA, PARK, CUTTINGHEIGHT, DATETIME, HEADLIGHT) that were rejected by the API due to a malformed request body, and adds full coverage of the official API: error confirmation, per-work-area cutting height/enabled, stay-out zone enable/disable, and the error/event message history. Full credit to ice987987 for the original adapter this project is built on.

Disclaimer

All product and company names or logos are trademarks™ or registered® trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them or any associated subsidiaries! This personal project is maintained in spare time and has no business goal. Husqvarna Automower is a trademark of Husqvarna Group.

Installation requirements

  • node.js >= v22 is required
  • ioBroker.js-controller >=6.0.11 is required
  • ioBroker.admin >= v7.8.23 is required
  • This adapter uses the Husqvarna Automower Connect API to request data (via WebSocket) and send commands (via REST API) for your Husqvarna lawn mower.

Please create an account and generate your personal Application key and Application secret by following these instructions via https://developer.husqvarnagroup.cloud. (Redirect URLs could be http://localhost:8080)

Control

You can send the following values to your Husqvarna lawn mower:

  • .ACTIONS.PAUSE: pause mower
  • .ACTIONS.PARKUNTILNEXTSCHEDULE: park mower until next scheduled run
  • .ACTIONS.PARKUNTILFURTHERNOTICE: park mower until further notice, overriding schedule
  • .ACTIONS.park.PARK: park mower for a duration of time .ACTIONS.park.parkTime (in minutes), overriding schedule
  • .ACTIONS.RESUMESCHEDULE: resume mower according to schedule
  • .ACTIONS.start.START: start mower and cut for a duration of time .ACTIONS.start.startTime (in minutes), overriding schedule
  • .ACTIONS.startInWorkArea.STARTINWORKAREA: start mower and cut for a duration of time .ACTIONS.startInWorkArea.duration (in minutes, optional, if zero (0) the override will be forever), in Area with ID .ACTIONS.startInWorkArea.workAreaId1
  • .ACTIONS.CUTTINGHEIGHT: Update cuttingHeight and get current status23
  • .ACTIONS.DATETIME: Date and time in seconds from 1970-01-01 in the mower. The timestamp is used by the mower to trigger the schedule. At the moment you can not get the timestamp from the mower.
  • .ACTIONS.HEADLIGHT: Update headlight and get current status1
  • .ACTIONS.schedule.SET: Update mower schedule with .ACTIONS.schedule.[i].start (minutes after midnight), .ACTIONS.schedule.[i].duration (in minutes), .ACTIONS.schedule.[i].monday, .ACTIONS.schedule.[i].tuesday, .ACTIONS.schedule.[i].wednesday, .ACTIONS.schedule.[i].thursday, .ACTIONS.schedule.[i].friday, .ACTIONS.schedule.[i].saturday, .ACTIONS.schedule.[i].sunday and .ACTIONS.schedule.[i].workAreaId and get current status 2
  • .ACTIONS.REFRESHSTATISTICS: Refresh statistic values outside the regular configured schedule
  • .ACTIONS.RESETCUTTINGBLADEUSAGETIME: Reset the cutting blade usage time counter (.statistics.cuttingBladeUsageTime). Same function as in the Automower Connect app, use after changing the blades.
  • .ACTIONS.CONFIRMERROR: Confirm a non-fatal mower error (only available if .capabilities.canConfirmError is true, and only works while .mower.isErrorConfirmable is true)
  • .ACTIONS.workAreaSettings.APPLYWORKAREASETTINGS: Update .cuttingHeight (0-100%) and/or .enabled for the work area given by .ACTIONS.workAreaSettings.workAreaId1
  • .ACTIONS.stayOutZoneSettings.APPLYSTAYOUTZONESETTINGS: Update .enabled for the stay-out zone given by .ACTIONS.stayOutZoneSettings.zoneId (not possible while .stayOutZones.dirty is true)4

Available values (readonly)

You get the following values from your Husqvarna lawn mower:

  • .battery.batteryPercent: Information about the battery in the Automower.
  • .capabilities.canConfirmError: If the Automower supports the command confirm error. The error also needs to be confirmable.
  • .capabilities.headlights: If the Automower supports headlights. If false, no headlights are available.
  • .capabilities.position: If the Automower supports GPS position. If false, no positions are available.
  • .capabilities.stayOutZones: If the Automower supports stay-out zones. If false, no stay-out zones are available.
  • .capabilities.workAreas: If the Automower supports work areas. If false, no work areas are avalilable.
  • .metadata.connected: Is the mower currently connected to the cloud. The mower needs to be connected to send command to the mower.
  • .metadata.statusTimestamp: Timestamp for the last status update in milliseconds since 1970-01-01T00:00:00 in UTC time. NOTE! This timestamp is generated in the backend and not from the Mower.
  • .mower.mode: Information about the mowers current mode.
  • .mower.activity: Information about the mowers current status.
  • .mower.inactiveReason: Inactive reason
  • .mower.state: Information about the mowers current status.
  • .mower.workAreaId: Current work area id. If the mower supports work areas and the mower is working on a work area. If no current work area is selected this attribute is not set.
  • .mower.errorCode: Information about the mowers current error status.
  • .mower.errorTimestamp: Timestamp for the last error code in milliseconds since 1970-01-01T00:00:00 in local time. NOTE! This timestamp is in local time for the mower and is coming directly from the mower.
  • .mower.isErrorConfirmable: If the mower has an errorCode this attribute state if the error is confirmable..
  • .planner.override: The Planner has an override feature, which can be used to override the operation decided by the Calendar. There is room for one override at a time, and it occurs from now and for a duration of time.
  • .planner.nextStartTimestamp: Timestamp for the next auto start in milliseconds since 1970-01-01T00:00:00 in local time. If the mower is charging then the value is the estimated time when it will be leaving the charging station. If the value is 0 then the mower should start now. NOTE! This timestamp is in local time for the mower and is coming directly from the mower.
  • .planner.restrictedReason: Restricted reason.
  • .planner.externalReason: External reason set by i.e. IFTTT, Google Assistant or Amazon Alexa. Ranges: 1000 to 1999: Google Assistant; 2000 to 2999:Amazon Alexa; 3000 to 3999: Developer Portal; 4000 to 4999: IFTTT, Wildlife consideration - 4000, Frost & rain guard - 4001 and Calendar connection - 4002; 100000 to 199 999: IFTTT applets; 200000 to 299 999: Developer Portal.
  • .positions.latitude: Position latitude5
  • .positions.longitude: Position longitude5
  • .positions.latlong: Position "latitude;longitude"5
  • .positions.positions: Positions5
  • .stayOutZones.dirty: 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.1
  • .stayOutZones.zones: List of all stay-out zones for the Automower.1
  • .statistics.cuttingBladeUsageTime: The number of seconds since the last reset of the cutting blade usage counter.1
  • .statistics.numberOfChargingCycles: Number of charging cycles.1
  • .statistics.numberOfCollisions: The total number of collisions.1
  • .statistics.totalChargingTime: Total charging time in seconds.1
  • .statistics.totalCuttingTime: Total cutting time in seconds.1
  • .statistics.totalDriveDistance: Total driven distance in meters. It's a calculated value based on totalRunningTime multiply with average speed for the mower depending on the model.1
  • .statistics.totalRunningTime: The total running time in seconds. (the wheel motors have been running)1
  • .statistics.totalSearchingTime: The total searching time in seconds.1
  • .system.id: Device ID
  • .system.model: The model name of the Automower
  • .system.name: The name given to the Automower by the user when pairing the Automower
  • .system.serialNumber: The serial number for the Automower
  • .system.type: Device type
  • .workAreas.[workAreaId].workAreaId: Work area ID1
  • .workAreas.[workAreaId].name: Name of the work area1
  • .workAreas.[workAreaId].cuttingHeight: Cutting height in percent (0 ... 100%)1
  • .workAreas.[workAreaId].enabled: If the work area is enabled or disabled.1
  • .workAreas.[workAreaId].progress: The progrss on a work are. Only available for EPOS mowers and systematic mowing work areas.1
  • .workAreas.[workAreaId].lastTimeCompleted: Timestamp in seconds from 1970-01-01 when the work area was last completed. The timestamp is in local time on the mower. Only available for EPOS mowers and systematic mowing work areas.
  • .workAreas.[workAreaId].lastTimeAbandoned: Timestamp in seconds from 1970-01-01 when the work area was last abandoned. Only available for EPOS mowers and systematic mowing work areas.
  • .workAreas.[workAreaId].type: Type of the work area (random or systematic mowing).
  • .workAreas.[workAreaId].useGlobalCuttingHeight: If true, the global cutting height setting is used instead of .cuttingHeight of this work area.
  • .workAreas.[workAreaId].orientation: Configured mowing orientation in degrees. Only for systematic mowing work areas.
  • .workAreas.[workAreaId].orientationShift: Configured shift added between mowing sessions in degrees. Only for systematic mowing work areas.
  • .workAreas.[workAreaId].currentOrientation: Current mowing orientation in degrees. Only for systematic mowing work areas.
  • .messages.messages: Full list of error/event messages as returned by the API (JSON array, latest first, max. ~1000 entries).
  • .messages.lastTime, .messages.lastCode, .messages.lastSeverity, .messages.lastLatitude, .messages.lastLongitude: Convenience states with the most recent message, updated both via polling and live via the WebSocket message push event.

ioBroker.vis bindings

the following code can be used for html-bindings in adapter ioBroker.vis to translate the status description and error codes into text for better visualization:

  • Datapoint husqvarna-automower-connect.0.[mowerID from DP .system.id].mower.errorCode:

    (EN)

    {value1:husqvarna-automower-connect.0.[mowerID from DP .system.id].mower.errorCode;value1 === "0" ? "Unexpected error" :: (value1 === "1" ? "Outside working area" :: (value1 === "2" ? "No loop signal" :: (value1 === "3" ? "Wrong loop signal" :: (value1 === "4" ? "Loop sensor problem, front" :: (value1 === "5" ? "Loop sensor problem, rear" :: (value1 === "6" ? "Loop sensor problem, left" :: (value1 === "7" ? "Loop sensor problem, right" :: (value1 === "8" ? "Wrong PIN code" :: (value1 === "9" ? "Trapped" :: (value1 === "10" ? "Upside down" :: (value1 === "11" ? "Low battery" :: (value1 === "12" ? "Empty battery" :: (value1 === "13" ? "No drive" :: (value1 === "14" ? "Mower lifted" :: (value1 === "15" ? "Lifted" :: (value1 === "16" ? "Stuck in charging station" :: (value1 === "17" ? "Charging station blocked" :: (value1 === "18" ? "Collision sensor problem, rear" :: (value1 === "19" ? "Collision sensor problem, front" :: (value1 === "20" ? "Wheel motor blocked, right" :: (value1 === "21" ? "Wheel motor blocked, left" :: (value1 === "22" ? "Wheel drive problem, right" :: (value1 === "23" ? "Wheel drive problem, left" :: (value1 === "24" ? "Cutting system blocked" :: (value1 === "25" ? "Cutting system blocked" :: (value1 === "26" ? "Invalid sub-device combination" :: (value1 === "27" ? "Settings restored" :: (value1 === "28" ? "Memory circuit problem" :: (value1 === "29" ? "Slope too steep" :: (value1 === "30" ? "Charging system problem" :: (value1 === "31" ? "STOP button problem" :: (value1 === "32" ? "Tilt sensor problem" :: (value1 === "33" ? "Mower tilted" :: (value1 === "34" ? "Cutting stopped - slope too steep" :: (value1 === "35" ? "Wheel motor overloaded, right" :: (value1 === "36" ? "Wheel motor overloaded, left" :: (value1 === "37" ? "Charging current too high" :: (value1 === "38" ? "Electronic problem" :: (value1 === "39" ? "Cutting motor problem" :: (value1 === "40" ? "Limited cutting height range" :: (value1 === "41" ? "Unexpected cutting height adj" :: (value1 === "42" ? "Limited cutting height range" :: (value1 === "43" ? "Cutting height problem, drive" :: (value1 === "44" ? "Cutting height problem, curr" :: (value1 === "45" ? "Cutting height problem, dir" :: (value1 === "46" ? "Cutting height blocked" :: (value1 === "47" ? "Cutting height problem" :: (value1 === "48" ? "No response from charger" :: (value1 === "49" ? "Ultrasonic problem" :: (value1 === "50" ? "Guide 1 not found" :: (value1 === "51" ? "Guide 2 not found" :: (value1 === "52" ? "Guide 3 not found" :: (value1 === "53" ? "GPS navigation problem" :: (value1 === "54" ? "Weak GPS signal" :: (value1 === "55" ? "Difficult finding home" :: (value1 === "56" ? "Guide calibration accomplished" :: (value1 === "57" ? "Guide calibration failed" :: (value1 === "58" ? "Temporary battery problem" :: (value1 === "59" ? "Temporary battery problem" :: (value1 === "60" ? "Temporary battery problem" :: (value1 === "61" ? "Temporary battery problem" :: (value1 === "62" ? "Temporary battery problem" :: (value1 === "63" ? "Temporary battery problem" :: (value1 === "64" ? "Temporary battery problem" :: (value1 === "65" ? "Temporary battery problem" :: (value1 === "66" ? "Battery problem" :: (value1 === "67" ? "Battery problem" :: (value1 === "68" ? "Temporary battery problem" :: (value1 === "69" ? "Alarm! Mower switched off" :: (value1 === "70" ? "Alarm! Mower stopped" :: (value1 === "71" ? "Alarm! Mower lifted" :: (value1 === "72" ? "Alarm! Mower tilted" :: (value1 === "73" ? "Alarm! Mower in motion" :: (value1 === "74" ? "Alarm! Outside geofence" :: (value1 === "75" ? "Connection changed" :: (value1 === "76" ? "Connection NOT changed" :: (value1 === "77" ? "Com board not available" :: (value1 === "78" ? "Slipped - Mower has Slipped. Situation not solved with moving pattern" :: (value1 === "79" ? "Invalid battery combination - Invalid combination of different battery types." :: (value1 === "80" ? "Cutting system imbalance --Warning--" :: (value1 === "81" ? "Safety function faulty" :: (value1 === "82" ? "Wheel motor blocked, rear right" :: (value1 === "83" ? "Wheel motor blocked, rear left" :: (value1 === "84" ? "Wheel drive problem, rear right" :: (value1 === "85" ? "Wheel drive problem, rear left" :: (value1 === "86" ? "Wheel motor overloaded, rear right" :: (value1 === "87" ? "Wheel motor overloaded, rear left" :: (value1 === "88" ? "Angular sensor problem" :: (value1 === "89" ? "Invalid system configuration" :: (value1 === "90" ? "No power in charging station" :: (value1 === "91" ? "Switch cord problem" :: (value1 === "92" ? "Work area not valid" :: (value1 === "93" ? "No accurate position from satellites" :: (value1 === "94" ? "Reference station communication problem" :: (value1 === "95" ? "Folding sensor activated" :: (value1 === "96" ? "Right brush motor overloaded" :: (value1 === "97" ? "Left brush motor overloaded" :: (value1 === "98" ? "Ultrasonic Sensor 1 defect" :: (value1 === "99" ? "Ultrasonic Sensor 2 defect" :: (value1 === "100" ? "Ultrasonic Sensor 3 defect" :: (value1 === "101" ? "Ultrasonic Sensor 4 defect" :: (value1 === "102" ? "Cutting drive motor 1 defect" :: (value1 === "103" ? "Cutting drive motor 2 defect" :: (value1 === "104" ? "Cutting drive motor 3 defect" :: (value1 === "105" ? "Lift Sensor defect" :: (value1 === "106" ? "Collision sensor defect" :: (value1 === "107" ? "Docking sensor defect" :: (value1 === "108" ? "Folding cutting deck sensor defect" :: (value1 === "109" ? "Loop sensor defect" :: (value1 === "110" ? "Collision sensor error" :: (value1 === "111" ? "No confirmed position" :: (value1 === "112" ? "Cutting system major imbalance" :: (value1 === "113" ? "Complex working area" :: (value1 === "114" ? "Too high discharge current" :: (value1 === "115" ? "Too high internal current" :: (value1 === "116" ? "High charging power loss" :: (value1 === "117" ? "High internal power loss" :: (value1 === "118" ? "Charging system problem" :: (value1 === "119" ? "Zone generator problem" :: (value1 === "120" ? "Internal voltage error" :: (value1 === "121" ? "High internal temerature" :: (value1 === "122" ? "CAN error" :: (value1 === "123" ? "Destination not reachable" :: (value1 === "124" ? "Destination blocked" :: (value1 === "125" ? "Battery needs replacement" :: (value1 === "126" ? "Battery near end of life" :: (value1 === "127" ? "Battery problem" :: (value1 === "128" ? "Multiple reference stations detected" :: (value1 === "129" ? "Auxiliary cutting means blocked" :: (value1 === "130" ? "Imbalanced auxiliary cutting disc detected" :: (value1 === "131" ? "Lifted in link arm" :: (value1 === "132" ? "EPOS accessory missing" :: (value1 === "133" ? "Bluetooth com with CS failed" :: (value1 === "134" ? "Invalid SW configuration" :: (value1 === "135" ? "Radar problem" :: (value1 === "136" ? "Work area tampered" :: (value1 === "137" ? "High temperature in cutting motor, right" :: (value1 === "138" ? "High temperature in cutting motor, center" :: (value1 === "139" ? "High temperature in cutting motor, left" :: (value1 === "141" ? "Wheel brush motor problem" :: (value1 === "143" ? "Accessory power problem" :: (value1 === "144" ? "Boundary wire problem" :: (value1 === "701" ? "Connectivity problem" :: (value1 === "702" ? "Connectivity settings restored" :: (value1 === "703" ? "Connectivity problem" :: (value1 === "704" ? "Connectivity problem" :: (value1 === "705" ? "Connectivity problem" :: (value1 === "706" ? "Poor signal quality" :: (value1 === "707" ? "SIM card requires PIN" :: (value1 === "708" ? "SIM card locked" :: (value1 === "709" ? "SIM card not found" :: (value1 === "710" ? "SIM card locked" :: (value1 === "711" ? "SIM card locked" :: (value1 === "712" ? "SIM card locked" :: (value1 === "713" ? "Geofence problem" :: (value1 === "714" ? "Geofence problem" :: (value1 === "715" ? "Connectivity problem" :: (value1 === "716" ? "Connectivity problem" :: (value1 === "717" ? "SMS could not be sent" :: (value1 === "724" ? "Communication circuit board SW must be updated" :: "errorCode #" + value1 + " unknown")))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))}
    
  • Datapoint husqvarna-automower-connect.0.[mowerID from DP .system.id].mower.activity:

    (EN)

    {value1:husqvarna-automower-connect.0.[mowerID from DP .system.id].mower.activity;value1 === "UNKNOWN" ? "Unknown activity" :: (value1 === "NOT_APPLICABLE" ? "Manual start required in mower." :: (value1 === "MOWING" ? "Mower is mowing lawn. If in demo mode the blades are not in operation." :: (value1 === "GOING_HOME" ? "Mower is going home to the charging station." :: (value1 === "CHARGING" ? "Mower is charging in station due to low battery." :: (value1 === "LEAVING" ? "Mower is leaving the charging station." :: (value1 === "PARKED_IN_CS" ? "Mower is parked in charging station." :: (value1 === "STOPPED_IN_GARDEN" ? "Mower has stopped. Needs manual action to resume." :: "activity #" + value1 + " unknown")))))))}
    
  • Datapoint husqvarna-automower-connect.0.[mowerID from DP .system.id].mower.mode:

    (EN)

    {value1:husqvarna-automower-connect.0.[mowerID from DP .system.id].mower.mode;value1 === "MAIN_AREA" ? "Mower will mow until low battery. Go home and charge. Leave and continue mowing. Week schedule is used. Schedule can be overridden with forced park or forced mowing." :: (value1 === "DEMO" ? "No blade operation - Mower will mow until low battery. Go home and charge. Leave and continue mowing. Week schedule is used. Schedule can be overridden with forced park or forced mowing." :: (value1 === "SECONDARY_AREA" ? "Mower is in secondary area. Schedule is overridden with forced park or forced mowing. Mower will mow for request time or untill the battery runs out." :: (value1 === "HOME" ? "Mower goes home and parks forever. Week schedule is not used. Cannot be overridden with forced mowing." :: (value1 === "UNKNOWN" ? "Unknown mode" :: "mode #" + value1 + " unknown"))))}
    
  • Datapoint husqvarna-automower-connect.0.[mowerID from DP .system.id].mower.state:

    (EN)

    {value1:husqvarna-automower-connect.0.[mowerID from DP .system.id].mower.state;value1 === "UNKNOWN" ? "Unknown state" :: (value1 === "NOT_APPLICABLE" ? "Not Applicable" :: (value1 === "PAUSED" ? "Mower has been paused by user." :: (value1 === "IN_OPERATION" ? "See value in activity for status." :: (value1 === "WAIT_UPDATING" ? "Mower is downloading new firmware." :: (value1 === "WAIT_POWER_UP" ? "Mower is performing power up tests." :: (value1 === "RESTRICTED" ? "Mower can currently not mow due to week calender, or override park." :: (value1 === "OFF" ? "Mower is turned off." :: (value1 === "STOPPED" ? "Mower is stopped, requires manual action." :: (value1 === "ERROR" ? "An error has occurred. Check errorCode. Mower requires manual action." :: (value1 === "FATAL_ERROR" ? "An fatal error has occurred. Check errorCode. Mower requires manual action." :: (value1 === "ERROR_AT_POWER_UP" ? "An error at power up has occurred. Check errorCode. Mower requires manual action." :: "state #" + value1 + " unknown")))))))))))}
    

Script for statistics

(initial script by @ArnoD15, modified by @ice987987)

The following value will be calculated:

  • Charging Time Today and Charging Time Total
  • Mowing Time Today and Mowing Time Total
  • Driven Distance Today and Driven Distance Total
  • Distance between mower and charging station
  • Convert start and end time of schedules to minutes and hours
  • Create/update google maps link
  • Possibility to park mower during rain until next schedule
  • Calculation of remaining knive running time in percent

For use, copy the following code into a new Javascript-Script and fill in the following variables: instance, pathLevel1, pathLevel2, mowerID, sID_RainSensor and targetBladeCuttingTime in section USER CONFIGURATION.

//***************************************************************************************************
//++++++++++++++++++++++++++++++++++++++++ USER CONFIGURATION +++++++++++++++++++++++++++++++++++++++

const instance = '0_userdata.0';                                                    // Type your instance name
const pathLevel1 = 'husqvarna';                                                     // Type your path name
const pathLevel2 = ['statistics', 'schedules', 'general', 'blades', 'actions'];     // Type your folder names
const mowerID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';                             // Mower ID from Husqvarna automower
const sID_RainSensor = 'hm-rpc.0.12345678901234.1.RAINING';                         // Path rain sensor (true = rain)
const targetBladeCuttingTime = 180_000_000;                                         // Which time should a set of knives run in milliseconds (180_000_000ms = 50h)

//++++++++++++++++++++++++++++++++++++++ END USER CONFIGURATION +++++++++++++++++++++++++++++++++++++
//***************************************************************************************************

// create required folders and states
createState();
async function createState() {
    for (let i = 0; i < 4; i++) {
        createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[1]}.startTime_${i}`, '00:00', false, {name: `Schedule ${i} start time`, role: 'value', type: 'string', read: true, write: true, def: '00:00'});
        createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[1]}.endTime_${i}`, '00:00', false, {name: `Schedule ${i} end time`, role: 'value', type: 'string', read: true, write: true, def: '00:00'});
    };
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[0]}.drivenDistanceToday`, 0, false, {name: 'Driven Distance Today', role: 'state', type: 'number', read: true, write: false, def: 0, unit: 'km'});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[0]}.drivenDistanceTotal`, 0, false, {name: 'Driven Distance Total', role: 'state', type: 'number', read: true, write: false, def: 0, unit: 'km'});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[0]}.chargingTimeToday`, 0, false, {name: 'Charging Time Today', role: 'state', type: 'number', read: true, write: false, def: 0, unit: 'ms'});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[0]}.mowingTimeToday`, 0, false, {name: 'Mowing Time Total', role: 'state', type: 'number', read: true, write: false, def: 0, unit: 'ms'});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[3]}.currentBladeCuttingTime`, 0, false, {name: 'Current Blade Cutting Time', desc: 'How many seconds was the current set of knives run', role: 'state', type: 'number', read: true, write: false, def: 0, unit: 'ms'});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[3]}.reset`, false, false, {name: 'Reset', desc: 'Restart counter after knife change', role: 'button', type: 'boolean', read: true, write: true, def: false});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[3]}.changeBlades`, false, false, {name: 'Change Blades', role: 'state', type: 'boolean', read: true, write: false, def: false});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[3]}.remainingCuttingCapacity`, 100, false, {name: 'Remaining cutting Capacity', desc: 'in percent', role: 'state', type: 'number', read: true, write: false, def: 100, max: 100, unit: '%'});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[0]}.distanceFromChargingStation`, 0, false, {name: 'Distance from charging station', role: 'state', type: 'number', read: true, write: false, def: 0, unit: 'm'});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[2]}.GoogleMapsLink`, '', false, {name: 'Google Maps Link', role: 'value', type: 'string', read: true, write: false, def: ''});
    await createStateAsync(`${instance}.${pathLevel1}.${pathLevel2[4]}.parkAfterNextChargingCycle`, false, false, {name: 'Park after next charging cycle', role: 'state', type: 'boolean', read: true, write: true, def: false});
    log('-==== folders and states created ====-', 'debug');
};

const sID_HusqvarnaSchedules = [];
$(`state[id=husqvarna-automower-connect.0.${mowerID}.ACTIONS.schedule.*.start]`).each(function(id) {
    sID_HusqvarnaSchedules.push(id);
});
$(`state[id=husqvarna-automower-connect.0.${mowerID}.ACTIONS.schedule.*.duration]`).each(function(id) {
    sID_HusqvarnaSchedules.push(id);
});

let drivenDistanceToday = getState(`${instance}.${pathLevel1}.${pathLevel2[0]}.drivenDistanceToday`).val;
let drivenDistanceTotal = getState(`${instance}.${pathLevel1}.${pathLevel2[0]}.drivenDistanceTotal`).val;
let drivenDistance = 0;
let chargingTimeToday = getState(`${instance}.${pathLevel1}.${pathLevel2[0]}.chargingTimeToday`).val;
let chargingTime = 0;
let mowingTimeToday = getState(`${instance}.${pathLevel1}.${pathLevel2[0]}.mowingTimeToday`).val;
let mowingTime = 0;
let bladeCuttingTime = 0;
let remainingBladeCapacity = 0;
let chargingStationLatitude = 0;
let chargingStationLongitude = 0;
let distanceFromChargingStation = 0;

// reset variables "[...]Today" every midnight
schedule('0 0 * * *', function () {
    drivenDistanceToday = 0;
    setState(`${instance}.${pathLevel1}.${pathLevel2[0]}.drivenDistanceToday`, drivenDistanceToday, true);
    chargingTimeToday = 0;
    setState(`${instance}.${pathLevel1}.${pathLevel2[0]}.chargingTimeToday`, chargingTimeToday, true);
    mowingTimeToday = 0;
    setState(`${instance}.${pathLevel1}.${pathLevel2[0]}.mowingTimeToday`, mowingTimeToday, true);
});

// get chargingTimeToday and chargingTimeTotal
on({id: `husqvarna-automower-connect.0.${mowerID}.mower.activity`, oldVal: 'CHARGING'}, function (obj) {
    chargingTime = obj.state.ts - obj.oldState.ts;
    log(`chargingTime: ${chargingTime / 1000}s`, 'debug');
    chargingTimeToday = chargingTime + chargingTimeToday;
    setState(`${instance}.${pathLevel1}.${pathLevel2[0]}.chargingTimeToday`, chargingTimeToday, true);
});

// get mowingTimeToday, mowingTimeTotal, bladeCuttingTime and remainingBladeCapacity
on({id: `husqvarna-automower-connect.0.${mowerID}.mower.activity`, oldVal: 'MOWING'}, function (obj) {
    mowingTime = obj.state.ts - obj.oldState.ts;
    log(`mowingTime: ${mowingTime / 1000}s`, 'debug');
    mowingTimeToday = mowingTime + mowingTimeToday;
    setState(`${instance}.${pathLevel1}.${pathLevel2[0]}.mowingTimeToday`, mowingTimeToday, true);

    let currentBladeCuttingTime = getState(`${instance}.${pathLevel1}.${pathLevel2[3]}.currentBladeCuttingTime`).val;

    bladeCuttingTime = mowingTime + currentBladeCuttingTime;
    setState(`${instance}.${pathLevel1}.${pathLevel2[3]}.currentBladeCuttingTime`, bladeCuttingTime, true);

    remainingBladeCapacity = 100 - (currentBladeCuttingTime * 100) / targetBladeCuttingTime;
    setState(`${instance}.${pathLevel1}.${pathLevel2[3]}.remainingCuttingCapacity`, remainingBladeCapacity, true);
});

// reset values after blade change
on({id: `${instance}.${pathLevel1}.${pathLevel2[3]}.reset`, val: true, ack: false}, function () {
    setState(`${instance}.${pathLevel1}.${pathLevel2[3]}.remainingCuttingCapacity`, 100, true);
    setState(`${instance}.${pathLevel1}.${pathLevel2[3]}.changeBlades`, false, true);
    setState(`${instance}.${pathLevel1}.${pathLevel2[3]}.currentBladeCuttingTime`, 0, true);
});

// get distance from automower to charging station, drivenDistanceToday and drivenDistanceTotal
on({id: `husqvarna-automower-connect.0.${mowerID}.positions.latlong`, change: 'ne'}, async function (obj) {
    if (getState(`husqvarna-automower-connect.0.${mowerID}.mower.activity`).val === 'CHARGING' || getState(`husqvarna-automower-connect.0.${mowerID}.mower.activity`).val === 'PARKED_IN_CS') {
        if (chargingStationLatitude !== 0 && chargingStationLongitude !== 0) {
            chargingStationLatitude = (Number(obj.state.val.split(';')[0]) + Number(chargingStationLatitude)) / 2;
            chargingStationLongitude = (Number(obj.state.val.split(';')[1]) + Number(chargingStationLongitude)) / 2;
        } else {
            chargingStationLatitude = obj.state.val.split(';')[0];
            chargingStationLongitude = obj.state.val.split(';')[1];
        };
    };
    distanceFromChargingStation = 1000 * (6378.388 * Math.acos(Math.sin(obj.state.val.split(';')[0] * (Math.PI / 180)) * Math.sin(chargingStationLatitude * (Math.PI / 180)) + Math.cos(obj.state.val.split(';')[0] * (Math.PI / 180)) * Math.cos(chargingStationLatitude * (Math.PI / 180)) * Math.cos(chargingStationLongitude * (Math.PI / 180) - obj.state.val.split(';')[1] * (Math.PI / 180)))); // reference: https://www.kompf.de/gps/distcalc.html
    log(`distanceFromChargingStation: ${round(distanceFromChargingStation, 2)}m`, 'debug');
    await setStateAsync(`${instance}.${pathLevel1}.${pathLevel2[0]}.distanceFromChargingStation`, distanceFromChargingStation, true);

    if (getState(`husqvarna-automower-connect.0.${mowerID}.mower.activity`).val === 'MOWING' || getState(`husqvarna-automower-connect.0.${mowerID}.mower.activity`).val === 'GOING_HOME' || getState(`husqvarna-automower-connect.0.${mowerID}.mower.activity`).val === 'LEAVING') {
        drivenDistance = 6378.388 * Math.acos(Math.sin(obj.state.val.split(';')[0] * (Math.PI / 180)) * Math.sin(obj.oldState.val.split(';')[0] * (Math.PI / 180)) + Math.cos(obj.state.val.split(';')[0] * (Math.PI / 180)) * Math.cos(obj.oldState.val.split(';')[0] * (Math.PI / 180)) * Math.cos(obj.oldState.val.split(';')[1] * (Math.PI / 180) - obj.state.val.split(';')[1] * (Math.PI / 180))); // reference: https://www.kompf.de/gps/distcalc.html
        log(`distanceDriven: ${round(drivenDistance * 1000, 2)}m`, 'debug');
        drivenDistanceToday = drivenDistanceToday + drivenDistance;
        drivenDistanceTotal = drivenDistanceTotal + drivenDistance;
        await setStateAsync(`${instance}.${pathLevel1}.${pathLevel2[0]}.drivenDistanceToday`, round(drivenDistanceToday, 2), true);
        await setStateAsync(`${instance}.${pathLevel1}.${pathLevel2[0]}.drivenDistanceTotal`, round(drivenDistanceTotal, 2), true);
    };
});

// Convert start and end time to minutes
$(`state[id=${instance}.${pathLevel1}.${pathLevel2[1]}.*]`).on(async function (obj) {
    if (obj.id.split('.')[obj.id.split('.').length - 1].split('_')[0] === 'startTime') {
        let startTime = obj.state.val.split(':')[0] * 60 + Number(obj.state.val.split(':')[1]);
        setState(`husqvarna-automower-connect.0.${mowerID}.ACTIONS.schedule.${obj.id.split('.')[obj.id.split('.').length - 1].split('_')[1]}.start`, startTime, false);
        let endTime = (await getStateAsync(`${instance}.${pathLevel1}.${pathLevel2[1]}.endTime_${obj.id.split('.')[obj.id.split('.').length - 1].split('_')[1]}`)).val;
        let duration = endTime.split(':')[0] * 60 + Number(endTime.split(':')[1]) - startTime;
        setState(`husqvarna-automower-connect.0.${mowerID}.ACTIONS.schedule.${obj.id.split('.')[obj.id.split('.').length - 1].split('_')[1]}.duration`, duration, false);
    } else {
        let startTime = (await getStateAsync(`${instance}.${pathLevel1}.${pathLevel2[1]}.startTime_${obj.id.split('.')[obj.id.split('.').length - 1].split('_')[1]}`)).val;
        let startTimeMin = startTime.split(':')[0] * 60 + Number(startTime.split(':')[1]);
        let duration = obj.state.val.split(':')[0] * 60 + Number(obj.state.val.split(':')[1]) - startTimeMin;
        setState(`husqvarna-automower-connect.0.${mowerID}.ACTIONS.schedule.${obj.id.split('.')[obj.id.split('.').length - 1].split('_')[1]}.duration`, duration, false);
    };
});

// Convert start and end time to hh:mm
on({id: sID_HusqvarnaSchedules, change: 'ne', ack: true}, async function (obj) {
    if (obj.id.split('.')[obj.id.split('.').length - 1] === 'start') {
        let m = obj.state.val % 60;
        let h = (obj.state.val - m) / 60;
        let HHMM = `${(h < 10 ? '0' : '')}${h.toString()}:${(m < 10 ? '0' : '')}${m.toString()}`;
        await setStateAsync(`${instance}.${pathLevel1}.${pathLevel2[1]}.startTime_${obj.id.split('.')[obj.id.split('.').length - 2]}`, HHMM, true);
        let endTime = obj.state.val + (await getStateAsync(`husqvarna-automower-connect.0.${mowerID}.ACTIONS.schedule.${obj.id.split('.')[obj.id.split('.').length - 2]}.duration`)).val;
        let m1 = endTime % 60;
        let h1 = (endTime - m1) / 60;
        let HHMM1 = `${(h1 < 10 ? '0' : '')}${h1.toString()}:${(m1 < 10 ? '0' : '')}${m1.toString()}`;
        await setStateAsync(`${instance}.${pathLevel1}.${pathLevel2[1]}.endTime_${obj.id.split('.')[obj.id.split('.').length - 2]}`, HHMM1, true);
    } else {
        let startTime = (await getStateAsync(`husqvarna-automower-connect.0.${mowerID}.ACTIONS.schedule.${obj.id.split('.')[obj.id.split('.').length - 2]}.start`)).val;
        let endTime = startTime + obj.state.val;
        let m = endTime % 60;
        let h = (endTime - m) / 60;
        let HHMM = `${(h < 10 ? '0' : '')}${h.toString()}:${(m < 10 ? '0' : '')}${m.toString()}`;
        await setStateAsync(`${instance}.${pathLevel1}.${pathLevel2[1]}.endTime_${obj.id.split('.')[obj.id.split('.').length - 2]}`, HHMM, true);
    };
});

// update google maps link
on({id: `husqvarna-automower-connect.0.${mowerID}.positions.latlong`, change: 'ne'}, async function (obj) {
    let arrayLatLong = getState(obj.id).val.split(';');
    let GoogleLink = `https://www.google.com/maps/place/${arrayLatLong[0]},${arrayLatLong[1]}/@?hl=de`;
    await setStateAsync(`${instance}.${pathLevel1}.${pathLevel2[2]}.GoogleMapsLink`, GoogleLink, true);
});

// during rain, park until next schedule
on({id: sID_RainSensor, change: 'ne', val: true}, async function () {
   await setStateAsync(`husqvarna-automower-connect.0.${mowerID}.ACTIONS.PARKUNTILNEXTSCHEDULE`, true);
   log('-==== It is raining. Mower is parked. ====-', 'info');
});

// park after next charging cycle
on({id: `husqvarna-automower-connect.0.${mowerID}.mower.activity`, change: 'ne', val: 'CHARGING'}, function () {
    if (getState(`${instance}.${pathLevel1}.${pathLevel2[4]}.parkAfterNextChargingCycle`).val) {
        setState(`husqvarna-automower-connect.0.${mowerID}.ACTIONS.PARKUNTILFURTHERNOTICE`, true, true);
        setState(`${instance}.${pathLevel1}.${pathLevel2[4]}.parkAfterNextChargingCycle`, false, true);
    };
});

// round
function round(digit, digits) {
    digit = (Math.round(digit * Math.pow(10, digits)) / Math.pow(10, digits));
    return digit;
};

How to report issues and feature requests

  • For issues Please use GitHub issues -> "Bug report" and fill in the form.

    Set the adapter to debug log mode (Instances -> Expert mode -> Column Log level). Get the logfile from disk (subdirectory "log" in ioBroker installation directory and not from Admin because Admin cuts the lines). Check that there are no personal information before you publish your log.

  • For feature requests Please use GitHub issues -> "Feature request" and fill in the form.

Changelog

1.0.8 (22.07.2026)

  • (Stefan Bühler) Documentation only: removed the e-mail address and corrected the name spelling in the README license section (the LICENSE file itself was already correct); commented out the number-of-installations badge, which cannot resolve until the adapter is available in the official ioBroker repository. No functional changes.

1.0.7 (22.07.2026)

  • (Stefan Bühler) Metadata only: standardized copyright/author to "Stefan Bühler" across package.json, io-package.json and LICENSE (dropped the GitHub username and email address from the copyright line; ice987987's original-author credit is unchanged). No functional changes.

1.0.6 (17.07.2026)

  • (Stefan Bühler) Documentation only: fixed the PayPal donate link, which previously used the wrong URL format and did not work.

1.0.5 (17.07.2026)

  • (Stefan Bühler) FIX: system.serialNumber declared type 'number', but role 'info.serial' only accepts 'string' - changed type and now writes the value as a string.
  • (Stefan Bühler) FIX: positions.latlong used role 'value.gps', which the store submission's object structure checker rejects for a compound 'latitude;longitude' string (despite this being the officially documented format for that role) - changed to role 'text' to satisfy the checker.
  • (Stefan Bühler) The startup migration added in 1.0.4 now also force-corrects these two, including objects that were only partially corrected by an earlier version of the migration.

1.0.4 (17.07.2026)

  • (Stefan Bühler) FIX: 1.0.3 corrected several wrong object roles/types (ACTIONS.HEADLIGHT, ACTIONS.schedule fields, messages.messages, system.id/type/serialNumber), but setObjectNotExistsAsync() never touches an object that already exists - so installations updating from before 1.0.3 kept the old, incorrect objects forever. Added a one-time startup migration that force-corrects exactly those known objects via extendObjectAsync(), without touching anything else.

1.0.3 (17.07.2026)

  • (Stefan Bühler) Corrected several object role/type mismatches found by the ioBroker store submission's object structure check (ACTIONS.HEADLIGHT, ACTIONS.schedule fields, messages.messages, system.id/type/serialNumber). No functional/API changes - purely metadata (common.role/common.type) corrections.

1.0.2 (16.07.2026)

  • (Stefan Bühler) Enabled automated npm releases via GitHub Actions using npm Trusted Publishing (OIDC) - no more manual publishing, and this and all future tagged releases are automatically signed with npm provenance. No functional/API changes.

1.0.1 (16.07.2026)

  • (Stefan Bühler) Cleanup release addressing the ioBroker adapter store checker findings, no functional/API changes.
  • (Stefan Bühler) Removed devDependencies already bundled by @iobroker/testing (chai, mocha, sinon and their @types/*)
  • (Stefan Bühler) Updated @iobroker/testing, admin and js-controller minimum versions; fixed a peer-dependency conflict (globals)
  • (Stefan Bühler) Migrated to @tsconfig/node22 and the current standard Dependabot auto-merge workflow (automerge-dependabot.yml)
  • (Stefan Bühler) Fixed copyright line formatting in README.md/LICENSE (Markdown line-break spacing); synced README installation requirements with io-package.json

1.0.0 (14.07.2026)

  • (Stefan Bühler) Renamed/continued as ioBroker.husqvarna-automower-connect, a complete, actively maintained fork of ioBroker.husqvarna-automower. Full credit to ice987987 for the original adapter. BREAKING: adapter instance namespace changes from husqvarna-automower.x to husqvarna-automower-connect.x - create a new instance and re-enter Application Key/Secret.
  • (Stefan Bühler) fix: START, STARTINWORKAREA, PARK, CUTTINGHEIGHT, DATETIME and HEADLIGHT sent a malformed request body (attributes was a sibling of data instead of nested inside data.attributes) and were rejected by the API
  • (Stefan Bühler) fix: DATETIME used type: 'dateTime' instead of the required type: 'settings'
  • (Stefan Bühler) fix: HEADLIGHT used type: 'HeadLight' instead of type: 'settings', and sent the mode flat instead of nested in attributes.headlight.mode
  • (Stefan Bühler) fix: HEADLIGHT validation compared against 'ALWAYS OFF' (space) instead of 'ALWAYS_OFF' (underscore)
  • (Stefan Bühler) fix: .ACTIONS.CUTTINGHEIGHT was undefined until the first WebSocket update after adapter start (REST response returns cuttingHeight as a plain number, not {height: N})
  • (Stefan Bühler) fix: memory leak in the internal capabilities cache (grew by one duplicate entry on every statistics poll)
  • (Stefan Bühler) added .ACTIONS.CONFIRMERROR, .ACTIONS.RESETCUTTINGBLADEUSAGETIME, .ACTIONS.workAreaSettings (set cutting height/enabled per work area) and .ACTIONS.stayOutZoneSettings (enable/disable a stay-out zone)
  • (Stefan Bühler) added .messages channel with the error/event message history (REST on startup/on-demand + live WebSocket updates)
  • (Stefan Bühler) added missing .workAreas.[workAreaId] fields: type, useGlobalCuttingHeight, orientation, orientationShift, currentOrientation, lastTimeAbandoned
  • (Stefan Bühler) BREAKING: node.js >= v22 is required (v18 reached end-of-life April 2025, v20 reached end-of-life April 2026)
  • (Stefan Bühler) security: applicationSecret, applicationKey and the live OAuth access token were logged in plaintext at debug level in several places; added redaction so they can no longer end up in a shared logfile
  • (Stefan Bühler) security: token invalidation on adapter stop used a malformed request (wrong endpoint/headers) and never actually revoked the token with Husqvarna; fixed to use the correct POST /v1/oauth2/revoke
  • (Stefan Bühler) security: axios updated 1.8.4 -> 1.18.1 (fixes several CVEs, including the critical CVE-2026-40175) and ws updated 8.18.3 -> 8.21.1 (fixes two High-severity CVEs); npm audit --omit=dev now reports 0 vulnerabilities
  • (Stefan Bühler) fix: statisticsInterval validation was permanently unreachable (&& instead of ||), so any configured value was silently accepted
  • (Stefan Bühler) hardening: Application Key/Secret format check is now anchored; stayOutZoneSettings.zoneId is URL-encoded before use
  • (Stefan Bühler) optimization: message history is no longer re-polled on every statistics interval tick (only on startup/on-demand), to avoid roughly doubling the request volume against Husqvarna's 10 000 requests/month budget

0.6.0-beta.12 and older

Older changelog entries can be found in CHANGELOG_OLD.md.

License

MIT License

Copyright (c) 2025 ice987987 mathias.frei1@gmail.com
Copyright (c) 2026 Stefan Bühler (modifications and additions in this fork)

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Footnotes

  1. If a value is missing or zero (0) the mower does not support the value 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18

  2. Do not use for 550 EPOS and Ceora due to Husqvarna's API-limitation 2

  3. not supported models: 405X, 415X and 435X AWD (you will get the error "This mower use missions and can not be updated by this endpoint")

  4. only available if .capabilities.stayOutZones is true

  5. If no GPS-Signal is available, those values are not updated 2 3 4

About

Control your Husqvarna-Automower together with the "Connect Modul" and the new "Automower Connect API" via a WebSocket connection.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages