From cb5593ef310e6ae245a07ddb08896f107b7e3b3c Mon Sep 17 00:00:00 2001 From: Martin TOUZOT Date: Fri, 15 May 2026 13:04:27 +0200 Subject: [PATCH 1/4] docs(readme): reorganize and clarify CI badges - Update tests, build and codecov badges - Update arm-runner job with a code coverage workflow - Update python-lint job with mypy workflow - Update install.sh with coverage only for CI - Add Python Lint, release, license and Python version badges --- .github/workflows/arm-runner.yml | 12 ++++++++++++ .github/workflows/python-lint.yml | 17 +++++++++++++++-- README.md | 10 +++++++++- install.sh | 6 ++++-- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/workflows/arm-runner.yml b/.github/workflows/arm-runner.yml index 8684a6fb..3ea3a63c 100644 --- a/.github/workflows/arm-runner.yml +++ b/.github/workflows/arm-runner.yml @@ -54,6 +54,18 @@ jobs: # And it also fails with aarch64 binaries of postgresql. sudo -u pi taskset -c 0 /bin/bash /opt/pynab/install.sh ci-chroot-test + - name: Retrieve coverage file + run: | + cp ${{ steps.arm_runner_tests.outputs.image_mount_path }}/opt/pynab/coverage.xml ./coverage-${{ matrix.target }}.xml || true + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage-*.xml + fail_ci_if_error: true + flags: unittests-${{ matrix.target }} + token: ${{ secrets.CODECOV_TOKEN }} + create_image: name: Build image runs-on: ubuntu-latest diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml index 93e36d37..949a9307 100644 --- a/.github/workflows/python-lint.yml +++ b/.github/workflows/python-lint.yml @@ -14,14 +14,27 @@ jobs: - name: Checkout pynab uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.9' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mypy + - name: Run Python lint checks uses: ricardochaves/python-lint@v1.4.0 with: python-root-list: "nab*" + use-mypy: true use-pylint: false - use-mypy: false - # https://github.com/PyCQA/pycodestyle/issues/373 + extra-pycodestyle-options: "--ignore=E121,E123,E126,E226,E24,E704,W503,W504,E203 --exclude=*/migrations/" extra-flake8-options: "--extend-ignore=E203,E501" extra-black-options: "-l 79" extra-isort-options: "-l 79 --profile black --indent 4" + + extra-mypy-options: "--ignore-missing-imports" \ No newline at end of file diff --git a/README.md b/README.md index 823b301d..55bdd1a2 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@ # Nabaztag en Python pour Raspberry Pi -[![build (qemu)](https://github.com/mtouzot/pynab/actions/workflows/arm-runner.yml/badge.svg?branch=main)](https://github.com/mtouzot/pynab/actions/workflows/arm-runner.yml) [![tests](https://github.com/mtouzot/pynab/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/mtouzot/pynab/actions/workflows/tests.yml) +[![build (raspberry pi)](https://github.com/mtouzot/pynab/actions/workflows/arm-runner.yml/badge.svg?branch=main)](https://github.com/mtouzot/pynab/actions/workflows/arm-runner.yml) +![coverage](https://codecov.io/gh/mtouzot/pynab/branch/main/graph/badge.svg) + +![lint](https://github.com/mtouzot/pynab/actions/workflows/python-lint.yml/badge.svg) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +![release](https://img.shields.io/github/v/release/mtouzot/pynab) +![python](https://img.shields.io/badge/python-3.9-blue) + +![license](https://img.shields.io/github/license/mtouzot/pynab) + ## Cartes Ce système est conçu pour deux cartes pour **Nabaztag** (v1) et **Nabaztag:Tag** (v2) : diff --git a/install.sh b/install.sh index 5b2acd86..e12a5509 100755 --- a/install.sh +++ b/install.sh @@ -380,10 +380,12 @@ else fi if [ $test -eq 1 ]; then - echo "Running tests" if [ $ci_chroot -eq 1 ]; then - sudo CI=1 venv/bin/pytest + echo "Running tests with coverage (CI chroot)" + sudo CI=1 venv/bin/coverage run -m pytest + sudo venv/bin/coverage xml -o /opt/pynab/coverage.xml else + echo "Running tests" sudo venv/bin/pytest fi fi From df8ac2099c613b068573fa3f555205fb54c52ced Mon Sep 17 00:00:00 2001 From: Martin TOUZOT Date: Sat, 16 May 2026 09:04:23 +0200 Subject: [PATCH 2/4] chore(pyproject): add mypy configuration and type checking rules --- .github/workflows/python-lint.yml | 3 +-- pyproject.toml | 26 ++++++++++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml index 949a9307..d9db05b4 100644 --- a/.github/workflows/python-lint.yml +++ b/.github/workflows/python-lint.yml @@ -36,5 +36,4 @@ jobs: extra-flake8-options: "--extend-ignore=E203,E501" extra-black-options: "-l 79" extra-isort-options: "-l 79 --profile black --indent 4" - - extra-mypy-options: "--ignore-missing-imports" \ No newline at end of file + extra-mypy-options: "--ignore-missing-imports --exclude /migrations/" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 551fce8b..1b98ce1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,11 +2,29 @@ line-length = 79 [tool.mypy] -plugins = ["mypy_django_plugin.main"] -exclude = [ - "migrations" -] +python_version = 3.9 +ignore_missing_imports = true +follow_imports = "silent" +exclude = '(^|/)(migrations|\.venv|build|dist)/' namespace_packages = true +plugins = ["mypy_django_plugin.main"] +disallow_any_generics = false +disallow_subclassing_any = false +warn_return_any = false +warn_unused_ignores = true +check_untyped_defs = true +allow_untyped_globals = true + +[[tool.mypy.overrides]] +module = "*.migrations.*" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "migrations.*" +ignore_errors = true [tool.django-stubs] django_settings_module = "nabweb.settings" + +[mypy.plugins.django-stubs] +django_settings_module = "nabweb.settings" \ No newline at end of file From 98193d68cc46cc1c21c12b6c12d842459ad2868d Mon Sep 17 00:00:00 2001 From: Martin TOUZOT Date: Sat, 16 May 2026 13:04:07 +0200 Subject: [PATCH 3/4] refactor(types): restructure TypedDict packet hierarchy and factorize common fields --- nabcommon/nabservice.py | 2 +- nabcommon/typing.py | 596 ++++++++++++++++++---------------------- nabd/nabd.py | 26 +- 3 files changed, 281 insertions(+), 343 deletions(-) diff --git a/nabcommon/nabservice.py b/nabcommon/nabservice.py index 1b238d70..7443550e 100644 --- a/nabcommon/nabservice.py +++ b/nabcommon/nabservice.py @@ -11,7 +11,7 @@ import traceback from abc import ABC, abstractmethod from enum import Enum -from typing import Any, List, Optional, Tuple, cast +from typing import Any, List, Optional, Tuple from lockfile import AlreadyLocked, LockFailed # type: ignore from lockfile.pidlockfile import PIDLockFile # type: ignore diff --git a/nabcommon/typing.py b/nabcommon/typing.py index 72bcace1..9af19808 100644 --- a/nabcommon/typing.py +++ b/nabcommon/typing.py @@ -1,373 +1,301 @@ import datetime -import sys -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Literal, TypedDict, Union -AnyPacket = Dict[str, Any] +# ========================= +# Base types +# ========================= +_Number = Union[int, float] +AnyPacket = Dict[str, Any] PaletteColor = int HTMLColor = str Color = Union[PaletteColor, HTMLColor] Resources = str +NLUIntent = Dict[str, Any] -_Number = Union[int, float] +StateName = Literal["asleep", "idle", "interactive", "playing", "recording"] + + +# ========================= +# Base packets +# ========================= + + +class BasePacket(TypedDict): + type: str + + +class ServiceRequestPacket(TypedDict, total=False): + request_id: str + + +# ========================= +# State +# ========================= + + +class StatePacket(TypedDict): + type: Literal["state"] + state: StateName + + +# ========================= +# Animation / Info +# ========================= + + +class AnimationItem(TypedDict, total=False): + left: Color + center: Color + right: Color + + +class Animation(TypedDict): + tempo: _Number + colors: List[AnimationItem] + + +class InfoPacket(ServiceRequestPacket, total=False): + type: Literal["info"] + info_id: str + animation: Animation + + +# ========================= +# Ears +# ========================= + + +class EarsPacket(TypedDict, total=False): + type: Literal["ears"] + left: int + right: int + event: bool + + +# ========================= +# Command / Message +# ========================= + +ChoreographyURN = Union[Literal["urn:x-chor:streaming"], str] + + +class CommandSequenceItem(TypedDict, total=False): + audio: List[Resources] + choreography: Union[Resources, ChoreographyURN] + + +class CommandPacket(TypedDict, ServiceRequestPacket, total=False): + type: Literal["command"] + sequence: List[CommandSequenceItem] + expiration: datetime.datetime + cancelable: bool + + +class MessagePacket(TypedDict, ServiceRequestPacket, total=False): + type: Literal["message"] + body: List[CommandSequenceItem] + signature: CommandSequenceItem + expiration: datetime.datetime + cancelable: bool + + +# ========================= +# Simple commands +# ========================= + + +class CancelPacket(TypedDict, ServiceRequestPacket, total=False): + type: Literal["cancel"] + + +class WakeupPacket(TypedDict, ServiceRequestPacket, total=False): + type: Literal["wakeup"] + + +class SleepPacket(TypedDict, ServiceRequestPacket, total=False): + type: Literal["sleep"] + + +# ========================= +# Mode +# ========================= + +EventTypes = Union[Literal["asr", "button", "ears", "rfid/*"], str] + + +class ModePacket(TypedDict, ServiceRequestPacket, total=False): + type: Literal["mode"] + mode: Literal["idle", "interactive"] + events: List[EventTypes] + + +# ========================= +# Test / RFID write +# ========================= + + +class TestPacket(TypedDict, ServiceRequestPacket, total=False): + type: Literal["test"] + test: str + + +class RfidWritePacket(TypedDict, ServiceRequestPacket, total=False): + type: Literal["rfid_write"] + uid: str + picture: int + app: int + tech: str + timeout: _Number + data: str + + +# ========================= +# Responses +# ========================= + + +class ResponseBase(TypedDict, ServiceRequestPacket, total=False): + type: Literal["response"] + + +class ResponseOKPacket(ResponseBase): + status: Literal["ok"] + + +class ResponseCanceledPacket(ResponseBase): + status: Literal["canceled"] + + +class ResponseExpiredPacket(ResponseBase): + status: Literal["expired"] + + +class ResponseFailurePacket(ResponseBase): + status: Literal["failure"] + + +class ResponseErrorPacket(ResponseBase): + status: Literal["error"] + class_: str + message: str + + +# ---- NFC ---- -NLUIntent = Dict[str, Any] -if sys.version_info < (3, 8): - StatePacket = Dict[str, str] - AnimationItem = Dict[str, Color] - Animation = Dict[str, Any] - InfoPacket = Dict[str, Any] - EarsPacket = Dict[str, Any] - ChoreographyURN = str - CommandSequenceItem = Dict[str, Any] - CommandPacket = Dict[str, Any] - MessagePacket = Dict[str, Any] - CancelPacket = Dict[str, str] - WakeupPacket = Dict[str, str] - SleepPacket = Dict[str, str] - EventTypes = str - ModePacket = Dict[str, Any] - TestPacket = Dict[str, str] - RfidWritePacket = Dict[str, Any] - - ResponseNFCOKPacketProto = Dict[str, str] - ResponseNFCErrorPacketProto = Dict[str, str] - ResponseNFCTimeoutPacketProto = Dict[str, str] - - ResponseOKPacketProto = Dict[str, str] - ResponseErrorPacketProto = Dict[str, str] - ResponseCanceledPacketProto = Dict[str, str] - ResponseExpiredPacketProto = Dict[str, str] - ResponseFailurePacketProto = Dict[str, str] - ResponseGestaltPacketProto = Dict[str, Any] - - ResponseNFCOKPacket = Dict[str, str] - ResponseNFCErrorPacket = Dict[str, str] - ResponseNFCTimeoutPacket = Dict[str, str] - - ResponseOKPacket = Dict[str, str] - ResponseErrorPacket = Dict[str, str] - ResponseCanceledPacket = Dict[str, str] - ResponseExpiredPacket = Dict[str, str] - ResponseFailurePacket = Dict[str, str] - ResponseGestaltPacket = Dict[str, Any] - - ServiceRequestPacket = Dict[str, str] - - ServicePacket = Dict[str, Any] - - ButtonEventType = str - - ASREventPacket = Dict[str, Any] - ButtonEventPacket = Dict[str, Any] - EarEventPacket = Dict[str, Any] - EarsEventPacket = Dict[str, Any] - RfidEventPacket = Dict[str, Any] - - ResponsePacketProto = Dict[str, Any] - ResponsePacket = Dict[str, Any] - EventPacket = Dict[str, Any] - - NabdPacket = Dict[str, Any] -else: - from typing import Literal, TypedDict - - StateName = Literal[ - "asleep", "idle", "interactive", "playing", "recording" - ] - - class StatePacket(TypedDict): - type: Literal["state"] - state: StateName - - class _InfoPacketBase(TypedDict): - type: Literal["info"] - info_id: str - - class AnimationItem(TypedDict, total=False): - left: Color - center: Color - right: Color - - class Animation(TypedDict): - tempo: _Number - colors: List[AnimationItem] - - class InfoPacket(_InfoPacketBase, total=False): - request_id: str - animation: Animation - - class _EarsPacketBase(TypedDict): - type: Literal["ears"] - - class EarsPacket(_EarsPacketBase, total=False): - request_id: str - left: int - right: int - event: bool - - ChoreographyURN = Union[Literal["urn:x-chor:streaming"], str] - - class CommandSequenceItem(TypedDict, total=False): - audio: List[Resources] - choreography: Union[Resources, ChoreographyURN] - - class _CommandPacketBase(TypedDict): - type: Literal["command"] - sequence: List[CommandSequenceItem] - - class CommandPacket(_CommandPacketBase, total=False): - request_id: str - expiration: datetime.datetime - cancelable: bool +class ResponseNFCOKPacket(ResponseOKPacket, total=False): + uid: str - class _MessagePacketBase(TypedDict): - type: Literal["message"] - body: List[CommandSequenceItem] - class MessagePacket(_MessagePacketBase, total=False): - request_id: str - signature: CommandSequenceItem - expiration: datetime.datetime - cancelable: bool +class ResponseNCFErrorPacket(ResponseErrorPacket, total=False): + uid: str - class CancelPacket(TypedDict): - type: Literal["cancel"] - request_id: str - class _WakeupPacketBase(TypedDict): - type: Literal["wakeup"] +class ResponseNFCTimeoutPacket(ResponseBase): + status: Literal["timeout"] + message: str - class WakeupPacket(_WakeupPacketBase, total=False): - request_id: str - class _SleepPacketBase(TypedDict): - type: Literal["sleep"] +# ---- Gestalt ---- - class SleepPacket(_SleepPacketBase, total=False): - request_id: str - class _ModePacketBase(TypedDict): - type: Literal["mode"] - mode: Literal["idle", "interactive"] +class ResponseGestaltPacket(ResponseBase, total=False): + state: StateName + connections: int + hardware: str + uptime: int - EventTypes = Union[Literal["asr", "button", "ears", "rfid/*"], str] - class ModePacket(_ModePacketBase, total=False): - events: List[EventTypes] - request_id: str +# ========================= +# Events +# ========================= - class _TestPacketBase(TypedDict): - type: Literal["test"] - test: str - class TestPacket(_TestPacketBase, total=False): - request_id: str +class EventBase(BasePacket): + time: float - class _RfidWritePacketBase(TypedDict): - type: Literal["rfid_write"] - uid: str - picture: int - app: int - tech: str - class RfidWritePacket(_RfidWritePacketBase, total=False): - request_id: str - timeout: _Number - data: str +class ASREventPacket(EventBase): + type: Literal["asr_event"] + nlu: NLUIntent - class ResponseOKPacketProto(TypedDict): - status: Literal["ok"] - class _ResponseOKPacketBase(ResponseOKPacketProto): - type: Literal["response"] +ButtonEventType = Literal[ + "up", + "down", + "click", + "hold", + "click_and_hold", + "double_click", + "triple_click", +] - class ResponseOKPacket(_ResponseOKPacketBase, total=False): - request_id: str - class ResponseCanceledPacketProto(TypedDict): - status: Literal["canceled"] +class ButtonEventPacket(EventBase): + type: Literal["button_event"] + event: ButtonEventType - class _ResponseCanceledPacketBase(ResponseCanceledPacketProto): - type: Literal["response"] - class ResponseCanceledPacket(_ResponseCanceledPacketBase, total=False): - request_id: str +class EarEventPacket(EventBase): + type: Literal["ear_event"] + ear: Literal["left", "right"] - class ResponseExpiredPacketProto(TypedDict): - status: Literal["expired"] - class _ResponseExpiredPacketBase(ResponseExpiredPacketProto): - type: Literal["response"] +class EarsEventPacket(EventBase): + type: Literal["ears_event"] + left: int + right: int - class ResponseExpiredPacket(_ResponseExpiredPacketBase, total=False): - request_id: str - ResponseErrorPacketProto = TypedDict( - "ResponseErrorPacketProto", - {"status": Literal["error"], "class": str, "message": str}, - ) +class RfidEventPacket(EventBase, total=False): + type: Literal["rfid_event"] + tech: str + uid: str + event: Literal["removed", "detected"] + support: Literal["formatted", "foreign-data", "locked", "empty", "unknown"] + locked: bool + picture: str + tag_info: dict + app: str + data: str - class _ResponseErrorPacketBase(ResponseErrorPacketProto): - type: Literal["response"] - class ResponseErrorPacket(_ResponseErrorPacketBase, total=False): - request_id: str +# ========================= +# Unions +# ========================= - class ResponseFailurePacketProto(TypedDict): - status: Literal["failure"] +ResponsePacket = Union[ + ResponseOKPacket, + ResponseCanceledPacket, + ResponseExpiredPacket, + ResponseFailurePacket, + ResponseErrorPacket, + ResponseGestaltPacket, + ResponseNFCOKPacket, + ResponseNCFErrorPacket, + ResponseNFCTimeoutPacket, +] - class _ResponseFailurePacketBase(ResponseFailurePacketProto): - type: Literal["response"] +EventPacket = Union[ + ASREventPacket, + ButtonEventPacket, + EarEventPacket, + EarsEventPacket, + RfidEventPacket, +] - class ResponseFailurePacket(_ResponseFailurePacketBase, total=False): - request_id: str - - class ResponseNFCOKPacketProto(ResponseOKPacketProto, total=False): - uid: str - - class _ResponseNFCOKPacketBase(ResponseNFCOKPacketProto): - type: Literal["response"] - - class ResponseNFCOKPacket(_ResponseNFCOKPacketBase, total=False): - request_id: str - - class ResponseNFCErrorPacketProto(ResponseErrorPacketProto, total=False): - uid: str - - class _ResponseNFCErrorPacketBase(ResponseNFCErrorPacketProto): - type: Literal["response"] - - class ResponseNFCErrorPacket(_ResponseNFCErrorPacketBase, total=False): - request_id: str - - class ResponseNFCTimeoutPacketProto(TypedDict): - status: Literal["timeout"] - message: str - - class _ResponseNFCTimeoutPacketBase(ResponseNFCTimeoutPacketProto): - type: Literal["response"] - - class ResponseNFCTimeoutPacket(_ResponseNFCTimeoutPacketBase, total=False): - request_id: str - - class _ResponseGestaltPacketProtoBase(TypedDict): - state: StateName - connections: int - hardware: str - - class ResponseGestaltPacketProto( - _ResponseGestaltPacketProtoBase, total=False - ): - uptime: int - - class _ResponseGestaltPacketBase(ResponseGestaltPacketProto): - type: Literal["response"] - - class ResponseGestaltPacket(_ResponseGestaltPacketBase, total=False): - request_id: str - - class ServiceRequestPacket(TypedDict, total=False): - request_id: str - - ServicePacket = Union[ - InfoPacket, - EarsPacket, - CommandPacket, - MessagePacket, - CancelPacket, - WakeupPacket, - SleepPacket, - ModePacket, - RfidWritePacket, - TestPacket, - ] - - class ASREventPacket(TypedDict): - type: Literal["asr_event"] - nlu: NLUIntent - time: float - - ButtonEventType = Literal[ - "up", - "down", - "click", - "hold", - "click_and_hold", - "double_click", - "triple_click", - ] - - class ButtonEventPacket(TypedDict): - type: Literal["button_event"] - event: ButtonEventType - time: float - - class EarEventPacket(TypedDict): - type: Literal["ear_event"] - ear: Literal["left", "right"] - time: float - - class EarsEventPacket(TypedDict): - type: Literal["ears_event"] - left: int - right: int - time: float - - class _RfidEventPacketBase(TypedDict): - type: Literal["rfid_event"] - tech: str - uid: str - event: Literal["removed", "detected"] - support: Literal[ - "formatted", "foreign-data", "locked", "empty", "unknown" - ] - time: float - - class RfidEventPacket(_RfidEventPacketBase, total=False): - locked: bool - picture: str - tag_info: dict - app: str - data: str - - _ResponseNFCPacketProto = Union[ - ResponseNFCOKPacketProto, - ResponseNFCErrorPacketProto, - ResponseNFCTimeoutPacketProto, - ] - - ResponsePacketProto = Union[ - _ResponseNFCPacketProto, - ResponseOKPacketProto, - ResponseErrorPacketProto, - ResponseCanceledPacketProto, - ResponseExpiredPacketProto, - ResponseFailurePacketProto, - ResponseGestaltPacketProto, - ] - - _ResponseNFCPacket = Union[ - ResponseNFCOKPacket, ResponseNFCErrorPacket, ResponseNFCTimeoutPacket - ] - - ResponsePacket = Union[ - _ResponseNFCPacket, - ResponseOKPacket, - ResponseErrorPacket, - ResponseCanceledPacket, - ResponseExpiredPacket, - ResponseFailurePacket, - ResponseGestaltPacket, - ] - - EventPacket = Union[ - ASREventPacket, - ButtonEventPacket, - EarEventPacket, - EarsEventPacket, - RfidEventPacket, - ] - - NabdPacket = Union[ResponsePacket, StatePacket, EventPacket] +ServicePacket = Union[ + InfoPacket, + EarsPacket, + CommandPacket, + MessagePacket, + CancelPacket, + WakeupPacket, + SleepPacket, + ModePacket, + RfidWritePacket, + TestPacket, +] + +NabdPacket = Union[StatePacket, ResponsePacket, EventPacket] diff --git a/nabd/nabd.py b/nabd/nabd.py index 70cdd291..4370f9d7 100644 --- a/nabd/nabd.py +++ b/nabd/nabd.py @@ -33,11 +33,12 @@ MessagePacket, ModePacket, NabdPacket, + ResponseCanceledPacket, ResponseErrorPacketProto, - ResponseExpiredPacketProto, - ResponseFailurePacketProto, + ResponseExpiredPacket, + ResponseFailurePacket, ResponseGestaltPacketProto, - ResponseOKPacketProto, + ResponseOKPacket, ResponsePacket, ResponsePacketProto, RfidWritePacket, @@ -63,10 +64,19 @@ IdleQueueItem = Tuple[ServicePacket, asyncio.StreamWriter] -STATUS_EXPIRED = cast(ResponseExpiredPacketProto, {"status": "expired"}) -STATUS_OK = cast(ResponseOKPacketProto, {"status": "ok"}) -STATUS_CANCELED = cast(ResponseOKPacketProto, {"status": "canceled"}) -STATUS_FAILURE = cast(ResponseFailurePacketProto, {"status": "failure"}) +STATUS_EXPIRED: ResponseExpiredPacket = { + "type": "response", + "status": "expired", +} +STATUS_OK: ResponseOKPacket = {"type": "response", "status": "ok"} +STATUS_CANCELED: ResponseCanceledPacket = { + "type": "response", + "status": "canceled", +} +STATUS_FAILURE: ResponseFailurePacket = { + "type": "response", + "status": "failure", +} def status_error( @@ -1184,7 +1194,7 @@ def rfid_callback( app_str = self._get_rfid_app(app) packet["app"] = app_str if app_data is not None: - app_data_str_bin = app_data.split(b"\xFF", 1)[0] + app_data_str_bin = app_data.split(b"\xff", 1)[0] app_data_str = app_data_str_bin.decode("utf8") packet["data"] = app_data_str event_type = "rfid/" + app_str From 8734478a564d2c98c7ce2c73c4cf9e7453a6d770 Mon Sep 17 00:00:00 2001 From: Martin TOUZOT Date: Sat, 16 May 2026 17:26:53 +0200 Subject: [PATCH 4/4] fix: narrow packet types and resolve mypy TypedDict errors --- nabcommon/nabpacketrouter.py | 11 ++++ nabcommon/nabregistry.py | 28 +++++++++ nabcommon/nabservice.py | 69 +++++++++++++++++++-- nabcommon/typing.py | 28 +++++---- nabd/nabd.py | 113 +++++++++++++++++------------------ nabsurprised/nabsurprised.py | 20 ++++--- nabtaichid/nabtaichid.py | 29 ++++----- nabweatherd/nabweatherd.py | 39 ++++++------ 8 files changed, 219 insertions(+), 118 deletions(-) create mode 100644 nabcommon/nabpacketrouter.py create mode 100644 nabcommon/nabregistry.py diff --git a/nabcommon/nabpacketrouter.py b/nabcommon/nabpacketrouter.py new file mode 100644 index 00000000..a6fd3ad5 --- /dev/null +++ b/nabcommon/nabpacketrouter.py @@ -0,0 +1,11 @@ +from .typing import NabdPacket +from .nabregistry import NabRegistry + + +class NabPacketRouter: + def __init__(self, registry: NabRegistry): + self.registry = registry + + async def dispatch(self, packet: NabdPacket): + if handler := self.registry.get(packet["type"]): + await handler(packet) diff --git a/nabcommon/nabregistry.py b/nabcommon/nabregistry.py new file mode 100644 index 00000000..8fb76c94 --- /dev/null +++ b/nabcommon/nabregistry.py @@ -0,0 +1,28 @@ +from typing import Callable, Awaitable, Dict + +from .typing import NabdPacket + +NabHandler = Callable[[NabdPacket], Awaitable[None]] + + +class NabRegistry: + """ + Registry for packet handlers + """ + + def __init__(self): + self.__handlers = Dict[str, NabHandler] = {} + + def register(self, packet_type: str): + """ + Register a handler for a given packet type + """ + + def decorator(fn: NabHandler): + self.__handlers[packet_type] = fn + return fn + + return decorator + + def get(self, packet_type: str): + return self.__handlers.get(packet_type) diff --git a/nabcommon/nabservice.py b/nabcommon/nabservice.py index 7443550e..dad13451 100644 --- a/nabcommon/nabservice.py +++ b/nabcommon/nabservice.py @@ -16,9 +16,16 @@ from lockfile import AlreadyLocked, LockFailed # type: ignore from lockfile.pidlockfile import PIDLockFile # type: ignore -from nabcommon import nablogging, settings +from nabcommon import nablogging, nabpacketrouter, nabregistry, settings -from .typing import NabdPacket +from .typing import ( + NabdPacket, + ASREventPacket, + ButtonEvenPacket, + EarEventPacket, + EarsEventPacket, + RfidEventPacket, +) class NabService(ABC): @@ -31,8 +38,55 @@ def __init__(self): self.writer = None self.loop = None self.running = True + self.registry = nabregistry.NabRegistry() + self.router = nabpacketrouter.NabPacketRouter(self.registry) signal.signal(signal.SIGUSR1, self.signal_handler) + def register_handlers(self): + """ + Register handlers for NabdPackets object, if any. + """ + + @self.registry.register("asr_event") + async def handle_asr(packet: ASREventPacket): + await self._handle_asr_forecast(packet) + + @self.registry.register("button_event") + async def handle_button(packet: ButtonEvenPacket): + await self._handle_button_event(packet) + + @self.registry.register("ear_event") + async def handle_ear(packet: EarEventPacket): + await self._handle_ear_event(packet) + + @self.registry.register("ears_event") + async def handle_ears(packet: EarsEventPacket): + await self._handle_ears_event(packet) + + @self.registry.register("rfid_event") + async def handle_rfid(packet: RfidEventPacket): + await self._handle_rfid_forecast(packet) + + @abstractmethod + async def _handle_asr_forecast(self, packet: ASREventPacket): + pass + + @abstractmethod + async def _handle_button_event(self, packet: ButtonEvenPacket): + pass + + @abstractmethod + async def _handle_ear_event(self, packet: EarEventPacket): + pass + + @abstractmethod + async def _handle_ears_event(self, packet: EarsEventPacket): + pass + + @abstractmethod + async def _handle_rfid_forecast(self, packet: RfidEventPacket): + pass + def signal_handler(self, sig, frame): self.loop.call_soon_threadsafe( lambda: self.loop.create_task(self.reload_config()) @@ -43,9 +97,14 @@ async def reload_config(self): """ Reload configuration (on USR1 signal). """ + raise NotImplementedError("reload_config should be implemented") async def process_nabd_packet(self, packet: NabdPacket) -> None: - pass + """ + Process a packet from nabd. + Default implementation is to dispatch it to the router. + """ + await self.router.dispatch(packet) async def client_loop(self): try: @@ -81,9 +140,7 @@ async def client_loop(self): try: packet = json.loads(line.decode("utf8")) logging.debug(f"process nabd packet: {packet}") - await self.process_nabd_packet( - cast(NabdPacket, packet) - ) + await self.router.dispatch(packet) except json.decoder.JSONDecodeError as e: logging.error( f"Invalid JSON packet from nabd: {line}\n{e}" diff --git a/nabcommon/typing.py b/nabcommon/typing.py index 9af19808..71c9aec3 100644 --- a/nabcommon/typing.py +++ b/nabcommon/typing.py @@ -1,4 +1,5 @@ import datetime +from enum import Enum from typing import Any, Dict, List, Literal, TypedDict, Union # ========================= @@ -13,7 +14,13 @@ Resources = str NLUIntent = Dict[str, Any] -StateName = Literal["asleep", "idle", "interactive", "playing", "recording"] + +class StateName(str, Enum): + IDLE = "idle" + ASLEEP = "asleep" + INTERACTIVE = "interactive" + PLAYING = "playing" + RECORDING = "recording" # ========================= @@ -212,12 +219,9 @@ class ResponseGestaltPacket(ResponseBase, total=False): # ========================= -class EventBase(BasePacket): - time: float - - -class ASREventPacket(EventBase): +class ASREventPacket(TypedDict): type: Literal["asr_event"] + time: float nlu: NLUIntent @@ -232,24 +236,28 @@ class ASREventPacket(EventBase): ] -class ButtonEventPacket(EventBase): +class ButtonEventPacket(TypedDict): type: Literal["button_event"] + time: float event: ButtonEventType -class EarEventPacket(EventBase): +class EarEventPacket(TypedDict): type: Literal["ear_event"] + time: float ear: Literal["left", "right"] -class EarsEventPacket(EventBase): +class EarsEventPacket(TypedDict): type: Literal["ears_event"] + time: float left: int right: int -class RfidEventPacket(EventBase, total=False): +class RfidEventPacket(TypedDict, total=False): type: Literal["rfid_event"] + time: float tech: str uid: str event: Literal["removed", "detected"] diff --git a/nabd/nabd.py b/nabd/nabd.py index 4370f9d7..ac50e2e5 100644 --- a/nabd/nabd.py +++ b/nabd/nabd.py @@ -11,7 +11,6 @@ import sys import time import traceback -from enum import Enum from typing import Deque, Dict, List, Optional, Tuple, Type, Union, cast import dateutil.parser @@ -23,6 +22,7 @@ from nabcommon.typing import ( Animation, AnyPacket, + ButtonEventPacket, ButtonEventType, CommandPacket, CommandSequenceItem, @@ -34,17 +34,17 @@ ModePacket, NabdPacket, ResponseCanceledPacket, - ResponseErrorPacketProto, + ResponseErrorPacket, ResponseExpiredPacket, ResponseFailurePacket, - ResponseGestaltPacketProto, ResponseOKPacket, + ResponseGestaltPacket, ResponsePacket, - ResponsePacketProto, RfidWritePacket, ServicePacket, ServiceRequestPacket, SleepPacket, + StateName, TestPacket, ) @@ -79,29 +79,19 @@ } -def status_error( - error_class: str, error_message: str -) -> ResponseErrorPacketProto: +def status_error(error_class: str, error_message: str) -> ResponseErrorPacket: return cast( - ResponseErrorPacketProto, + ResponseErrorPacket, {"status": "error", "class": error_class, "message": error_message}, ) def status_error_malformed_packet( error_message: str, -) -> ResponseErrorPacketProto: +) -> ResponseErrorPacket: return status_error("MalformedPacket", error_message) -class State(Enum): - IDLE = "idle" - ASLEEP = "asleep" - INTERACTIVE = "interactive" - PLAYING = "playing" - RECORDING = "recording" - - class Nabd: SLEEP_EAR_POSITION = 10 INIT_EAR_POSITION = 0 @@ -122,7 +112,7 @@ def __init__(self, nabio: NabIO): self.info: Dict[ str, Animation ] = {} # Info persists across service connections. - self.state = State.IDLE + self.state = StateName.IDLE # Dictionary of writers, i.e. connected services # For each writer, value is the list of registered events self.service_writers: Dict[asyncio.StreamWriter, List[str]] = {} @@ -215,12 +205,15 @@ async def idle_worker_loop(self): await self._do_transition_to_idle() while self.running: # Check if we have something to do. - if self.state == State.IDLE and len(self.idle_queue) > 0: + if ( + self.state == StateName.IDLE + and len(self.idle_queue) > 0 + ): item = self.idle_queue.popleft() await self.process_idle_item(item) else: if ( - self.state == State.IDLE + self.state == StateName.IDLE and len(self.info.items()) > 0 ): for key, value in self.info.copy().items(): @@ -255,7 +248,7 @@ async def exit_interactive(self): # interactive -> playing or interactive -> idle depending on the # command queue self.interactive_service_writer = None - await self.transition_to(State.IDLE) + await self.transition_to(StateName.IDLE) async def process_idle_item(self, item: IdleQueueItem): """ @@ -269,24 +262,24 @@ async def process_idle_item(self, item: IdleQueueItem): ): self.write_response_packet(item[0], STATUS_EXPIRED, item[1]) if len(self.idle_queue) == 0: - await self.set_state(State.IDLE) + await self.set_state(StateName.IDLE) break else: item = self.idle_queue.popleft() else: if item[0]["type"] == "command": - await self.set_state(State.PLAYING) + await self.set_state(StateName.PLAYING) await self.perform(item[0], item[1]) if len(self.idle_queue) == 0: - await self.set_state(State.IDLE) + await self.set_state(StateName.IDLE) break else: item = self.idle_queue.popleft() elif item[0]["type"] == "message": - await self.set_state(State.PLAYING) + await self.set_state(StateName.PLAYING) await self.perform(item[0], item[1]) if len(self.idle_queue) == 0: - await self.set_state(State.IDLE) + await self.set_state(StateName.IDLE) break else: item = self.idle_queue.popleft() @@ -301,14 +294,14 @@ async def process_idle_item(self, item: IdleQueueItem): self.idle_queue.append(item) else: self.write_response_packet(item[0], STATUS_OK, item[1]) - await self.set_state(State.ASLEEP) + await self.set_state(StateName.ASLEEP) break elif ( item[0]["type"] == "mode" and item[0]["mode"] == "interactive" ): self.write_response_packet(item[0], STATUS_OK, item[1]) - await self.set_state(State.INTERACTIVE) + await self.set_state(StateName.INTERACTIVE) self.interactive_service_writer = item[1] if "events" in item[0]: self.interactive_service_events = item[0]["events"] @@ -316,19 +309,19 @@ async def process_idle_item(self, item: IdleQueueItem): self.interactive_service_events = ["ears", "button"] break elif item[0]["type"] == "test": - await self.set_state(State.PLAYING) + await self.set_state(StateName.PLAYING) await self.do_process_test_packet( cast(TestPacket, item[0]), item[1] ) if len(self.idle_queue) == 0: - await self.set_state(State.IDLE) + await self.set_state(StateName.IDLE) break else: item = self.idle_queue.popleft() elif item[0]["type"] == "rfid_write": await self.do_process_rfid_write_packet(item[0], item[1]) if len(self.idle_queue) == 0: - await self.set_state(State.IDLE) + await self.set_state(StateName.IDLE) break else: item = self.idle_queue.popleft() @@ -349,9 +342,9 @@ async def set_state(self, new_state): Thread: idle loop (only called from process_idle_item) """ if new_state != self.state: - if new_state == State.IDLE: + if new_state == StateName.IDLE: await self._do_transition_to_idle() - if new_state == State.ASLEEP: + if new_state == StateName.ASLEEP: await self.sleep_setup() self.state = new_state self.broadcast_state() @@ -363,7 +356,7 @@ async def transition_to(self, new_state): async with self.idle_cv: if self.state != new_state: self.state = new_state - if new_state == State.IDLE: + if new_state == StateName.IDLE: await self._do_transition_to_idle() self.idle_cv.notify() self.broadcast_state() @@ -454,7 +447,7 @@ async def process_ears_packet( self.ears["left"] = packet["left"] if "right" in packet: self.ears["right"] = packet["right"] - if self.state == State.IDLE: + if self.state == StateName.IDLE: if "event" in packet and packet["event"]: # Simulate an ears_event now = time.time() @@ -600,8 +593,8 @@ async def process_wakeup_packet( """Process a wakeup packet""" assert packet["type"] == "wakeup" self.write_response_packet(packet, STATUS_OK, writer) - if self.state == State.ASLEEP: - await self.transition_to(State.IDLE) + if self.state == StateName.ASLEEP: + await self.transition_to(StateName.IDLE) async def process_sleep_packet( self, any_packet: AnyPacket, writer: asyncio.StreamWriter @@ -609,7 +602,7 @@ async def process_sleep_packet( """Process a sleep packet""" assert any_packet["type"] == "sleep" packet = cast(SleepPacket, any_packet) - if self.state == State.ASLEEP: + if self.state == StateName.ASLEEP: self.write_response_packet(packet, STATUS_OK, writer) else: async with self.idle_cv: @@ -685,8 +678,8 @@ async def process_gestalt_packet( stdout=subprocess.PIPE, ) proc.wait() - response: ResponseGestaltPacketProto = { - "state": self.state.value, + response: ResponseGestaltPacket = { + "state": self.state, "connections": len(self.service_writers), "hardware": await self.nabio.gestalt(), } @@ -720,7 +713,7 @@ async def process_test_packet( """Process a test packet (for hardware tests)""" packet = self.__check_test_packet(any_packet, writer) if packet: - if self.state == State.ASLEEP: + if self.state == StateName.ASLEEP: await self.do_process_test_packet(packet, writer) else: async with self.idle_cv: @@ -745,7 +738,7 @@ def __check_test_packet( async def do_process_test_packet( self, packet: TestPacket, writer: asyncio.StreamWriter ): - response: ResponsePacketProto + response: ResponsePacket result = await self.nabio.test(packet["test"]) if result: response = STATUS_OK @@ -759,7 +752,7 @@ async def process_rfid_write_packet( """Process a rfid_write packet""" packet = self.__check_rfid_write_packet(any_packet, writer) if packet is not None: - if self.state == State.ASLEEP: + if self.state == StateName.ASLEEP: await self.do_process_rfid_write_packet(packet, writer) else: async with self.idle_cv: @@ -910,7 +903,7 @@ def _test_event_mask(self, event_type: str, events: List[str]) -> bool: matching = event_type0 + "/*" in events return matching - def broadcast_event(self, event_type, response: EventPacket): + def broadcast_event(self, event_type: str, response: EventPacket): if self.interactive_service_writer is None: logging.debug(f"broadcast event: {event_type}, {response}") for sw, events in self.service_writers.items(): @@ -929,16 +922,15 @@ def write_response_cancelable( original_packet: Union[CommandPacket, MessagePacket], writer: asyncio.StreamWriter, ): - if self.playing_canceled: - status = STATUS_CANCELED - else: - status = STATUS_OK + status: ResponsePacket = ( + STATUS_CANCELED if self.playing_canceled else STATUS_OK + ) self.write_response_packet(original_packet, status, writer) def write_response_packet( self, original_packet: Union[None, AnyPacket, ServicePacket], - template: ResponsePacketProto, + template: ResponsePacket, writer: asyncio.StreamWriter, ): response_packet: AnyPacket = cast(AnyPacket, template) @@ -954,7 +946,8 @@ def broadcast_state(self): self.write_state_packet(sw) def write_state_packet(self, writer: asyncio.StreamWriter): - self.write_packet({"type": "state", "state": self.state.value}, writer) + packet = cast(NabdPacket, {"type": "state", "state": self.state}) + self.write_packet(packet, writer) # Handle service through TCP/IP protocol async def service_loop( @@ -1038,9 +1031,9 @@ def button_callback( """ Thread: run_loop """ - if button_event == "hold" and self.state == State.IDLE: + if button_event == "hold" and self.state == StateName.IDLE: asyncio.ensure_future(self.start_asr()) - elif button_event == "up" and self.state == State.RECORDING: + elif button_event == "up" and self.state == StateName.RECORDING: asyncio.ensure_future(self.stop_asr()) elif button_event == "triple_click": asyncio.ensure_future(self._shutdown(False)) @@ -1055,21 +1048,25 @@ def button_callback( asyncio.ensure_future(self.nabio.cancel(True)) self.playing_canceled = True else: - self.broadcast_event( - "button", + packet = cast( + ButtonEventPacket, { "type": "button_event", "event": button_event, "time": event_time, }, ) + self.broadcast_event( + "button", + packet, + ) async def start_asr(self): """ Thread: run_loop """ assert self.asr is not None - await self.transition_to(State.RECORDING) + await self.transition_to(StateName.RECORDING) if self.nabio.rfid is not None: self.nabio.rfid.disable_polling() await self.nabio.start_acquisition(self.asr.decode_chunk) @@ -1086,7 +1083,7 @@ async def stop_asr(self): logging.debug(f"NLU response: {str(response)}") if self.nabio.rfid is not None: self.nabio.rfid.enable_polling() - await self.transition_to(State.IDLE) + await self.transition_to(StateName.IDLE) if response is None: # Did not understand await self.nabio.asr_failed() @@ -1145,7 +1142,7 @@ async def _ears_moved(self): (left, right) = await self.nabio.detect_ears_positions() self.ears["left"] = left self.ears["right"] = right - if self.state != State.ASLEEP: + if self.state != StateName.ASLEEP: now = time.time() self.broadcast_event( "ears", @@ -1198,7 +1195,7 @@ def rfid_callback( app_data_str = app_data_str_bin.decode("utf8") packet["data"] = app_data_str event_type = "rfid/" + app_str - if self.state != State.ASLEEP and not flags & TagFlags.REMOVED: + if self.state != StateName.ASLEEP and not flags & TagFlags.REMOVED: asyncio.ensure_future(self.nabio.rfid_detected_feedback()) self.broadcast_event(event_type, packet) diff --git a/nabsurprised/nabsurprised.py b/nabsurprised/nabsurprised.py index 7c787a9c..c95ac626 100644 --- a/nabsurprised/nabsurprised.py +++ b/nabsurprised/nabsurprised.py @@ -3,7 +3,7 @@ import sys from nabcommon.nabservice import NabRandomService -from nabcommon.typing import NabdPacket +from nabcommon.typing import ASREventPacket, RfidEventPacket from . import rfid_data @@ -90,17 +90,19 @@ def compute_random_delta(self, frequency): NabSurprised.FREQUENCY_SECONDS[NabSurprised.RARELY], ) # nosec B311 - async def process_nabd_packet(self, packet: NabdPacket): - if packet["type"] == "asr_event": - intent = packet["nlu"]["intent"] + async def _handle_asr_forecast(self, packet: ASREventPacket) -> None: + nlu = packet.get("nlu", {}) + + intent = nlu.get("intent") + if intent in NabSurprised.NLU_INTENTS: if intent in NabSurprised.NLU_INTENTS: _, type = intent.split("/") await self._do_perform(None, None, type) - elif ( - packet["type"] == "rfid_event" - and packet["app"] == "nabsurprised" - and packet["event"] == "detected" - ): + + async def _handle_rfid_event(self, packet: RfidEventPacket) -> None: + rfid_event = packet.get("event", "removed") + app = packet.get("app", "") + if rfid_event == "detected" and app == "nabsurprised": if "data" in packet: lang, type = rfid_data.unserialize( packet["data"].encode("utf8") diff --git a/nabtaichid/nabtaichid.py b/nabtaichid/nabtaichid.py index 3fb43f78..0a70ba0f 100644 --- a/nabtaichid/nabtaichid.py +++ b/nabtaichid/nabtaichid.py @@ -3,7 +3,7 @@ import sys from nabcommon.nabservice import NabRandomService -from nabcommon.typing import NabdPacket +from nabcommon.typing import ASREventPacket, RfidEventPacket class NabTaichid(NabRandomService): @@ -34,19 +34,20 @@ async def perform(self, expiration, args, config): def compute_random_delta(self, frequency): return (256 - frequency) * 60 * (random.uniform(0, 255) + 64) / 128 - async def process_nabd_packet(self, packet: NabdPacket): - if ( - packet["type"] == "asr_event" - and packet["nlu"]["intent"] == "nabtaichid/taichi" - ): - now = datetime.datetime.now(datetime.timezone.utc) - expiration = now + datetime.timedelta(minutes=1) - await self.perform(expiration, None, None) - elif ( - packet["type"] == "rfid_event" - and packet["app"] == "nabtaichid" - and packet["event"] == "detected" - ): + async def _handle_asr_forecast(self, packet: ASREventPacket) -> None: + nlu = packet.get("nlu", {}) + + if nlu.get("intent") != "nabtaichid/taichi": + return + + now = datetime.datetime.now(datetime.timezone.utc) + expiration = now + datetime.timedelta(minutes=1) + await self.perform(expiration, None, None) + + async def _handle_rfid_forecast(self, packet: RfidEventPacket) -> None: + rfid_event = packet.get("event", "removed") + app = packet.get("app", "") + if (app == "nabtaichid") and (rfid_event == "detected"): now = datetime.datetime.now(datetime.timezone.utc) expiration = now + datetime.timedelta(minutes=1) await self.perform(expiration, None, None) diff --git a/nabweatherd/nabweatherd.py b/nabweatherd/nabweatherd.py index 6b01d30b..61fae759 100644 --- a/nabweatherd/nabweatherd.py +++ b/nabweatherd/nabweatherd.py @@ -8,7 +8,7 @@ from meteofrance_api.client import MeteoFranceClient, Place from nabcommon.nabservice import NabInfoService -from nabcommon.typing import NabdPacket +from nabcommon.typing import ASREventPacket, RfidEventPacket from . import rfid_data @@ -595,27 +595,24 @@ async def _do_perform_additional(self, config, type): await self.perform_additional(expiration, type, info_data, config) - async def process_nabd_packet(self, packet: NabdPacket): + async def _handle_asr_forecast(self, packet: ASREventPacket) -> None: + nlu = packet.get("nlu", {}) - if ( - packet["type"] == "asr_event" - and packet["nlu"]["intent"] == "nabweatherd/forecast" - ): - if "date" in packet["nlu"] and packet["nlu"]["date"][ - :10 - ] != datetime.datetime.now().strftime("%Y-%m-%d"): - type = "tomorrow" - else: - type = "today" - logging.debug(f"ASR triggered forecast for {type}") - await self._do_perform(type) - elif ( - packet["type"] == "rfid_event" - and packet["app"] == "nabweatherd" - and packet["event"] == "detected" - ): - if "data" in packet: - type = rfid_data.unserialize(packet["data"].encode("utf8")) + if nlu.get("intent") != "nabweatherd/forecast": + return + + today_str = datetime.datetime.now().strftime("%Y-%m-%d") + type = "tomorrow" if nlu.get("date", "")[:10] != today_str else "today" + logging.debug(f"ASR triggered forecast for {type}") + await self._do_perform(type) + + async def _handle_rfid_forecast(self, packet: RfidEventPacket) -> None: + rfid_event = packet.get("event", "removed") + app = packet.get("app", "") + data = packet.get("data", None) + if (app == "nabweatherd") and (rfid_event == "detected"): + if data is not None: + type = rfid_data.unserialize(data.encode("utf8")) else: type = "today" logging.debug(f"RFID triggered forecast for {type}")