Files
x3-dashboard/x3_dashboard/server.py
T

1348 lines
53 KiB
Python

#!/usr/bin/env python3
"""Local XTeink X3 status feed.
Serves a small set of endpoints that can be consumed by current reader firmware
or future custom firmware:
/x3/status.json
/x3/status.md
/x3/status.svg
/x3/status.png
/x3/status.bmp
/x3/status.epub
/x3/opds
"""
from __future__ import annotations
import argparse
import datetime as dt
import html
import io
import json
import os
import sys
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import textwrap
from typing import Any
import urllib.error
import urllib.parse
import urllib.request
import zipfile
try:
from PIL import Image, ImageDraw, ImageFont
except Exception: # pragma: no cover - exercised on machines without Pillow
Image = None
ImageDraw = None
ImageFont = None
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_ENV_FILE = PROJECT_ROOT / ".env"
DEFAULT_OPTIONS_FILE = Path("/data/options.json")
DISPLAY_WIDTH = 528
DISPLAY_HEIGHT = 792
DEFAULT_REFRESH_SECONDS = 300
MESSAGE_HISTORY_HOURS = 168
MESSAGE_HISTORY_LIMIT = 50
MESSAGES_PER_PAGE = 5
DEFAULT_ENTITY_IDS = {
"wellbeing_days": "",
"steps_primary": "",
"steps_total": "",
"room_temp": "",
"outside_temp": "",
"solar_daily": "",
"battery_soc": "",
"power_consumption": "",
"local_calendar_sensor": "",
"calendar_1": "",
"calendar_2": "",
"room_lux": "",
"room_presence": "",
"desk_presence": "",
"window_open": "",
"projector": "",
"phone_battery": "",
"phone_battery_state": "",
"network_present": "",
"network_rssi": "",
"network_last_seen": "",
"network_direct_clients": "",
"network_direct_repeaters": "",
"network_node_one_online": "",
"network_node_two_online": "",
"latest_bridge": "",
"latest_external": "",
"latest_message": "",
}
DEFAULT_MESSAGE_ENTITY_IDS = {
"dashboard_pubsub": "",
"bridge": "",
"external": "",
"secure_external": "",
}
DEFAULT_OPTIONS: dict[str, Any] = {
"title": "X3 Dashboard",
"subtitle": "Home Assistant status",
"network_label": "Network",
"wellbeing_label": "Wellbeing",
"refresh_seconds": DEFAULT_REFRESH_SECONDS,
"message_history_hours": MESSAGE_HISTORY_HOURS,
"message_history_limit": MESSAGE_HISTORY_LIMIT,
"messages_per_page": MESSAGES_PER_PAGE,
"entity_ids": DEFAULT_ENTITY_IDS,
"message_entity_ids": DEFAULT_MESSAGE_ENTITY_IDS,
}
CONFIG: dict[str, Any] = DEFAULT_OPTIONS.copy()
def load_env(path: Path = DEFAULT_ENV_FILE) -> None:
if not path.exists():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def normalise_entity_map(values: dict[str, Any] | None, defaults: dict[str, str]) -> dict[str, str]:
merged = defaults.copy()
for key, raw_value in (values or {}).items():
target_key = str(key)
if target_key not in merged:
continue
merged[target_key] = str(raw_value or "").strip()
return merged
def load_options(path: Path = DEFAULT_OPTIONS_FILE) -> dict[str, Any]:
options = DEFAULT_OPTIONS.copy()
if path.exists():
loaded = json.loads(path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
options.update({key: value for key, value in loaded.items() if key not in {"entity_ids", "message_entity_ids"}})
options["entity_ids"] = normalise_entity_map(loaded.get("entity_ids"), DEFAULT_ENTITY_IDS)
options["message_entity_ids"] = normalise_entity_map(
loaded.get("message_entity_ids"), DEFAULT_MESSAGE_ENTITY_IDS
)
else:
options["entity_ids"] = DEFAULT_ENTITY_IDS.copy()
options["message_entity_ids"] = DEFAULT_MESSAGE_ENTITY_IDS.copy()
return options
def active_entity_ids() -> dict[str, str]:
return {
key: value
for key, value in (CONFIG.get("entity_ids") or {}).items()
if isinstance(value, str) and value.strip()
}
def active_message_entity_ids() -> dict[str, str]:
return {
key: value
for key, value in (CONFIG.get("message_entity_ids") or {}).items()
if isinstance(value, str) and value.strip()
}
def option_int(key: str, default: int, minimum: int = 1, maximum: int = 9999) -> int:
try:
parsed = int(str(CONFIG.get(key, default)).strip())
except (TypeError, ValueError):
return default
return max(minimum, min(maximum, parsed))
def now_local() -> dt.datetime:
return dt.datetime.now().astimezone()
def parse_ha_time(value: str | None) -> dt.datetime | None:
if not value or value in {"unknown", "unavailable", "none"}:
return None
try:
if value.endswith("Z"):
value = value[:-1] + "+00:00"
return dt.datetime.fromisoformat(value).astimezone()
except ValueError:
return None
def human_age(value: str | None, now: dt.datetime | None = None) -> str:
parsed = parse_ha_time(value)
if not parsed:
return "unknown"
now = now or now_local()
seconds = max(0, int((now - parsed).total_seconds()))
if seconds < 60:
return f"{seconds}s ago"
minutes = seconds // 60
if minutes < 60:
return f"{minutes}m ago"
hours = minutes // 60
if hours < 48:
return f"{hours}h ago"
return f"{hours // 24}d ago"
def clean_state(value: Any, fallback: str = "unknown") -> str:
if value is None:
return fallback
text = str(value).strip()
return text if text else fallback
def state_bool(value: str | None) -> bool | None:
if value == "on":
return True
if value == "off":
return False
return None
class HomeAssistantClient:
def __init__(self) -> None:
supervisor_token = os.getenv("SUPERVISOR_TOKEN", "")
ha_token = os.getenv("HA_TOKEN", "")
if supervisor_token and not ha_token:
self.base_url = os.getenv("HA_URL", "http://supervisor/core").rstrip("/")
self.token = supervisor_token
else:
self.base_url = os.getenv("HA_URL", "").rstrip("/")
self.token = ha_token
if not self.token:
raise RuntimeError("HA_TOKEN or SUPERVISOR_TOKEN is required.")
if not self.base_url:
raise RuntimeError("HA_URL is required when running outside the Home Assistant add-on.")
def state(self, entity_id: str) -> dict[str, Any] | None:
url = f"{self.base_url}/api/states/{urllib.parse.quote(entity_id, safe='.')}"
req = urllib.request.Request(
url,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=8) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise
def states(self, entity_ids: dict[str, str]) -> dict[str, dict[str, Any] | None]:
return {key: self.state(entity_id) for key, entity_id in entity_ids.items() if entity_id}
def history_for_entity(self, entity_id: str, start: dt.datetime) -> list[list[dict[str, Any]]]:
start_utc = start.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
query = urllib.parse.urlencode(
{
"filter_entity_id": entity_id,
"significant_changes_only": "0",
}
)
url = f"{self.base_url}/api/history/period/{urllib.parse.quote(start_utc, safe='')}?{query}"
req = urllib.request.Request(
url,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=12) as resp:
data = json.loads(resp.read().decode("utf-8"))
return [series for series in data if isinstance(series, list)] if isinstance(data, list) else []
def history(self, entity_ids: list[str], start: dt.datetime) -> list[list[dict[str, Any]]]:
all_series: list[list[dict[str, Any]]] = []
if not entity_ids:
return all_series
fallback_start = max(start, now_local() - dt.timedelta(hours=6))
for entity_id in entity_ids:
series = self.history_for_entity(entity_id, start)
if not series and fallback_start > start:
series = self.history_for_entity(entity_id, fallback_start)
all_series.extend(series)
return all_series
def st(states: dict[str, dict[str, Any] | None], key: str, fallback: str = "unknown") -> str:
return clean_state((states.get(key) or {}).get("state"), fallback)
def attr(states: dict[str, dict[str, Any] | None], key: str, name: str, fallback: Any = None) -> Any:
return ((states.get(key) or {}).get("attributes") or {}).get(name, fallback)
def parse_bridge_signature(value: str) -> dict[str, str]:
"""Parse HA bridge signatures like '1783779161|g8py<->#g8py||message'."""
if value in {"unknown", "unavailable", "", "none"}:
return {"time": "unknown", "route": "", "message": "No recent message"}
parts = value.split("|", 2)
timestamp = parts[0] if parts else ""
route = parts[1] if len(parts) > 2 else ""
message = parts[2] if len(parts) > 2 else parts[1] if len(parts) > 1 else value
message = message.lstrip("|").strip()
time_text = "unknown"
try:
ts = int(timestamp)
time_text = dt.datetime.fromtimestamp(ts).astimezone().strftime("%H:%M")
except ValueError:
pass
return {
"time": time_text,
"route": route.strip(),
"message": message or "No recent message",
}
def epoch_to_local(timestamp: str | int | None) -> dt.datetime | None:
try:
return dt.datetime.fromtimestamp(int(str(timestamp).strip())).astimezone()
except (TypeError, ValueError, OSError):
return None
def message_time_text(timestamp: str | int | None, fallback: str | None = None) -> str:
parsed = epoch_to_local(timestamp)
if parsed:
return parsed.strftime("%a %H:%M")
fallback_dt = parse_ha_time(fallback)
if fallback_dt:
return fallback_dt.strftime("%a %H:%M")
return "unknown"
def parse_x3_message_signature(value: str, fallback_time: str | None = None) -> dict[str, Any] | None:
"""Parse 'timestamp|source|title|message' values from x3_message_feed_last."""
if value in {"unknown", "unavailable", "", "none"}:
return None
parts = value.split("|", 3)
if len(parts) < 4:
return None
timestamp, source, title, message = parts
parsed_time = epoch_to_local(timestamp) or parse_ha_time(fallback_time)
title = title.strip()
message = message.strip()
if not message and not title:
return None
return {
"timestamp": int(parsed_time.timestamp()) if parsed_time else 0,
"time": message_time_text(timestamp, fallback_time),
"source": source.strip() or "HA",
"title": title,
"message": message or title,
"kind": "pubsub",
}
def parse_bridge_message_signature(source: str, value: str, fallback_time: str | None = None) -> dict[str, Any] | None:
if value in {"unknown", "unavailable", "", "none"}:
return None
parts = value.split("|", 2)
timestamp = parts[0] if parts else ""
parsed_time = epoch_to_local(timestamp) or parse_ha_time(fallback_time)
bridge = parse_bridge_signature(value)
message = bridge["message"].strip()
if not message or message == "No recent message":
return None
title = "Message"
if source == "bridge" and bridge["route"]:
title = bridge["route"].replace("<->", " to ")
elif source in {"external", "secure_external"}:
title = "External message"
return {
"timestamp": int(parsed_time.timestamp()) if parsed_time else 0,
"time": message_time_text(timestamp, fallback_time),
"source": title,
"title": title,
"message": message,
"kind": "mesh",
}
def parse_message_state(source: str, state: str, fallback_time: str | None = None) -> dict[str, Any] | None:
if source == "dashboard_pubsub":
return parse_x3_message_signature(state, fallback_time)
return parse_bridge_message_signature(source, state, fallback_time)
def compact_message_key(message: dict[str, Any]) -> tuple[int, str]:
text = str(message.get("message", ""))
return int(message.get("timestamp") or 0), " ".join(text.lower().split())
def dedupe_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[tuple[int, str]] = set()
unique: list[dict[str, Any]] = []
for message in sorted(messages, key=lambda item: int(item.get("timestamp") or 0), reverse=True):
key = compact_message_key(message)
if key in seen:
continue
seen.add(key)
unique.append(message)
return unique
def positive_int(value: str | None, default: int, minimum: int = 1, maximum: int = 999) -> int:
try:
parsed = int(str(value or "").strip())
except ValueError:
return default
return max(minimum, min(maximum, parsed))
def query_positive_int(values: list[str] | None, default: int, minimum: int = 1, maximum: int = 999) -> int:
for value in reversed(values or []):
try:
parsed = int(str(value or "").strip())
except ValueError:
continue
return max(minimum, min(maximum, parsed))
return default
def page_count(total_items: int, per_page: int) -> int:
return max(1, (total_items + per_page - 1) // per_page)
def choose_steps(states: dict[str, dict[str, Any] | None]) -> str:
primary = st(states, "steps_primary", "")
if primary and primary not in {"unknown", "unavailable"}:
return primary
total = st(states, "steps_total", "")
return total if total else "unknown"
def build_calendar_events(states: dict[str, dict[str, Any] | None], now: dt.datetime, days_ahead: int = 14) -> list[dict[str, Any]]:
"""Combine events from CalDAV calendars (HA service) and local sensor."""
import urllib.parse
all_events: list[dict[str, Any]] = []
caldav_keys = ["calendar_1", "calendar_2"]
caldav_entity_ids = [CONFIG.get("entity_ids", {}).get(k, "") for k in caldav_keys]
caldav_entity_ids = [eid for eid in caldav_entity_ids if eid and eid.startswith("calendar.")]
local_sensor_id = CONFIG.get("entity_ids", {}).get("local_calendar_sensor", "")
blacklist_str = str(CONFIG.get("calendar_blacklist") or "")
blacklist_keywords = [kw.strip().lower() for kw in blacklist_str.split(",") if kw.strip()]
client = None
end_date = now + dt.timedelta(days=days_ahead)
start_iso = now.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
end_iso = end_date.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
for cal_id in caldav_entity_ids:
try:
if client is None:
client = HomeAssistantClient()
payload = {
"entity_id": cal_id,
"start_date_time": start_iso,
"end_date_time": end_iso,
}
url = f"{client.base_url}/api/services/calendar/get_events?return_response"
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {client.token}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=12) as resp:
data = json.loads(resp.read().decode("utf-8"))
events = data.get("service_response", {}).get(cal_id, {}).get("events", [])
for ev in events:
start_raw = ev.get("start", "?")
if isinstance(start_raw, dict):
start_raw = start_raw.get("dateTime", start_raw.get("date", "?"))
end_raw = ev.get("end", "?")
if isinstance(end_raw, dict):
end_raw = end_raw.get("dateTime", end_raw.get("date", "?"))
all_events.append({
"summary": ev.get("summary", "?"),
"start": start_raw,
"end": end_raw,
"source": "caldav",
})
except Exception as exc:
print(f"calendar fetch error for {cal_id}: {exc}", file=sys.stderr)
if local_sensor_id:
sensor_state = states.get("local_calendar_sensor")
if sensor_state and sensor_state.get("attributes", {}).get("events"):
for ev in sensor_state["attributes"]["events"]:
all_events.append({
"summary": ev.get("summary", "?"),
"start": ev.get("start", "?"),
"end": ev.get("end", "?"),
"source": "local",
})
def sort_key(ev: dict[str, Any]) -> str:
s = str(ev.get("start", ""))
try:
return dt.datetime.fromisoformat(s).isoformat()
except (ValueError, TypeError):
return "9999"
all_events.sort(key=sort_key)
# Vergangene Termine herausfiltern (ab jetzt)
now_iso = now.isoformat()
all_events = [ev for ev in all_events if str(ev.get("start", "")) >= now_iso]
# Blacklist: Termine mit bestimmten Stichworten ausblenden
if blacklist_keywords:
all_events = [ev for ev in all_events if not any(kw in str(ev.get("summary", "")).lower() for kw in blacklist_keywords)]
# Dedupliziere Termine mit gleichem summary + start
seen = set()
unique_events = []
for ev in all_events:
key = (str(ev.get("summary", "")).strip().lower(), str(ev.get("start", ""))[:10])
if key not in seen:
seen.add(key)
unique_events.append(ev)
return unique_events[:15]
def build_rain_forecast(now: dt.datetime) -> str:
"""Check weather forecast for rain in next 24h, return 'vorauss. Regen um HH:MM' or 'kein Regen erwartet'."""
try:
client = HomeAssistantClient()
payload = {"entity_id": "weather.forecast_home", "type": "hourly"}
url = f"{client.base_url}/api/services/weather/get_forecasts?return_response"
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {client.token}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=12) as resp:
data = json.loads(resp.read().decode("utf-8"))
forecast = data.get("service_response", {}).get("weather.forecast_home", {}).get("forecast", [])
for f in forecast[:24]:
cond = str(f.get("condition", ""))
precip = f.get("precipitation", 0) or 0
if cond in ("rainy", "pouring", "snowy", "snowy-rainy") or precip > 0.1:
dt_str = f.get("datetime", "")
try:
dt_obj = dt.datetime.fromisoformat(dt_str)
return f"vorauss. Regen um {dt_obj.strftime('%H:%M')} Uhr"
except (ValueError, TypeError):
return "vorauss. Regen"
return "kein Regen (24h)"
except Exception:
return "Wetter unbekannt"
def build_feed() -> dict[str, Any]:
now = now_local()
client = HomeAssistantClient()
entity_ids = active_entity_ids()
states = client.states(entity_ids)
network_label = str(CONFIG.get("network_label") or "Network")
wellbeing_label = str(CONFIG.get("wellbeing_label") or "Wellbeing")
latest_bridge = parse_bridge_signature(st(states, "latest_bridge", ""))
latest_external = parse_bridge_signature(st(states, "latest_external", ""))
latest_x3_message = parse_message_state(
"dashboard_pubsub",
st(states, "latest_message", ""),
(states.get("latest_message") or {}).get("last_changed"),
)
room_presence = state_bool(st(states, "room_presence", "unknown"))
projector = state_bool(st(states, "projector", "unknown"))
window_open = state_bool(st(states, "window_open", "unknown"))
network_present = state_bool(st(states, "network_present", "unknown"))
node_one_online = state_bool(st(states, "network_node_one_online", "unknown"))
node_two_online = state_bool(st(states, "network_node_two_online", "unknown"))
office_status = "occupied" if room_presence else "empty" if room_presence is False else "unknown"
projector_status = "projector on" if projector else "projector off" if projector is False else "projector unknown"
window_status = "window open" if window_open else "window closed" if window_open is False else "window unknown"
mesh_status = "online" if network_present else "offline" if network_present is False else "unknown"
room_temp_val = st(states, "room_temp", "unknown")
outside_temp_val = st(states, "outside_temp", "unknown")
solar_daily_val = st(states, "solar_daily", "unknown")
battery_soc_val = st(states, "battery_soc", "unknown")
power_consumption_val = st(states, "power_consumption", "unknown")
rain_forecast = build_rain_forecast(now)
headline_parts = []
if network_present:
headline_parts.append(f"{network_label} OK")
if st(states, "wellbeing_days", "unknown") not in {"unknown", "unavailable"}:
headline_parts.append(f"{st(states, 'wellbeing_days')} days")
headline = " · ".join(headline_parts) or str(CONFIG.get("title") or "X3 status")
payload = {
"generated_at": now.isoformat(timespec="seconds"),
"display": {"width": DISPLAY_WIDTH, "height": DISPLAY_HEIGHT, "orientation": "portrait"},
"headline": headline,
"summary": [
{"label": wellbeing_label, "value": f"{st(states, 'wellbeing_days')} days"},
{"label": "Steps", "value": choose_steps(states)},
{"label": "Room", "value": f"{office_status}, {room_temp_val}C"},
{"label": network_label, "value": f"{mesh_status}, RSSI {st(states, 'network_rssi')} dBm"},
],
"office": {
"presence": office_status,
"desk": st(states, "desk_presence"),
"temperature_c": room_temp_val,
"outside_temp_c": outside_temp_val,
"rain_forecast": rain_forecast,
"solar_daily": solar_daily_val,
"battery_soc": battery_soc_val,
"power_consumption": power_consumption_val,
"lux": st(states, "room_lux"),
"projector": projector_status,
"window": window_status,
},
"network": {
"label": network_label,
"status": mesh_status,
"rssi_dbm": st(states, "network_rssi"),
"last_seen": st(states, "network_last_seen"),
"last_seen_age": human_age(st(states, "network_last_seen"), now),
"direct_clients": st(states, "network_direct_clients"),
"direct_repeaters": st(states, "network_direct_repeaters"),
"loft_online": bool(node_one_online),
"loft_yagi_online": bool(node_two_online),
"latest_bridge": latest_bridge,
"latest_external": latest_external,
},
"latest_message": latest_x3_message
or {
"time": latest_bridge["time"],
"source": latest_bridge["route"] or "Message",
"title": latest_bridge["route"] or "Message",
"message": latest_bridge["message"],
"kind": "bridge",
},
"phone": {
"battery_percent": st(states, "phone_battery"),
"battery_state": st(states, "phone_battery_state"),
},
"calendar_events": build_calendar_events(states, now),
"raw_entity_ids": entity_ids,
}
return payload
def build_message_feed(
hours: int | None = None,
limit: int | None = None,
page: int = 1,
per_page: int | None = None,
) -> dict[str, Any]:
now = now_local()
client = HomeAssistantClient()
message_entity_ids = active_message_entity_ids()
hours = hours or option_int("message_history_hours", MESSAGE_HISTORY_HOURS, minimum=1, maximum=720)
limit = limit or option_int("message_history_limit", MESSAGE_HISTORY_LIMIT, minimum=1, maximum=500)
per_page = per_page or option_int("messages_per_page", MESSAGES_PER_PAGE, minimum=1, maximum=10)
start = now - dt.timedelta(hours=hours)
entity_lookup = {entity_id: key for key, entity_id in message_entity_ids.items()}
messages: list[dict[str, Any]] = []
try:
history = client.history(list(message_entity_ids.values()), start)
except Exception:
history = []
for series in history:
for row in series:
if not isinstance(row, dict):
continue
source = entity_lookup.get(str(row.get("entity_id", "")))
if not source:
continue
parsed = parse_message_state(source, clean_state(row.get("state"), ""), row.get("last_changed"))
if parsed:
messages.append(parsed)
states = client.states(message_entity_ids)
for source, state_data in states.items():
parsed = parse_message_state(source, clean_state((state_data or {}).get("state"), ""), (state_data or {}).get("last_changed"))
if parsed:
messages.append(parsed)
all_messages = dedupe_messages(messages)[:limit]
total_messages = len(all_messages)
total_pages = page_count(total_messages, per_page)
page = max(1, min(page, total_pages))
start_index = (page - 1) * per_page
page_messages = all_messages[start_index : start_index + per_page]
return {
"generated_at": now.isoformat(timespec="seconds"),
"display": {"width": DISPLAY_WIDTH, "height": DISPLAY_HEIGHT, "orientation": "portrait"},
"title": "X3 Messages",
"subtitle": str(CONFIG.get("subtitle") or "Home Assistant status"),
"hours": hours,
"pagination": {
"page": page,
"page_count": total_pages,
"per_page": per_page,
"total": total_messages,
"has_previous": page > 1,
"has_next": page < total_pages,
},
"messages": page_messages,
"raw_entity_ids": message_entity_ids,
}
def build_dashboard_manifest(base_url: str = "") -> dict[str, Any]:
message_feed = build_message_feed(page=1)
pagination = message_feed["pagination"]
prefix = base_url.rstrip("/")
refresh_seconds = option_int("refresh_seconds", DEFAULT_REFRESH_SECONDS, minimum=30, maximum=86400)
return {
"version": 1,
"generated_at": now_local().isoformat(timespec="seconds"),
"display": {"width": DISPLAY_WIDTH, "height": DISPLAY_HEIGHT, "orientation": "portrait"},
"refresh_seconds": refresh_seconds,
"default_page": "status",
"controls": {
"next_page": "right",
"previous_page": "left",
"next_message_page": "down",
"previous_message_page": "up",
"refresh": "confirm",
"home": "back",
},
"pages": [
{
"id": "status",
"title": "Status",
"type": "image",
"content_type": "image/bmp",
"url": f"{prefix}/x3/status.bmp",
"refresh_seconds": refresh_seconds,
},
{
"id": "messages",
"title": "Messages",
"type": "paged_image",
"content_type": "image/bmp",
"url": f"{prefix}/x3/messages.bmp?page={{page}}",
"page": 1,
"page_count": pagination["page_count"],
"refresh_seconds": refresh_seconds,
},
],
}
def render_markdown(feed: dict[str, Any]) -> str:
mesh = feed["network"]
office = feed["office"]
phone = feed["phone"]
latest = feed.get("latest_message") or mesh["latest_bridge"]
lines = [
f"# {feed['headline']}",
"",
f"Updated: {feed['generated_at']}",
"",
"## Now",
]
for item in feed["summary"]:
lines.append(f"- {item['label']}: {item['value']}")
lines.extend(
[
"",
"## Küche",
f"- Innen: {office['temperature_c']} C",
f"- Aussen: {office['outside_temp_c']} C",
f"- Solar heute: {office['solar_daily']} kWh",
f"- Batterie: {office['battery_soc']}%",
f"- Netz: {office['power_consumption']} kWh",
"",
f"## {mesh.get('label', 'Network')}",
f"- {mesh['status']}, RSSI {mesh['rssi_dbm']} dBm",
f"- Seen {mesh['last_seen_age']}",
f"- Direct: {mesh['direct_clients']} clients, {mesh['direct_repeaters']} repeaters",
f"- Node 1: {'online' if mesh['loft_online'] else 'offline'}",
f"- Node 2: {'online' if mesh['loft_yagi_online'] else 'offline'}",
"",
"## Letzte Meldung",
f"{latest.get('time', 'unknown')} {latest.get('source', '')}",
"",
latest.get("message", "No recent message"),
"",
]
)
return "\n".join(lines)
def render_messages_markdown(feed: dict[str, Any]) -> str:
pagination = feed.get("pagination", {})
lines = [
f"# {feed['title']}",
"",
f"Page {pagination.get('page', 1)} of {pagination.get('page_count', 1)}",
"",
f"Updated: {feed['generated_at']}",
"",
]
messages = feed.get("messages", [])
if not messages:
lines.append("No recent messages.")
for message in messages:
title = message.get("title") or message.get("source") or "Message"
lines.extend(
[
f"## {message.get('time', 'unknown')} {title}",
message.get("message", ""),
"",
]
)
return "\n".join(lines)
def wrap_svg_text(text: str, max_chars: int) -> list[str]:
wrapped: list[str] = []
for line in text.splitlines():
if not line:
wrapped.append("")
else:
wrapped.extend(textwrap.wrap(line, width=max_chars, break_long_words=False) or [""])
return wrapped
def render_svg(feed: dict[str, Any]) -> str:
mesh = feed["network"]
office = feed["office"]
latest = feed.get("latest_message") or mesh["latest_bridge"]
rows = [
(40, feed["headline"], 34, "bold"),
(92, now_local().strftime("%a %H:%M"), 54, "bold"),
(168, f"Küche: Innen {office['temperature_c']}C Aussen {office['outside_temp_c']}C", 28, "bold"),
(210, f"Solar {office['solar_daily']} kWh · Batterie {office['battery_soc']}%", 26, "normal"),
(264, f"Netz {office['power_consumption']} kWh · {mesh.get('label', 'Network')} {mesh['status']}", 28, "bold"),
(330, f"Seen {mesh['last_seen_age']} · {mesh['direct_clients']} clients", 24, "normal"),
(430, "Letzte Meldung", 28, "bold"),
]
message_lines = wrap_svg_text(f"{latest.get('time', 'unknown')} {latest.get('message', '')}", 24)[:7]
y = 472
for line in message_lines:
rows.append((y, line, 25, "normal"))
y += 34
rows.extend(
[
(758, feed["generated_at"].split("+")[0].replace("T", " "), 18, "normal"),
]
)
text_nodes = "\n".join(
f'<text x="24" y="{y}" font-size="{size}" font-weight="{weight}">{html.escape(text)}</text>'
for y, text, size, weight in rows
)
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{DISPLAY_WIDTH}" height="{DISPLAY_HEIGHT}" viewBox="0 0 {DISPLAY_WIDTH} {DISPLAY_HEIGHT}">
<rect width="100%" height="100%" fill="white"/>
<rect x="16" y="16" width="{DISPLAY_WIDTH - 32}" height="{DISPLAY_HEIGHT - 32}" fill="none" stroke="black" stroke-width="3"/>
<line x1="24" y1="122" x2="{DISPLAY_WIDTH - 24}" y2="122" stroke="black" stroke-width="2"/>
<line x1="24" y1="300" x2="{DISPLAY_WIDTH - 24}" y2="300" stroke="black" stroke-width="2"/>
<line x1="24" y1="408" x2="{DISPLAY_WIDTH - 24}" y2="408" stroke="black" stroke-width="2"/>
<g font-family="Arial, Helvetica, sans-serif" fill="black">
{text_nodes}
</g>
</svg>
"""
def load_font(size: int, bold: bool = False) -> Any:
assert ImageFont is not None
candidates = [
"/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Supplemental/Verdana Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Verdana.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
]
for path in candidates:
if path and Path(path).exists():
try:
return ImageFont.truetype(path, size=size)
except Exception:
continue
return ImageFont.load_default(size=size)
def draw_wrapped(draw: Any, xy: tuple[int, int], text: str, font: Any, width: int, line_gap: int = 8, max_lines: int | None = None) -> int:
x, y = xy
words = text.split()
lines: list[str] = []
current = ""
for word in words:
trial = f"{current} {word}".strip()
bbox = draw.textbbox((0, 0), trial, font=font)
if bbox[2] - bbox[0] <= width or not current:
current = trial
else:
lines.append(current)
current = word
if current:
lines.append(current)
if max_lines is not None:
lines = lines[:max_lines]
for line in lines:
draw.text((x, y), line, font=font, fill=0)
bbox = draw.textbbox((x, y), line, font=font)
y = bbox[3] + line_gap
return y
def render_status_image(feed: dict[str, Any]) -> Any:
if Image is None or ImageDraw is None or ImageFont is None:
raise RuntimeError("Pillow is not installed. Run: python3 -m pip install -r x3-feed/requirements.txt")
img = Image.new("L", (DISPLAY_WIDTH, DISPLAY_HEIGHT), 255)
draw = ImageDraw.Draw(img)
title_font = load_font(34, bold=True)
clock_font = load_font(58, bold=True)
section_font = load_font(28, bold=True)
body_font = load_font(25, bold=False)
small_font = load_font(18, bold=False)
draw.rectangle((16, 16, DISPLAY_WIDTH - 16, DISPLAY_HEIGHT - 16), outline=0, width=3)
draw.text((24, 30), now_local().strftime("%a %H:%M"), font=clock_font, fill=0)
draw.line((24, 100, DISPLAY_WIDTH - 24, 100), fill=0, width=2)
office = feed["office"]
y = 120
draw.text((24, y), "Wetter", font=section_font, fill=0)
y += 42
draw.text((24, y), f"Regen: {office.get('rain_forecast', 'unbekannt')}", font=body_font, fill=0)
y += 32
draw.text((24, y), f"Innen: {office['temperature_c']} C", font=body_font, fill=0)
y += 32
draw.text((24, y), f"Aussen: {office['outside_temp_c']} C", font=body_font, fill=0)
draw.line((24, 260, DISPLAY_WIDTH - 24, 260), fill=0, width=2)
events = feed.get("calendar_events", [])
y = 284
if not events:
draw.text((24, y), "Keine Termine", font=body_font, fill=0)
else:
ev_font = load_font(22, bold=False)
date_font = load_font(22, bold=True)
grouped: dict[str, list[str]] = {}
order: list[str] = []
for ev in events[:15]:
start = str(ev.get("start", ""))
try:
start_dt = dt.datetime.fromisoformat(start)
date_key = start_dt.strftime("%a %d.%m.")
except (ValueError, TypeError):
date_key = start[:10]
if date_key not in grouped:
grouped[date_key] = []
order.append(date_key)
grouped[date_key].append(str(ev.get("summary", "?")))
for date_key in order:
if y > 760:
break
draw.text((24, y), date_key, font=date_font, fill=0)
y += 28
summaries = ", ".join(grouped[date_key])
y = draw_wrapped(draw, (24, y), summaries, ev_font, DISPLAY_WIDTH - 48, line_gap=3, max_lines=2)
y += 12
return img
def render_png(feed: dict[str, Any]) -> bytes:
img = render_status_image(feed)
out = io.BytesIO()
img.save(out, format="PNG", optimize=True)
return out.getvalue()
def render_bmp(feed: dict[str, Any]) -> bytes:
img = render_status_image(feed)
out = io.BytesIO()
img.save(out, format="BMP")
return out.getvalue()
def render_messages_image(feed: dict[str, Any]) -> Any:
if Image is None or ImageDraw is None or ImageFont is None:
raise RuntimeError("Pillow is not installed. Run: python3 -m pip install -r x3-feed/requirements.txt")
img = Image.new("L", (DISPLAY_WIDTH, DISPLAY_HEIGHT), 255)
draw = ImageDraw.Draw(img)
title_font = load_font(36, bold=True)
section_font = load_font(24, bold=True)
body_font = load_font(21, bold=False)
small_font = load_font(17, bold=False)
draw.rectangle((16, 16, DISPLAY_WIDTH - 16, DISPLAY_HEIGHT - 16), outline=0, width=3)
draw.text((24, 34), "Messages", font=title_font, fill=0)
draw.text((24, 80), str(CONFIG.get("subtitle") or "Home Assistant status"), font=body_font, fill=0)
pagination = feed.get("pagination", {})
page_text = f"Page {pagination.get('page', 1)}/{pagination.get('page_count', 1)}"
draw.text((24, 112), f"{now_local().strftime('%a %H:%M')} {page_text}", font=section_font, fill=0)
draw.line((24, 150, DISPLAY_WIDTH - 24, 150), fill=0, width=2)
messages = feed.get("messages", [])
y = 170
if not messages:
draw.text((24, y), "No recent messages", font=body_font, fill=0)
for message in messages:
if y > DISPLAY_HEIGHT - 80:
break
source = message.get("source") or message.get("title") or "Message"
header = f"{message.get('time', 'unknown')} {source}"
draw_wrapped(draw, (24, y), header, section_font, DISPLAY_WIDTH - 48, line_gap=3, max_lines=1)
y += 32
y = draw_wrapped(draw, (24, y), message.get("message", ""), body_font, DISPLAY_WIDTH - 48, line_gap=4, max_lines=3)
y += 12
draw.line((24, y, DISPLAY_WIDTH - 24, y), fill=0, width=1)
y += 14
nav_hint = "Up/Down pages Left/Right tabs"
if not pagination.get("has_previous") and not pagination.get("has_next"):
nav_hint = "Left/Right tabs"
footer = f"{nav_hint} {feed['generated_at'].split('+')[0].replace('T', ' ')}"
draw.text((24, DISPLAY_HEIGHT - 42), footer, font=small_font, fill=0)
return img
def render_messages_png(feed: dict[str, Any]) -> bytes:
img = render_messages_image(feed)
out = io.BytesIO()
img.save(out, format="PNG", optimize=True)
return out.getvalue()
def render_messages_bmp(feed: dict[str, Any]) -> bytes:
img = render_messages_image(feed)
out = io.BytesIO()
img.save(out, format="BMP")
return out.getvalue()
def render_opds(feed: dict[str, Any]) -> str:
updated = feed["generated_at"]
title = "Home Assistant Dashboard"
entry_title = "X3 Dashboard"
return f"""<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:crosspoint:x3-ha-dashboard</id>
<title>{html.escape(title)}</title>
<updated>{html.escape(updated)}</updated>
<entry>
<id>urn:crosspoint:x3-ha-dashboard:current</id>
<title>{html.escape(entry_title)}</title>
<updated>{html.escape(updated)}</updated>
<author><name>Home Assistant</name></author>
<summary>{html.escape(feed["headline"])} · {html.escape(now_local().strftime('%a %H:%M'))}</summary>
<link rel="http://opds-spec.org/acquisition" href="/x3/status.epub" type="application/epub+zip"/>
</entry>
</feed>
"""
def render_epub(feed: dict[str, Any]) -> bytes:
png = render_png(feed)
title = "X3 Home Assistant Dashboard"
body = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>{html.escape(title)}</title>
<style>
body {{ margin: 0; padding: 0; }}
img {{ display: block; width: 100%; height: auto; }}
</style>
</head>
<body>
<img src="status.png" alt="Home Assistant dashboard"/>
</body>
</html>
"""
nav = f"""<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en">
<head><title>{html.escape(title)}</title></head>
<body>
<nav epub:type="toc" id="toc">
<h1>{html.escape(title)}</h1>
<ol><li><a href="dashboard.xhtml">Dashboard</a></li></ol>
</nav>
</body>
</html>
"""
opf = f"""<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="book-id">urn:crosspoint:x3-ha-dashboard:current</dc:identifier>
<dc:title>{html.escape(title)}</dc:title>
<dc:creator>Home Assistant</dc:creator>
<dc:language>en</dc:language>
<meta property="dcterms:modified">{feed["generated_at"].split("+")[0]}Z</meta>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item id="dashboard" href="dashboard.xhtml" media-type="application/xhtml+xml"/>
<item id="status-png" href="status.png" media-type="image/png"/>
</manifest>
<spine>
<itemref idref="dashboard"/>
</spine>
</package>
"""
container = """<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
"""
out = io.BytesIO()
with zipfile.ZipFile(out, "w") as epub:
epub.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", compress_type=zipfile.ZIP_STORED)
epub.writestr("META-INF/container.xml", container, compress_type=zipfile.ZIP_DEFLATED)
epub.writestr("OEBPS/content.opf", opf, compress_type=zipfile.ZIP_DEFLATED)
epub.writestr("OEBPS/nav.xhtml", nav, compress_type=zipfile.ZIP_DEFLATED)
epub.writestr("OEBPS/dashboard.xhtml", body, compress_type=zipfile.ZIP_DEFLATED)
epub.writestr("OEBPS/status.png", png, compress_type=zipfile.ZIP_DEFLATED)
return out.getvalue()
class X3Handler(BaseHTTPRequestHandler):
server_version = "X3Feed/0.1"
def log_message(self, fmt: str, *args: Any) -> None:
print(f"{self.address_string()} - {fmt % args}")
def send_bytes(self, body: bytes, content_type: str, status: HTTPStatus = HTTPStatus.OK) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def send_text(self, text: str, content_type: str, status: HTTPStatus = HTTPStatus.OK) -> None:
self.send_bytes(text.encode("utf-8"), content_type, status)
def do_GET(self) -> None: # noqa: N802
parsed_url = urllib.parse.urlparse(self.path)
path = parsed_url.path.rstrip("/") or "/"
query = urllib.parse.parse_qs(parsed_url.query)
message_page = query_positive_int(query.get("page"), 1, minimum=1, maximum=99)
if path in {"/favicon.ico"}:
self.send_response(HTTPStatus.NO_CONTENT)
self.end_headers()
return
try:
feed = build_feed()
if path in {"/", "/x3"}:
html_body = f"""<!doctype html>
<html><head><meta charset="utf-8"><title>X3 Feed</title>
<style>body{{font-family:system-ui,sans-serif;margin:24px;background:#eee}} img{{border:1px solid #222;background:white;width:264px;height:396px;image-rendering:auto}} pre{{background:white;padding:16px;max-width:760px;white-space:pre-wrap}}</style>
</head><body>
<h1>X3 Feed</h1>
<p><a href="/x3/dashboard.json">Manifest</a> · <a href="/x3/status.json">JSON</a> · <a href="/x3/status.md">Markdown</a> · <a href="/x3/status.svg">SVG</a> · <a href="/x3/status.png">PNG</a> · <a href="/x3/status.bmp">BMP</a> · <a href="/x3/status.epub">EPUB</a> · <a href="/x3/messages.bmp">Messages</a> · <a href="/x3/opds">OPDS</a></p>
<img src="/x3/status.png" alt="X3 status preview">
<pre>{html.escape(render_markdown(feed))}</pre>
</body></html>"""
self.send_text(html_body, "text/html; charset=utf-8")
elif path == "/x3/dashboard.json":
host = self.headers.get("Host", "")
base_url = f"http://{host}" if host else ""
self.send_text(json.dumps(build_dashboard_manifest(base_url), indent=2, sort_keys=True) + "\n", "application/json; charset=utf-8")
elif path == "/x3/status.json":
self.send_text(json.dumps(feed, indent=2, sort_keys=True) + "\n", "application/json; charset=utf-8")
elif path in {"/x3/status.md", "/x3/status.txt"}:
self.send_text(render_markdown(feed), "text/markdown; charset=utf-8")
elif path == "/x3/status.svg":
self.send_text(render_svg(feed), "image/svg+xml; charset=utf-8")
elif path == "/x3/status.png":
self.send_bytes(render_png(feed), "image/png")
elif path == "/x3/status.bmp":
self.send_bytes(render_bmp(feed), "image/bmp")
elif path == "/x3/status.epub":
self.send_bytes(render_epub(feed), "application/epub+zip")
elif path == "/x3/messages.json":
message_feed = build_message_feed(page=message_page)
self.send_text(json.dumps(message_feed, indent=2, sort_keys=True) + "\n", "application/json; charset=utf-8")
elif path in {"/x3/messages.md", "/x3/messages.txt"}:
message_feed = build_message_feed(page=message_page)
self.send_text(render_messages_markdown(message_feed), "text/markdown; charset=utf-8")
elif path == "/x3/messages.png":
message_feed = build_message_feed(page=message_page)
self.send_bytes(render_messages_png(message_feed), "image/png")
elif path == "/x3/messages.bmp":
message_feed = build_message_feed(page=message_page)
self.send_bytes(render_messages_bmp(message_feed), "image/bmp")
elif path == "/x3/opds":
self.send_text(render_opds(feed), "application/atom+xml;profile=opds-catalog; charset=utf-8")
else:
self.send_text("Not found\n", "text/plain; charset=utf-8", HTTPStatus.NOT_FOUND)
except Exception as exc:
payload = {"error": str(exc), "type": type(exc).__name__}
self.send_text(json.dumps(payload, indent=2) + "\n", "application/json; charset=utf-8", HTTPStatus.INTERNAL_SERVER_ERROR)
import threading
import time as _time
def sync_shelly_cloud() -> None:
"""Fetch WS90 data from Shelly Cloud and push to HA as sensors."""
auth_key = str(CONFIG.get("shelly_cloud_key") or "").strip()
server = str(CONFIG.get("shelly_cloud_server") or "").strip()
device_id = str(CONFIG.get("shelly_ws90_device_id") or "").strip()
if not auth_key or not device_id:
return
try:
data = urllib.parse.urlencode({"id": device_id, "auth_key": auth_key}).encode()
req = urllib.request.Request(f"{server}/device/status", data=data, method="POST")
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode("utf-8"))
if not result.get("isok"):
print(f"Shelly Cloud error: {result}", file=sys.stderr)
return
status = result.get("data", {}).get("device_status", {})
sensors = {
"sensor.shelly_cloud_ws90_precipitation": (
str(status.get("precipitation:0", {}).get("value", "unknown")),
{"unit_of_measurement": "mm", "device_class": "precipitation", "state_class": "total_increasing", "friendly_name": "WS90 Niederschlag (Cloud)"},
),
"sensor.shelly_cloud_ws90_temperature": (
str(status.get("temperature:0", {}).get("tC", "unknown")),
{"unit_of_measurement": "°C", "device_class": "temperature", "state_class": "measurement", "friendly_name": "WS90 Temperatur (Cloud)"},
),
"sensor.shelly_cloud_ws90_humidity": (
str(status.get("humidity:0", {}).get("rh", "unknown")),
{"unit_of_measurement": "%", "device_class": "humidity", "state_class": "measurement", "friendly_name": "WS90 Luftfeuchtigkeit (Cloud)"},
),
"sensor.shelly_cloud_ws90_wind_speed": (
str(status.get("speed:0", {}).get("value", "unknown")),
{"unit_of_measurement": "km/h", "device_class": "wind_speed", "state_class": "measurement", "friendly_name": "WS90 Windgeschwindigkeit (Cloud)"},
),
"sensor.shelly_cloud_ws90_gust_speed": (
str(status.get("speed:1", {}).get("value", "unknown")),
{"unit_of_measurement": "km/h", "device_class": "wind_speed", "state_class": "measurement", "friendly_name": "WS90 Böengeschwindigkeit (Cloud)"},
),
"sensor.shelly_cloud_ws90_wind_direction": (
str(status.get("direction:0", {}).get("value", "unknown")),
{"unit_of_measurement": "°", "device_class": "wind_direction", "state_class": "measurement", "friendly_name": "WS90 Windrichtung (Cloud)"},
),
"sensor.shelly_cloud_ws90_pressure": (
str(status.get("pressure:0", {}).get("value", "unknown")),
{"unit_of_measurement": "hPa", "device_class": "pressure", "state_class": "measurement", "friendly_name": "WS90 Luftdruck (Cloud)"},
),
"sensor.shelly_cloud_ws90_uv_index": (
str(status.get("UV:0", {}).get("value", "unknown")),
{"state_class": "measurement", "friendly_name": "WS90 UV-Index (Cloud)"},
),
"sensor.shelly_cloud_ws90_illuminance": (
str(status.get("illuminance:0", {}).get("lux", "unknown")),
{"unit_of_measurement": "lx", "device_class": "illuminance", "state_class": "measurement", "friendly_name": "WS90 Beleuchtungsstärke (Cloud)"},
),
"sensor.shelly_cloud_ws90_battery": (
str(status.get("devicepower:0", {}).get("battery", {}).get("percent", "unknown")),
{"unit_of_measurement": "%", "device_class": "battery", "state_class": "measurement", "friendly_name": "WS90 Batterie (Cloud)"},
),
"binary_sensor.shelly_cloud_ws90_rain_detected": (
"on" if status.get("moisture_alarm:0", {}).get("value") else "off",
{"device_class": "moisture", "friendly_name": "WS90 Regen erkannt (Cloud)"},
),
}
client = HomeAssistantClient()
for entity_id, (state, attrs) in sensors.items():
payload = {"state": state, "attributes": attrs}
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{client.base_url}/api/states/{entity_id}",
data=body,
method="POST",
headers={"Authorization": f"Bearer {client.token}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()
precip = status.get("precipitation:0", {}).get("value", "?")
print(f"Shelly Cloud sync: precip={precip}mm")
except Exception as exc:
print(f"Shelly Cloud sync error: {exc}", file=sys.stderr)
def shelly_cloud_loop() -> None:
"""Background thread: sync Shelly Cloud every 10 minutes."""
while True:
sync_shelly_cloud()
_time.sleep(600)
def main() -> int:
parser = argparse.ArgumentParser(description="Serve XTeink X3 Home Assistant feed")
parser.add_argument("--host", default=os.getenv("X3_FEED_HOST", "127.0.0.1"))
parser.add_argument("--port", type=int, default=int(os.getenv("X3_FEED_PORT", "8787")))
parser.add_argument("--env-file", default=os.getenv("X3_ENV_FILE", str(DEFAULT_ENV_FILE)))
parser.add_argument("--options-file", default=os.getenv("X3_OPTIONS_FILE", str(DEFAULT_OPTIONS_FILE)))
args = parser.parse_args()
global CONFIG
load_env(Path(args.env_file))
CONFIG = load_options(Path(args.options_file))
shelly_thread = threading.Thread(target=shelly_cloud_loop, daemon=True)
shelly_thread.start()
server = ThreadingHTTPServer((args.host, args.port), X3Handler)
print(f"X3 feed listening on http://{args.host}:{args.port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopping X3 feed")
return 0
if __name__ == "__main__":
raise SystemExit(main())