Source code for etho.services.GOVZeroService

#!/usr/bin/env python
import asyncio
import logging
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Optional

import defopt

from .ZeroService import BaseZeroService
from . import register_service
from .utils.log_exceptions import for_all_methods, log_exceptions

try:
    from bleak import AdvertisementData, BleakScanner, BLEDevice

    bleak_import_error = None
except ImportError as bleak_import_error:
    AdvertisementData = Any
    BLEDevice = Any
    BleakScanner = None


DEFAULT_INTERVAL = 60.0
H5075_MANUFACTURER_ID = 0xEC88


def normalize_address(address: str) -> str:
    if address is None:
        raise ValueError("address is required")
    address = address.strip()
    if not address:
        raise ValueError("address is required")
    return address.upper()


[docs] @dataclass class H5075Measurement: timestamp: datetime temperature_c: float humidity: float
def decode_h5075_measurement(payload: bytes, timestamp: Optional[datetime] = None) -> H5075Measurement: if len(payload) < 4: raise ValueError("manufacturer payload is too short") raw = int.from_bytes(payload[1:4], byteorder="big", signed=False) is_negative = bool(raw & 0x800000) if is_negative: raw ^= 0x800000 temperature_c = int(raw / 1000) / 10.0 if is_negative: temperature_c = -temperature_c humidity = (raw % 1000) / 10.0 if timestamp is None: timestamp = datetime.now() return H5075Measurement(timestamp=timestamp, temperature_c=temperature_c, humidity=humidity) def format_measurement_log_line(measurement: H5075Measurement) -> str: return f"{measurement.timestamp:%Y-%m-%d %H:%M:%S},{measurement.temperature_c:.1f},{measurement.humidity:.1f}"
[docs] async def discover_h5075_sensors(duration: float = 10.0): """Return nearby H5075 addresses, names, and latest measurements.""" if bleak_import_error is not None: raise bleak_import_error duration = float(duration) if duration <= 0: raise ValueError("duration must be > 0") sensors = {} def detection_callback(device: BLEDevice, advertisement_data: AdvertisementData): address = getattr(device, "address", None) payload = advertisement_data.manufacturer_data.get(H5075_MANUFACTURER_ID) if address is None or payload is None: return try: measurement = decode_h5075_measurement(payload) except ValueError: return name = getattr(advertisement_data, "local_name", None) or getattr(device, "name", None) or "" sensors[normalize_address(address)] = (name, measurement) async with BleakScanner(detection_callback=detection_callback): await asyncio.sleep(duration) return sorted((address, name, measurement) for address, (name, measurement) in sensors.items())
[docs] @for_all_methods(log_exceptions(logging.getLogger(__name__))) @register_service class GOV(BaseZeroService): LOGGING_PORT = 1455 SERVICE_PORT = 4255 SERVICE_NAME = "GOV" CLIENT_START_GROUP = "pre" @classmethod def setup_client(cls, service_key, service_index, prot, defaults, playlistfile, save_prefix, preview, new_console): if preview: return None this = defaults.copy() this.update(prot[service_key]) if prot[service_key].get("port") is None: prot[service_key]["port"] = cls.SERVICE_PORT + service_index interval = prot[service_key].get("interval") if interval is None: interval = DEFAULT_INTERVAL service = cls.make( this["serializer"], this["host"], this["python_exe"], new_console=new_console, port=prot[service_key]["port"], ) service.setup(prot[service_key]["address"], interval, prot["maxduration"] + 10) service.init_local_logger(f"{this['savefolder']}/{save_prefix}/{save_prefix}_{service_key.lower()}.log") return service def setup(self, address: str, interval: float = DEFAULT_INTERVAL, duration: float = 0): if bleak_import_error is not None: raise bleak_import_error interval = DEFAULT_INTERVAL if interval is None else float(interval) if interval <= 0: raise ValueError("interval must be > 0") self.address = normalize_address(address) self.interval = interval self.duration = float(duration) self.measurement: Optional[H5075Measurement] = None self.data = None self._state_lock = threading.Lock() self._latest_measurement: Optional[H5075Measurement] = None self._latest_measurement_seen_at: Optional[float] = None self._last_logged_measurement_seen_at: Optional[float] = None self._last_emit_monotonic: Optional[float] = None self._thread_stopper = threading.Event() self._scanner_thread = threading.Thread(target=self._scanner_worker, args=(self._thread_stopper,), daemon=True) if self.duration > 0: self._thread_timer = threading.Timer(self.duration, self.finish, kwargs={"stop_service": True}) self.info = { "job": { "address": self.address, "interval": f"{self.interval}s", "duration": f"{self.duration}s", } } def start(self): self._time_started = time.time() self._scanner_thread.start() self._emit_info(f"scanning for {self.address}") self._emit_info(f"logging at {self.interval:.1f}s resolution") if hasattr(self, "_thread_timer"): self._emit_info(f"duration {self.duration} seconds") self._thread_timer.start() self._emit_info("finish timer started") def _scanner_worker(self, stop_event): try: asyncio.run(self._scanner_loop(stop_event)) except Exception: self.log.exception("scanner loop failed") async def _scanner_loop(self, stop_event): async with BleakScanner(detection_callback=self._detection_callback): while not stop_event.is_set(): self._maybe_log_latest_measurement() await asyncio.sleep(0.2) def _detection_callback(self, device: BLEDevice, advertisement_data: AdvertisementData): if getattr(device, "address", None) is None: return if normalize_address(device.address) != self.address: return manufacturer_data = advertisement_data.manufacturer_data.get(H5075_MANUFACTURER_ID) if manufacturer_data is None: return measurement = decode_h5075_measurement(manufacturer_data, timestamp=datetime.now()) seen_at = time.monotonic() self._record_measurement(measurement, seen_at) self._maybe_log_latest_measurement(now_monotonic=seen_at) def _record_measurement(self, measurement: H5075Measurement, seen_at: Optional[float] = None): if seen_at is None: seen_at = time.monotonic() with self._state_lock: self.measurement = measurement self._latest_measurement = measurement self._latest_measurement_seen_at = seen_at def _maybe_log_latest_measurement(self, now_monotonic: Optional[float] = None): if now_monotonic is None: now_monotonic = time.monotonic() with self._state_lock: if self._latest_measurement is None or self._latest_measurement_seen_at is None: return if self._latest_measurement_seen_at == self._last_logged_measurement_seen_at: return if self._last_emit_monotonic is not None and now_monotonic - self._last_emit_monotonic < self.interval: return line = format_measurement_log_line(self._latest_measurement) self.data = line self._last_logged_measurement_seen_at = self._latest_measurement_seen_at self._last_emit_monotonic = now_monotonic self._emit_info(line) def _write_to_console(self, message: str): try: print(message, flush=True) except OSError: # Hidden/disowned Windows service processes can have an invalid stdout. # Logging should continue even when there is no usable console. pass def _emit_info(self, message: str): self.log.info(message) self._write_to_console(message) def _emit_warning(self, message: str): self.log.warning(message) self._write_to_console(message) def finish(self, stop_service=False): self._emit_warning("stopping") if hasattr(self, "_thread_stopper"): self._thread_stopper.set() if hasattr(self, "_thread_timer"): self._thread_timer.cancel() if hasattr(self, "_scanner_thread") and self._scanner_thread.is_alive() and threading.current_thread() is not self._scanner_thread: self._scanner_thread.join(timeout=5) self._emit_warning(" stopped ") self._flush_loggers() if stop_service: time.sleep(2) self.service_stop() def disp(self): pass def is_busy(self): return hasattr(self, "_scanner_thread") and self._scanner_thread.is_alive() def test(self): return bleak_import_error is None def cleanup(self): self.finish() return True
def cli(serializer: str = "default", port: Optional[str] = None): if port is None: port = GOV.SERVICE_PORT s = GOV(serializer=serializer) s.bind(f"tcp://0.0.0.0:{port}") print("running GOVZeroService") s.run() print("done") if __name__ == "__main__": defopt.run(cli)