Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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`.
Expand Down
8 changes: 7 additions & 1 deletion docs/sensors.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 35 additions & 8 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<user>/Sensorius/switch_settings/<gateway-id>-ac1100-<plug-id>/switch.toml`
on Linux or `/Users/<user>/Sensorius/switch_settings/<gateway-id>-ac1100-<plug-id>/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
Expand Down
2 changes: 1 addition & 1 deletion sensorius/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
5 changes: 5 additions & 0 deletions sensorius/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
88 changes: 87 additions & 1 deletion sensorius/saiEcowitt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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.")
Expand All @@ -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),
Expand Down Expand Up @@ -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)
Expand All @@ -320,17 +326,22 @@ 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(
sensor_id,
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")),
Expand All @@ -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"):
Expand Down Expand Up @@ -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
Loading