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.