from __future__ import annotations import logging import time import urllib.error import urllib.request from datetime import datetime, timezone from typing import Any from sqlalchemy import select from app.config import config from app.db import init_db, session_scope from app.models import Device, Reading from app.switchbot import SwitchBotClient, SwitchBotError logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) logger = logging.getLogger(__name__) SENSOR_DEVICE_TYPES = {"WoIOSensor"} def is_all_zero_reading(status: dict[str, Any]) -> bool: return status.get("temperature") == 0 and status.get("humidity") == 0 and status.get("battery") == 0 def upsert_device(device_data: dict[str, Any]) -> None: device_id = device_data["deviceId"] with session_scope() as session: device = session.get(Device, device_id) if device is None: device = Device(id=device_id, name=device_data.get("deviceName", device_id), device_type="") session.add(device) device.name = device_data.get("deviceName", device_id) device.device_type = device_data.get("deviceType", "") device.enable_cloud_service = bool(device_data.get("enableCloudService")) device.hub_device_id = device_data.get("hubDeviceId") device.last_seen_at = datetime.now(timezone.utc).replace(tzinfo=None) def sync_devices(client: SwitchBotClient) -> list[Device]: logger.info("Syncing SwitchBot device list") devices = client.devices() logger.info("SwitchBot returned %s devices", len(devices)) for device_data in devices: upsert_device(device_data) with session_scope() as session: return list(session.scalars(select(Device).order_by(Device.name))) def save_reading(device: Device, status: dict[str, Any]) -> None: if "temperature" not in status and "humidity" not in status: logger.info("Skipping %s (%s): status has no temperature/humidity", device.name, device.id) return if is_all_zero_reading(status): logger.info("Skipping %s (%s): status is all zeros", device.name, device.id) return reading = Reading( device_id=device.id, recorded_at=datetime.now(timezone.utc).replace(tzinfo=None), temperature=status.get("temperature"), humidity=status.get("humidity"), battery=status.get("battery"), version=status.get("version"), ) with session_scope() as session: session.add(reading) logger.info( "Recorded %s: temp=%s humidity=%s battery=%s", device.name, reading.temperature, reading.humidity, reading.battery, ) def _ping_healthcheck(success: bool, detail: str | None = None) -> None: url = config.healthcheck_url if not url: logger.debug("Healthcheck ping skipped: HEALTHCHECK_URL not configured") return try: if success: logger.debug("Pinging healthcheck (success): %s", url) urllib.request.urlopen(url, timeout=10) # noqa: S310 logger.info("Healthcheck ping sent successfully") else: fail_url = f"{url}/fail" logger.debug("Pinging healthcheck (fail): %s — %s", fail_url, detail) payload = (detail or "").encode() req = urllib.request.Request(fail_url, data=payload, method="POST") urllib.request.urlopen(req, timeout=10) # noqa: S310 logger.info("Healthcheck fail ping sent: %s", detail) except Exception as exc: # noqa: BLE001 logger.warning("Healthcheck ping failed: %s", exc) def collect_once(client: SwitchBotClient) -> None: logger.info("Starting collection cycle") devices = sync_devices(client) for device in devices: if device.device_type not in SENSOR_DEVICE_TYPES: logger.info("Skipping %s (%s): %s is not a sensor", device.name, device.id, device.device_type) continue if not device.enable_cloud_service: logger.info("Skipping %s (%s): cloud service disabled", device.name, device.id) continue try: status = client.status(device.id) except SwitchBotError as exc: logger.warning("Could not read %s (%s): %s", device.name, device.id, exc) continue save_reading(device, status) logger.info("Collection cycle complete") def main() -> int: if not config.switchbot_token or not config.switchbot_secret: logger.error("SWITCHBOT_TOKEN and SWITCHBOT_SECRET are required") return 2 init_db() client = SwitchBotClient(config.switchbot_token, config.switchbot_secret) interval = max(60, config.collect_interval_seconds) logger.info("Collector started with %s second interval", interval) while True: started = time.monotonic() try: collect_once(client) _ping_healthcheck(True) except Exception as exc: logger.exception("Collector cycle failed") _ping_healthcheck(False, str(exc)) elapsed = time.monotonic() - started time.sleep(max(1, interval - elapsed)) if __name__ == "__main__": raise SystemExit(main())