Husqvarna Automower Status Card

A status card for the Husqvarna Automower binding.

Husqvarna Automower only. Uses Husqvarna-specific activity strings (MOWING, LEAVING), error codes (0–12, 110), and Thing actions (parkUntilFurtherNotice() / resumeSchedule()).

Shows live activity (green/grey/red badge), error codes decoded to plain text, battery level (tap → Analyzer), and last-update timestamp.

Optional extras — all hidden when not configured:

  • Manual pause toggle — pause/resume the mower directly from the card; integrates with schedule automation via a NAND group
  • GPS satellite track map — collapsible accordion with today’s GPS track on Esri World Imagery (requires a companion HTML file copied to /etc/openhab/html/)
  • Weather guard strip — sunset / hot / cold / rain / frost icons; active guards are lit, inactive ones dimmed

Two setup tiers are documented: Tier 1 (five items, done) and Tier 2 (full automation with a NAND group, JS Scripting schedule rule, and Threshold Alert rules for weather conditions).

Screenshots

Changelog

Version 1.3.1

  • Removed the duplicate status sentence below the activity badge — it repeated the same MOWING/CHARGING/error info already shown in the badge next to the battery icon, using a hardcoded error-code table that duplicated the binding’s own status#error-message channel (already surfaced via the optional Summary Detail rule)
  • That row now shows only the Last Update timestamp

Version 1.3.0

  • Added Control Actions prop group with Home/Start/Pause button row (all optional — card stays status-only when these props are not wired)
    • Home button opens a park popover: 30 min, 3 hr, 24 hr, until next schedule, until further notice
    • Pause button (orange) appears when mowing/leaving; sends pause command to automowerManualPause item
    • Start button (green) appears when idle; sends resume command to automowerResume item
  • Added optional Work Area Name prop — shows the active work area below the status text
  • Improved inline status decoding: all activity/state values now show friendly labels (GOING_HOME → “Going Home”, CHARGING → “Charging”, NOT_APPLICABLE + IN_OPERATION → “Planning”, etc.) without requiring an external rule
  • Badge colour expanded: Charging → yellow, Going Home → teal (previously only Mowing/Leaving were coloured)
  • Bundled rules/automower_state_summary.js — community-contributed rule for richer status detail including work area progress percentage (advanced, optional)

Version 1.2.0

  • Added status overlay — semi-transparent chip in the top-right corner showing current lat/lon and last-updated time; appears once the first position is received
  • Fixed persistence fetch to use boundary=false, preventing OpenHAB from injecting artificial boundary points into the GPS track
  • Added Advanced: self-hosting section to README documenting how to serve Leaflet and satellite tiles locally for offline/resilient operation

Version 1.1.0

  • Added Persistence Service prop — dropdown selector (InfluxDB, RRD4J, MapDB, JDBC, or OpenHAB default) for the GPS track history service; passed to automower-map.html as ?serviceId= URL parameter
  • Reorganized widget props into three groups: Binding Channels, GPS Track Map, and Schedule & Weather Guards
  • Improved prop labels and descriptions throughout
  • GPS map setup docs updated to explain that RRD4J does not support Location items

Version 1.0.0

  • initial release

Resources

Hi @Ltty - Nice work - I had an initial look at this, and considering leveraging aspects for my local installation.

If you are interested, here are some suggestions:

  • Download and serve up the unpkg components from your local OpenHAB server
  • Ditto for downloading the Tiles, and serving them up from your OpenHAB server

I am definitely a fan of minimising dependencies on external systems/sites, so doing the above should help take these out of the mix, e.g. :

# Create directory tree
sudo -u openhab mkdir -p /etc/openhab/html/mower/leaflet/images
sudo -u openhab mkdir -p /etc/openhab/html/mower/tiles

# Download Leaflet JS and CSS (pinned to v1.9.4)
sudo -u openhab curl -sSL https://unpkg.com/leaflet@1.9.4/dist/leaflet.js -o /etc/openhab/html/mower/leaflet/leaflet.js
sudo -u openhab curl -sSL https://unpkg.com/leaflet@1.9.4/dist/leaflet.css -o /etc/openhab/html/mower/leaflet/leaflet.css

# Download standard Leaflet marker assets (referenced by leaflet.css)
sudo -u openhab curl -sSL https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png -o /etc/openhab/html/mower/leaflet/images/marker-icon.png
sudo -u openhab curl -sSL https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png -o /etc/openhab/html/mower/leaflet/images/marker-icon-2x.png
sudo -u openhab curl -sSL https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png -o /etc/openhab/html/mower/leaflet/images/marker-shadow.png

Gemini threw together a file for downloading the tiles as follows:

import os
import math
import time
import urllib.request

# Bounding box derived from your KML polygon coordinates with a buffer
NORTH_LAT = -xx.yyyy
SOUTH_LAT = -xx.yyyy
WEST_LON  = xxx.yyyy
EAST_LON  = xxx.yyyy

# Zoom levels 17 (neighborhood), 18 (property), 19 & 20 (high-res yard level)
ZOOM_LEVELS = range(17, 21)

OUTPUT_DIR = "/etc/openhab/html/mower/tiles"
TILE_URL_TEMPLATE = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"

def deg2num(lat_deg, lon_deg, zoom):
    lat_rad = math.radians(lat_deg)
    n = 2.0 ** zoom
    xtile = int((lon_deg + 180.0) / 360.0 * n)
    ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
    return (xtile, ytile)

count = 0
for z in ZOOM_LEVELS:
    x_min, y_min = deg2num(NORTH_LAT, WEST_LON, z)
    x_max, y_max = deg2num(SOUTH_LAT, EAST_LON, z)

    # In Web Mercator Y increases going south
    for x in range(min(x_min, x_max), max(x_min, x_max) + 1):
        for y in range(min(y_min, y_max), max(y_min, y_max) + 1):
            tile_dir = f"{OUTPUT_DIR}/{z}/{x}"
            os.makedirs(tile_dir, exist_ok=True)
            tile_path = f"{tile_dir}/{y}.jpg"

            if not os.path.exists(tile_path):
                url = TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
                req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (openHAB Yard Map Tile Downloader)'})
                try:
                    with urllib.request.urlopen(req) as resp, open(tile_path, 'wb') as f:
                        f.write(resp.read())
                    print(f"Downloaded tile: {z}/{x}/{y}.jpg")
                    count += 1
                    time.sleep(0.1) # Gentle throttling
                except Exception as e:
                    print(f"Failed to fetch {z}/{x}/{y}: {e}")
            else:
                print(f"Already exists: {z}/{x}/{y}.jpg")

print(f"\nDone! Downloaded {count} new tile(s) to {OUTPUT_DIR}")

Then a quick test (Generated by Gemini using your automower-map.html as the basis):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Automower Live Tracker</title>
  
  <!-- Locally hosted Leaflet CSS -->
  <link rel="stylesheet" href="leaflet/leaflet.css"/>
  
  <style>
    html, body {
      height: 100%;
      margin: 0;
      padding: 0;
      background: #1a1a2e;
      overflow: hidden;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    }
    #map {
      width: 100%;
      height: 100vh;
      background: #1a1a2e;
    }
    #msg {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #8888aa;
      font-size: 14px;
      pointer-events: none;
      z-index: 1000;
    }
    #status-overlay {
      position: absolute;
      top: 10px;
      right: 10px;
      background: rgba(26, 26, 46, 0.85);
      border: 1px solid rgba(255, 255, 255, 0.15);
      color: #e0e0e0;
      padding: 6px 12px;
      border-radius: 6px;
      font-size: 11px;
      line-height: 1.4;
      z-index: 999;
      pointer-events: none;
      backdrop-filter: blur(4px);
    }
  </style>
</head>
<body>

  <div id="map"></div>
  <div id="msg">Loading track…</div>
  <div id="status-overlay" style="display: none;"></div>

  <!-- Locally hosted Leaflet JS -->
  <script src="leaflet/leaflet.js"></script>
  
  <script>
    var params    = new URLSearchParams(window.location.search);
    var itemName  = params.get('item') || 'Automower_Location';
    var serviceId = params.get('serviceId') || 'influxdb';

    // Center of your property coordinates
    var YARD_CENTER = [-xx.xxxx, xxx.xxxx];

    // Bounding box from your polygon coordinates
    var YARD_BOUNDS = [
      [-xxx.yyyy, xxx.xxxx], // Southwest
      [-xxx.yyyy, xxx.xxxx]  // Northeast
    ];

    var map      = null;
    var marker   = null;
    var polyline = null;
    var path     = [];

    var MOWER_ICON = L.divIcon({
      html: '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26"' +
            ' style="filter:drop-shadow(0 2px 5px rgba(0,0,0,0.7))">' +
            '<circle cx="5"  cy="5"  r="3.2" fill="#222" stroke="#fff" stroke-width="1.2"/>' +
            '<circle cx="21" cy="5"  r="3.2" fill="#222" stroke="#fff" stroke-width="1.2"/>' +
            '<circle cx="5"  cy="21" r="3.2" fill="#222" stroke="#fff" stroke-width="1.2"/>' +
            '<circle cx="21" cy="21" r="3.2" fill="#222" stroke="#fff" stroke-width="1.2"/>' +
            '<rect x="5" y="5" width="16" height="16" rx="3" fill="#4CAF50" stroke="#fff" stroke-width="1.5"/>' +
            '<circle cx="13" cy="13" r="3.5" fill="rgba(255,255,255,0.25)" stroke="rgba(255,255,255,0.6)" stroke-width="1"/>' +
            '</svg>',
      className: '',
      iconSize: [26, 26],
      iconAnchor: [13, 13]
    });

    function updateOverlay(lat, lon) {
      var overlay = document.getElementById('status-overlay');
      var now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
      overlay.innerHTML = '<strong>' + itemName + '</strong><br>' +
                          lat.toFixed(6) + ', ' + lon.toFixed(6) + '<br>' +
                          '<span style="color:#888;">Updated: ' + now + '</span>';
      overlay.style.display = 'block';
    }

    function initMap(lat, lon) {
      document.getElementById('msg').style.display = 'none';
      if (map) return;

      var startLat = (lat !== undefined && lat !== null) ? lat : YARD_CENTER[0];
      var startLon = (lon !== undefined && lon !== null) ? lon : YARD_CENTER[1];

      map = L.map('map', {
        zoomControl: false,
        attributionControl: false,
        maxBounds: YARD_BOUNDS,
        maxBoundsViscosity: 0.8
      }).setView([startLat, startLon], 19);

      // Point directly to locally stored tiles
      L.tileLayer('tiles/{z}/{x}/{y}.jpg', {
        minZoom: 17,
        maxZoom: 21,
        maxNativeZoom: 20,
        errorTileUrl: ''
      }).addTo(map);

      window.addEventListener('resize', function() { map.invalidateSize(); });
      setTimeout(function() { map.invalidateSize(); }, 200);
    }

    function redraw() {
      if (!map || path.length === 0) return;
      var last = path[path.length - 1];

      if (polyline) map.removeLayer(polyline);
      if (path.length > 1) {
        polyline = L.polyline(path, {
          color: '#FF6B35',
          weight: 3,
          opacity: 0.9,
          lineJoin: 'round'
        }).addTo(map);
      }

      if (marker) map.removeLayer(marker);
      marker = L.marker([last[0], last[1]], { icon: MOWER_ICON }).addTo(map);
      updateOverlay(last[0], last[1]);
    }

    function parseState(text) {
      if (!text || text === 'NULL' || text === 'UNDEF') return null;
      var parts = text.trim().split(',');
      if (parts.length < 2) return null;
      var lat = parseFloat(parts[0]);
      var lon = parseFloat(parts[1]);
      return isNaN(lat) || isNaN(lon) ? null : [lat, lon];
    }

    function todayMidnightISO() {
      var d = new Date();
      d.setHours(0, 0, 0, 0);
      return d.toISOString();
    }

    function fetchHistory() {
      if (!itemName) {
        initMap(YARD_CENTER[0], YARD_CENTER[1]);
        return;
      }

      var url = '/rest/persistence/items/' + encodeURIComponent(itemName) +
                '?starttime=' + encodeURIComponent(todayMidnightISO()) +
                '&serviceId=' + encodeURIComponent(serviceId) +
                '&boundary=false';

      fetch(url)
        .then(function(r) { return r.ok ? r.json() : Promise.reject(r.status); })
        .then(function(data) {
          var pts = (data.data || []).map(function(d) { return parseState(d.state); })
                                     .filter(function(p) { return p !== null; });

          if (pts.length === 0) {
            document.getElementById('msg').textContent = 'No movement recorded today';
            fetchPosition();
            return;
          }

          path = pts;
          var last = path[path.length - 1];
          initMap(last[0], last[1]);
          redraw();
          map.fitBounds(L.polyline(path).getBounds(), { padding: [25, 25], maxZoom: 19 });
        })
        .catch(function() {
          fetchPosition();
        });
    }

    function fetchPosition() {
      if (!itemName) return;

      fetch('/rest/items/' + encodeURIComponent(itemName) + '/state', { headers: { Accept: 'text/plain' } })
        .then(function(r) { return r.ok ? r.text() : Promise.reject(r.status); })
        .then(function(text) {
          var pos = parseState(text);
          if (!pos) {
            initMap(YARD_CENTER[0], YARD_CENTER[1]);
            return;
          }

          initMap(pos[0], pos[1]);

          var last = path[path.length - 1];
          var moved = !last || Math.abs(last[0] - pos[0]) > 0.000005 || Math.abs(last[1] - pos[1]) > 0.000005;

          if (!moved) return;

          path.push(pos);
          redraw();
          map.panTo([pos[0], pos[1]]);
        })
        .catch(function() {
          initMap(YARD_CENTER[0], YARD_CENTER[1]);
        });
    }

    fetchHistory();
    setInterval(fetchPosition, 30000);
  </script>
</body>
</html>

As a basic test, for running locally, seems to work to display current position (Will have to wait a day or 2 to see history, as I only enabled persistence on that item tonight).

Have a look at Automower (Husqvarna) MainUI Page to see an approach I used to allow basic configuration and control of the mower.

You could consider using the Rule I created there, which creates a synthetic status item, which aligns mostly to what you would see in the Husqvarna app itself. It’s a bit friendlier than the raw items they present you !! Feel free to use this with your widget if you like.

I’ve updated the widget and included your optional improvements. Release notes in the original post :slight_smile:

Cheers for that - I will give that a proper look in the next week.

I did try the adapted test page using the local tiles today, including remotely via OpenHAB cloud. I am very sure that the ‘locally served’ tiles got cached on the OpenHAB client, as the scroll/zoom was extremely responsive.

Whilst this is based on observation, not empirical evidence, my home internet connection is bad, on a good day, especially upload (which is what the remote connection would use).

Note: Satellite imagery is updated periodically by Esri. Local tiles become stale over time. Re-run the downloader script periodically (e.g. yearly) to refresh them.

Good point - The script probably could be run from within OpenHAB (say via CRON on the last day of every month for example). I got Gemini to update this to a JS script, which also works nicely:

const Files = Java.type("java.nio.file.Files");
const Paths = Java.type("java.nio.file.Paths");
const StandardCopyOption = Java.type("java.nio.file.StandardCopyOption");
const URI = Java.type("java.net.URI");

const NORTH_LAT = -0.0000;
const SOUTH_LAT = -0.0000;
const WEST_LON  = 0.0000;
const EAST_LON  = 0.0000;
const ZOOM_LEVELS = [17, 18, 19, 20];

const OUTPUT_DIR = "/etc/openhab/html/mower/tiles";
const URL_TEMPLATE = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}";

function deg2num(lat, lon, zoom) {
  let latRad = (lat * Math.PI) / 180.0;
  let n = Math.pow(2, zoom);
  let xtile = Math.floor(((lon + 180.0) / 360.0) * n);
  let ytile = Math.floor((1.0 - Math.asinh(Math.tan(latRad)) / Math.PI) / 2.0 * n);
  return { x: xtile, y: ytile };
}

let updated = 0;
let unchanged = 0;

ZOOM_LEVELS.forEach(z => {
  let min = deg2num(NORTH_LAT, WEST_LON, z);
  let max = deg2num(SOUTH_LAT, EAST_LON, z);

  let xStart = Math.min(min.x, max.x);
  let xEnd   = Math.max(min.x, max.x);
  let yStart = Math.min(min.y, max.y);
  let yEnd   = Math.max(min.y, max.y);

  for (let x = xStart; x <= xEnd; x++) {
    for (let y = yStart; y <= yEnd; y++) {
      let targetDir = Paths.get(OUTPUT_DIR, String(z), String(x));
      let targetFile = Paths.get(OUTPUT_DIR, String(z), String(x), `${y}.jpg`);
      let tileUrl = URL_TEMPLATE.replace("{z}", z).replace("{x}", x).replace("{y}", y);

      let conn = URI.create(tileUrl).toURL().openConnection();
      conn.setRequestProperty("User-Agent", "openHAB-TileChecker/1.0");

      // If local file exists, tell server to only return data if modified since our local timestamp
      if (Files.exists(targetFile)) {
        let lastModifiedMillis = Files.getLastModifiedTime(targetFile).toMillis();
        conn.setIfModifiedSince(lastModifiedMillis);
      }

      conn.connect();
      let responseCode = conn.getResponseCode();

      if (responseCode === 200) {
        // 200 OK: Tile is new or updated
        Files.createDirectories(targetDir);
        let inStream = conn.getInputStream();
        Files.copy(inStream, targetFile, StandardCopyOption.REPLACE_EXISTING);
        inStream.close();

        // Sync local file timestamp to server's Last-Modified header if present
        let serverLastModified = conn.getLastModified();
        if (serverLastModified > 0) {
          let fileTime = Java.type("java.nio.file.attribute.FileTime").fromMillis(serverLastModified);
          Files.setLastModifiedTime(targetFile, fileTime);
        }
        updated++;
      } else if (responseCode === 304) {
        // 304 Not Modified: Imagery has not changed
        unchanged++;
      }
      
      conn.disconnect();
    }
  }
});

console.info(`Tile check finished: ${updated} updated/downloaded, ${unchanged} unchanged.`);

I’m using the automower binding’s work-area Thing to feed a status widget (originally based on the automower-control-page rule from this thread). Everything works except one channel:

  • Automower: 415X, single work area (“main area”)
  • work-area#name, work-area#cutting-height, work-area#enabled all report correctly and update live
  • work-area#progress (linked to a Number:Dimensionless item) has never received a single value — it’s stayed UNDEF since the Thing was created, including while the mower is actively mowing right now. Checked InfluxDB persistence history for that item: zero datapoints ever recorded.

Binding/OpenHAB: 5.2.1, automower binding via openHAB add-ons (not a custom build).

Is work-area#progress known to require something the base Connect API doesn’t provide, or is there a binding-side gotcha I’m missing? Want to confirm before I assume it’s just not available for this mower before giving up on showing “X% done in work area” in the widget.

Hi @Ltty - Its working for me:

And also gets picked up vie the synthetic status rule I am using as well.

If the area is set to ‘irregular’ mowing, you probably wont see any progress updates (I initially had an area configured like that).

My mower is a 430X, so same generation as yours. I’m also on OH 5.2.1.

Any clues in the binding trace-logs?

Alternatively (I have not needed to try this myself yet, so its just a theory), I think you can use the Husqavrna Developer API Portal to build a request - My thinking is if you cannot see the progress there, nothing you can do on the OpenHAB side, and visa -versa - If it does present the progress, and you cannot see it in OH, then maybe there is something funky in the binding.

Got it, you most likely have a 430x NERA, which supports systematic mowing. Unfortunately, the older 415/430x generation, which mine is, only support irregular mowing.