work: Автоматический режим управления скоростью
отлажен!
This commit is contained in:
+418
-128
@@ -202,7 +202,11 @@ from datetime import datetime, timezone
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from .models import QingpingState
|
||||
|
||||
from app.my_dataclasses import (
|
||||
WATCHDOG_INTERVAL,
|
||||
QINGPING_SAMPLE_TIMEOUT,
|
||||
QINGPING_RECOVERY_TIMEOUT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -228,13 +232,12 @@ class QingpingService:
|
||||
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._device_online = False
|
||||
|
||||
self._recovery_waiting = False
|
||||
self._recovery_started_monotonic: float | None = None
|
||||
|
||||
self._last_device_timestamp: int | None = None
|
||||
self._reboot_detected = False
|
||||
|
||||
@property
|
||||
def state(self) -> QingpingState:
|
||||
@@ -243,15 +246,11 @@ class QingpingService:
|
||||
|
||||
@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
|
||||
with self._lock:
|
||||
return (
|
||||
self._state.mqtt_connected
|
||||
and self._device_online
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
client = mqtt.Client(
|
||||
@@ -298,128 +297,206 @@ class QingpingService:
|
||||
self._client.loop_stop()
|
||||
self._client = None
|
||||
|
||||
|
||||
def status(self) -> dict:
|
||||
result = self.state.to_dict()
|
||||
result["online"] = self.online
|
||||
return result
|
||||
with self._lock:
|
||||
state = self._state
|
||||
|
||||
online = (
|
||||
state.mqtt_connected
|
||||
and self._device_online
|
||||
)
|
||||
|
||||
recovery_waiting = (
|
||||
self._recovery_waiting
|
||||
)
|
||||
|
||||
return {
|
||||
**state.to_dict(),
|
||||
"online": online,
|
||||
"recovery_waiting": recovery_waiting,
|
||||
}
|
||||
|
||||
def _on_connect(
|
||||
self,
|
||||
client,
|
||||
userdata,
|
||||
flags,
|
||||
reason_code,
|
||||
properties,
|
||||
):
|
||||
if reason_code.is_failure:
|
||||
logger.warning(
|
||||
self,
|
||||
client,
|
||||
_userdata,
|
||||
_flags,
|
||||
reason_code,
|
||||
_properties=None,
|
||||
) -> None:
|
||||
if reason_code != 0:
|
||||
logger.error(
|
||||
"Qingping MQTT connection failed: %s",
|
||||
reason_code,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Qingping MQTT connected"
|
||||
)
|
||||
|
||||
client.subscribe(self._up_topic)
|
||||
|
||||
now_monotonic = time.monotonic()
|
||||
|
||||
with self._lock:
|
||||
self._state = replace(
|
||||
self._state,
|
||||
mqtt_connected=True,
|
||||
temperature=None,
|
||||
humidity=None,
|
||||
co2=None,
|
||||
pm25=None,
|
||||
pm10=None,
|
||||
battery=None,
|
||||
sample_timestamp=None,
|
||||
sample_received_at=None,
|
||||
)
|
||||
|
||||
self._connected_since = time.monotonic()
|
||||
self._heartbeat_seen = False
|
||||
self._recovery_sent = False
|
||||
self._last_sample_monotonic = None
|
||||
|
||||
self._device_online = False
|
||||
|
||||
self._recovery_waiting = True
|
||||
self._recovery_started_monotonic = (
|
||||
now_monotonic
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Qingping MQTT connected"
|
||||
"Qingping startup initialization"
|
||||
)
|
||||
|
||||
self._send_recovery()
|
||||
|
||||
def _on_disconnect(
|
||||
self,
|
||||
client,
|
||||
userdata,
|
||||
disconnect_flags,
|
||||
reason_code,
|
||||
properties,
|
||||
):
|
||||
self,
|
||||
_client,
|
||||
_userdata,
|
||||
_disconnect_flags,
|
||||
reason_code,
|
||||
_properties=None,
|
||||
) -> None:
|
||||
logger.warning(
|
||||
"Qingping MQTT disconnected: %s",
|
||||
reason_code,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._state = replace(
|
||||
self._state,
|
||||
mqtt_connected=False,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Qingping MQTT disconnected: %s",
|
||||
reason_code,
|
||||
)
|
||||
self._device_online = False
|
||||
self._recovery_waiting = False
|
||||
self._recovery_started_monotonic = None
|
||||
|
||||
def _on_message(
|
||||
self,
|
||||
client,
|
||||
userdata,
|
||||
message,
|
||||
):
|
||||
self,
|
||||
_client,
|
||||
_userdata,
|
||||
message,
|
||||
) -> None:
|
||||
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)
|
||||
now = datetime.now(timezone.utc)
|
||||
now_monotonic = time.monotonic()
|
||||
|
||||
with self._lock:
|
||||
self._state = replace(
|
||||
self._state,
|
||||
last_message_at=now,
|
||||
message_type = int(
|
||||
payload.get("type")
|
||||
)
|
||||
|
||||
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,
|
||||
logger.debug(
|
||||
"Qingping MQTT packet received: type=%s",
|
||||
message_type,
|
||||
)
|
||||
|
||||
# Любой пакет означает, что устройство
|
||||
# физически присутствует в MQTT.
|
||||
with self._lock:
|
||||
self._state = replace(
|
||||
self._state,
|
||||
last_message_at=now,
|
||||
)
|
||||
|
||||
start_recovery = False
|
||||
|
||||
with self._lock:
|
||||
if (
|
||||
not self._device_online
|
||||
and
|
||||
not self._recovery_waiting
|
||||
):
|
||||
self._state = replace(
|
||||
self._state,
|
||||
temperature=None,
|
||||
humidity=None,
|
||||
co2=None,
|
||||
pm25=None,
|
||||
pm10=None,
|
||||
battery=None,
|
||||
sample_timestamp=None,
|
||||
sample_received_at=None,
|
||||
)
|
||||
|
||||
self._last_sample_monotonic = None
|
||||
|
||||
self._device_online = True
|
||||
|
||||
self._recovery_waiting = True
|
||||
self._recovery_started_monotonic = (
|
||||
now_monotonic
|
||||
)
|
||||
|
||||
start_recovery = True
|
||||
|
||||
if start_recovery:
|
||||
logger.info(
|
||||
"Qingping packet received while offline: "
|
||||
"type=%s -> starting recovery",
|
||||
message_type,
|
||||
)
|
||||
|
||||
self._send_recovery()
|
||||
|
||||
# Единственный пакет, который реально
|
||||
# обрабатываем как данные.
|
||||
if message_type == 17:
|
||||
self._handle_sensor_data(
|
||||
payload,
|
||||
now,
|
||||
)
|
||||
return
|
||||
|
||||
if message_type == 13:
|
||||
self._handle_heartbeat(
|
||||
payload,
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Qingping service packet received: type=%s",
|
||||
message_type,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Qingping MQTT packet processing failed"
|
||||
)
|
||||
|
||||
|
||||
def _handle_heartbeat(
|
||||
self,
|
||||
payload: dict,
|
||||
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_info = payload.get("wifi_info")
|
||||
|
||||
wifi_rssi = None
|
||||
|
||||
wifi_info = payload.get("wifi_info")
|
||||
|
||||
if isinstance(wifi_info, str):
|
||||
parts = wifi_info.split(",")
|
||||
|
||||
@@ -427,15 +504,26 @@ class QingpingService:
|
||||
try:
|
||||
wifi_rssi = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
logger.warning(
|
||||
"Qingping invalid RSSI in wifi_info: %r",
|
||||
wifi_info,
|
||||
)
|
||||
|
||||
firmware = payload.get("sw_version")
|
||||
|
||||
with self._lock:
|
||||
self._state = replace(
|
||||
self._state,
|
||||
wifi_rssi=wifi_rssi,
|
||||
firmware=payload.get("sw_version"),
|
||||
firmware=firmware,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Qingping heartbeat updated: "
|
||||
"rssi=%s firmware=%s",
|
||||
wifi_rssi,
|
||||
firmware,
|
||||
)
|
||||
def _handle_sensor_data(
|
||||
self,
|
||||
payload: dict,
|
||||
@@ -498,7 +586,30 @@ class QingpingService:
|
||||
"battery",
|
||||
)
|
||||
|
||||
now_monotonic = time.monotonic()
|
||||
|
||||
with self._lock:
|
||||
current_timestamp = (
|
||||
self._state.sample_timestamp
|
||||
)
|
||||
|
||||
# CGDN1 может повторять одну историческую точку.
|
||||
# Такой пакет НЕ считается новым измерением.
|
||||
if (
|
||||
current_timestamp is not None
|
||||
and sample_timestamp <= current_timestamp
|
||||
):
|
||||
logger.debug(
|
||||
"Qingping duplicate sensor sample ignored: "
|
||||
"timestamp=%s current=%s",
|
||||
sample_timestamp,
|
||||
current_timestamp,
|
||||
)
|
||||
return
|
||||
|
||||
was_recovering = self._recovery_waiting
|
||||
was_offline = not self._device_online
|
||||
|
||||
self._state = replace(
|
||||
self._state,
|
||||
temperature=temperature,
|
||||
@@ -511,16 +622,123 @@ class QingpingService:
|
||||
sample_received_at=received_at,
|
||||
)
|
||||
|
||||
self._last_sample_monotonic = (
|
||||
time.monotonic()
|
||||
)
|
||||
self._last_sample_monotonic = (
|
||||
now_monotonic
|
||||
)
|
||||
|
||||
self._reboot_detected = False
|
||||
self._device_online = True
|
||||
|
||||
self._recovery_waiting = False
|
||||
self._recovery_started_monotonic = None
|
||||
|
||||
# if was_offline or was_recovering:
|
||||
# logger.info(
|
||||
# "Qingping sensor stream online: "
|
||||
# "timestamp=%s co2=%s",
|
||||
# sample_timestamp,
|
||||
# co2,
|
||||
# )
|
||||
# else:
|
||||
# logger.debug(
|
||||
# "Qingping sensor sample accepted: "
|
||||
# "timestamp=%s co2=%s",
|
||||
# sample_timestamp,
|
||||
# co2,
|
||||
# )
|
||||
|
||||
async def _watchdog_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(
|
||||
WATCHDOG_INTERVAL
|
||||
)
|
||||
|
||||
self._watchdog_tick(
|
||||
time.monotonic()
|
||||
)
|
||||
|
||||
def _watchdog_tick(
|
||||
self,
|
||||
now: float,
|
||||
) -> None:
|
||||
recovery_timeout = None
|
||||
sample_timeout = None
|
||||
invalid_recovery_state = False
|
||||
|
||||
with self._lock:
|
||||
|
||||
# Ждём type 17 после recovery.
|
||||
if self._recovery_waiting:
|
||||
|
||||
if (
|
||||
self._recovery_started_monotonic
|
||||
is None
|
||||
):
|
||||
invalid_recovery_state = True
|
||||
|
||||
self._recovery_waiting = False
|
||||
self._device_online = False
|
||||
|
||||
else:
|
||||
elapsed = (
|
||||
now
|
||||
- self._recovery_started_monotonic
|
||||
)
|
||||
|
||||
if (
|
||||
elapsed
|
||||
> QINGPING_RECOVERY_TIMEOUT
|
||||
):
|
||||
recovery_timeout = elapsed
|
||||
|
||||
self._recovery_waiting = False
|
||||
self._recovery_started_monotonic = None
|
||||
self._device_online = False
|
||||
|
||||
# Нормальная работа:
|
||||
# следим за последним НОВЫМ type 17.
|
||||
elif self._last_sample_monotonic is not None:
|
||||
|
||||
elapsed = (
|
||||
now
|
||||
- self._last_sample_monotonic
|
||||
)
|
||||
|
||||
if (
|
||||
elapsed
|
||||
> QINGPING_SAMPLE_TIMEOUT
|
||||
and
|
||||
self._device_online
|
||||
):
|
||||
sample_timeout = elapsed
|
||||
|
||||
self._device_online = False
|
||||
|
||||
if invalid_recovery_state:
|
||||
logger.error(
|
||||
"Qingping invalid recovery state: "
|
||||
"recovery_waiting=True but "
|
||||
"recovery_started_monotonic=None"
|
||||
)
|
||||
|
||||
if recovery_timeout is not None:
|
||||
logger.warning(
|
||||
"Qingping recovery timeout: "
|
||||
"no type 17 for %.1f sec "
|
||||
"-> offline",
|
||||
recovery_timeout,
|
||||
)
|
||||
|
||||
if sample_timeout is not None:
|
||||
logger.warning(
|
||||
"Qingping sensor stream lost: "
|
||||
"no type 17 for %.1f sec "
|
||||
"-> offline",
|
||||
sample_timeout,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sample_timestamp(
|
||||
sample: dict,
|
||||
) -> int:
|
||||
def _sample_timestamp(sample: dict) -> int:
|
||||
|
||||
timestamp = sample.get("timestamp")
|
||||
|
||||
if isinstance(timestamp, dict):
|
||||
@@ -532,10 +750,7 @@ class QingpingService:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _value(
|
||||
sample: dict,
|
||||
key: str,
|
||||
):
|
||||
def _value(sample: dict, key: str):
|
||||
value = sample.get(key)
|
||||
|
||||
if isinstance(value, dict):
|
||||
@@ -545,35 +760,92 @@ class QingpingService:
|
||||
|
||||
async def _watchdog_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
await asyncio.sleep(
|
||||
WATCHDOG_INTERVAL
|
||||
)
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
# --------------------------------------------------
|
||||
# СЦЕНАРИЙ 1
|
||||
#
|
||||
# Recovery уже отправлен.
|
||||
# Ждём type 17 максимум 30 секунд.
|
||||
# --------------------------------------------------
|
||||
|
||||
if self._recovery_waiting:
|
||||
|
||||
if self._recovery_started_monotonic is None:
|
||||
logger.error(
|
||||
"Qingping invalid recovery state: "
|
||||
"recovery_waiting=True but "
|
||||
"recovery_started_monotonic=None"
|
||||
)
|
||||
|
||||
self._recovery_waiting = False
|
||||
self._device_online = False
|
||||
continue
|
||||
|
||||
elapsed = now - self._recovery_started_monotonic
|
||||
|
||||
if elapsed > QINGPING_RECOVERY_TIMEOUT:
|
||||
|
||||
logger.warning(
|
||||
"Qingping recovery timeout: "
|
||||
"no type 17 for %.1f sec "
|
||||
"-> offline",
|
||||
elapsed,
|
||||
)
|
||||
|
||||
self._recovery_waiting = False
|
||||
self._recovery_started_monotonic = (
|
||||
None
|
||||
)
|
||||
|
||||
self._device_online = False
|
||||
|
||||
if self._client is None:
|
||||
continue
|
||||
|
||||
state = self.state
|
||||
# --------------------------------------------------
|
||||
# СЦЕНАРИЙ 2
|
||||
#
|
||||
# До сих пор не получили вообще ни одного
|
||||
# измерения type 17.
|
||||
#
|
||||
# Ничего делать не надо.
|
||||
# Первый любой MQTT-пакет запустит recovery.
|
||||
# --------------------------------------------------
|
||||
|
||||
if not state.mqtt_connected:
|
||||
if self._last_sample_monotonic is None:
|
||||
continue
|
||||
|
||||
if self._recovery_sent:
|
||||
continue
|
||||
# --------------------------------------------------
|
||||
# СЦЕНАРИЙ 3
|
||||
#
|
||||
# Нормально работали, но type 17
|
||||
# перестали приходить.
|
||||
# --------------------------------------------------
|
||||
|
||||
# Явно увидели reboot CGDN1.
|
||||
if self._reboot_detected:
|
||||
self._send_recovery()
|
||||
continue
|
||||
elapsed = (
|
||||
now
|
||||
- self._last_sample_monotonic
|
||||
)
|
||||
|
||||
# Или сервис подключился, 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
|
||||
elapsed
|
||||
> QINGPING_SAMPLE_TIMEOUT
|
||||
and
|
||||
self._device_online
|
||||
):
|
||||
self._send_recovery()
|
||||
logger.warning(
|
||||
"Qingping sensor stream lost: "
|
||||
"no type 17 for %.1f sec "
|
||||
"-> offline",
|
||||
elapsed,
|
||||
)
|
||||
|
||||
self._device_online = False
|
||||
|
||||
|
||||
def _send_recovery(self) -> None:
|
||||
if self._client is None:
|
||||
@@ -598,8 +870,6 @@ class QingpingService:
|
||||
)
|
||||
|
||||
if result.rc == mqtt.MQTT_ERR_SUCCESS:
|
||||
self._recovery_sent = True
|
||||
|
||||
logger.warning(
|
||||
"Qingping recovery command sent"
|
||||
)
|
||||
@@ -607,4 +877,24 @@ class QingpingService:
|
||||
logger.warning(
|
||||
"Failed to send Qingping recovery: %s",
|
||||
result.rc,
|
||||
)
|
||||
)
|
||||
|
||||
def _reset_sample_session(self) -> None:
|
||||
logger.debug(
|
||||
"Qingping sensor session reset"
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._state = replace(
|
||||
self._state,
|
||||
temperature=None,
|
||||
humidity=None,
|
||||
co2=None,
|
||||
pm25=None,
|
||||
pm10=None,
|
||||
battery=None,
|
||||
sample_timestamp=None,
|
||||
sample_received_at=None,
|
||||
)
|
||||
|
||||
self._last_sample_monotonic = None
|
||||
Reference in New Issue
Block a user