Source code for pylocal_akuvox.models.users

# SPDX-FileCopyrightText: 2026 Andrew Grimberg <tykeal@bardicgrove.org>
# SPDX-License-Identifier: Apache-2.0

"""User-domain data model."""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from pylocal_akuvox._capability_defaults import DEFAULT_USER_FIELD_ALIASES
from pylocal_akuvox.exceptions import AkuvoxParseError

if TYPE_CHECKING:
    from pylocal_akuvox._capability_profile import DeviceCapabilities


[docs] @dataclass(frozen=True, kw_only=True) class User: """Local user account stored on the device.""" name: str user_id: str schedule_relay: str id: str | None = None web_relay: str | None = None private_pin: str | None = None card_code: str | None = None lift_floor_num: str | None = None user_type: str | None = None source: str | None = None source_type: str | None = None
[docs] @classmethod def from_api_response( cls, data: dict[str, Any], *, capabilities: DeviceCapabilities | None = None, ) -> User: """Create User from API response data. ``capabilities`` is an optional :class:`DeviceCapabilities` record. When supplied, the parser consults ``capabilities.field_aliases.get("schedule_relay")`` to find the schedule-relay field, walking each name in ``FieldAliases.read`` in declared order and using the first match. When omitted (or when the record has no ``"schedule_relay"`` entry), the parser falls back to the canonical ``schedule_relay`` chain:: FieldAliases( read=("ScheduleRelay", "Schedule-Relay", "Schedule"), write=("ScheduleRelay", "Schedule-Relay"), ) byte-identical to the pre-refactor hardcoded chain — preserving FR-016 (no externally observable change for default callers). """ aliases = DEFAULT_USER_FIELD_ALIASES if capabilities is not None: aliases = capabilities.field_aliases.get( "schedule_relay", DEFAULT_USER_FIELD_ALIASES ) # Defensive: a capability record with an empty ``read`` tuple # for the schedule-relay field (incomplete matrix entry, or # an unexpected probe output) would otherwise make every # parse raise ``AkuvoxParseError`` even for payloads that # carry the legacy keys. Fall back to the default chain so # incomplete capability data degrades gracefully rather than # bricking the read path (Copilot review round 1). if not aliases.read: aliases = DEFAULT_USER_FIELD_ALIASES missing = object() schedule_relay: Any = missing for key in aliases.read: if key in data: schedule_relay = data[key] break if schedule_relay is missing: # Reuse the pre-refactor message verbatim so the existing # regression assertions in test_models.py (which probe the # exact substring set) keep passing under the default # fallback path (FR-016 / SC-008). msg = ( "Missing required field 'ScheduleRelay' (or 'Schedule-Relay'/" "'Schedule' on some firmwares) in user data" ) raise AkuvoxParseError(msg) try: return cls( name=data["Name"], user_id=data["UserID"], schedule_relay=schedule_relay, id=data.get("ID"), web_relay=data.get("WebRelay"), private_pin=data.get("PrivatePIN") or None, card_code=data.get("CardCode") or None, lift_floor_num=data.get("LiftFloorNum"), user_type=data.get("Type"), source=data.get("Source"), source_type=data.get("SourceType"), ) except KeyError as exc: msg = f"Missing required field {exc} in user data" raise AkuvoxParseError(msg) from exc
[docs] def to_api_payload(self) -> dict[str, str]: """Convert to PascalCase dict for add/set API calls.""" payload: dict[str, str] = { "Name": self.name, "UserID": self.user_id, "ScheduleRelay": self.schedule_relay, } if self.id is not None: payload["ID"] = self.id if self.web_relay is not None: payload["WebRelay"] = self.web_relay if self.private_pin is not None: payload["PrivatePIN"] = self.private_pin if self.card_code is not None: payload["CardCode"] = self.card_code if self.lift_floor_num is not None: payload["LiftFloorNum"] = self.lift_floor_num if self.user_type is not None: payload["Type"] = self.user_type return payload