Source code for bac_py.transport.sc.vmac

"""VMAC addressing and Device UUID for BACnet/SC (AB.1.5)."""

from __future__ import annotations

import os
import uuid
from dataclasses import dataclass

from bac_py.transport.sc.types import UUID_LENGTH, VMAC_BROADCAST, VMAC_LENGTH


[docs] @dataclass(frozen=True, slots=True) class SCVMAC: """6-byte virtual MAC address for BACnet/SC nodes (AB.1.5.2). VMAC addresses use the EUI-48 format. The broadcast VMAC is ``FF:FF:FF:FF:FF:FF``. The all-zeros address ``00:00:00:00:00:00`` is reserved to indicate an unknown or uninitialized VMAC. """ address: bytes def __post_init__(self) -> None: if len(self.address) != VMAC_LENGTH: msg = f"VMAC must be {VMAC_LENGTH} bytes, got {len(self.address)}" raise ValueError(msg) # -- Factories -- @classmethod def _from_trusted(cls, data: bytes) -> SCVMAC: """Fast-path: skip validation (caller guarantees 6 bytes).""" obj = object.__new__(cls) object.__setattr__(obj, "address", data) return obj
[docs] @classmethod def random(cls) -> SCVMAC: """Generate a random locally-administered unicast VMAC. Sets the locally-administered bit (bit 1 of first octet) and clears the multicast bit (bit 0 of first octet) per IEEE 802. """ raw = bytearray(os.urandom(VMAC_LENGTH)) raw[0] = (raw[0] | 0x02) & 0xFE # local-admin, unicast return cls(bytes(raw))
[docs] @classmethod def broadcast(cls) -> SCVMAC: """Return the local broadcast VMAC (FF:FF:FF:FF:FF:FF).""" return cls(VMAC_BROADCAST)
[docs] @classmethod def from_hex(cls, hex_str: str) -> SCVMAC: """Parse a VMAC from hex string (with or without colons/hyphens). Accepts formats: ``"AABBCCDDEEFF"``, ``"AA:BB:CC:DD:EE:FF"``, ``"AA-BB-CC-DD-EE-FF"``. """ cleaned = hex_str.replace(":", "").replace("-", "") if len(cleaned) != VMAC_LENGTH * 2: msg = f"Invalid VMAC hex string length: {hex_str!r}" raise ValueError(msg) return cls(bytes.fromhex(cleaned))
# -- Properties -- @property def is_broadcast(self) -> bool: """Return True if this is the broadcast VMAC.""" return self.address == VMAC_BROADCAST @property def is_uninitialized(self) -> bool: """Return True if this is the all-zeros (uninitialized) VMAC.""" return self.address == b"\x00\x00\x00\x00\x00\x00" # -- Display -- def __str__(self) -> str: return ":".join(f"{b:02X}" for b in self.address) def __repr__(self) -> str: return f"SCVMAC('{self}')"
[docs] @dataclass(frozen=True, slots=True) class DeviceUUID: """16-byte device UUID for BACnet/SC (AB.1.5.3). Every BACnet/SC device has a UUID (RFC 4122) that persists across restarts and is independent of VMAC or device instance. """ value: bytes def __post_init__(self) -> None: if len(self.value) != UUID_LENGTH: msg = f"Device UUID must be {UUID_LENGTH} bytes, got {len(self.value)}" raise ValueError(msg)
[docs] @classmethod def generate(cls) -> DeviceUUID: """Generate a new random UUID (version 4).""" return cls(uuid.uuid4().bytes)
[docs] @classmethod def from_hex(cls, hex_str: str) -> DeviceUUID: """Parse UUID from hex string (with or without hyphens). Accepts ``"550e8400e29b41d4a716446655440000"`` or standard ``"550e8400-e29b-41d4-a716-446655440000"`` format. """ cleaned = hex_str.replace("-", "") if len(cleaned) != UUID_LENGTH * 2: msg = f"Invalid UUID hex string length: {hex_str!r}" raise ValueError(msg) return cls(bytes.fromhex(cleaned))
def __str__(self) -> str: h = self.value.hex() return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}" def __repr__(self) -> str: return f"DeviceUUID('{self}')"