work: Автоматический режим управления скоростью

отлажен!
This commit is contained in:
Fedorov Dmitriy
2026-09-19 15:48:44 +03:00
parent cdec9b0996
commit f98cc2d6a3
6 changed files with 867 additions and 145 deletions
+2 -3
View File
@@ -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 {
+22
View File
@@ -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
#Классы #Классы
+394 -104
View File
@@ -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()
message_type = int(
payload.get("type")
)
logger.debug(
"Qingping MQTT packet received: type=%s",
message_type,
)
# Любой пакет означает, что устройство
# физически присутствует в MQTT.
with self._lock: with self._lock:
self._state = replace( self._state = replace(
self._state, self._state,
last_message_at=now, last_message_at=now,
) )
message_type = str(payload.get("type")) start_recovery = False
if message_type == "13": with self._lock:
self._handle_heartbeat(payload) if (
return 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,
)
if message_type in {"12", "17"}: 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( self._handle_sensor_data(
payload, payload,
now, 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,
@@ -512,15 +623,122 @@ class QingpingService:
) )
self._last_sample_monotonic = ( self._last_sample_monotonic = (
now_monotonic
)
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() time.monotonic()
) )
self._reboot_detected = False 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
)
if self._client is None: 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 continue
state = self.state 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 not state.mqtt_connected:
continue continue
if self._recovery_sent: # --------------------------------------------------
# СЦЕНАРИЙ 2
#
# До сих пор не получили вообще ни одного
# измерения type 17.
#
# Ничего делать не надо.
# Первый любой MQTT-пакет запустит recovery.
# --------------------------------------------------
if self._last_sample_monotonic is None:
continue continue
# Явно увидели reboot CGDN1. # --------------------------------------------------
if self._reboot_detected: # СЦЕНАРИЙ 3
self._send_recovery() #
continue # Нормально работали, но type 17
# перестали приходить.
# --------------------------------------------------
elapsed = (
now
- self._last_sample_monotonic
)
# Или сервис подключился, 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
View File
@@ -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
View File
@@ -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:
+415
View File
@@ -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"
)