From 466ffc2b6bd0fcedb967f723edfda42b3f8a2d2b Mon Sep 17 00:00:00 2001 From: twfarley Date: Thu, 10 Sep 2026 12:40:13 -0600 Subject: [PATCH] Add GW1200 AC1100 smart plug discovery and control --- docs/configuration.md | 13 +- docs/sensors.md | 8 +- docs/user_guide.md | 43 +++- sensorius/__init__.py | 2 +- sensorius/app.py | 5 + sensorius/saiEcowitt.py | 88 ++++++- sensorius/saiEcowittSwitch.py | 251 +++++++++++++++++++ sensorius/saiHomeAssistantMqtt.py | 7 +- sensorius/saiHtml.py | 30 ++- sensorius/saiSwitch.py | 7 +- sensorius/saiSwitchFactory.py | 3 + sensorius/saiSwitchSettingsManager.py | 2 +- sensorius/saiWebRoutes.py | 44 +++- system_settings/factory/settings.toml | 2 + testApparatus/playwright_host.py | 24 ++ testApparatus/test_ecowitt_ingest.py | 1 + testApparatus/test_ecowitt_routes.py | 6 +- testApparatus/test_ecowitt_switch.py | 294 +++++++++++++++++++++++ tests/ecowitt-smart-plugs.spec.js | 61 +++++ ui_templates/modals/system_settings.html | 47 +++- 20 files changed, 902 insertions(+), 36 deletions(-) create mode 100644 sensorius/saiEcowittSwitch.py create mode 100644 testApparatus/test_ecowitt_switch.py create mode 100644 tests/ecowitt-smart-plugs.spec.js diff --git a/docs/configuration.md b/docs/configuration.md index b16ac94..85c5fce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -278,6 +278,8 @@ UPDATE_PERIOD_SEC = 300 ENABLED = false GATEWAY_URL = "" POLL_INTERVAL_SEC = 300 +SMART_PLUG_INTERVAL_SEC = 30 +SMART_PLUGS_JSON = "[]" SENSOR_ID = "" INVENTORY_JSON = "[]" RAIN_SOURCE = "" @@ -312,13 +314,16 @@ Runtime notes: - WeeWX MQTT settings are applied live through the running MQTT ingest client when available. If MQTT ingest is not running, the settings apply when MQTT ingest starts. -- Ecowitt settings control one read-only GW1100, GW1200, or compatible generic - Ecowitt LAN gateway. `SENSOR_ID` is generated +- Ecowitt settings control weather polling from one GW1100, GW1200, or compatible + Ecowitt LAN gateway, plus AC1100 discovery and control on GW1200. `SENSOR_ID` is generated from the gateway MAC during discovery; `INVENTORY_JSON` is an informational snapshot of valid registered sensors. `RAIN_SOURCE` and `RAIN_RESET_HOUR` are discovered gateway metadata used to preserve rain-counter correctness. - Enable/disable and interval changes are - read by the always-registered poller without a process restart. + `SMART_PLUG_INTERVAL_SEC` independently controls AC1100 status queries (15–60 + seconds, default 30); `SMART_PLUGS_JSON` stores the last discovered plug list. + Disable stops both pollers and blocks plug commands. Enable/disable and interval + changes apply without a process restart. Saving discovery activates new plugs + and their automation monitors immediately. - Home Assistant and farmOS secrets are obfuscated at rest by `sensorius.saiSettings`. This is reversible obfuscation, not encryption. - `[WeatherForecast].PROVIDER` accepts `met_no`, `open_meteo`, `us`, or `none`. diff --git a/docs/sensors.md b/docs/sensors.md index d34f830..59d5a32 100644 --- a/docs/sensors.md +++ b/docs/sensors.md @@ -1,7 +1,7 @@ # Sensors And Metrics Sensorius supports local Raspberry Pi sensors, MQTT-discovered Nodus sensors, -optional WeeWX station ingest, and read-only Ecowitt LAN gateway polling. All readings are normalized into the same +optional WeeWX station ingest, and Ecowitt LAN gateway weather polling. All readings are normalized into the same database and dashboard model. Each sensor defines a `measurements` list that determines the metric names @@ -33,6 +33,12 @@ WeeWX station ingest: Ecowitt gateway ingest: +AC1100 smart plugs paired with a GW1200 use a separate local IoT API and appear +as switches, with configurable labels and locations. Their 15–60 second status +queries are independent of weather polling. Commands are followed by status +confirmation; observed relay changes use the standard switch event history. +See the Ecowitt section in the [user guide](user_guide.md#ecowitt-gateway). + - Discovers GW1100 and GW1200 gateways, plus other compatible Ecowitt gateways, and their registered sensors through the local generic HTTP API. GW1200 support includes WH65/WS69-class traditional-rain arrays; the Ambient Weather diff --git a/docs/user_guide.md b/docs/user_guide.md index b5666cc..88af7f2 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -642,17 +642,21 @@ Ecowitt Gateway fields and controls are arranged in two columns: - **GW1100 / GW1200 URL**: the gateway base address, such as `http://192.168.1.100` or a local hostname. Do not include a path, credentials, query, or fragment. -- **Find Sensors**: queries the gateway's read-only version, network, sensor +- **Find Devices**: queries the gateway's read-only version, network, sensor inventory, live-data, and rain-priority endpoints. Both inventory pages are - checked. -- **Available Valid GW Sensors**: lists registered gateway sensors and whether + checked; GW1200 gateways also return their registered AC1100 smart plugs. +- **Discovered GW Sensors**: lists registered gateway sensors and whether their data family is present in the current live response. All valid listed sensors and supported additional channels are ingested. -- **Data Retrieval Interval**: polling period from 60 through 3600 seconds. +- **Data Retrieval Interval**: weather polling period from 60 through 3600 seconds. +- **Discovered GW Smart Plugs**: lists all paired AC1100 plugs, including offline + plugs. AC1100 support in Sensorius requires a GW1200. +- **Smart Plug Query Interval (seconds)**: independent status polling from 15 + through 60 seconds, default 30. Weather polling keeps its own interval. - **Save Gateway**: revalidates the gateway, derives a stable Sensorius station - ID from its MAC address, creates station settings, and enables polling. + ID from its MAC address, creates station and plug settings, and enables polling. - **Disable**: stops polling without deleting station settings or historical - readings. + readings. It also prevents Sensorius plug commands while disabled. Configure the GW1100 or GW1200 on the same trusted LAN first. A DHCP reservation is recommended. For an Ambient Weather WS-2000 outdoor array, use a North @@ -665,10 +669,33 @@ local API's unit tags are authoritative; gateway-local unit settings can differ from Ecowitt app display preferences. Sensorius normalizes wind speed into its canonical mph metric, while wind direction drives the compass and 6/24-hour wind roses. As with WeeWX, that -combined Sensor Tile's current reading and statistics show wind speed. Sensorius reads -the gateway only; it does not change Wi-Fi, sensor registration, calibration, +combined Sensor Tile's current reading and statistics show wind speed. Weather ingestion +is read-only; Sensorius does not change Wi-Fi, sensor registration, calibration, rain settings, MQTT, firmware, weather-service configuration, or gateway units. +Pair each AC1100 with the GW1200 using Ecowitt first, then choose **Find Devices** +and **Save Gateway**. Saved plugs appear in the normal dashboard switch cards. +Open the switch settings gear to set its location and channel label (default +**Plug**). The normal toggle, event list, timers, and Advanced automations apply; +manual toggles are blocked while an enabled automation owns the plug. + +Sensorius does not send a relay command at startup. Until a successful status +query, the dashboard shows **Unknown / Awaiting status**. After a user or +Sensorius automation command, status is queried every two seconds, up to five +attempts. Only confirmed relay states enter the event list. Periodic queries +also record observed changes made using the plug button or Ecowitt app. Changes +that happen entirely between queries cannot be recovered. Offline plugs retain +their last confirmed state and reject commands. A command confirmation failure +is reported instead of assuming the relay changed. + +Plug definitions persist at +`/home//Sensorius/switch_settings/-ac1100-/switch.toml` +on Linux or `/Users//Sensorius/switch_settings/-ac1100-/switch.toml` +on macOS. Discovery preserves user labels, locations, and channel identities. +Existing saved plugs survive rediscovery; an unpaired plug becomes unavailable. +This integration has simulated API coverage; physical GW1200/AC1100 verification +is still required. + GW1200 support is tested against Ecowitt's generic LAN API schema. Because the Ambient-branded WH65B hardware response has not yet been captured, verify the outdoor temperature, humidity, wind, traditional rain, solar/light, and UV diff --git a/sensorius/__init__.py b/sensorius/__init__.py index 2cc197c..9a9e649 100644 --- a/sensorius/__init__.py +++ b/sensorius/__init__.py @@ -4,4 +4,4 @@ release notes, and supporting tooling can report a consistent build identity. """ -__version__ = "v0.26.252.5" +__version__ = "v0.26.253.1" diff --git a/sensorius/app.py b/sensorius/app.py index 28dc34a..ba25226 100644 --- a/sensorius/app.py +++ b/sensorius/app.py @@ -385,6 +385,8 @@ def seed_switch_state_history_once(data_logger, switch_controllers): try: for ctrl in (switch_controllers or {}).values(): + if getattr(ctrl, "is_ecowitt", False): + continue sid = getattr(ctrl, "switch_id", None) if not sid: continue @@ -696,6 +698,8 @@ async def main(shutdown_requested: Event | None = None): # --- Always-on supervisors --- weewx_ingest = WeeWXArchiveIngest(settings=settings, data_logger=data_logger, supervisor=supervisor) ecowitt_ingest = EcowittGatewayIngest(settings=settings, data_logger=data_logger, supervisor=supervisor) + ecowitt_ingest.switch_controllers = switch_controllers + await ecowitt_ingest.activate_smart_plugs() farmos_bridge = saiFarmOSBridge(settings=settings, data_logger=data_logger, supervisor=supervisor) email_notifications = EmailNotificationService( settings=settings, @@ -714,6 +718,7 @@ async def main(shutdown_requested: Event | None = None): supervisor=supervisor, ) supervisor.add(weewx_ingest.run, name="WeeWX Archive Ingest", fatal_on_timeout=False, fatal_on_error=False) + supervisor.add(ecowitt_ingest.run_smart_plugs, name="Ecowitt Smart Plugs", fatal_on_timeout=False, fatal_on_error=False) supervisor.add(ecowitt_ingest.run, name="Ecowitt Gateway Ingest", fatal_on_timeout=False, fatal_on_error=False) supervisor.add(data_logger.run_retention, supervisor, name="Database Retention", fatal_on_timeout=False, fatal_on_error=False) supervisor.add(farmos_bridge.run, name="FarmOS Bridge", fatal_on_timeout=False, fatal_on_error=False) diff --git a/sensorius/saiEcowitt.py b/sensorius/saiEcowitt.py index 75c5d8d..30353a7 100644 --- a/sensorius/saiEcowitt.py +++ b/sensorius/saiEcowitt.py @@ -154,6 +154,7 @@ def __init__(self, *, settings, data_logger, supervisor=None): self.settings = settings self.data_logger = data_logger self.supervisor = supervisor + self.switch_controllers = {} self._request_lock = asyncio.Lock() self._last_rain_day: float | None = None self._last_rain_timestamp: str = "" @@ -239,6 +240,10 @@ async def discover(self, gateway_url: Any) -> dict[str, Any]: page2 = await self._get_json(client, base_url, "get_sensors_info", page=2) live = await self._get_json(client, base_url, "get_livedata_info") rain_totals = await self._get_json(client, base_url, "get_rain_totals") + from .saiEcowittSwitch import discover_smart_plugs, supports_smart_plugs + smart_plugs = [] + if isinstance(version, dict) and supports_smart_plugs(version.get("version")): + smart_plugs = await discover_smart_plugs(client, base_url) if not isinstance(version, dict) or not isinstance(network, dict) or not isinstance(live, dict): raise EcowittError("Gateway response schema is not supported.") @@ -265,6 +270,7 @@ async def discover(self, gateway_url: Any) -> dict[str, Any]: "gateway_model": version_text or "Ecowitt Gateway", "firmware": version_text, "inventory": inventory, + "smart_plugs": smart_plugs, "rain_source": source, "rain_reset_hour": reset_hour, "live_metric_count": len(values), @@ -310,7 +316,7 @@ def _sensor_reporting(sensor: dict[str, Any], live_sections: set[str]) -> bool: return "ch_lds" in live_sections return bool(live_sections.intersection({"common_list", "rain", "piezoRain", "wh25", "ch_ec"})) - def save_configuration(self, discovery: dict[str, Any], poll_interval_sec: Any) -> None: + def save_configuration(self, discovery: dict[str, Any], poll_interval_sec: Any, smart_plug_interval_sec: Any = 30) -> None: """Persist a successfully discovered gateway and materialize its station.""" try: interval = int(poll_interval_sec) @@ -320,6 +326,8 @@ def save_configuration(self, discovery: dict[str, Any], poll_interval_sec: Any) raise EcowittError( f"Retrieval interval must be between {MIN_POLL_INTERVAL_SEC} and {MAX_POLL_INTERVAL_SEC} seconds." ) + from .saiEcowittSwitch import query_interval, save_smart_plugs + plug_interval = query_interval(smart_plug_interval_sec) sensor_id = str(discovery.get("sensor_id", "") or "").strip() inventory = discovery.get("inventory") if isinstance(discovery.get("inventory"), list) else [] ensure_ecowitt_sensor_settings( @@ -327,10 +335,13 @@ def save_configuration(self, discovery: dict[str, Any], poll_interval_sec: Any) inventory=inventory, gateway_model=str(discovery.get("gateway_model", "") or "Ecowitt Gateway"), ) + save_smart_plugs(discovery) self.settings.set_many_in_memory([ ("Ecowitt", "ENABLED", True), ("Ecowitt", "GATEWAY_URL", str(discovery.get("gateway_url", "") or "")), ("Ecowitt", "POLL_INTERVAL_SEC", interval), + ("Ecowitt", "SMART_PLUG_INTERVAL_SEC", plug_interval), + ("Ecowitt", "SMART_PLUGS_JSON", _inventory_json(discovery.get("smart_plugs", []))), ("Ecowitt", "SENSOR_ID", sensor_id), ("Ecowitt", "INVENTORY_JSON", _inventory_json(inventory)), ("Ecowitt", "RAIN_SOURCE", str(discovery.get("rain_source", "traditional") or "traditional")), @@ -351,6 +362,8 @@ def status(self) -> dict[str, Any]: "enabled": self.enabled, "gateway_url": self.gateway_url, "poll_interval_sec": self.poll_interval_sec, + "smart_plug_interval_sec": self.smart_plug_interval_sec, + "smart_plugs": self.smart_plug_status(), "sensor_id": self.sensor_id or result.get("sensor_id", ""), }) if not result.get("inventory"): @@ -486,3 +499,76 @@ async def run(self) -> None: self._last_error_log_mono = now_mono printDM(message, location=MODULE, level="warning") await self._sleep_with_heartbeat(self.poll_interval_sec) + + @property + def smart_plug_interval_sec(self) -> int: + """Return the independent 15–60 second plug polling interval.""" + from .saiEcowittSwitch import query_interval + try: + return query_interval(self.settings.get_setting( + "Ecowitt", "SMART_PLUG_INTERVAL_SEC", 30, reload_if_changed=True)) + except EcowittError: + return 30 + + def smart_plug_status(self) -> list[dict]: + """Return saved device identities enriched with confirmed runtime status.""" + try: + plugs = json.loads(str(self.settings.get_setting( + "Ecowitt", "SMART_PLUGS_JSON", "[]", reload_if_changed=True) or "[]")) + except (ValueError, TypeError): + plugs = [] + if not isinstance(plugs, list): + return [] + result = [] + for plug in plugs: + if not isinstance(plug, dict) or type(plug.get("id")) is not int: + continue + row = {**plug, "online": False} + sid = f"{self.sensor_id}-ac1100-{plug['id']:08x}" + ctrl = self.switch_controllers.get(sid) + if ctrl: + row.update(online=ctrl.available, state=ctrl.get_state(ctrl.get_switch_names()[0]) + if ctrl.confirmed else None, error=ctrl.last_error) + result.append(row) + return result + + async def activate_smart_plugs(self) -> None: + """Attach saved plugs to the live dashboard and shared automation monitors.""" + from .saiEcowittSwitch import EcowittSwitchController + from .saiSwitch import build_switch_controller + from .saiSwitchSettingsManager import SwitchSettingsManager + manager = SwitchSettingsManager("switch_settings") + ids = await asyncio.to_thread(manager.list_switches) + for sid in ids: + doc = await asyncio.to_thread(manager.load, sid) + sw = (doc or {}).get("Switch", {}) + if sw.get("TYPE") != "ecowitt": + continue + ctrl = self.switch_controllers.get(sid) + if ctrl is None: + ctrl = build_switch_controller(switch_settings=doc, supervisor=self.supervisor, + data_logger=self.data_logger) + self.switch_controllers[sid] = ctrl + if self.supervisor: + self.supervisor.add(ctrl.run_controladora_monitor, ctrl.sensor, + name=f"{sid} Controladora Monitor", + fatal_on_timeout=False, fatal_on_error=False) + if isinstance(ctrl, EcowittSwitchController): + ctrl.service = self + + async def run_smart_plugs(self) -> None: + """Poll saved plugs independently of the weather retrieval interval.""" + from .saiEcowittSwitch import IOT_TASK_NAME + while True: + if self.enabled: + for ctrl in list(self.switch_controllers.values()): + if getattr(ctrl, "is_ecowitt", False): + if self.supervisor: + self.supervisor.feedthedogs(IOT_TASK_NAME) + await ctrl.poll_status() + remaining = self.smart_plug_interval_sec + while remaining > 0: + if self.supervisor: + self.supervisor.feedthedogs(IOT_TASK_NAME) + await asyncio.sleep(min(5, remaining)) + remaining -= 5 diff --git a/sensorius/saiEcowittSwitch.py b/sensorius/saiEcowittSwitch.py new file mode 100644 index 0000000..02971af --- /dev/null +++ b/sensorius/saiEcowittSwitch.py @@ -0,0 +1,251 @@ +"""Discover and control GW1200 AC1100 plugs through the local IoT API. + +Weather ingestion remains independent. Controllers expose the normal switch +contract, schedule nonblocking commands, and log only gateway-confirmed states. +""" + +import asyncio +import re +import time + +import httpx + +from .saiSwitch import SwitchController, RemoteSwitchController +from .saiSwitchSettingsManager import SwitchSettingsManager + +IOT_TASK_NAME = "Ecowitt Smart Plugs" + + +def supports_smart_plugs(version): + """Limit smart-plug discovery to the supported GW1200 gateway family.""" + return bool(re.search(r"\bGW1200[A-Z]?(?:_|\b)", str(version), re.I)) + + +def query_interval(value): + """Validate a smart-plug query interval in whole seconds.""" + from .saiEcowitt import EcowittError + try: + interval = int(value) + if str(value).strip() != str(interval) or not 15 <= interval <= 60: + raise ValueError + except (TypeError, ValueError): + raise EcowittError("Smart plug query interval must be a whole number from 15 to 60 seconds.") + return interval + + +async def discover_smart_plugs(client, base_url): + """Read registered AC1100 identities without changing any relay state.""" + from .saiEcowitt import EcowittError, MAX_RESPONSE_BYTES + response = await client.get(f"{base_url}/get_iot_device_list") + response.raise_for_status() + if len(response.content) > MAX_RESPONSE_BYTES: + raise EcowittError("Gateway IoT device list was unexpectedly large.") + payload = response.json() + if not isinstance(payload, dict) or not isinstance(payload.get("command"), list): + raise EcowittError("Gateway IoT device list schema is not supported.") + plugs = {} + for item in payload["command"]: + if not isinstance(item, dict) or item.get("model") != 2: + continue + device_id = item.get("id") + if type(device_id) is not int or not 0 < device_id <= 0xffffffff: + raise EcowittError("Gateway returned an invalid AC1100 identity.") + plugs[device_id] = {"id": device_id, "model": 2, "name": "AC1100", + "online": item.get("rfnet_state") == 1, "signal": item.get("signal", 0)} + return list(plugs.values()) + + +def save_smart_plugs(discovery, manager=None): + """Materialize stable switch identities while preserving labels and locations.""" + manager = manager or SwitchSettingsManager("switch_settings") + for plug in discovery.get("smart_plugs", []): + sid = f"{discovery['sensor_id']}-ac1100-{plug['id']:08x}" + doc = manager.load(sid) or {} + sw = doc.setdefault("Switch", {}) + sw.update(TYPE="ecowitt", DEVICE="AC1100", SWITCH_DEVICE_ID=sid, + ECOWITT_GATEWAY_ID=discovery["sensor_id"], ECOWITT_DEVICE_ID=plug["id"]) + sw.setdefault("SWITCH_LOCATION", "Unknown") + sw.setdefault("SWITCH_1_LABEL", "Plug") + sw.setdefault("SWITCH_1_CHANNEL_ID", f"{sid}-1") + sw.setdefault("SWITCH_1_LAST_STATE", False) + if doc != (manager.load(sid) or {}): + manager.save(sid, doc) + + +class EcowittSwitchBackend: + """Expose one channel without sending startup or synchronous network writes.""" + + def __init__(self, settings): + sw = settings.get("Switch", {}) + self.channels = [{"n": 1, "name": sw.get("SWITCH_1_LABEL", "Plug")}] + self.is_present = True + + def get_switch_names(self): + """Return the user-defined plug label.""" + return [self.channels[0]["name"]] + + def set_state(self, name, on): + """Leave startup state application to the first status query.""" + return False + + +class EcowittSwitchController(SwitchController): + """Use shared automation and timers with asynchronous confirmed HTTP control.""" + + is_ecowitt = True + _capture_settings_signature = RemoteSwitchController._capture_settings_signature + _refresh_definition_from_settings = RemoteSwitchController._refresh_definition_from_settings + + def __init__(self, **kwargs): + self._settings_signature = None + self.service = None + self.online = False + self.confirmed = False + self.last_error = "Awaiting gateway status" + self.last_query = 0.0 + self.command_task = None + self._io_lock = asyncio.Lock() + super().__init__(**kwargs) + + def _apply_remote_settings_doc(self, doc): + prior = {key: dict(getattr(self, key, {}) or {}) for key in + ("last_state", "last_set_time", "auto_off_seconds", "auto_off_deadline", "override_script")} + RemoteSwitchController._apply_remote_settings_doc(self, doc) + label = self.switch.get_switch_names()[0] + for key, values in prior.items(): + if len(values) == 1: + getattr(self, key)[label] = next(iter(values.values())) + self.data_logger.upsert_switch_identity(switch_key=self._switch_key(label), + switch_id=self.switch_id, label=label, location=self.location) + + def get_switch_names(self): + """Return labels refreshed from the normal switch settings dialog.""" + self._refresh_definition_from_settings() + return super().get_switch_names() + + def get_state(self, name): + """Return the last confirmed relay state without network I/O.""" + self._refresh_definition_from_settings() + return super().get_state(name) + + @property + def available(self): + """Report freshness and whether this plug belongs to the enabled gateway.""" + return bool(self.service and self.service.enabled and self.online and self.confirmed + and self._switch_block().get("ECOWITT_GATEWAY_ID") == self.service.sensor_id + and time.monotonic() - self.last_query <= self.service.smart_plug_interval_sec * 2 + 10) + + async def _request(self, command): + from .saiEcowitt import EcowittError, MAX_RESPONSE_BYTES, normalize_gateway_url + if not self.service or not self.service.enabled: + raise EcowittError("Ecowitt gateway is disabled.") + sw = self._switch_block() + if sw.get("ECOWITT_GATEWAY_ID") != self.service.sensor_id: + raise EcowittError("Plug belongs to a different Ecowitt gateway.") + command = {**command, "model": 2, "id": int(sw["ECOWITT_DEVICE_ID"])} + async with self.service._request_lock: + async with httpx.AsyncClient(timeout=5, follow_redirects=False) as client: + response = await client.post( + f"{normalize_gateway_url(self.service.gateway_url)}/parse_quick_cmd_iot", + json={"command": [command]}, + ) + response.raise_for_status() + if len(response.content) > MAX_RESPONSE_BYTES: + raise EcowittError("Gateway IoT response was unexpectedly large.") + return response.json() + + async def _read_status(self, source="ecowitt/poll"): + from .saiEcowitt import EcowittError + payload = await self._request({"cmd": "read_device"}) + device_id = int(self._switch_block()["ECOWITT_DEVICE_ID"]) + items = payload.get("command", []) if isinstance(payload, dict) else [] + item = next((row for row in items if isinstance(row, dict) + and row.get("model") == 2 and row.get("id") == device_id), None) + if not item or type(item.get("ac_status")) is not int or item["ac_status"] not in (0, 1): + raise EcowittError("Gateway returned no valid AC1100 relay status.") + if int(item.get("warning", 0)) & 128: + raise EcowittError("AC1100 is offline at the gateway.") + label = self.get_switch_names()[0] + on = item["ac_status"] == 1 + previous = self.last_state.get(label, False) + changed = previous != on + record_event = changed + if not self.confirmed: + latest = await asyncio.to_thread(self.data_logger.get_latest_switch_state, self._switch_key(label)) + record_event = latest is None or (latest == "On") != on + self.last_state[label] = on + if not self.confirmed or changed: + if self._switch_block().get("SWITCH_1_LAST_STATE") != on: + await asyncio.to_thread(SwitchSettingsManager("switch_settings").update_setting, + self.switch_id, "SWITCH_1_LAST_STATE", on) + self._sync_auto_off_state(label, on, restart=bool(changed and on and source.startswith("manual")), + allow_create_if_missing=source.startswith("manual")) + if changed: + self.last_set_time[label] = time.monotonic() + if record_event: + from .saiUtils import get_timestamp + timestamp = get_timestamp() + await asyncio.to_thread(self.data_logger.log_switch_event, + switch_key=self._switch_key(label), is_on=on, + timestamp=timestamp, source=source, sensor_id=f"Switch_{self.switch_id}") + from . import saiWebRoutes as routes + broadcast = getattr(getattr(getattr(routes, "app", None), "state", None), "switch_broadcast", None) + if broadcast: + await broadcast({"type": "switch_event", "key": self._switch_key(label), + "ui_key": f"{self.switch_id}::{label}", "state": on, + "timestamp": timestamp, "source": source, + **self.get_auto_off_status(label)}) + self.confirmed = self.online = True + self.last_query = time.monotonic() + self.last_error = "" + return on + + async def poll_status(self): + """Refresh relay status and record physical/app/automation transitions.""" + async with self._io_lock: + try: + return await self._read_status() + except Exception as exc: + self.online = False + self.last_error = str(exc) + return None + + def set_state(self, name, on, *, force=False, event_source="manual/ui"): + """Queue one command; success here means accepted, not yet confirmed.""" + if name not in self.get_switch_names() or not self.available: + return False + if self.command_task and not self.command_task.done(): + return False + previous = self.get_state(name) + if not force and previous == bool(on): + return False + elapsed = time.monotonic() - self.last_set_time.get(name, 0) + if not self.override_script.get(name, False) and elapsed < (self.min_off_time if on else self.min_on_time): + return False + try: + loop = asyncio.get_running_loop() + except RuntimeError: + self.last_error = "AC1100 commands require the runtime event loop." + return False + self.command_task = loop.create_task(self._command(bool(on), event_source)) + return True + + async def _command(self, on, source): + async with self._io_lock: + try: + command = {"cmd": "quick_stop"} + if on: + command = dict(cmd="quick_run", on_type=0, off_type=0, always_on=1, + on_time=0, off_time=0, val_type=0, val=0) + await self._request(command) + for attempt in range(5): + await asyncio.sleep(2) + if await self._read_status(source) == on: + if str(source).startswith("auto"): + self.auto_off_deadline[self.get_switch_names()[0]] = None + return True + self.last_error = "Gateway did not confirm the requested plug state." + except Exception as exc: + self.online = False + self.last_error = str(exc) + return False diff --git a/sensorius/saiHomeAssistantMqtt.py b/sensorius/saiHomeAssistantMqtt.py index 69c6491..f113767 100644 --- a/sensorius/saiHomeAssistantMqtt.py +++ b/sensorius/saiHomeAssistantMqtt.py @@ -627,7 +627,12 @@ async def _handle_switch_command_async(self, switch_id: str, channel_id: str, de if switch_id and resolved_switch_id and switch_id != resolved_switch_id and DEBUG: printDM(f"HA switch_id mismatch: got={switch_id}, resolved={resolved_switch_id} for channel_id={channel_id}", location=MODULE) - ctrl.set_state(label, desired_on, force=True) + accepted = ctrl.set_state(label, desired_on, force=True) + if getattr(ctrl, "is_ecowitt", False): + if not accepted: + return + await asyncio.shield(ctrl.command_task) + desired_on = bool(ctrl.get_state(label)) await self.publish_switch_state(resolved_switch_id or switch_id, channel_id, desired_on) # --------------------------------------------------------------------- diff --git a/sensorius/saiHtml.py b/sensorius/saiHtml.py index c151869..60380ae 100644 --- a/sensorius/saiHtml.py +++ b/sensorius/saiHtml.py @@ -878,6 +878,10 @@ def _enable_field_value(sw_block: dict, idx: int): last_state = last_state, last_set_time = last_time, override_script = override, + channel_id_for_label = dict(getattr(ctrl, "channel_id_for_label", {}) or {}), + is_ecowitt = bool(getattr(ctrl, "is_ecowitt", False)), + available = bool(getattr(ctrl, "available", True)), + confirmed = bool(getattr(ctrl, "confirmed", True)), ) loc_key = _norm_loc(effective_loc) @@ -1532,9 +1536,12 @@ def _render_switch_cards_for_location(location: str, matched_switches: list): f" data-automation-enabled='{'1' if automation_enabled else '0'}' " f" data-state='{state_str}' " f" onclick='toggleSwitchInline(this)'>" - f"{'On' if is_on else 'Off'}" + f"{'Unknown' if getattr(switch_ctrl, 'is_ecowitt', False) and not switch_ctrl.confirmed else ('On' if is_on else 'Off')}" f"" ) + if getattr(switch_ctrl, "is_ecowitt", False): + plug_status = "Online" if switch_ctrl.available else ("Offline" if switch_ctrl.confirmed else "Awaiting status") + yield f"
{plug_status}
" yield "".join(( f"
", f"
", @@ -7307,6 +7314,12 @@ def _format_stat_value(stat_value): yield " const isOn = !!(stateData && (stateData.state===true || String(stateData.state).toLowerCase()==='on'));" yield " const lastTime = stateData && stateData.time ? stateData.time : '';" yield " setSwitchBoxState(box, isOn);" + yield " const plugStatus = box.parentElement && box.parentElement.querySelector('.ecowitt-plug-status');" + yield " if (plugStatus && stateData && typeof stateData.online === 'boolean') {" + yield " plugStatus.textContent = stateData.online ? 'Online' : (stateData.confirmed ? 'Offline' : 'Awaiting status');" + yield " plugStatus.title = stateData.error || '';" + yield " if (!stateData.confirmed) box.textContent = 'Unknown';" + yield " }" yield " try { console.debug('[switch-ui] setSwitchBoxState', { key, name, isOn, boxKey: box.dataset ? box.dataset.switchKey : '', boxText: (box.textContent || '').trim() }); } catch (_) {}" yield " updateSwitchTimerUi(key, Object.assign({}, stateData || {}, { state: isOn }), name);" yield " if (labelEl) {" @@ -7582,14 +7595,15 @@ def _format_stat_value(stat_value): yield " continue;" yield " }" yield "" - yield " const hasTime = Object.prototype.hasOwnProperty.call(data,'time');" + yield " const hasTime = Object.prototype.hasOwnProperty.call(data,'time') || Array.isArray(data?.events);" yield " let events=[];" - yield " if(Array.isArray(data?.time)){" + yield " if(Array.isArray(data?.events)){" + yield " events = data.events.slice();" + yield " } else if(Array.isArray(data?.time)){" yield " events = data.time.slice();" yield " } else if(typeof data?.time==='string'){" yield " events = [data.time];" - yield " } else if(Array.isArray(data?.events)){" - yield " events = data.events.slice();" + yield " } else if(data && (data.state!==undefined || data.time!==undefined)){" yield " const isOn = (data.state===true) || (String(data.state).toLowerCase()==='on');" yield " const ts = (typeof data.time==='string') ? data.time : '';" @@ -7750,7 +7764,11 @@ def _format_stat_value(stat_value): # Keep the status map keyed by 'switch_id::label' when possible yield " updateSwitchEventsFromStatus({ [key || `::${real}`]: data });" yield " })" - yield " .catch(e => console.warn('toggleSwitchInline failed', e))" + yield " .catch(e => {" + yield " console.warn('toggleSwitchInline failed', e);" + yield " const status = el.parentElement && el.parentElement.querySelector('.ecowitt-plug-status');" + yield " if (status) status.textContent = 'Plug command failed; check gateway status.';" + yield " })" yield " .finally(() => {" yield " el.classList.remove('switch-pending');" yield " scheduleSwitchStatusRefreshes([1500, 6000, 12000]);" diff --git a/sensorius/saiSwitch.py b/sensorius/saiSwitch.py index 0b94ab4..2f7b9ab 100644 --- a/sensorius/saiSwitch.py +++ b/sensorius/saiSwitch.py @@ -126,7 +126,7 @@ def _has_install_marker(val) -> bool: # If this is a Pi GPIO switch, ensure settings match detected hardware/template try: sw_type = str(sw.get("TYPE", "pi")).strip().lower() - if sw_type not in REMOTE_SWITCH_TYPES: + if sw_type not in REMOTE_SWITCH_TYPES and sw_type != "ecowitt": from .saiSwitchFactory import ensure_switch_settings_for_host refreshed = ensure_switch_settings_for_host(self.switch_id, self.location) if isinstance(self.settings, dict): @@ -2899,6 +2899,11 @@ def set_state(self, name, on: bool, *, force: bool = False, event_source: str = def build_switch_controller(*, switch_settings=None, supervisor=None, sensor=None, mqtt_ingest=None, data_logger=None): """Build the appropriate local or remote shared switch controller.""" + if _switch_type_from_settings(switch_settings) == "ecowitt": + from .saiEcowittSwitch import EcowittSwitchController + return EcowittSwitchController( + switch_settings=switch_settings, supervisor=supervisor, sensor=sensor, data_logger=data_logger, + ) if is_remote_switch_settings(switch_settings): return RemoteSwitchController( switch_settings=switch_settings, diff --git a/sensorius/saiSwitchFactory.py b/sensorius/saiSwitchFactory.py index 67f40b0..ce07bde 100644 --- a/sensorius/saiSwitchFactory.py +++ b/sensorius/saiSwitchFactory.py @@ -555,6 +555,9 @@ def create_switch(settings=None, mqtt_client=None): """Create a local GPIO or MQTT switch implementation from settings.""" sw = (settings or {}).get("Switch", {}) or {} typ = str(sw.get("TYPE", "pi")).strip().lower() + if typ == "ecowitt": + from .saiEcowittSwitch import EcowittSwitchBackend + return EcowittSwitchBackend(settings) if typ in ("picow", "pico2w", "nodus", "remote", "mqtt"): return MQTTSwitch(settings=settings, mqtt_client=mqtt_client) # default: Pi GPIO diff --git a/sensorius/saiSwitchSettingsManager.py b/sensorius/saiSwitchSettingsManager.py index f1eb11a..cebb016 100644 --- a/sensorius/saiSwitchSettingsManager.py +++ b/sensorius/saiSwitchSettingsManager.py @@ -724,7 +724,7 @@ def _ensure_local_identity_fields(self, switch_id: str, switch_block: dict) -> s return "" sw_type = str(switch_block.get("TYPE", "pi") or "pi").strip().lower() - is_remote = sw_type in {"picow", "pico2w", "nodus", "mqtt", "remote"} + is_remote = sw_type in {"picow", "pico2w", "nodus", "mqtt", "remote", "ecowitt"} serial = str(switch_block.get("DEVICE_SERIAL_NUM", "") or "").strip() if not is_remote: diff --git a/sensorius/saiWebRoutes.py b/sensorius/saiWebRoutes.py index 337cd33..82bdabc 100644 --- a/sensorius/saiWebRoutes.py +++ b/sensorius/saiWebRoutes.py @@ -9877,8 +9877,18 @@ async def ecowitt_save(request: Request): service.save_configuration, discovery, payload.get("poll_interval_sec", ECOWITT_DEFAULT_POLL_INTERVAL_SEC), + payload.get("smart_plug_interval_sec", 30), ) - return JSONResponse({"ok": True, **discovery, "poll_interval_sec": service.poll_interval_sec}) + controllers = getattr(request.app.state, "switch_controllers", None) + if not isinstance(controllers, dict): + controllers = globals().get("switch_controllers") + service.switch_controllers = controllers if isinstance(controllers, dict) else {} + globals()["switch_controllers"] = service.switch_controllers + request.app.state.switch_controllers = service.switch_controllers + await service.activate_smart_plugs() + _invalidate_dashboard_caches() + return JSONResponse({"ok": True, **discovery, "poll_interval_sec": service.poll_interval_sec, + "smart_plug_interval_sec": service.smart_plug_interval_sec}) except EcowittError as exc: return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) except Exception as exc: @@ -13627,6 +13637,9 @@ def deep_merge_ordered(base: OrderedDict, update: dict | OrderedDict) -> Ordered if override_key in form: sw_block[override_key] = str(form.get(override_key, "") or "").strip().lower() in {"1", "true", "on", "yes"} + if existing_doc["Switch"].get("TYPE") == "ecowitt" and "SWITCH_1_LABEL" in sw_block: + sw_block["SWITCH_1_LABEL"] = sw_block["SWITCH_1_LABEL"] or "Plug" + merged_doc = deep_merge_ordered(OrderedDict(existing_doc), OrderedDict({"Switch": sw_block})) base_dir = Path(getattr(manager, "base_dir", "switch_settings")) @@ -15103,6 +15116,18 @@ def _remote_liveness_payload(sid: str) -> dict: if key in states: states[key].update(timer_info) + for ctrl in ctrl_by_switch_id.values(): + if not getattr(ctrl, "is_ecowitt", False): + continue + for label in ctrl.get_switch_names(): + for key in (ctrl._switch_key(label), f"{ctrl.switch_id}::{label}", + f"{ctrl.channel_id_for_label[label]}::{label}"): + states.setdefault(key, {}).update( + state=ctrl.get_state(label), online=ctrl.available, + availability="online" if ctrl.available else "offline", + confirmed=ctrl.confirmed, error=ctrl.last_error, + ) + _switch_status_cache_payload = states _switch_status_cache_until = time.monotonic() + _SWITCH_STATUS_CACHE_TTL_SEC return JSONResponse(states) @@ -15506,7 +15531,7 @@ def _switch_id_matches(ctrl, wanted_sid: str | None, label: str) -> bool: sid, matched_label, ctrl, - prefer_live_current=remote, + prefer_live_current=remote or bool(getattr(ctrl, "is_ecowitt", False)), live_current=current, ) response_state = bool(new_state) @@ -15577,7 +15602,16 @@ def _timer_response_payload(state_value: bool, ts_value, *, note: str | None = N payload["note"] = note return payload - if not remote: + if getattr(ctrl, "is_ecowitt", False): + if not ctrl.available: + return JSONResponse({"error": "plug_unavailable", "message": ctrl.last_error or "Plug is offline or gateway polling is disabled."}, status_code=503) + ok = ctrl.set_state(matched_label, new_state, force=True) + if ok: + ok = await asyncio.shield(ctrl.command_task) + if not ok: + return JSONResponse({"error": "plug_confirmation_failed", "message": ctrl.last_error}, status_code=502) + response_state = bool(ctrl.get_state(matched_label)) + elif not remote: # Direct GPIO on this Pi try: ok = bool(ctrl.set_state(matched_label, new_state, force=True)) @@ -15640,7 +15674,7 @@ def _timer_response_payload(state_value: bool, ts_value, *, note: str | None = N # Persist SWITCH_n_LAST_STATE try: - if sid and not remote: + if sid and not remote and not getattr(ctrl, "is_ecowitt", False): mgr = SwitchSettingsManager("switch_settings") # Prefer controller helper if available idx = None @@ -15662,7 +15696,7 @@ def _timer_response_payload(state_value: bool, ts_value, *, note: str | None = N ts = ts_map.get(matched_label, time.time()) # Persist UI-originated switch events only for local/direct controllers. # For remote/Nodus, history should be written only from confirmed MQTT event/state ingest. - if not remote: + if not remote and not getattr(ctrl, "is_ecowitt", False): try: if sid: try: diff --git a/system_settings/factory/settings.toml b/system_settings/factory/settings.toml index bfa4ad4..20c8d06 100644 --- a/system_settings/factory/settings.toml +++ b/system_settings/factory/settings.toml @@ -45,6 +45,8 @@ UPDATE_PERIOD_SEC = 300 ENABLED = false GATEWAY_URL = "" POLL_INTERVAL_SEC = 300 +SMART_PLUG_INTERVAL_SEC = 30 +SMART_PLUGS_JSON = "[]" SENSOR_ID = "" INVENTORY_JSON = "[]" RAIN_SOURCE = "" diff --git a/testApparatus/playwright_host.py b/testApparatus/playwright_host.py index 2d6a2c0..761d179 100644 --- a/testApparatus/playwright_host.py +++ b/testApparatus/playwright_host.py @@ -155,3 +155,27 @@ def weather_forecast(request: Request, units: str = "Metric"): "latest": {}, "moon": {"updated_at": start.isoformat()}, "forecast": forecast, "app_version": "playwright", }) + + +@app.get("/ac1100-fixture", response_class=HTMLResponse) +def ac1100_dashboard() -> HTMLResponse: + """Render the actual switch card with an isolated AC1100 definition.""" + from sensorius.saiSwitchSettingsManager import SwitchSettingsManager + + sid = "ecowitt-aabbccddeeff-ac1100-000008d1" + channel_id = f"{sid}-1" + SwitchSettingsManager().save(sid, {"Switch": { + "TYPE": "ecowitt", "DEVICE": "AC1100", "SWITCH_DEVICE_ID": sid, + "SWITCH_LOCATION": "Greenhouse", "SWITCH_1_LABEL": "Plug", + "SWITCH_1_CHANNEL_ID": channel_id, + }}) + controller = SimpleNamespace( + switch_id=sid, location="Greenhouse", is_present=True, is_ecowitt=True, + available=True, confirmed=True, switches=["Plug"], last_state={"Plug": False}, + last_set_time={}, override_script={}, channel_id_for_label={"Plug": channel_id}, + ) + return HTMLResponse("".join(render_dashboard( + "All", None, [], {}, {}, SimpleNamespace(expected_gauge_map={}), + switch_controllers={sid: controller}, gauge_config=get_gauge_config(), + expected_gauge_map={}, expected_display_style_map={}, + ))) diff --git a/testApparatus/test_ecowitt_ingest.py b/testApparatus/test_ecowitt_ingest.py index b295552..594fdf5 100644 --- a/testApparatus/test_ecowitt_ingest.py +++ b/testApparatus/test_ecowitt_ingest.py @@ -235,6 +235,7 @@ async def test_gw1200_discovers_wh65_array_and_normalizes_ws2000_metrics(monkeyp } _Client.responses = { "get_version": {"version": "Version: GW1200A_V1.0.0", "platform": "ecowitt"}, + "get_iot_device_list": {"command": []}, "get_network_info": {"mac": "A4:CF:12:34:56:78"}, ("get_sensors_info", 1): [{ "img": "wh69", diff --git a/testApparatus/test_ecowitt_routes.py b/testApparatus/test_ecowitt_routes.py index 6ca772f..e2e9288 100644 --- a/testApparatus/test_ecowitt_routes.py +++ b/testApparatus/test_ecowitt_routes.py @@ -35,6 +35,7 @@ def get_setting(self, _section, _key, default=None, **_kwargs): class _Service: poll_interval_sec = 120 + smart_plug_interval_sec = 30 def __init__(self): self.saved = None @@ -52,9 +53,12 @@ async def discover(self, gateway_url): "live_metric_count": 9, } - def save_configuration(self, discovery, interval): + def save_configuration(self, discovery, interval, plug_interval=30): self.saved = (discovery, interval) + async def activate_smart_plugs(self): + pass + def status(self): return {"state": "online", "enabled": True, "inventory": []} diff --git a/testApparatus/test_ecowitt_switch.py b/testApparatus/test_ecowitt_switch.py new file mode 100644 index 0000000..71a580e --- /dev/null +++ b/testApparatus/test_ecowitt_switch.py @@ -0,0 +1,294 @@ +"""Exercise AC1100 discovery, confirmation, persistence, and switch contracts. + +HTTP responses model the documented Ecowitt IoT API without physical hardware; +SQLite and settings use isolated real files to verify event and identity behavior. +""" + +import asyncio +import json +from types import SimpleNamespace + +import httpx +import pytest + +from sensorius.saiDataLogger import saiDataLogger +from sensorius.saiEcowitt import EcowittError, EcowittGatewayIngest +from sensorius.saiEcowittSwitch import ( + EcowittSwitchController, discover_smart_plugs, query_interval, + save_smart_plugs, supports_smart_plugs, +) +from sensorius.saiSwitch import build_switch_controller +from sensorius.saiSwitchSettingsManager import SwitchSettingsManager +from testApparatus.test_ecowitt_ingest import _Settings + +DISCOVERY = dict(sensor_id="ecowitt-aabbccddeeff", gateway_url="http://gateway.test", + gateway_model="GW1200A_V1.0.0", smart_plugs=[dict(id=2257, model=2)]) +SID = "ecowitt-aabbccddeeff-ac1100-000008d1" + + +@pytest.fixture +def plug(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("sensorius.saiSwitch.get_mqtt_client", lambda *args: None) + save_smart_plugs(DISCOVERY) + manager = SwitchSettingsManager() + logger = saiDataLogger(str(tmp_path / "events.db")) + controller = build_switch_controller(switch_settings=manager.load(SID), data_logger=logger) + settings = _Settings() + settings.values.update({("Ecowitt", "ENABLED"): True, + ("Ecowitt", "GATEWAY_URL"): DISCOVERY["gateway_url"], + ("Ecowitt", "SENSOR_ID"): DISCOVERY["sensor_id"]}) + service = EcowittGatewayIngest(settings=settings, data_logger=logger) + service.switch_controllers = {SID: controller} + controller.service = service + return controller, manager, logger + + +@pytest.mark.parametrize("model,expected", [("Version: GW1200A_V1.0.0", True), ("GW1200", True), + ("GW1100A_V2.3.1", False), ("GW2000", False)]) +def test_supported_gateway(model, expected): + assert supports_smart_plugs(model) is expected + + +@pytest.mark.parametrize("value", [14, 61, "15.5", True, None, "bad"]) +def test_interval_rejects_invalid_values(value): + with pytest.raises(EcowittError): + query_interval(value) + + +@pytest.mark.parametrize("value", [15, 30, 60, "45"]) +def test_interval_accepts_bounds(value): + assert query_interval(value) == int(value) + + +@pytest.mark.asyncio +async def test_discovery_filters_other_models_and_preserves_offline_plugs(): + async def handler(request): + assert request.url.path == "/get_iot_device_list" + return httpx.Response(200, json={"command": [ + {"model": 1, "id": 123, "rfnet_state": 1}, + {"model": 2, "id": 2257, "rfnet_state": 1}, + {"model": 2, "id": 2258, "rfnet_state": 0}, + ]}) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + plugs = await discover_smart_plugs(client, "http://gateway.test") + assert [p["id"] for p in plugs] == [2257, 2258] + assert [p["online"] for p in plugs] == [True, False] + + +def test_factory_and_rediscovery_preserve_user_settings(plug): + ctrl, manager, _ = plug + assert isinstance(ctrl, EcowittSwitchController) + assert not ctrl.confirmed + assert not ctrl.set_state("Plug", True) + manager.update_setting(SID, "SWITCH_1_LABEL", "Heater") + manager.update_setting(SID, "SWITCH_LOCATION", "Greenhouse") + old_channel = manager.load(SID)["Switch"]["SWITCH_1_CHANNEL_ID"] + save_smart_plugs(DISCOVERY) + assert ctrl.get_switch_names() == ["Heater"] + assert ctrl.location == "Greenhouse" + assert ctrl.channel_id_for_label["Heater"] == old_channel + assert "SWITCH_1_PIN" not in manager.load(SID)["Switch"] + + +@pytest.mark.asyncio +async def test_poll_events_only_on_observed_changes_and_label_rename(plug, monkeypatch): + ctrl, manager, logger = plug + state = 1 + async def request(command): + assert command == {"cmd": "read_device"} + return {"command": [{"model": 2, "id": 2257, "ac_status": state, "warning": 0}]} + monkeypatch.setattr(ctrl, "_request", request) + assert await ctrl.poll_status() is True + assert ctrl.available + await ctrl.poll_status() + events = logger.get_last_switch_events(ctrl._switch_key("Plug"), limit=10) + assert len(events) == 1 + manager.update_setting(SID, "SWITCH_1_LABEL", "Heater") + assert ctrl.get_state("Heater") is True + state = 0 + assert await ctrl.poll_status() is False + assert logger.get_latest_switch_state(ctrl._switch_key("Heater")) == "Off" + assert manager.load(SID)["Switch"]["SWITCH_1_LAST_STATE"] is False + + +@pytest.mark.asyncio +async def test_command_uses_http_and_waits_for_relay_confirmation(plug, monkeypatch): + ctrl, _, logger = plug + calls = [] + states = iter([0, 0, 1]) + async def handler(request): + command = json.loads(request.content)["command"][0] + calls.append(command) + assert request.url.path == "/parse_quick_cmd_iot" + assert command["id"] == 2257 and command["model"] == 2 + if command["cmd"] == "read_device": + return httpx.Response(200, json={"command": [{"model": 2, "id": 2257, + "ac_status": next(states), "warning": 0}]}) + return httpx.Response(200, json={"command": []}) + original_client = httpx.AsyncClient + monkeypatch.setattr("sensorius.saiEcowittSwitch.httpx.AsyncClient", + lambda **kw: original_client(transport=httpx.MockTransport(handler), **kw)) + async def no_wait(*args): + return None + monkeypatch.setattr("sensorius.saiEcowittSwitch.asyncio.sleep", no_wait) + await ctrl.poll_status() + ctrl.min_off_time = 0 + assert ctrl.set_state("Plug", True, event_source="auto/rule:Heating") + assert not ctrl.get_state("Plug") + assert not ctrl.set_state("Plug", False) + assert await ctrl.command_task is True + assert ctrl.get_state("Plug") + assert [c["cmd"] for c in calls] == ["read_device", "quick_run", "read_device", "read_device"] + events = logger.get_last_switch_events(ctrl._switch_key("Plug"), limit=10, include_source=True) + assert len(events) == 2 + assert any(row[2] == "auto/rule:Heating" for row in events) + + +@pytest.mark.asyncio +async def test_failed_confirmation_does_not_log_requested_state(plug, monkeypatch): + ctrl, _, logger = plug + async def request(command): + return {"command": [{"model": 2, "id": 2257, "ac_status": 0}]} + async def no_wait(*args): + return None + monkeypatch.setattr(ctrl, "_request", request) + monkeypatch.setattr("sensorius.saiEcowittSwitch.asyncio.sleep", no_wait) + await ctrl.poll_status() + ctrl.min_off_time = 0 + assert ctrl.set_state("Plug", True) + assert await ctrl.command_task is False + assert not ctrl.get_state("Plug") + assert "did not confirm" in ctrl.last_error + assert len(logger.get_last_switch_events(ctrl._switch_key("Plug"), limit=10)) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", [ + {"command": []}, {"command": [{"model": 2, "id": 999, "ac_status": 1}]}, + {"command": [{"model": 2, "id": 2257, "ac_status": 1, "warning": 128}]}, +]) +async def test_missing_or_offline_device_cannot_be_controlled(plug, monkeypatch, payload): + ctrl, _, _ = plug + async def request(command): + return payload + monkeypatch.setattr(ctrl, "_request", request) + assert await ctrl.poll_status() is None + assert not ctrl.available + assert not ctrl.set_state("Plug", True) + + +@pytest.mark.asyncio +async def test_activation_is_idempotent_and_disable_blocks_commands(plug): + ctrl, _, _ = plug + service = ctrl.service + service.switch_controllers.clear() + registered = [] + service.supervisor = SimpleNamespace(add=lambda *args, **kw: registered.append(kw["name"])) + await service.activate_smart_plugs() + await service.activate_smart_plugs() + assert len(service.switch_controllers) == 1 + assert len(registered) == 1 + service.disable() + ctrl.online = ctrl.confirmed = True + assert not ctrl.available + assert not ctrl.set_state("Plug", True) + + +@pytest.mark.asyncio +async def test_real_routes_toggle_status_and_edit_without_mqtt(plug, monkeypatch): + from fastapi import FastAPI + import sensorius.saiWebRoutes as routes + from testApparatus.test_ecowitt_routes import _FastStats, _Settings as RouteSettings + + ctrl, manager, logger = plug + monkeypatch.setattr(routes, "FastStats", _FastStats) + monkeypatch.setattr(routes, "data_logger", logger) + monkeypatch.setattr(routes, "switch_controllers", {SID: ctrl}, raising=False) + monkeypatch.setattr(routes, "_switch_status_cache_payload", None) + monkeypatch.delenv("SENSORIUS_API_KEY", raising=False) + state = 0 + async def request(command): + nonlocal state + if command["cmd"] == "quick_run": + state = 1 + elif command["cmd"] == "quick_stop": + state = 0 + return {"command": [{"model": 2, "id": 2257, "ac_status": state}]} + async def no_wait(*args): + pass + monkeypatch.setattr(ctrl, "_request", request) + monkeypatch.setattr("sensorius.saiEcowittSwitch.asyncio.sleep", no_wait) + await ctrl.poll_status() + ctrl.min_off_time = ctrl.min_on_time = 0 + app = FastAPI() + app.state.switch_controllers = {SID: ctrl} + app.state.ecowitt_service = ctrl.service + await routes.register_routes(app, RouteSettings(), object(), object(), None) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test", headers={"Origin": "http://test"}) as client: + response = await client.post("/switch/toggle", params={"switch_id": SID, "switch_name": "Plug", + "switch_key": f"{ctrl.channel_id_for_label['Plug']}::Plug"}) + assert response.status_code == 200, response.text + assert response.json()["state"] is True + assert len(logger.get_last_switch_events(ctrl._switch_key("Plug"), limit=10)) == 2 + response = await client.get("/switch-status-update") + assert response.status_code == 200, response.text + status = response.json()[f"{SID}::Plug"] + assert status["online"] and status["confirmed"] and status["state"] + assert response.json()[f"{ctrl.channel_id_for_label['Plug']}::Plug"]["online"] is True + response = await client.post("/submit-switch-settings", data={ + "switch_id": SID, "location": "Greenhouse", "SWITCH_1_LABEL": "Heater", + }, headers={"Accept": "application/json"}) + assert response.status_code in (200, 303), response.text + assert ctrl.get_switch_names() == ["Heater"] + assert ctrl.get_state("Heater") is True + assert manager.load(SID)["Switch"]["SWITCH_LOCATION"] == "Greenhouse" + assert len(logger.get_last_switch_events(ctrl._switch_key("Heater"), limit=10)) == 2 + monkeypatch.setattr("sensorius.saiAutomationManager.AutomationManager.get_advanced_state_for_switch_key", + lambda *args: {"enabled_any": True}) + response = await client.post("/switch/toggle", params={"switch_id": SID, "switch_name": "Heater"}) + assert response.status_code == 423 + assert ctrl.get_state("Heater") is True + + +@pytest.mark.asyncio +async def test_restart_queries_state_without_sending_commands_or_duplicate_events(plug, monkeypatch): + ctrl, manager, logger = plug + requests = [] + async def request(command): + requests.append(command["cmd"]) + return {"command": [{"model": 2, "id": 2257, "ac_status": 1}]} + monkeypatch.setattr(ctrl, "_request", request) + await ctrl.poll_status() + restarted = build_switch_controller(switch_settings=manager.load(SID), data_logger=logger) + restarted.service = ctrl.service + monkeypatch.setattr(restarted, "_request", request) + assert not restarted.confirmed + assert not restarted.set_state("Plug", False) + await restarted.poll_status() + assert requests == ["read_device", "read_device"] + assert len(logger.get_last_switch_events(restarted._switch_key("Plug"), limit=10)) == 1 + + +@pytest.mark.asyncio +async def test_homeassistant_publishes_confirmed_state_after_command(plug, monkeypatch): + from sensorius.saiHomeAssistantMqtt import rPiHomeAssistantBridge + ctrl, _, _ = plug + ctrl.confirmed = ctrl.online = True + ctrl.last_query = __import__('time').monotonic() + ctrl.min_off_time = 0 + async def request(command): + return {"command": [{"model": 2, "id": 2257, "ac_status": 0}]} + async def no_wait(*args): + pass + monkeypatch.setattr(ctrl, "_request", request) + monkeypatch.setattr("sensorius.saiEcowittSwitch.asyncio.sleep", no_wait) + published = [] + bridge = rPiHomeAssistantBridge.__new__(rPiHomeAssistantBridge) + bridge.switch_controllers = {SID: ctrl} + async def publish(*args): + published.append(args) + bridge.publish_switch_state = publish + await bridge._handle_switch_command_async(SID, ctrl.channel_id_for_label["Plug"], True) + assert published == [(SID, ctrl.channel_id_for_label["Plug"], False)] + assert ctrl.last_error == "Gateway did not confirm the requested plug state." diff --git a/tests/ecowitt-smart-plugs.spec.js b/tests/ecowitt-smart-plugs.spec.js new file mode 100644 index 0000000..ba968b2 --- /dev/null +++ b/tests/ecowitt-smart-plugs.spec.js @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; + +const sid = 'ecowitt-aabbccddeeff-ac1100-000008d1'; +const discovery = { + gateway_url: 'http://gateway.test', gateway_model: 'GW1200A_V1.0.0', + inventory: [{ id: 'AB12', name: 'Weather array', reporting: true }], + smart_plugs: [{ id: 2257, model: 2, online: true }, { id: 2258, model: 2, online: false }], + live_metric_count: 9, poll_interval_sec: 300, smart_plug_interval_sec: 30, +}; + +test('GW1200 discovery separates sensors and plugs and saves query interval', async ({ page }, testInfo) => { + await page.route('https://cdn.jsdelivr.net/**', route => route.fulfill({ body: '' })); + await page.route('**/ecowitt/status', route => route.fulfill({ json: { ...discovery, label: 'Online' } })); + await page.route('**/ecowitt/discover', route => route.fulfill({ json: discovery })); + let saved; + await page.route('**/ecowitt/save', route => { + saved = route.request().postDataJSON(); + return route.fulfill({ json: { ...discovery, smart_plug_interval_sec: 15 } }); + }); + await page.goto('/'); + await page.getByRole('link', { name: 'Open General Settings' }).click(); + const modal = page.locator('#setupPiModal'); + await modal.getByRole('button', { name: 'Add Device', exact: true }).click(); + await modal.locator('summary').filter({ hasText: /^Ecowitt Gateway$/ }).click(); + await modal.getByRole('button', { name: 'Find Devices', exact: true }).click(); + await expect(modal.getByText('Discovered GW Sensors', { exact: true })).toBeVisible(); + await expect(modal.getByText('Discovered GW Smart Plugs', { exact: true })).toBeVisible(); + await expect(modal.locator('#ecowitt-smart-plug-list [role="listitem"]')).toHaveCount(2); + await expect(modal.locator('#ecowitt-smart-plug-list')).toContainText('2258 — offline'); + await modal.locator('#ecowitt-smart-plug-interval').fill('15'); + await modal.getByRole('button', { name: 'Save Gateway', exact: true }).click(); + await expect.poll(() => saved?.smart_plug_interval_sec).toBe('15'); + await page.screenshot({ path: testInfo.outputPath('ecowitt-discovery.png'), fullPage: true }); +}); + +test('AC1100 uses switch card, canonical toggle key, and event list', async ({ page }, testInfo) => { + await page.route('https://cdn.jsdelivr.net/**', route => route.fulfill({ body: '' })); + let offline = false; + await page.route('**/switch-status-update', route => route.fulfill({ json: offline ? { + [`${sid}-1::Plug`]: { state: true, online: false, confirmed: true, error: 'AC1100 is offline at the gateway.' }, + } : {} })); + let toggleUrl; + await page.route('**/switch/toggle?**', route => { + toggleUrl = new URL(route.request().url()); + return route.fulfill({ json: { state: true, switch_id: sid, label: 'Plug', time: '', events: ['On 2026-09-10 12:00:00 (manual)'] } }); + }); + await page.goto('/ac1100-fixture'); + const toggle = page.locator(`button[data-switch-id="${sid}"][data-switch-name="Plug"]`); + await expect(toggle).toHaveText('Off'); + await expect(page.locator('.ecowitt-plug-status')).toHaveText('Online'); + await expect(page.locator('h3').filter({ hasText: 'Greenhouse' })).toBeVisible(); + await toggle.click(); + await expect(toggle).toHaveText('On'); + expect(toggleUrl.searchParams.get('switch_key')).toBe(`${sid}-1::Plug`); + await expect(page.locator('.switch-events-list li')).toHaveAttribute('data-raw-event', 'On 2026-09-10 12:00:00 (manual)'); + await expect(page.locator('.switch-events-list li')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('ac1100-switch.png'), fullPage: true }); + offline = true; + await expect(page.locator('.ecowitt-plug-status')).toHaveText('Offline'); + await expect(toggle).toHaveText('On'); +}); diff --git a/ui_templates/modals/system_settings.html b/ui_templates/modals/system_settings.html index e7e98e6..5df88d9 100644 --- a/ui_templates/modals/system_settings.html +++ b/ui_templates/modals/system_settings.html @@ -1606,19 +1606,29 @@

Add Device

- - + +
- +
-
Enter the gateway URL and choose Find Sensors.
+
Enter the gateway URL and choose Find Devices.
+
+ +
+
Choose Find Devices to discover GW1200 smart plugs.
+
+
+
+ + +
@@ -3233,6 +3243,25 @@

Confirm Nodus Wi-Fi Update

}); } + function renderEcowittSmartPlugs(plugs) { + const list = document.getElementById("ecowitt-smart-plug-list"); + if (!list) return; + list.replaceChildren(); + const devices = Array.isArray(plugs) ? plugs : []; + if (!devices.length) { + list.textContent = "No AC1100 smart plugs discovered (GW1200 required)."; + return; + } + devices.forEach(plug => { + const row = document.createElement("div"); + row.setAttribute("role", "listitem"); + row.className = "integration-state-detail"; + const state = typeof plug.state === "boolean" ? (plug.state ? " — On" : " — Off") : ""; + row.textContent = `AC1100 — ID ${plug.id} — ${plug.online ? "online" : "offline"}${state}${plug.error ? " — " + plug.error : ""}`; + list.appendChild(row); + }); + } + async function loadEcowittStatus() { try { const resp = await fetch("/ecowitt/status", { cache: "no-store" }); @@ -3243,6 +3272,9 @@

Confirm Nodus Wi-Fi Update

if (url && !url.value && data.gateway_url) url.value = data.gateway_url; if (interval && data.poll_interval_sec) interval.value = String(data.poll_interval_sec); if (Array.isArray(data.inventory) && data.inventory.length) renderEcowittSensors(data.inventory); + renderEcowittSmartPlugs(data.smart_plugs); + const plugInterval = document.getElementById("ecowitt-smart-plug-interval"); + if (plugInterval && data.smart_plug_interval_sec) plugInterval.value = data.smart_plug_interval_sec; const parts = [data.label || "Ecowitt state unknown"]; if (data.last_accepted_reading) parts.push("Latest reading: " + data.last_accepted_reading); if (data.last_error) parts.push(data.last_error); @@ -3269,10 +3301,12 @@

Confirm Nodus Wi-Fi Update

if (!resp.ok) throw new Error(data.error || "Gateway discovery failed."); ecowittDiscovery = data; renderEcowittSensors(data.inventory); + renderEcowittSmartPlugs(data.smart_plugs); if (save) save.disabled = false; setStatus("ecowitt-status", `${data.gateway_model || "Ecowitt gateway"}: ${data.inventory.length} valid sensor(s), ${data.live_metric_count || 0} live metric(s).`); } catch (e) { renderEcowittSensors([]); + renderEcowittSmartPlugs([]); const msg = e && e.message ? e.message : "Gateway discovery failed."; setStatus("ecowitt-status", msg); showSettingsToast(msg, "error"); @@ -3283,7 +3317,7 @@

Confirm Nodus Wi-Fi Update

async function saveEcowittGateway() { if (!ecowittDiscovery) { - setStatus("ecowitt-status", "Choose Find Sensors before saving."); + setStatus("ecowitt-status", "Choose Find Devices before saving."); return; } const gateway_url = (document.getElementById("ecowitt-gateway-url")?.value || "").trim(); @@ -3293,7 +3327,7 @@

Confirm Nodus Wi-Fi Update

const resp = await fetch("/ecowitt/save", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ gateway_url, poll_interval_sec }) + body: JSON.stringify({ gateway_url, poll_interval_sec, smart_plug_interval_sec: document.getElementById("ecowitt-smart-plug-interval").value }) }); const data = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(data.error || "Failed to save Ecowitt gateway."); @@ -3301,6 +3335,7 @@

Confirm Nodus Wi-Fi Update

shell.dataset.ecowittPollInterval = String(data.poll_interval_sec || poll_interval_sec); ecowittDiscovery = data; renderEcowittSensors(data.inventory); + renderEcowittSmartPlugs(data.smart_plugs); setStatus("ecowitt-status", "Ecowitt gateway saved and polling enabled."); showSettingsToast("Ecowitt gateway saved", "ok"); } catch (e) {