work: Автоматический режим управления скоростью
отлажен!
This commit is contained in:
+2
-3
@@ -219,6 +219,7 @@ def get_status() -> dict:
|
|||||||
if state is not None
|
if state is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
"auto": get_auto_status(),
|
||||||
|
|
||||||
"qingping": qingping_service.status(),
|
"qingping": qingping_service.status(),
|
||||||
|
|
||||||
@@ -430,9 +431,7 @@ def get_auto_status() -> dict:
|
|||||||
"target_speed": None,
|
"target_speed": None,
|
||||||
"auto_speed": None,
|
"auto_speed": None,
|
||||||
"last_error": None,
|
"last_error": None,
|
||||||
"config_error": (
|
"config_error": auto_load_error,
|
||||||
auto_load_error
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -42,5 +42,27 @@ AUTO_CONFIG_FILE = (
|
|||||||
/ "auto.yaml"
|
/ "auto.yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
WATCHDOG_INTERVAL = 5.0
|
||||||
|
|
||||||
|
# Type 13 обычно приходит примерно раз в 60 секунд.
|
||||||
|
# Даём дополнительный запас.
|
||||||
|
HEARTBEAT_STALE_AFTER = 90.0
|
||||||
|
|
||||||
|
# После появления устройства ждём первый sensor sample
|
||||||
|
# не дольше этого времени.
|
||||||
|
FIRST_SAMPLE_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
# Если новые sensor samples не приходят дольше этого времени,
|
||||||
|
# считаем sensor stream зависшим.
|
||||||
|
SAMPLE_STALE_AFTER = 45.0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
QINGPING_SAMPLE_TIMEOUT = 60.0
|
||||||
|
QINGPING_RECOVERY_TIMEOUT = 30.0
|
||||||
|
|
||||||
#Классы
|
#Классы
|
||||||
|
|
||||||
|
|||||||
+417
-127
@@ -202,7 +202,11 @@ from datetime import datetime, timezone
|
|||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
from .models import QingpingState
|
from .models import QingpingState
|
||||||
|
from app.my_dataclasses import (
|
||||||
|
WATCHDOG_INTERVAL,
|
||||||
|
QINGPING_SAMPLE_TIMEOUT,
|
||||||
|
QINGPING_RECOVERY_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -228,13 +232,12 @@ class QingpingService:
|
|||||||
self._watchdog_task: asyncio.Task | None = None
|
self._watchdog_task: asyncio.Task | None = None
|
||||||
|
|
||||||
self._last_sample_monotonic: float | None = None
|
self._last_sample_monotonic: float | None = None
|
||||||
self._connected_since: float | None = None
|
|
||||||
|
|
||||||
self._heartbeat_seen = False
|
self._device_online = False
|
||||||
self._recovery_sent = False
|
|
||||||
|
self._recovery_waiting = False
|
||||||
|
self._recovery_started_monotonic: float | None = None
|
||||||
|
|
||||||
self._last_device_timestamp: int | None = None
|
|
||||||
self._reboot_detected = False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> QingpingState:
|
def state(self) -> QingpingState:
|
||||||
@@ -243,15 +246,11 @@ class QingpingService:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def online(self) -> bool:
|
def online(self) -> bool:
|
||||||
state = self.state
|
with self._lock:
|
||||||
|
return (
|
||||||
if not state.mqtt_connected:
|
self._state.mqtt_connected
|
||||||
return False
|
and self._device_online
|
||||||
|
)
|
||||||
if self._last_sample_monotonic is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return time.monotonic() - self._last_sample_monotonic < 60
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
client = mqtt.Client(
|
client = mqtt.Client(
|
||||||
@@ -298,128 +297,206 @@ class QingpingService:
|
|||||||
self._client.loop_stop()
|
self._client.loop_stop()
|
||||||
self._client = None
|
self._client = None
|
||||||
|
|
||||||
|
|
||||||
def status(self) -> dict:
|
def status(self) -> dict:
|
||||||
result = self.state.to_dict()
|
with self._lock:
|
||||||
result["online"] = self.online
|
state = self._state
|
||||||
return result
|
|
||||||
|
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(
|
def _on_connect(
|
||||||
self,
|
self,
|
||||||
client,
|
client,
|
||||||
userdata,
|
_userdata,
|
||||||
flags,
|
_flags,
|
||||||
reason_code,
|
reason_code,
|
||||||
properties,
|
_properties=None,
|
||||||
):
|
) -> None:
|
||||||
if reason_code.is_failure:
|
if reason_code != 0:
|
||||||
logger.warning(
|
logger.error(
|
||||||
"Qingping MQTT connection failed: %s",
|
"Qingping MQTT connection failed: %s",
|
||||||
reason_code,
|
reason_code,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Qingping MQTT connected"
|
||||||
|
)
|
||||||
|
|
||||||
client.subscribe(self._up_topic)
|
client.subscribe(self._up_topic)
|
||||||
|
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._state = replace(
|
self._state = replace(
|
||||||
self._state,
|
self._state,
|
||||||
mqtt_connected=True,
|
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._last_sample_monotonic = None
|
||||||
self._heartbeat_seen = False
|
|
||||||
self._recovery_sent = False
|
self._device_online = False
|
||||||
|
|
||||||
|
self._recovery_waiting = True
|
||||||
|
self._recovery_started_monotonic = (
|
||||||
|
now_monotonic
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Qingping MQTT connected"
|
"Qingping startup initialization"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._send_recovery()
|
||||||
|
|
||||||
def _on_disconnect(
|
def _on_disconnect(
|
||||||
self,
|
self,
|
||||||
client,
|
_client,
|
||||||
userdata,
|
_userdata,
|
||||||
disconnect_flags,
|
_disconnect_flags,
|
||||||
reason_code,
|
reason_code,
|
||||||
properties,
|
_properties=None,
|
||||||
):
|
) -> None:
|
||||||
|
logger.warning(
|
||||||
|
"Qingping MQTT disconnected: %s",
|
||||||
|
reason_code,
|
||||||
|
)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._state = replace(
|
self._state = replace(
|
||||||
self._state,
|
self._state,
|
||||||
mqtt_connected=False,
|
mqtt_connected=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.warning(
|
self._device_online = False
|
||||||
"Qingping MQTT disconnected: %s",
|
self._recovery_waiting = False
|
||||||
reason_code,
|
self._recovery_started_monotonic = None
|
||||||
)
|
|
||||||
|
|
||||||
def _on_message(
|
def _on_message(
|
||||||
self,
|
self,
|
||||||
client,
|
_client,
|
||||||
userdata,
|
_userdata,
|
||||||
message,
|
message,
|
||||||
):
|
) -> None:
|
||||||
try:
|
try:
|
||||||
payload = json.loads(
|
payload = json.loads(
|
||||||
message.payload.decode("utf-8")
|
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:
|
message_type = int(
|
||||||
self._state = replace(
|
payload.get("type")
|
||||||
self._state,
|
|
||||||
last_message_at=now,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
message_type = str(payload.get("type"))
|
logger.debug(
|
||||||
|
"Qingping MQTT packet received: type=%s",
|
||||||
if message_type == "13":
|
message_type,
|
||||||
self._handle_heartbeat(payload)
|
|
||||||
return
|
|
||||||
|
|
||||||
if message_type in {"12", "17"}:
|
|
||||||
self._handle_sensor_data(
|
|
||||||
payload,
|
|
||||||
now,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Любой пакет означает, что устройство
|
||||||
|
# физически присутствует в 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(
|
def _handle_heartbeat(
|
||||||
self,
|
self,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._heartbeat_seen = True
|
wifi_info = payload.get("wifi_info")
|
||||||
|
|
||||||
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_rssi = None
|
||||||
|
|
||||||
wifi_info = payload.get("wifi_info")
|
|
||||||
|
|
||||||
if isinstance(wifi_info, str):
|
if isinstance(wifi_info, str):
|
||||||
parts = wifi_info.split(",")
|
parts = wifi_info.split(",")
|
||||||
|
|
||||||
@@ -427,15 +504,26 @@ class QingpingService:
|
|||||||
try:
|
try:
|
||||||
wifi_rssi = int(parts[1])
|
wifi_rssi = int(parts[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
logger.warning(
|
||||||
|
"Qingping invalid RSSI in wifi_info: %r",
|
||||||
|
wifi_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
firmware = payload.get("sw_version")
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._state = replace(
|
self._state = replace(
|
||||||
self._state,
|
self._state,
|
||||||
wifi_rssi=wifi_rssi,
|
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(
|
def _handle_sensor_data(
|
||||||
self,
|
self,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
@@ -498,7 +586,30 @@ class QingpingService:
|
|||||||
"battery",
|
"battery",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
|
||||||
with self._lock:
|
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 = replace(
|
||||||
self._state,
|
self._state,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
@@ -511,16 +622,123 @@ class QingpingService:
|
|||||||
sample_received_at=received_at,
|
sample_received_at=received_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._last_sample_monotonic = (
|
self._last_sample_monotonic = (
|
||||||
time.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
|
@staticmethod
|
||||||
def _sample_timestamp(
|
def _sample_timestamp(sample: dict) -> int:
|
||||||
sample: dict,
|
|
||||||
) -> int:
|
|
||||||
timestamp = sample.get("timestamp")
|
timestamp = sample.get("timestamp")
|
||||||
|
|
||||||
if isinstance(timestamp, dict):
|
if isinstance(timestamp, dict):
|
||||||
@@ -532,10 +750,7 @@ class QingpingService:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _value(
|
def _value(sample: dict, key: str):
|
||||||
sample: dict,
|
|
||||||
key: str,
|
|
||||||
):
|
|
||||||
value = sample.get(key)
|
value = sample.get(key)
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
@@ -545,35 +760,92 @@ class QingpingService:
|
|||||||
|
|
||||||
async def _watchdog_loop(self) -> None:
|
async def _watchdog_loop(self) -> None:
|
||||||
while True:
|
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
|
continue
|
||||||
|
|
||||||
state = self.state
|
# --------------------------------------------------
|
||||||
|
# СЦЕНАРИЙ 2
|
||||||
|
#
|
||||||
|
# До сих пор не получили вообще ни одного
|
||||||
|
# измерения type 17.
|
||||||
|
#
|
||||||
|
# Ничего делать не надо.
|
||||||
|
# Первый любой MQTT-пакет запустит recovery.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
if not state.mqtt_connected:
|
if self._last_sample_monotonic is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self._recovery_sent:
|
# --------------------------------------------------
|
||||||
continue
|
# СЦЕНАРИЙ 3
|
||||||
|
#
|
||||||
|
# Нормально работали, но type 17
|
||||||
|
# перестали приходить.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
# Явно увидели reboot CGDN1.
|
elapsed = (
|
||||||
if self._reboot_detected:
|
now
|
||||||
self._send_recovery()
|
- self._last_sample_monotonic
|
||||||
continue
|
)
|
||||||
|
|
||||||
# Или сервис подключился, heartbeat есть,
|
|
||||||
# но свежих sensorData так и не появилось.
|
|
||||||
if (
|
if (
|
||||||
self._connected_since is not None
|
elapsed
|
||||||
and self._heartbeat_seen
|
> QINGPING_SAMPLE_TIMEOUT
|
||||||
and self._last_sample_monotonic is None
|
and
|
||||||
and time.monotonic()
|
self._device_online
|
||||||
- self._connected_since
|
|
||||||
> 30
|
|
||||||
):
|
):
|
||||||
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:
|
def _send_recovery(self) -> None:
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
@@ -598,8 +870,6 @@ class QingpingService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if result.rc == mqtt.MQTT_ERR_SUCCESS:
|
if result.rc == mqtt.MQTT_ERR_SUCCESS:
|
||||||
self._recovery_sent = True
|
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Qingping recovery command sent"
|
"Qingping recovery command sent"
|
||||||
)
|
)
|
||||||
@@ -608,3 +878,23 @@ class QingpingService:
|
|||||||
"Failed to send Qingping recovery: %s",
|
"Failed to send Qingping recovery: %s",
|
||||||
result.rc,
|
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
|
||||||
+2
-2
@@ -7,10 +7,10 @@ co2:
|
|||||||
hysteresis: 100
|
hysteresis: 100
|
||||||
|
|
||||||
thresholds:
|
thresholds:
|
||||||
- ppm: 800
|
- ppm: 700
|
||||||
speed: 2
|
speed: 2
|
||||||
|
|
||||||
- ppm: 1000
|
- ppm: 900
|
||||||
speed: 3
|
speed: 3
|
||||||
|
|
||||||
- ppm: 1300
|
- ppm: 1300
|
||||||
|
|||||||
+8
-12
@@ -8,30 +8,30 @@ templates:
|
|||||||
|
|
||||||
workday:
|
workday:
|
||||||
|
|
||||||
- time: "00:00"
|
|
||||||
action:
|
|
||||||
type: set
|
|
||||||
power: off
|
|
||||||
|
|
||||||
- time: "07:00"
|
- time: "07:00"
|
||||||
action:
|
action:
|
||||||
type: set
|
type: set
|
||||||
power: on
|
power: on
|
||||||
speed: 2
|
speed: 2
|
||||||
heater: on
|
heater: off
|
||||||
target_temp: 20
|
target_temp: 20
|
||||||
mode: outside
|
mode: outside
|
||||||
|
|
||||||
- time: "09:00"
|
- time: "15:45"
|
||||||
action:
|
action:
|
||||||
type: set
|
type: set
|
||||||
speed: 1
|
speed: 1
|
||||||
|
|
||||||
- time: "13:00"
|
- time: "15:46"
|
||||||
action:
|
action:
|
||||||
type: auto
|
type: auto
|
||||||
speed: 1
|
speed: 1
|
||||||
|
|
||||||
|
- time: "15:47"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
|
||||||
- time: "17:00"
|
- time: "17:00"
|
||||||
action:
|
action:
|
||||||
type: set
|
type: set
|
||||||
@@ -73,10 +73,6 @@ templates:
|
|||||||
type: auto
|
type: auto
|
||||||
speed: 2
|
speed: 2
|
||||||
|
|
||||||
- time: "00:05"
|
|
||||||
action:
|
|
||||||
type: set
|
|
||||||
speed: 1
|
|
||||||
|
|
||||||
|
|
||||||
weekend:
|
weekend:
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from app.my_dataclasses import (
|
||||||
|
QINGPING_SAMPLE_TIMEOUT,
|
||||||
|
QINGPING_RECOVERY_TIMEOUT,
|
||||||
|
)
|
||||||
|
from app.qingping.service import QingpingService
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 0.0
|
||||||
|
|
||||||
|
def monotonic(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> None:
|
||||||
|
self.now += seconds
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMqttClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.subscriptions = []
|
||||||
|
self.published = []
|
||||||
|
|
||||||
|
def subscribe(self, topic):
|
||||||
|
self.subscriptions.append(topic)
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
):
|
||||||
|
self.published.append(
|
||||||
|
(
|
||||||
|
topic,
|
||||||
|
json.loads(payload),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
rc=mqtt.MQTT_ERR_SUCCESS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMessage:
|
||||||
|
def __init__(self, payload: dict):
|
||||||
|
self.payload = json.dumps(
|
||||||
|
payload
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sensor_message(
|
||||||
|
timestamp: int,
|
||||||
|
co2: int,
|
||||||
|
) -> FakeMessage:
|
||||||
|
return FakeMessage(
|
||||||
|
{
|
||||||
|
"type": 17,
|
||||||
|
"sensorData": [
|
||||||
|
{
|
||||||
|
"timestamp": {
|
||||||
|
"value": timestamp
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"value": 25.0
|
||||||
|
},
|
||||||
|
"humidity": {
|
||||||
|
"value": 50.0
|
||||||
|
},
|
||||||
|
"co2": {
|
||||||
|
"value": co2
|
||||||
|
},
|
||||||
|
"pm25": {
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"pm10": {
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"battery": {
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def service_message() -> FakeMessage:
|
||||||
|
return FakeMessage(
|
||||||
|
{
|
||||||
|
"type": 13,
|
||||||
|
"wifi_info": (
|
||||||
|
"TestWiFi,-50,1,"
|
||||||
|
"00:00:00:00:00:00"
|
||||||
|
),
|
||||||
|
"sw_version": "4.8.5",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_state(
|
||||||
|
service: QingpingService,
|
||||||
|
*,
|
||||||
|
online: bool,
|
||||||
|
recovery_waiting: bool,
|
||||||
|
co2,
|
||||||
|
):
|
||||||
|
status = service.status()
|
||||||
|
|
||||||
|
assert status["online"] is online, status
|
||||||
|
assert (
|
||||||
|
status["recovery_waiting"]
|
||||||
|
is recovery_waiting
|
||||||
|
), status
|
||||||
|
|
||||||
|
assert status["co2"] == co2, status
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_recovery_cycle() -> None:
|
||||||
|
print()
|
||||||
|
print("TEST — DOUBLE RECOVERY CYCLE")
|
||||||
|
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
service = QingpingService(
|
||||||
|
host="127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = FakeMqttClient()
|
||||||
|
|
||||||
|
# _send_recovery() использует self._client.
|
||||||
|
service._client = client
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.qingping.service.time.monotonic",
|
||||||
|
clock.monotonic,
|
||||||
|
):
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# START
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("1. MQTT connect")
|
||||||
|
|
||||||
|
service._on_connect(
|
||||||
|
client,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 1
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Первый type 17.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("2. First sensor sample")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=1000,
|
||||||
|
co2=900,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Первый отказ.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("3. First sensor timeout")
|
||||||
|
|
||||||
|
clock.advance(
|
||||||
|
QINGPING_SAMPLE_TIMEOUT + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service._watchdog_tick(
|
||||||
|
clock.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recovery пока НЕ отправлялся.
|
||||||
|
assert len(client.published) == 1
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Устройство снова появляется.
|
||||||
|
# Любой пакет запускает recovery.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("4. First recovery")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
service_message(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 2
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Новый type 17.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("5. First recovery completed")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=1015,
|
||||||
|
co2=1000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Второй отказ.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("6. Second sensor timeout")
|
||||||
|
|
||||||
|
clock.advance(
|
||||||
|
QINGPING_SAMPLE_TIMEOUT + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service._watchdog_tick(
|
||||||
|
clock.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 2
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Второе восстановление.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("7. Second recovery")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
service_message(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 3
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=1030,
|
||||||
|
co2=1100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1100,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("8. Second recovery completed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_timeout_and_retry() -> None:
|
||||||
|
print()
|
||||||
|
print("TEST — RECOVERY TIMEOUT AND RETRY")
|
||||||
|
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
service = QingpingService(
|
||||||
|
host="127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = FakeMqttClient()
|
||||||
|
|
||||||
|
service._client = client
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.qingping.service.time.monotonic",
|
||||||
|
clock.monotonic,
|
||||||
|
):
|
||||||
|
|
||||||
|
# Startup recovery.
|
||||||
|
service._on_connect(
|
||||||
|
client,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 1
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type 17 так и не пришёл.
|
||||||
|
clock.advance(
|
||||||
|
QINGPING_RECOVERY_TIMEOUT + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service._watchdog_tick(
|
||||||
|
clock.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Любой следующий пакет должен разрешить
|
||||||
|
# новую recovery-попытку.
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
service_message(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 2
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Теперь приходит type 17.
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=2000,
|
||||||
|
co2=1200,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_double_recovery_cycle()
|
||||||
|
test_recovery_timeout_and_retry()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"ALL QINGPING RECOVERY TESTS PASSED"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user