Files
astrape-oracle/classes/weather/ecowitt.py
T
rpotter6298 9a4ee8e0cf Flatten to a single package directory
The checkout is now astrape/astrape-oracle, not astrape/astrape-oracle/<package>. The package
contents move to the repository root and pyproject maps the package name to
'.' via package-dir, with the subpackages listed explicitly.

The systemd units move out to /srv/astrape/deploy/systemd, outside every
repository, because they describe this deployment rather than any one
component.

Verified both install modes: editable, which is how the deployment runs, and
a plain wheel install, which is what the container build does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:05:17 +02:00

152 lines
5.3 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from urllib.request import urlopen
import json
import re
from gibil.classes.models import WeatherStationObservation
class EcowittGw3000Client:
"""Fetches local live data from an Ecowitt GW3000/GW3000A gateway."""
def __init__(self, host: str, timeout_seconds: float = 5) -> None:
self.host = host.removeprefix("http://").removeprefix("https://").rstrip("/")
self.timeout_seconds = timeout_seconds
def fetch_observation(self) -> WeatherStationObservation:
received_at = datetime.now(timezone.utc)
with urlopen(self._live_data_url(), timeout=self.timeout_seconds) as response:
payload = json.loads(response.read().decode("utf-8"))
return EcowittGw3000Parser().parse(
payload=payload,
received_at=received_at,
)
def _live_data_url(self) -> str:
return f"http://{self.host}/get_livedata_info"
class EcowittGw3000Parser:
"""Normalizes GW3000 live-data JSON into a station observation."""
common_id_names = {
"0x02": "temperature_c",
"0x07": "relative_humidity_pct",
"0x03": "dew_point_c",
"0x0B": "wind_speed_m_s",
"0x0C": "wind_gust_m_s",
"0x19": "wind_max_m_s",
"0x0A": "wind_direction_deg",
"0x15": "solar_radiation_w_m2",
"0x17": "uv_index",
}
rain_id_names = {
"0x0D": "rain_event_mm",
"0x0E": "rain_rate_mm_h",
"0x7C": "rain_hourly_mm",
"0x10": "rain_daily_mm",
"0x11": "rain_weekly_mm",
"0x12": "rain_monthly_mm",
"0x13": "rain_yearly_mm",
}
def parse(
self,
payload: dict[str, Any],
received_at: datetime | None = None,
) -> WeatherStationObservation:
if received_at is None:
received_at = datetime.now(timezone.utc)
common = self._parse_id_list(payload.get("common_list", []))
rain = self._parse_id_list(payload.get("rain", []))
indoor = self._parse_indoor(payload.get("wh25", []))
return WeatherStationObservation(
observed_at=received_at,
received_at=received_at,
source="ecowitt_gw3000",
temperature_c=common.get("temperature_c"),
relative_humidity_pct=common.get("relative_humidity_pct"),
dew_point_c=common.get("dew_point_c"),
wind_speed_m_s=common.get("wind_speed_m_s"),
wind_gust_m_s=common.get("wind_gust_m_s"),
wind_max_m_s=common.get("wind_max_m_s"),
wind_direction_deg=common.get("wind_direction_deg"),
solar_radiation_w_m2=common.get("solar_radiation_w_m2"),
uv_index=common.get("uv_index"),
rain_rate_mm_h=rain.get("rain_rate_mm_h"),
rain_event_mm=rain.get("rain_event_mm"),
rain_hourly_mm=rain.get("rain_hourly_mm"),
rain_daily_mm=rain.get("rain_daily_mm"),
rain_weekly_mm=rain.get("rain_weekly_mm"),
rain_monthly_mm=rain.get("rain_monthly_mm"),
rain_yearly_mm=rain.get("rain_yearly_mm"),
indoor_temperature_c=indoor.get("indoor_temperature_c"),
indoor_humidity_pct=indoor.get("indoor_humidity_pct"),
absolute_pressure_hpa=indoor.get("absolute_pressure_hpa"),
relative_pressure_hpa=indoor.get("relative_pressure_hpa"),
metadata={
"raw_payload": payload,
"debug": payload.get("debug", []),
},
)
def _parse_id_list(self, rows: object) -> dict[str, float]:
parsed: dict[str, float] = {}
if not isinstance(rows, list):
return parsed
for row in rows:
if not isinstance(row, dict):
continue
row_id = str(row.get("id", ""))
key = self.common_id_names.get(row_id) or self.rain_id_names.get(row_id)
if key is None:
continue
value = self._parse_number(row.get("val"))
if value is None:
continue
if key in {"wind_speed_m_s", "wind_gust_m_s", "wind_max_m_s"}:
value = self._knots_to_m_s(value)
parsed[key] = value
return parsed
def _parse_indoor(self, rows: object) -> dict[str, float]:
if not isinstance(rows, list) or not rows:
return {}
row = rows[0]
if not isinstance(row, dict):
return {}
values = {
"indoor_temperature_c": self._parse_number(row.get("intemp")),
"indoor_humidity_pct": self._parse_number(row.get("inhumi")),
"absolute_pressure_hpa": self._parse_number(row.get("abs")),
"relative_pressure_hpa": self._parse_number(row.get("rel")),
}
return {
key: value
for key, value in values.items()
if value is not None
}
def _parse_number(self, value: object) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
match = re.search(r"-?\d+(?:\.\d+)?", str(value))
if match is None:
return None
return float(match.group(0))
def _knots_to_m_s(self, value: float) -> float:
return value * 0.514444