9a4ee8e0cf
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>
152 lines
5.3 KiB
Python
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
|