work: добавил автоматический режим
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user