Source code for bac_py.types.primitives

"""BACnet primitive data types per ASHRAE 135-2016 Clause 20.2."""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from bac_py.types.enums import ObjectType

if TYPE_CHECKING:
    from enum import IntEnum


def _enum_name(member: IntEnum) -> str:
    """Convert UPPER_SNAKE enum name to lower-hyphen form.

    :param member: An :class:`~enum.IntEnum` member.
    :returns: Lowercased, hyphen-separated name string.
    """
    return member.name.lower().replace("_", "-")


def _enum_from_dict(enum_cls: type[IntEnum], data: int | str | dict[str, Any]) -> IntEnum:
    """Reconstruct an enum member from its JSON representation.

    Accepts an integer (raw numeric value), a string (hyphenated name),
    or a dictionary with ``{"value": int, "name": str}``.

    :param enum_cls: The :class:`~enum.IntEnum` subclass to reconstruct.
    :param data: Serialized enum value in any of the accepted formats.
    :returns: The corresponding enum member.
    :raises ValueError: If *data* cannot be converted to a member of *enum_cls*.
    """
    if isinstance(data, int):
        return enum_cls(data)
    if isinstance(data, str):
        name = data.upper().replace("-", "_")
        return enum_cls[name]
    if isinstance(data, dict):
        return enum_cls(data["value"])
    msg = f"Cannot convert {data!r} to {enum_cls.__name__}"
    raise ValueError(msg)


[docs] @dataclass(frozen=True, slots=True) class ObjectIdentifier: """BACnet Object Identifier -- 10-bit type, 22-bit instance (Clause 20.2.14). Uniquely identifies a BACnet object within a device by combining an :class:`~bac_py.types.enums.ObjectType` with an instance number. """ object_type: ObjectType """The object type (10-bit, 0--1023).""" instance_number: int """The instance number (22-bit, 0--4194303).""" def __post_init__(self) -> None: if not 0 <= int(self.object_type) <= 1023: msg = f"Object type must be 0-1023 (10-bit), got {int(self.object_type)}" raise ValueError(msg) if not 0 <= self.instance_number <= 0x3FFFFF: msg = f"Instance number must be 0-4194303, got {self.instance_number}" raise ValueError(msg)
[docs] def encode(self) -> bytes: """Encode to the 4-byte BACnet wire format. The 32-bit value is composed as ``(object_type << 22) | instance_number``, encoded big-endian. :returns: 4-byte encoded object identifier. """ value = (int(self.object_type) << 22) | (self.instance_number & 0x3FFFFF) return value.to_bytes(4, "big")
[docs] @classmethod def decode(cls, data: bytes | memoryview) -> ObjectIdentifier: """Decode from the 4-byte BACnet wire format. :param data: At least 4 bytes of wire data. :returns: Decoded :class:`ObjectIdentifier` instance. """ value = int.from_bytes(data[:4], "big") return cls( object_type=ObjectType(value >> 22), instance_number=value & 0x3FFFFF, )
[docs] def to_dict(self) -> dict[str, Any]: """Convert to a JSON-serializable dictionary. :returns: Dictionary with ``"object_type"`` (hyphenated name) and ``"instance"`` keys. """ return { "object_type": _enum_name(self.object_type), "instance": self.instance_number, }
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> ObjectIdentifier: """Reconstruct from a JSON-friendly dictionary. :param data: Dictionary with ``"object_type"`` and ``"instance"`` keys. :returns: Decoded :class:`ObjectIdentifier` instance. :raises TypeError: If the resolved type is not an :class:`~bac_py.types.enums.ObjectType`. """ obj_type = _enum_from_dict(ObjectType, data["object_type"]) if not isinstance(obj_type, ObjectType): msg = f"Expected ObjectType, got {type(obj_type).__name__}" raise TypeError(msg) return cls( object_type=obj_type, instance_number=data["instance"], )
[docs] @dataclass(frozen=True, slots=True) class BACnetDate: """BACnet Date -- year, month, day, day_of_week (Clause 20.2.12). ``0xFF`` indicates an unspecified (wildcard) field. Year is stored as the actual year (e.g. 2024), but encoded on the wire as ``year - 1900``. """ year: int """Calendar year (e.g. 2024), or ``0xFF`` for unspecified.""" month: int """Month (1--12), or ``0xFF`` for unspecified.""" day: int """Day of month (1--31), or ``0xFF`` for unspecified.""" day_of_week: int """Day of week (1 = Monday ... 7 = Sunday), or ``0xFF`` for unspecified."""
[docs] def to_dict(self) -> dict[str, Any]: """Convert to a JSON-serializable dictionary. Wildcard values (``0xFF``) are represented as ``None``. :returns: Dictionary mapping field names to values or ``None``. """ return { "year": None if self.year == 0xFF else self.year, "month": None if self.month == 0xFF else self.month, "day": None if self.day == 0xFF else self.day, "day_of_week": None if self.day_of_week == 0xFF else self.day_of_week, }
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> BACnetDate: """Reconstruct from a JSON-friendly dictionary. ``None`` values are converted back to ``0xFF`` wildcards. :param data: Dictionary with ``"year"``, ``"month"``, ``"day"``, and ``"day_of_week"`` keys. :returns: Decoded :class:`BACnetDate` instance. """ return cls( year=0xFF if data["year"] is None else data["year"], month=0xFF if data["month"] is None else data["month"], day=0xFF if data["day"] is None else data["day"], day_of_week=0xFF if data["day_of_week"] is None else data["day_of_week"], )
[docs] @dataclass(frozen=True, slots=True) class BACnetTime: """BACnet Time -- hour, minute, second, hundredth (Clause 20.2.13). ``0xFF`` indicates an unspecified (wildcard) field. """ hour: int """Hour (0--23), or ``0xFF`` for unspecified.""" minute: int """Minute (0--59), or ``0xFF`` for unspecified.""" second: int """Second (0--59), or ``0xFF`` for unspecified.""" hundredth: int """Hundredths of a second (0--99), or ``0xFF`` for unspecified."""
[docs] def to_dict(self) -> dict[str, Any]: """Convert to a JSON-serializable dictionary. Wildcard values (``0xFF``) are represented as ``None``. :returns: Dictionary mapping field names to values or ``None``. """ return { "hour": None if self.hour == 0xFF else self.hour, "minute": None if self.minute == 0xFF else self.minute, "second": None if self.second == 0xFF else self.second, "hundredth": None if self.hundredth == 0xFF else self.hundredth, }
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> BACnetTime: """Reconstruct from a JSON-friendly dictionary. ``None`` values are converted back to ``0xFF`` wildcards. :param data: Dictionary with ``"hour"``, ``"minute"``, ``"second"``, and ``"hundredth"`` keys. :returns: Decoded :class:`BACnetTime` instance. """ return cls( hour=0xFF if data["hour"] is None else data["hour"], minute=0xFF if data["minute"] is None else data["minute"], second=0xFF if data["second"] is None else data["second"], hundredth=0xFF if data["hundredth"] is None else data["hundredth"], )
[docs] class BACnetDouble(float): """Sentinel type to distinguish Double (64-bit) from Real (32-bit) encoding. BACnet properties like LargeAnalogValue Present_Value are specified as Double (IEEE-754 64-bit, Application tag 5) per Clause 12.42. Since Python ``float`` is always 64-bit internally, this subclass serves as a marker so ``encode_property_value`` can emit the correct wire tag. """
[docs] class BitString: """BACnet Bit String with named-bit support (Clause 20.2.10). Stores raw bit data as bytes with an unused-bits count for the trailing byte. Provides indexed access to individual bits using MSB-first ordering within each byte. """ __slots__ = ("_data", "_unused_bits") def __init__(self, value: bytes, unused_bits: int = 0) -> None: """Initialise a BitString. :param value: Raw bytes containing the bit data. :param unused_bits: Number of unused trailing bits in the last byte (0--7). Must be 0 when *value* is empty. :raises ValueError: If *unused_bits* is out of range or non-zero with empty *value*. """ if not 0 <= unused_bits <= 7: msg = f"unused_bits must be 0-7, got {unused_bits}" raise ValueError(msg) if len(value) == 0 and unused_bits != 0: msg = "unused_bits must be 0 when data is empty" raise ValueError(msg) self._data = value self._unused_bits = unused_bits @property def data(self) -> bytes: """Raw byte data backing this bit string.""" return self._data @property def unused_bits(self) -> int: """Number of unused trailing bits in the last byte (0--7).""" return self._unused_bits def __len__(self) -> int: """Return the total number of significant bits.""" return len(self._data) * 8 - self._unused_bits def __getitem__(self, index: int) -> bool: """Get the bit value at *index* (MSB-first within each byte). :param index: Zero-based bit index. :returns: ``True`` if the bit is set, ``False`` otherwise. :raises IndexError: If *index* is out of range. """ if index < 0 or index >= len(self): msg = f"Bit index {index} out of range for BitString of length {len(self)}" raise IndexError(msg) byte_index = index // 8 bit_index = 7 - (index % 8) return bool(self._data[byte_index] & (1 << bit_index)) def __eq__(self, other: object) -> bool: if not isinstance(other, BitString): return NotImplemented return self._data == other._data and self._unused_bits == other._unused_bits def __hash__(self) -> int: return hash((self._data, self._unused_bits)) def __repr__(self) -> str: bits = "".join("1" if self[i] else "0" for i in range(len(self))) return f"BitString({bits!r})"
[docs] def to_dict(self) -> dict[str, Any]: """Convert to a JSON-serializable dictionary. :returns: Dictionary with ``"bits"`` (list of booleans) and ``"unused_bits"`` keys. """ total_bits = len(self) return { "bits": [self[i] for i in range(total_bits)], "unused_bits": self._unused_bits, }
[docs] @classmethod def from_dict(cls, data: dict[str, Any]) -> BitString: """Reconstruct from a JSON-friendly dictionary. :param data: Dictionary with ``"bits"`` (list of booleans) and optionally ``"unused_bits"`` keys. :returns: Decoded :class:`BitString` instance. """ bits: list[bool] = data["bits"] unused = data.get("unused_bits", 0) byte_count = (len(bits) + 7) // 8 result = bytearray(byte_count) for i, bit in enumerate(bits): if bit: result[i // 8] |= 1 << (7 - (i % 8)) return cls(bytes(result), unused)