work: добавил автоматический режим

This commit is contained in:
Fedorov Dmitriy
2026-09-18 23:59:42 +03:00
parent 7dbfd8b069
commit cdec9b0996
24 changed files with 3449 additions and 33 deletions
+610
View File
@@ -0,0 +1,610 @@
# 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,
)