# from datetime import datetime, timedelta, timezone # # from bleak import BleakScanner # from bleak.backends.device import BLEDevice # from bleak.backends.scanner import AdvertisementData # # from app.qingping.models import QingpingState # from app.qingping.parser import parse_cgdn1 # # # QINGPING_SERVICE_UUID = "0000fdcd-0000-1000-8000-00805f9b34fb" # # # class QingpingService: # def __init__(self, mac: str, stale_after: float = 30.0): # self._mac = mac.upper() # self._stale_after = stale_after # # self._scanner: BleakScanner | None = None # self._state: QingpingState | None = None # # self._last_seen: datetime | None = None # self._last_error: str | None = None # # self._running = False # # @property # def state(self) -> QingpingState | None: # return self._state # # @property # def running(self) -> bool: # return self._running # # @property # def last_seen(self) -> datetime | None: # return self._last_seen # # @property # def last_error(self) -> str | None: # return self._last_error # # @property # def online(self) -> bool: # if not self._running: # return False # # if self._last_seen is None: # return False # # age = datetime.now(timezone.utc) - self._last_seen # # return age <= timedelta( # seconds=self._stale_after # ) # # async def start(self): # if self._running: # return # # try: # self._scanner = BleakScanner( # self._on_advertisement, # # service_uuids=[ # # QINGPING_SERVICE_UUID, # # ], # ) # # await self._scanner.start() # # self._running = True # self._last_error = None # # except Exception as exc: # self._scanner = None # self._running = False # self._last_error = str(exc) # # raise # # async def stop(self): # scanner = self._scanner # # self._scanner = None # self._running = False # # if scanner is not None: # await scanner.stop() # # # def _on_advertisement( # # self, # # device: BLEDevice, # # advertisement: AdvertisementData, # # ): # # if device.address.upper() != self._mac: # # return # # # # data = advertisement.service_data.get( # # QINGPING_SERVICE_UUID # # ) # # # # if not data: # # return # # # # try: # # state = parse_cgdn1( # # data, # # rssi=getattr( # # advertisement, # # "rssi", # # None, # # ), # # ) # # # # if state is None: # # return # # # # self._state = state # # # # self._last_seen = datetime.now( # # timezone.utc # # ) # # # # self._last_error = None # # # # except Exception as exc: # # self._last_error = str(exc) # # def _on_advertisement( # self, # device: BLEDevice, # advertisement: AdvertisementData, # ): # data = advertisement.service_data.get( # QINGPING_SERVICE_UUID # ) # # if not data: # return # # print( # "QINGPING:", # device.address, # device.name, # data.hex(" "), # ) # # if device.address.upper() != self._mac: # print( # "MAC mismatch:", # device.address, # "!=", # self._mac, # ) # return # # try: # state = parse_cgdn1( # data, # rssi=getattr( # advertisement, # "rssi", # None, # ), # ) # # print("PARSED:", state) # # if state is None: # return # # self._state = state # self._last_seen = datetime.now( # timezone.utc # ) # self._last_error = None # # except Exception as exc: # self._last_error = str(exc) # print("Qingping parse error:", exc) # # async def __aenter__(self): # await self.start() # return self # # async def __aexit__( # self, # exc_type, # exc_val, # exc_tb, # ): # await self.stop() import asyncio import json import logging import threading import time from dataclasses import replace from datetime import datetime, timezone import paho.mqtt.client as mqtt from .models import QingpingState logger = logging.getLogger(__name__) class QingpingService: def __init__( self, host: str, port: int = 1883, mac: str = "CCB5D131BA93", ): self._host = host self._port = port self._mac = mac self._up_topic = f"qingping/{mac}/up" self._down_topic = f"qingping/{mac}/down" self._state = QingpingState() self._lock = threading.Lock() self._client: mqtt.Client | None = None self._watchdog_task: asyncio.Task | None = None self._last_sample_monotonic: float | None = None self._connected_since: float | None = None self._heartbeat_seen = False self._recovery_sent = False self._last_device_timestamp: int | None = None self._reboot_detected = False @property def state(self) -> QingpingState: with self._lock: return self._state @property def online(self) -> bool: state = self.state if not state.mqtt_connected: return False if self._last_sample_monotonic is None: return False return time.monotonic() - self._last_sample_monotonic < 60 async def start(self) -> None: client = mqtt.Client( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id="tioncontroller-qingping", ) client.on_connect = self._on_connect client.on_disconnect = self._on_disconnect client.on_message = self._on_message client.reconnect_delay_set( min_delay=1, max_delay=30, ) self._client = client client.connect_async( self._host, self._port, keepalive=60, ) client.loop_start() self._watchdog_task = asyncio.create_task( self._watchdog_loop() ) async def stop(self) -> None: if self._watchdog_task is not None: self._watchdog_task.cancel() try: await self._watchdog_task except asyncio.CancelledError: pass self._watchdog_task = None if self._client is not None: self._client.disconnect() self._client.loop_stop() self._client = None def status(self) -> dict: result = self.state.to_dict() result["online"] = self.online return result def _on_connect( self, client, userdata, flags, reason_code, properties, ): if reason_code.is_failure: logger.warning( "Qingping MQTT connection failed: %s", reason_code, ) return client.subscribe(self._up_topic) with self._lock: self._state = replace( self._state, mqtt_connected=True, ) self._connected_since = time.monotonic() self._heartbeat_seen = False self._recovery_sent = False logger.info( "Qingping MQTT connected" ) def _on_disconnect( self, client, userdata, disconnect_flags, reason_code, properties, ): with self._lock: self._state = replace( self._state, mqtt_connected=False, ) logger.warning( "Qingping MQTT disconnected: %s", reason_code, ) def _on_message( self, client, userdata, message, ): try: payload = json.loads( message.payload.decode("utf-8") ) except (UnicodeDecodeError, json.JSONDecodeError): logger.warning( "Invalid Qingping MQTT message" ) return now = datetime.now(timezone.utc) with self._lock: self._state = replace( self._state, last_message_at=now, ) message_type = str(payload.get("type")) if message_type == "13": self._handle_heartbeat(payload) return if message_type in {"12", "17"}: self._handle_sensor_data( payload, now, ) def _handle_heartbeat( self, payload: dict, ) -> None: self._heartbeat_seen = True device_timestamp = payload.get("timestamp") if isinstance(device_timestamp, int): previous = self._last_device_timestamp if ( device_timestamp < 300 and ( previous is None or previous > 300 ) ): self._reboot_detected = True self._recovery_sent = False logger.info( "Qingping reboot detected" ) self._last_device_timestamp = device_timestamp wifi_rssi = None wifi_info = payload.get("wifi_info") if isinstance(wifi_info, str): parts = wifi_info.split(",") if len(parts) >= 2: try: wifi_rssi = int(parts[1]) except ValueError: pass with self._lock: self._state = replace( self._state, wifi_rssi=wifi_rssi, firmware=payload.get("sw_version"), ) def _handle_sensor_data( self, payload: dict, received_at: datetime, ) -> None: sensor_data = payload.get("sensorData") if not isinstance(sensor_data, list): return if not sensor_data: return sample = max( sensor_data, key=self._sample_timestamp, ) sample_timestamp = self._sample_timestamp( sample ) if sample_timestamp <= 0: return current_timestamp = ( self.state.sample_timestamp ) # CGDN1 после запуска может несколько раз # присылать одну и ту же историческую точку. if ( current_timestamp is not None and sample_timestamp <= current_timestamp ): return temperature = self._value( sample, "temperature", ) humidity = self._value( sample, "humidity", ) co2 = self._value( sample, "co2", ) pm25 = self._value( sample, "pm25", ) pm10 = self._value( sample, "pm10", ) battery = self._value( sample, "battery", ) with self._lock: self._state = replace( self._state, temperature=temperature, humidity=humidity, co2=co2, pm25=pm25, pm10=pm10, battery=battery, sample_timestamp=sample_timestamp, sample_received_at=received_at, ) self._last_sample_monotonic = ( time.monotonic() ) self._reboot_detected = False @staticmethod def _sample_timestamp( sample: dict, ) -> int: timestamp = sample.get("timestamp") if isinstance(timestamp, dict): timestamp = timestamp.get("value") try: return int(timestamp) except (TypeError, ValueError): return 0 @staticmethod def _value( sample: dict, key: str, ): value = sample.get(key) if isinstance(value, dict): return value.get("value") return value async def _watchdog_loop(self) -> None: while True: await asyncio.sleep(5) if self._client is None: continue state = self.state if not state.mqtt_connected: continue if self._recovery_sent: continue # Явно увидели reboot CGDN1. if self._reboot_detected: self._send_recovery() continue # Или сервис подключился, heartbeat есть, # но свежих sensorData так и не появилось. if ( self._connected_since is not None and self._heartbeat_seen and self._last_sample_monotonic is None and time.monotonic() - self._connected_since > 30 ): self._send_recovery() def _send_recovery(self) -> None: if self._client is None: return payload = { "type": "17", "timestamp": int(time.time()), "setting": { "report_interval": 15, "collect_interval": 15, "need_ack": 0, }, } result = self._client.publish( self._down_topic, json.dumps( payload, separators=(",", ":"), ), ) if result.rc == mqtt.MQTT_ERR_SUCCESS: self._recovery_sent = True logger.warning( "Qingping recovery command sent" ) else: logger.warning( "Failed to send Qingping recovery: %s", result.rc, )