Automower (Husqvarna) MainUI Page

Over the last few weeks, I have been having a bit of a play with getting a moderately functional dashboard going for the Automower.

My intent was to be able to control key functions from within OpenHAB, but a non-goal was for configuration, for which I will continue to use the Husqvarna App to perform, probably infrequently.

With a bit of help (and often hindrance) from Gemini, I have managed to get a basic prototype working, and I am sharing it here, for others who may wish to have a play with this. I’ll post any improvements I make here as well. While it display/works on a browser, it’s a Mobile-First design, so doesn’t look quite as pretty on a big screen.

There are a few steps needed to set it up and get it working, and these are detailed further down. In each section I have added background information, so you can understand the intent/reasoning of that step.

But before we get into the setup, here’s an overview of what it should look like, and its main functions:

First photo shows the summary box, which contains the basic controls and status of the mower. It’s worth noting that the Overall Status ('Mowing; in the above picture), Details (‘87 % done in House North’ in above picture) and the cutting height are all synthetic/proxy items, managed by a rule we will create as part of the setup process.

As you can see, the work areas are all listed below this, and they display a summary of their state (Enabled/Disabled/Mowing).

The next 2 photos show the options displayed when you hit the ‘Home’ button, or the ‘Start’ button (Noting the button will change to ‘Pause’ when mower is running). When starting the mower, you can either resume the schedule (which could mean it is parked until the next start), or ‘Override schedule’ to send it to a specific area.

The work areas are all accordion-lists, and can be expanded for additional control/status, as shown in the first photo below. The second photo shows the mower service status information, which appears below the work-areas accordion list.

The lists for the Work Areas are dynamically populated, so if you follow the below configuration steps for your current (and any future) work areas, you shouldn’t need to edit the page itself.

I have raised a couple of feature requests in the addons Github, which would make the setup process slightly easier, but for now just follow these steps, which include the workarounds.

Install & configure the Automower Binding

First step is to install the Automower binding, and create & configure the ‘Automower Connect Bridge’. You should then also be able to discover and add the Mower and Work Area things. Just follow the binding documentation for this step, but do not link any channels/items as yet.

Setup Groups for Mower and Work Areas

Background: The groups are used for both the mower and the work-areas, and allow items to be subsequently setup under these, in a consistent and predictable fashion.

Set up some groups for the mower (highlighted), and also for each of the work areas. I have created these as ‘Equipment’ type groups. You can call these groups what ever you like,but you will need to make sure you configure the page and rule to match. The following example shows the groups I have setup for our mower:

Add Tags to Work Area Groups

Background: This solution uses tags to find the work-areas relating to this mower, for the dynamically built menus (OH-Repeater), and also to loop through work-areas within the rule

For each of the Work Area groups created above, add a non-semantic tag as follows, but use whatever name makes sense for your installation… Just make sure you set it to match in the configuration of the UI Page, and the Rule

.

Create Items

Background: This solution relies on default item names as specified by the Binding channel, which are a combination of the parent group name, and the channel name. The rule and the page just use the base group name, and expect the suffixes to follow this pattern.

When using the ‘Add Points to Model’ approach, these will populate correctly with the ‘naming schema’ this solution expected. If you use any other naming convention, you will need to edit the rule and UI page accordingly.

Mower Thing

  • Select Add ‘Points to Model’
  • Select ‘Show Advanced’
  • Select the Parent Group for the Mower that you created in the previous step
  • Select the following Channels
    • status
      • state
      • activity
      • mode
      • inactive-reason
      • next-start
      • error-message
      • error-code
      • work-area
      • cutting-height
      • battery
      • last-update
    • command
      • pause
      • resume-schedule
      • park
      • park-until-next-schedule
      • park-until-further-notice
      • start-in-workarea
    • statistics
      • total-running-time
      • charging-cycles
      • cutting-blade-usage

Or just ‘Select all’ if you don’t mind having a few extra (unused) channels linked to items, or have some nefarious plan for using them in future (e.g. in other rules to control the Automower).

Work Area Thing

For each Work Area Thing:

  • Select Add ‘Points to Model’
  • Select ‘Show Advanced’
  • Select the Parent Group that you created in the earlier step, for the corresponding Work Area
  • Select the following Channels:
    • Name
    • Cutting Height
    • Enabled
    • Progress
    • Last Time Completed

Synthetic/Proxy Items

Background: The solution uses a few ‘proxy’ items to store values which are populated by the rule. This includes the Status and Detail strings which are displayed at the top of the page, which are ‘synthetic’ values comprised from multiple items in the mower. The cutting height is also derived from the Current Work Area Height, as the Mower Cutting height does not display the current height (but we use that value when no work areas are active).

Create these items under the Mower ‘Core’ group, using the same naming convention as you did for the points you added in the last step, with the following suffixes:

  • _Current_Cutting_Height (Number:Length - CM)
  • _Summary_Detail (String)
  • _Summary_Status (String)

As an example:

Number:Length automower_name_core_Current_Cutting_Height "Automower Current Cutting Height (Proxy Item)" (automower_name_core) [Point] { stateDescription=" " [pattern="%d cm"], unit="cm" }

String automower_name_core_Summary_Detail "Automower Summary Detail" (automower_name_core) [Point]

String automower_name_core_Summary_Status "Automower Summary Status" (automower_name_core) [Point]

Transform the cutting height values to CM

Background: So the Husqvarna API, and therefore the binding channels, has the cutting height as ‘1-9’ for the mower and ‘0-100%’ for the Work areas. Whilst I did try managing the conversion in the UI Page, to display as CM, some component would always display the raw item value, e.g. popover for sliders. Converting the item to store/display CM was an easier approach.

I have raised a GitHub feature request for additional channels presented in CM to be added to the binding (along with a min/max value), so maybe if addressed in future, this whole section could be skipped.

Change Cutting Height items dimension/unit:

For all mower & work area Cutting Height items:

  • Change the Dimension to be ‘Length’
  • Change the unit to be ‘CM’
  • Change the State Description to be ‘%d cm’

Create Transformation Script

Background: This (bloated!!) transform script can handle both the mower and workarea Thing to Item, and Item Command to Thing transforms. You also need to feed it the Min & Max cutting heights for your specific mower, and it will take care of the rest.

Create a JS transform script called automower_height, via Main UI, and populate it with the below code.
Note: If you use a file based script instead, you will need to adjust the syntax in the subsequent Transform sections.

(function(data) {
    // 1. Safe null/UNDEF/empty check
    if (data === null || data === undefined || data === "" || data === "UNDEF" || data === "null") {
        return "UNDEF"; 
    }

    var strData = String(data);

    // 2. Extract configuration parameters safely
    var mode = (typeof type !== 'undefined') ? String(type).toLowerCase() : 'mower';
    var minHeight = (typeof min !== 'undefined') ? parseFloat(min) : 2.0;
    var maxHeight = (typeof max !== 'undefined') ? parseFloat(max) : 6.0;
    var heightSpan = maxHeight - minHeight;

    if (heightSpan <= 0) return "UNDEF"; // Avoid division by zero

    // 3. Determine Direction
    var direction = (typeof dir !== 'undefined') ? String(dir).toLowerCase() : 'in';
    if (strData.indexOf("cm") !== -1) {
        direction = 'out';
    }

    // ==========================================
    // DIRECTION: IN (Thing -> Item / UI)
    // ==========================================
    if (direction === 'in') {
        var raw = parseFloat(strData);
        if (isNaN(raw)) return "UNDEF";
        // console.log("Automower Incoming Payload: " + data + " | Parsed Raw: " + raw);
        var cm = 0.0;
        if (mode === 'workarea') {
            cm = minHeight + ((raw / 100) * heightSpan);
        } else {
            cm = minHeight + (((raw - 1) / 8) * heightSpan);
        }

        // Clamp & round to nearest 0.5 cm
        cm = Math.max(minHeight, Math.min(maxHeight, cm));
        return (Math.round(cm * 2) / 2).toFixed(1);
    } 

    // ==========================================
    // DIRECTION: OUT (UI / Item Command -> Thing)
    // ==========================================
    else {
        var cmVal = parseFloat(strData.replace(/[^\d.]/g, ''));
        if (isNaN(cmVal)) return "UNDEF";

        if (mode === 'workarea') {
            if (cmVal <= minHeight) return "0";
            if (cmVal >= maxHeight) return "100";

            var targetPct = Math.round(((cmVal - minHeight) / heightSpan) * 100);
            return Math.max(0, Math.min(100, targetPct)).toString();
        } else {
            if (cmVal <= minHeight) return "1";
            if (cmVal >= maxHeight) return "9";

            var step = Math.round(1 + (((cmVal - minHeight) / heightSpan) * 8));
            return Math.max(1, Math.min(9, step)).toString();
        }
    }
})(input);

Mower Cutting Height Transformation

Add the following ECMAscript transforms to the mower Cutting Height Link. Adjust the min and max cutting height (in CM) to match your mower.

Thing to Item Transform (Mower):

config:js:automower_height?type=workarea&min=2.0&max=6.0&dir=in

Item Command to Thing Transform (Mower):

config:js:automower_height?type=mower&min=2.0&max=6.0&dir=out

Work Area Height Transformation

Add the following ECMAscript transforms to ALL Work Area Cutting Height Links. Adjust the min and max cutting height (in CM) to match your mower.

Thing to Item Transform (Work Area):

config:js:automower_height?type=workarea&min=2.0&max=6.0&dir=in

Item Command to Thing Transform (Work Area):

config:js:automower_height.js?type=workarea&dir=out&min=2&max=6

Setup Metadata for Work Area ID

Background: The Work Area ID is only available as a Thing Property, which Main UI pages cannot access. We need to know this ID, to be able to send a ‘Start in Area’ Command. As a work-around we will add this as Custom Item Meta-Data to the parent group for each work-area.

I have also logged a Github Feature Request for realising the Area ID as a channel, and if it is addressed, this step could also be skipped in future.

For each work area thing, look in ther properties for the workareaID

Then within the corresponding group you created for that work area, add the workAreaId metadata with the value from the Thing properties in the prior step. This is done via ‘Add Custom Namespace’.

Create rule for synthetic items

Background: We need this rule to run every-time one of the items changes, that it uses to build its synthetic Status and Detail messages. We also use a ‘every minute’ Cron trigger, to pickup changes in %complete from the active work area, without needing to add every work area completion item to the triggers.

This rule also finds the height for the current cutting area, and populates this into a proxy item, or falls back to the core mower cutting height if no area is active.

Create a rule (I called mine automower_state_summary ).

Use the following triggers (obviously changing the names to match your mower items) :

    triggers:
      - id: "1"
        config:
          itemName: automower_name_core_State
        type: ItemChanged
      - id: "2"
        config:
          itemName: automower_name_core_Activity
        type: ItemChanged
      - id: "3"
        config:
          itemName: automower_name_core_Mode
        type: ItemChanged
      - id: "5"
        config:
          itemName: automower_name_core_Inactive_Reason
        type: ItemChanged
      - id: "6"
        config:
          itemName: automower_name_core_Next_Start
        type: ItemChanged
      - id: "7"
        config:
          itemName: automower_name_core_Error_Message
        type: ItemUpdated
      - id: "8"
        config:
          itemName: automower_name_core_Error_Code
        type: ItemUpdated
      - id: "10"
        config:
          itemName: automower_name_core_Work_Area_Name
        type: ItemChanged
      - id: "9"
        config:
          cronExpression: 0 * * * * ? *
        type: Cron

Then add an ECMA Script Action as follows, and make sure you update the definitions for following 2 variables at the top, to match your installation:

  • BASE_PREFIX
  • WORK_AREA_TAG
// =========================================================================
// CONFIGURATION
// =========================================================================
var BASE_PREFIX   = "automower_marvin_core";        // Base prefix for core items
var WORK_AREA_TAG = "automower_marvin_work_areas";  // Independent tag for work areas

// =========================================================================
// ITEM STATE RETRIEVAL
// =========================================================================
var state          = items.getItem(BASE_PREFIX + "_State").state;
var activity       = items.getItem(BASE_PREFIX + "_Activity").state;
var mode           = items.getItem(BASE_PREFIX + "_Mode").state;
var inactiveReason = items.getItem(BASE_PREFIX + "_Inactive_Reason").state || "NONE";
var nextStartRaw   = items.getItem(BASE_PREFIX + "_Next_Start").state;
var errorMessage   = items.getItem(BASE_PREFIX + "_Error_Message").state || "NULL";
var errorCode      = items.getItem(BASE_PREFIX + "_Error_Code").state || "NULL";
var activeAreaName = items.getItem(BASE_PREFIX + "_Work_Area_Name").state;
var globalHeight   = items.getItem(BASE_PREFIX + "_Cutting_Height").state;

var mainStatus = "Unknown";
var mainDetail = "";

// =========================================================================
// WORK AREA PROGRESS & CUTTING HEIGHT LOOKUP
// =========================================================================
var activeProgress = null;
var activeHeight   = null;

if (activeAreaName && activeAreaName !== "NULL" && activeAreaName !== "UNDEF") {
    var areaItems = items.getItemsByTag(WORK_AREA_TAG);
    for (var i = 0; i < areaItems.length; i++) {
        var areaBase = areaItems[i].name;
        if (items.getItem(areaBase + "_Name").state === activeAreaName) {
            var progState   = items.getItem(areaBase + "_Progress").state;
            var heightState = items.getItem(areaBase + "_Cutting_Height").state;

            if (progState !== "NULL" && progState !== "UNDEF") {
                activeProgress = progState;
            }
            if (heightState !== "NULL" && heightState !== "UNDEF") {
                activeHeight = heightState;
            }
            break;
        }
    }
}

var currentCuttingHeight = activeHeight || globalHeight || "NULL";

// =========================================================================
// NEXT START FORMATTING
// =========================================================================
var nextStartText = "Until further notice";

if (nextStartRaw && nextStartRaw !== "NULL" && nextStartRaw !== "UNDEF" && nextStartRaw !== "") {
    var cleanStr = nextStartRaw.toString().replace(' ', 'T');
    var nextDate = new Date(cleanStr);
    
    if (!isNaN(nextDate.getTime())) {
        var now = new Date();
        var todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
        var targetStart = new Date(nextDate.getFullYear(), nextDate.getMonth(), nextDate.getDate());
        var diffDays = Math.round((targetStart - todayStart) / (1000 * 60 * 60 * 24));

        var hours = nextDate.getHours().toString().padStart(2, '0');
        var minutes = nextDate.getMinutes().toString().padStart(2, '0');
        var timeStr = hours + ":" + minutes;

        if (diffDays === 0) {
            nextStartText = "Next start today at " + timeStr;
        } else if (diffDays === 1) {
            nextStartText = "Next start tomorrow at " + timeStr;
        } else {
            var days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
            nextStartText = "Next start " + days[nextDate.getDay()] + " " + timeStr;
        }
    }
}

// =========================================================================
// STATUS EVALUATION
// =========================================================================

// 1. Errors & Fatal Faults
if (state === "ERROR" || state === "FATAL_ERROR") {
    mainStatus = "Error";
    if (errorMessage && errorMessage !== "NULL" && errorMessage !== "UNDEF" && errorMessage !== "") {
        mainDetail = errorMessage;
    } else if (errorCode && errorCode !== "NULL" && errorCode !== "UNDEF" && errorCode !== "") {
        mainDetail = "Error code: " + errorCode;
    } else {
        mainDetail = "Check mower console";
    }

// 2. Stopped / Paused
} else if (state === "STOPPED" || activity === "STOPPED_IN_GARDEN") {
    mainStatus = "Stopped";
    mainDetail = "Stopped in garden";
} else if (state === "PAUSED") {
    mainStatus = "Paused";
    mainDetail = "Manual pause";

// 3. EPOS Planning / Satellite Search / Transient Startup
} else if (inactiveReason === "PLANNING" || inactiveReason === "SEARCHING_FOR_SATELLITES" || activity === "NOT_APPLICABLE") {
    mainStatus = "Planning";
    mainDetail = (inactiveReason === "SEARCHING_FOR_SATELLITES") ? "Acquiring satellite signal" : "Preparing & calculating path";

// 4. Transit / Movement ("On The Way")
} else if (activity === "LEAVING") {
    mainStatus = "On The Way";
    mainDetail = (activeAreaName && activeAreaName !== "NULL" && activeAreaName !== "UNDEF") ? "Going to " + activeAreaName : "Going to Work Area";
} else if (activity === "GOING_HOME") {
    mainStatus = "On The Way";
    mainDetail = "Going to Charging Station";

// 5. Charging
} else if (activity === "CHARGING") {
    mainStatus = "Charging";
    if (nextStartRaw && nextStartRaw !== "NULL" && nextStartRaw !== "UNDEF" && nextStartRaw !== "") {
        mainDetail = nextStartText;
    } else if (state === "RESTRICTED_PARK_OVERRIDE") {
        mainDetail = "Until further notice";
    } else {
        mainDetail = "Charging battery";
    }

// 6. Parked / Restricted States
} else if (state === "RESTRICTED_PARK_OVERRIDE" || state === "RESTRICTED_WEEK_SCHEDULE" || activity === "PARKED_IN_CS" || mode === "HOME") {
    mainStatus = "Parked";
    
    if (state === "RESTRICTED_FROST") {
        mainDetail = "Frost guard active";
    } else if (state === "RESTRICTED_SENSOR") {
        mainDetail = "Weather timer active";
    } else {
        mainDetail = nextStartText;
    }

// 7. Active Mowing
} else if (activity === "MOWING") {
    mainStatus = "Mowing";
    
    if (activeAreaName && activeAreaName !== "NULL" && activeAreaName !== "UNDEF") {
        if (activeProgress !== null && activeProgress !== "") {
            mainDetail = activeProgress + " done in " + activeAreaName;
        } else {
            mainDetail = "Cutting " + activeAreaName;
        }
    } else if (mode === "SECONDARY_AREA") {
        mainDetail = "Cutting secondary area";
    } else {
        mainDetail = "Cutting main area";
    }

} else {
    mainStatus = (state === "IN_OPERATION") ? "Planning" : state;
    mainDetail = (activity !== "NULL" && activity !== "NOT_APPLICABLE") ? activity.replace(/_/g, ' ') : "Updating status...";
}

// =========================================================================
// POST UPDATES TO SYNTHETIC/PROXY ITEMS
// =========================================================================
items.getItem(BASE_PREFIX + "_Summary_Status").postUpdate(mainStatus);
items.getItem(BASE_PREFIX + "_Summary_Detail").postUpdate(mainDetail);
items.getItem(BASE_PREFIX + "_Current_Cutting_Height").postUpdate(currentCuttingHeight);

Create Main UI Page

Background: This is a dynamic page, which should not require configuration as you change work areas in future, but do remember that the applicable steps for Work Areas described in the earlier sections, will need to be repeated for any new work areas.

Create a layout page called automower_control_page via Main UI.

Paste the following into the code tab:

version: 1
pages:
  automower_control_page:
    tags: []
    props:
      parameters:
        - default: automower_name_core
          description: Base prefix for core items (e.g. automower_core)
          label: Core Item Prefix
          name: basePrefix
          required: false
          type: TEXT
        - default: automower_name_work_areas
          description: Independent tag assigned to work area items (e.g. automower_work_areas)
          label: Work Area Tag
          name: workAreasTag
          required: false
          type: TEXT
        - default: "2"
          description: Minimum allowed value for the cutting height slider
          label: Minimum Cutting Height
          name: minCuttingHeight
          required: false
          type: DECIMAL
        - default: "6"
          description: Maximum allowed value for the cutting height slider
          label: Maximum Cutting Height
          name: maxCuttingHeight
          required: false
          type: DECIMAL
      parameterGroups: []
    component: oh-layout-page
    config:
      label: Automower Control Page
      sidebar: true
    slots:
      canvas: []
      default:
        - component: oh-block
          config: {}
          slots:
            default:
              - component: oh-grid-row
                config:
                  style:
                    margin-bottom: 25px
                slots:
                  default:
                    - component: oh-grid-col
                      config:
                        width: "100"
                      slots:
                        default:
                          - component: oh-list-card
                            config:
                              outline: false
                              style:
                                background: transparent
                            slots:
                              default:
                                - component: oh-list-item
                                  config:
                                    action: navigate
                                    actionPage: page:overview
                                    actionPageTransition: f7-dive
                                    icon: f7:house_fill
                                    iconColor: green
                                    link: true
                                    noChevron: true
                                    style:
                                      background: rgba(39, 174, 96, 0.12)
                                      border-left: 4px solid
                                      border-radius: 8px
                                    subtitle: Return to home controls
                                    title: Overview Dashboard
              - component: oh-grid-row
                config: {}
                slots:
                  default:
                    - component: oh-grid-col
                      config:
                        desktop: "33"
                        width: "100"
                      slots:
                        default:
                          - component: oh-list-card
                            config:
                              noShadow: true
                              outline: false
                              style:
                                -webkit-backdrop-filter: blur(12px)
                                backdrop-filter: blur(12px)
                                background: "=themeOptions.dark === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)'"
                                border: "=themeOptions.dark === 'dark' ? '1px solid rgba(255, 255, 255, 0.12)' : '1px solid rgba(0, 0, 0, 0.08)'"
                                border-radius: 12px
                                margin-bottom: 16px
                                padding: 12px
                            slots:
                              default:
                                - component: f7-block
                                  config:
                                    style:
                                      align-items: center
                                      display: flex
                                      justify-content: space-between
                                      margin: 0
                                      padding: 0
                                  slots:
                                    default:
                                      - component: Label
                                        config:
                                          style:
                                            color: "=(items[props.basePrefix + '_Summary_Status'].state === 'Error') ? '#e74c3c' : (items[props.basePrefix + '_Summary_Status'].state === 'Paused' || items[props.basePrefix + '_Summary_Status'].state === 'Stopped') ? '#f39c12' : (items[props.basePrefix + '_Summary_Status'].state === 'Charging') ? '#f1c40f' : (items[props.basePrefix + '_Summary_Status'].state === 'Mowing') ? '#2ecc71' : (items[props.basePrefix + '_Summary_Status'].state === 'Planning' || items[props.basePrefix + '_Summary_Status'].state === 'On The Way') ? '#1abc9c' : '#3498db'"
                                            font-size: 24px
                                            font-weight: 800
                                            letter-spacing: 0.5px
                                          text: =items[props.basePrefix + '_Summary_Status'].state
                                      - component: f7-chip
                                        config:
                                          color: "=Number.parseFloat(items[props.basePrefix + '_Battery_Level'].state) >= 75 ? 'green' : (Number.parseFloat(items[props.basePrefix + '_Battery_Level'].state) >= 25 ? 'orange' : 'red')"
                                          iconF7: "=Number.parseFloat(items[props.basePrefix + '_Battery_Level'].state) >= 75 ? 'battery_100' : (Number.parseFloat(items[props.basePrefix + '_Battery_Level'].state) >= 25 ? 'battery_25' : 'battery_0')"
                                          text: =items[props.basePrefix + '_Battery_Level'].state + '%'
                                - component: f7-block
                                  config:
                                    style:
                                      margin: 4px 0 12px 0
                                      padding: 0
                                  slots:
                                    default:
                                      - component: Label
                                        config:
                                          style:
                                            color: "=(items[props.basePrefix + '_Summary_Status'].state === 'Error') ? '#e74c3c' : (themeOptions.dark === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.6)')"
                                            font-size: 14px
                                            font-weight: "=(items[props.basePrefix + '_Summary_Status'].state === 'Error') ? 600 : 500"
                                          text: =items[props.basePrefix + '_Summary_Detail'].state
                                - component: f7-block
                                  config:
                                    style:
                                      display: flex
                                      gap: 10px
                                      margin: 12px 0 8px 0
                                      padding: 0
                                  slots:
                                    default:
                                      - component: oh-button
                                        config:
                                          fill: true
                                          iconF7: house_fill
                                          popoverOpen: .park-popover
                                          style:
                                            --f7-button-bg-color: "#3498db"
                                            --f7-button-text-color: "#ffffff"
                                            border-radius: 8px
                                            flex: 1
                                      - component: oh-button
                                        config:
                                          action: command
                                          actionCommand: ON
                                          actionItem: =props.basePrefix + '_Pause'
                                          fill: true
                                          iconF7: pause_fill
                                          style:
                                            --f7-button-bg-color: "#f39c12"
                                            --f7-button-text-color: "#ffffff"
                                            border-radius: 8px
                                            flex: 1
                                          visible: =items[props.basePrefix + '_Summary_Status'].state === 'Mowing' || items[props.basePrefix + '_Summary_Status'].state === 'On The Way'
                                      - component: oh-button
                                        config:
                                          fill: true
                                          iconF7: play_fill
                                          popoverOpen: .play-popover
                                          style:
                                            --f7-button-bg-color: "#2ecc71"
                                            --f7-button-text-color: "#ffffff"
                                            border-radius: 8px
                                            flex: 1
                                          visible: =items[props.basePrefix + '_Summary_Status'].state !== 'Mowing' && items[props.basePrefix + '_Summary_Status'].state !== 'On The Way'
                                - component: f7-popover
                                  config:
                                    class: park-popover
                                  slots:
                                    default:
                                      - component: oh-list
                                        config:
                                          inset: false
                                        slots:
                                          default:
                                            - component: oh-list-item
                                              config:
                                                groupTitle: true
                                                title: Choose Duration
                                            - component: oh-list-item
                                              config:
                                                action: command
                                                actionCommand: "30"
                                                actionItem: =props.basePrefix + '_Park_with_Duration'
                                                icon: f7:timer
                                                popoverClose: true
                                                title: 30 Minutes
                                            - component: oh-list-item
                                              config:
                                                action: command
                                                actionCommand: "180"
                                                actionItem: =props.basePrefix + '_Park_with_Duration'
                                                icon: f7:timer
                                                popoverClose: true
                                                title: 3 Hours
                                            - component: oh-list-item
                                              config:
                                                action: command
                                                actionCommand: "1440"
                                                actionItem: =props.basePrefix + '_Park_with_Duration'
                                                icon: f7:timer
                                                popoverClose: true
                                                title: 24 Hours
                                            - component: oh-list-item
                                              config:
                                                groupTitle: true
                                                title: Park Until
                                            - component: oh-list-item
                                              config:
                                                action: command
                                                actionCommand: ON
                                                actionItem: =props.basePrefix + '_Park_Until_Next_Schedule'
                                                icon: f7:calendar
                                                popoverClose: true
                                                title: Until Next Schedule
                                            - component: oh-list-item
                                              config:
                                                action: command
                                                actionCommand: ON
                                                actionItem: =props.basePrefix + '_Park_and_Pause_the_Schedule'
                                                icon: f7:pause_circle
                                                popoverClose: true
                                                title: Until Further Notice
                                - component: f7-popover
                                  config:
                                    class: play-popover
                                  slots:
                                    default:
                                      - component: oh-list
                                        config:
                                          inset: false
                                        slots:
                                          default:
                                            - component: oh-list-item
                                              config:
                                                action: command
                                                actionCommand: ON
                                                actionItem: =props.basePrefix + '_Resume_Schedule'
                                                icon: f7:play_fill
                                                popoverClose: true
                                                title: Resume Schedule
                                            - component: oh-list-item
                                              config:
                                                groupTitle: true
                                                title: Select Work Area
                                            - component: oh-repeater
                                              config:
                                                fetchMetadata: workAreaId
                                                for: area
                                                fragment: true
                                                itemTags: =props.workAreasTag
                                                sourceType: itemsWithTags
                                              slots:
                                                default:
                                                  - component: oh-list-item
                                                    config:
                                                      action: command
                                                      actionCommand: =loop.area.metadata.workAreaId.value
                                                      actionItem: =props.basePrefix + '_Start_in_Work_Area'
                                                      icon: f7:layers
                                                      popoverClose: true
                                                      title: =items[loop.area.name + '_Name'].state
                                - component: div
                                  config:
                                    style:
                                      border-top: "=themeOptions.dark === 'dark' ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.08)'"
                                      margin: 8px 0
                                - component: oh-label-item
                                  config:
                                    icon: f7:layers
                                    iconColor: blue
                                    item: =props.basePrefix + '_Work_Area_Name'
                                    title: "=(device.desktop) ? 'Active Work Area' : 'Area'"
                                    visible: =items[props.basePrefix + '_Work_Area_Name'].state != 'NULL' && items[props.basePrefix + '_Work_Area_Name'].state != 'UNDEF'
                                - component: oh-label-item
                                  config:
                                    icon: f7:arrow_up_down_circle
                                    iconColor: green
                                    item: =props.basePrefix + '_Current_Cutting_Height'
                                    title: Cutting Height
                                - component: oh-list-item
                                  config:
                                    after: "=items[props.basePrefix + '_Last_Update'].state !== 'NULL' && items[props.basePrefix + '_Last_Update'].state !== 'UNDEF' ? dayjs(items[props.basePrefix + '_Last_Update'].state).format('DD-MMM HH:mm:ss') : '-'"
                                    icon: f7:clock
                                    iconColor: gray
                                    title: "=(device.desktop) ? 'Last Status Update' : 'Updated'"
                    - component: oh-grid-col
                      config:
                        desktop: "33"
                        width: "100"
                      slots:
                        default:
                          - component: oh-list-card
                            config:
                              accordionList: true
                              noShadow: true
                              outline: false
                              style:
                                -webkit-backdrop-filter: blur(12px)
                                backdrop-filter: blur(12px)
                                background: "=themeOptions.dark === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)'"
                                border: "=themeOptions.dark === 'dark' ? '1px solid rgba(255, 255, 255, 0.12)' : '1px solid rgba(0, 0, 0, 0.08)'"
                                border-radius: 12px
                                margin-bottom: 16px
                              title: Work Areas
                            slots:
                              default:
                                - component: oh-repeater
                                  config:
                                    accordionList: true
                                    for: i
                                    fragment: true
                                    itemTags: =props.workAreasTag
                                    sourceType: itemsWithTags
                                  slots:
                                    default:
                                      - component: oh-list-item
                                        config:
                                          accordionItem: true
                                          badge: "=items[loop.i.name + '_Enabled'].state !== 'ON' ? 'Disabled' : ((items[props.basePrefix + '_Summary_Status'].state === 'Mowing' || items[props.basePrefix + '_Summary_Status'].state === 'On The Way') && items[props.basePrefix + '_Work_Area_Name'].state === items[loop.i.name + '_Name'].state) ? 'Mowing' : 'Enabled'"
                                          badgeColor: "=items[loop.i.name + '_Enabled'].state !== 'ON' ? 'gray' : ((items[props.basePrefix + '_Summary_Status'].state === 'Mowing' || items[props.basePrefix + '_Summary_Status'].state === 'On The Way') && items[props.basePrefix + '_Work_Area_Name'].state === items[loop.i.name + '_Name'].state) ? 'blue' : 'green'"
                                          icon: f7:layers
                                          iconColor: "=((items[props.basePrefix + '_Summary_Status'].state === 'Mowing' || items[props.basePrefix + '_Summary_Status'].state === 'On The Way') && items[props.basePrefix + '_Work_Area_Name'].state === items[loop.i.name + '_Name'].state) ? 'green' : 'blue'"
                                          title: =items[loop.i.name + '_Name'].state
                                        slots:
                                          accordion:
                                            - component: oh-list
                                              config:
                                                nested: true
                                                style:
                                                  padding-left: 20px
                                              slots:
                                                default:
                                                  - component: oh-toggle-item
                                                    config:
                                                      icon: f7:power
                                                      iconColor: green
                                                      item: =loop.i.name + '_Enabled'
                                                      title: "=(device.desktop) ? 'Area Enabled' : 'Enabled'"
                                                  - component: oh-label-item
                                                    config:
                                                      icon: f7:chart_bar
                                                      iconColor: blue
                                                      item: =loop.i.name + '_Progress'
                                                      title: "=(device.desktop) ? 'Mowing Progress' : 'Progress'"
                                                  - component: oh-list-item
                                                    config:
                                                      after: "=items[loop.i.name + '_Last_Time_Completed'].state !== 'NULL' && items[loop.i.name + '_Last_Time_Completed'].state !== 'UNDEF' ? dayjs(items[loop.i.name + '_Last_Time_Completed'].state).format('DD-MMM HH:mm:ss') : '-'"
                                                      icon: f7:calendar_today
                                                      iconColor: gray
                                                      title: "=(device.desktop) ? 'Last Completed Time' : 'Completed'"
                                                  - component: oh-slider-item
                                                    config:
                                                      icon: f7:arrow_up_down_circle
                                                      iconColor: green
                                                      item: =loop.i.name + '_Cutting_Height'
                                                      label: true
                                                      max: =Number(props.maxCuttingHeight)
                                                      min: =Number(props.minCuttingHeight)
                                                      releaseOnly: true
                                                      step: 0.5
                                                      style:
                                                        --f7-list-item-after-font-weight: 600
                                                        --f7-list-item-after-min-width: 65px
                                                        --f7-range-bar-active-bg-color: "#1e90ff"
                                                        --f7-range-bar-bg-color: "=themeOptions.dark === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.04)'"
                                                        --f7-range-bar-border-radius: 7px
                                                        --f7-range-bar-size: 14px
                                                        --f7-range-knob-box-shadow: 0px 2px 6px rgba(0,0,0,0.3)
                                                        --f7-range-knob-color: "#ffffff"
                                                        --f7-range-knob-size: 22px
                                                        --f7-range-label-bg-color: "#1e90ff"
                                                        --f7-range-label-border-radius: 6px
                                                        --f7-range-label-text-color: "#ffffff"
                                                        padding-right: 4px
                                                      title: Cutting Height
                    - component: oh-grid-col
                      config:
                        desktop: "34"
                        width: "100"
                      slots:
                        default:
                          - component: oh-list-card
                            config:
                              noShadow: true
                              outline: false
                              style:
                                -webkit-backdrop-filter: blur(12px)
                                backdrop-filter: blur(12px)
                                background: "=themeOptions.dark === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.03)'"
                                border: "=themeOptions.dark === 'dark' ? '1px solid rgba(255, 255, 255, 0.12)' : '1px solid rgba(0, 0, 0, 0.08)'"
                                border-radius: 12px
                              title: Service & Maintenance
                            slots:
                              default:
                                - component: oh-label-item
                                  config:
                                    icon: f7:timer
                                    iconColor: gray
                                    item: =props.basePrefix + '_Total_Running_Time'
                                    title: "=(device.desktop) ? 'Total Running Time' : 'Total Hours'"
                                - component: oh-label-item
                                  config:
                                    icon: f7:bolt_circle_fill
                                    iconColor: orange
                                    item: =props.basePrefix + '_Number_of_Charging_Cycles'
                                    title: "=(device.desktop) ? 'Charging Cycles' : 'Charge Cycles'"
                                - component: oh-label-item
                                  config:
                                    icon: f7:scissors
                                    iconColor: red
                                    item: =props.basePrefix + '_Cutting_Blade_Usage_Time'
                                    title: "=(device.desktop) ? 'Cutting Blade Usage' : 'Blade Age'"
      grid: []
      masonry: []

And then update the 4 following parameters at the top of the page, to match your installation:

  • basePrefix - Base prefix for core items (e.g. automower_core)
  • workAreasTag - Independent tag assigned to work area items (e.g. automower_work_areas)
  • minCuttingHeight - Minimum allowed value for the cutting height slider
  • maxCuttingHeight - Maximum allowed value for the cutting height slider

Testing, Tips & Notes

I will add to this section, as I collect learnings

  • The logs will be your friend for any fault finding…Mismatched item names will make an appearance in there fairly quick !!
  • Remember that the Binding has a 5 minute update interval by default (for valid reasons) - Quite a few things will update immediately, others less so… (I think there was a recent change to using Websockets for some aspects, which may explain the immediate updates)
  • For the Work Area Names to display correctly in the UI, on Mobile, they need to be sufficiently short (Desktop is fine):
    • e.g. House South West, and House South East will both display in the Mobile UI as ‘House South’
    • Changing these to House SW and House SE fixed the problem. This is done in the Husqvarna App, in the ‘Map’ tab. As the work-area names are dynamically populated in the Main UI page, these will just come through automatically

Good luck…