"""
MODULE SERVEUR BACNET/IP - NIVEAU INDUSTRIEL
Version Spécification: 14.3 (BACpypes3 Native Architecture - Clean)
Auteur: Assistant

Ce module implémente un serveur BACnet/IP robuste utilisant bacpypes3.
Il construit manuellement la stack complète en suivant l'architecture BACpypes3:
Application -> ASAP -> NSAP -> LinkLayer

CHANGELOG V14.3:
- AMÉLIORATION: Extraction de toutes les constantes magiques (timing, adresses, protocole)
- AJOUT: Constantes de timing explicites (CPU_YIELD_INTERVAL, BUFFER_CONSUMER_INTERVAL, etc.)
- AJOUT: Constantes réseau (DEFAULT_BIND_ALL_ADDRESS, DEFAULT_BROADCAST_ADDRESS)
- AJOUT: Docstrings pour les tâches asynchrones (_cpu_yield_task, _watchdog_monitor_task, etc.)
- NETTOYAGE: Suppression du code mort (_fdr_maintenance_task commentée)
- AMÉLIORATION: Code plus lisible avec constantes nommées au lieu de valeurs magiques

CHANGELOG V14.2:
- REFACTOR: Pattern factory pour la création d'objets BACnet (réduction duplication)
- AJOUT: Constantes PRIORITY_ARRAY_SIZE et STATUS_FLAGS_SIZE
- AMÉLIORATION: Méthodes _create_*_object() avec docstrings complètes
- AMÉLIORATION: Gestion d'erreurs améliorée avec logs détaillés lors de la création d'objets
- AMÉLIORATION: Code plus maintenable et testable (52 lignes → 15 lignes + 4 factories)

CHANGELOG V14.1:
- FIX CRITIQUE: Correction du binding - utilisation de l'architecture complète BACpypes3
- ARCHITECTURE: Application -> ASAP -> NSAP -> NSE -> LinkLayer
- AJOUT: ApplicationServiceAccessPoint (ASAP) et NetworkServiceAccessPoint (NSAP)
- AJOUT: DeviceInfoCache pour gérer le cache des devices
- FIX: Utilisation de nsap.bind() (méthode spéciale) pour lier le LinkLayer
- Note: bind() de comm ne prend que 2 objets, pas 3 !

CHANGELOG V14.0:
- FIX CRITIQUE: Remplacement de BIPSimple (qui n'existe plus) par NormalLinkLayer
- ARCHITECTURE: Utilisation de l'API BACpypes3 moderne avec Link Layers
- IMPORTS: bacpypes3.ipv4.link (NormalLinkLayer, ForeignLinkLayer, BBMDLinkLayer)
- Note: BIPSimple n'existe pas dans BACpypes3 - c'était l'API de BACpypes v1/v2

CHANGELOG V13.0:
- ARCHITECTURE: Construction manuelle explicite : Application + NetworkServiceElement + BIPSimple.
- FIX: Stockage explicite de 'localDevice' dans l'Engine pour éviter AttributeError.
- IMPORTS: Utilisation de BIPSimple (bacpypes3.ipv4.service) confirmé par les logs.
"""

import asyncio
import concurrent.futures
import threading
import time
import logging
import gc
import weakref
import socket
import ipaddress
import sys
from collections import deque
from typing import Dict, List, Optional, Any, Union, Tuple, Callable

# --- IMPORTS ---
try:
    import bacpypes3
    from bacpypes3.settings import settings
    from bacpypes3.debugging import bacpypes_debugging, ModuleLogger
    from bacpypes3.argparse import SimpleArgumentParser

    # 1. Core
    from bacpypes3.app import Application, DeviceInfoCache
    from bacpypes3.comm import bind

    # 2. Service Access Points
    from bacpypes3.appservice import ApplicationServiceAccessPoint
    from bacpypes3.netservice import NetworkServiceAccessPoint, NetworkServiceElement

    # 3. Link Layer (BACpypes3 uses Link Layers, not BIPSimple)
    # Les couches de liaison appropriées sont dans bacpypes3.ipv4.link
    try:
        from bacpypes3.ipv4.link import NormalLinkLayer, ForeignLinkLayer, BBMDLinkLayer
    except ImportError:
        raise ImportError("CRITICAL: Link layers not found in bacpypes3.ipv4.link")

    # 4. Common
    from bacpypes3.pdu import PDU, Address
    from bacpypes3.apdu import IAmRequest, WhoIsRequest, ErrorRejectAbortNack
    from bacpypes3.primitivedata import ObjectIdentifier, CharacterString, Unsigned, Null, Real, Enumerated, Boolean
    from bacpypes3.basetypes import (
        EngineeringUnits, PropertyIdentifier, Reliability, StatusFlags,
        EventState, DeviceStatus, ServicesSupported, ObjectTypesSupported,
        PriorityArray, PriorityValue, BinaryPV
    )
    # Local objects (dynamic propertyList, statusFlags, proper BACnet read/write)
    from bacpypes3.local.device import DeviceObject
    from bacpypes3.local.analog import AnalogValueObject, AnalogValueObjectCmd
    from bacpypes3.local.binary import BinaryValueObject, BinaryValueObjectCmd
    from bacpypes3.local.multistate import MultiStateValueObject
    from bacpypes3.local.schedule import ScheduleObject
    from bacpypes3.local.cmd import Commandable
    from bacpypes3.basetypes import DateRange, DeviceObjectPropertyReference
    from bacpypes3.primitivedata import Date, Real, Unsigned as BacnetUnsigned
    from bacpypes3.primitivedata import Time as BacnetTime
    from bacpypes3.basetypes import (
        DailySchedule, TimeValue, SpecialEvent, SpecialEventPeriod,
        CalendarEntry, WeekNDay,
    )
    from bacpypes3.constructeddata import AnyAtomic, SequenceOf
    from bacpypes3.ipv4 import IPv4Address
    from bacpypes3.service.cov import SubscriptionContextManager

except ImportError as e:
    raise ImportError(f"ERREUR CRITIQUE: bacpypes3 manquant ou incompatible. {e}")

# --- MONKEY-PATCH: fix bacpypes3 Sequence.decode crash when _order is empty ---
# bacpypes3 raises "sequences must be ordered" when cls._order is falsy (empty list).
# This happens for certain APDU types (e.g. GetAlarmSummaryRequest) that have no
# ordered elements. The patch logs a warning and returns an empty instance instead
# of crashing, allowing the server to continue operating.
try:
    from bacpypes3.constructeddata import Sequence as _BacnetSequence
    _original_sequence_decode = _BacnetSequence.decode

    @classmethod  # type: ignore
    def _patched_sequence_decode(cls, tag_list, class_=None):
        if class_:
            cls = class_
        if not cls._order:
            logging.getLogger('bacnet.patch').warning(
                f'[BACnet] Sequence.decode: {cls.__name__} has no _order, '
                f'returning empty instance (elements={list(cls._elements.keys()) if hasattr(cls, "_elements") else "?"})'
            )
            return cls()
        return _original_sequence_decode.__func__(cls, tag_list)

    _BacnetSequence.decode = _patched_sequence_decode
except Exception:
    pass

# --- CONSTANTES ---
SPEC_VERSION = "14.3"
DEFAULT_PORT = 47808
DEFAULT_VENDOR_ID = 555

RELIABILITY_NO_SENSOR = Reliability.noSensor
RELIABILITY_NO_FAULT = Reliability.noFaultDetected
RELIABILITY_PROCESS_ERROR = Reliability.processError

# BACnet Protocol Constants
MAX_APDU_LENGTH = 1476            # Maximum APDU length accepted by this device

# Network Configuration Constants
DEFAULT_BIND_ALL_ADDRESS = "0.0.0.0"           # Bind to all interfaces
DEFAULT_BROADCAST_ADDRESS = "255.255.255.255"  # Global broadcast address

# Timing Constants (seconds)
CPU_YIELD_INTERVAL = 0.01                # CPU yield interval for event loop
BUFFER_CONSUMER_INTERVAL = 0.05          # Object update buffer polling interval
BUFFER_ERROR_RETRY_INTERVAL = 1.0        # Retry delay after buffer consumer error
WATCHDOG_CHECK_INTERVAL = 1.0            # Watchdog monitoring check interval
IAM_BROADCAST_INTERVAL = 30.0            # I-Am message broadcast interval
LOOP_MONITOR_INTERVAL = 0.1              # Event loop latency monitor interval (100ms)
LOOP_MONITOR_WARN_THRESHOLD = 0.05       # Warn if loop is late by more than 50ms beyond requested sleep

# Service Lifecycle Constants
SERVICE_STOP_TIMEOUT = 2.0               # Timeout for graceful service shutdown
HEALTH_CHECK_TIMEOUT = 2.0               # Maximum time between alive timestamps
CROSS_THREAD_READ_TIMEOUT = 1.0          # Timeout for cross-thread object reads

# --- HELPER CLASSES ---

class UnitMapper:
    def __init__(self, mapping: Dict[str, int]):
        self._map={}
        self._reverse_map={}
        for key in mapping.keys():
            code = mapping[key]
            self._map[key.lower()]=code
            if code not in self._reverse_map:
                self._reverse_map[code]=key

    def get_bacnet_unit(self, unit_str: Optional[str]) -> EngineeringUnits:
        if not unit_str: return EngineeringUnits.noUnits
        unit_code = self._map.get(unit_str.lower())
        if unit_code is not None:
            try: return EngineeringUnits(unit_code)
            except ValueError: pass
        return EngineeringUnits.noUnits

    def get_unit_string(self, code) -> str:
        """Reverse lookup: BACnet unit code (int) to human-readable string."""
        if code is None:
            return ''
        try:
            code = int(code)
        except (TypeError, ValueError):
            return str(code)
        try:
            if EngineeringUnits(code) == EngineeringUnits.noUnits:
                return ''
        except (ValueError, KeyError):
            pass
        s = self._reverse_map.get(code)
        if s:
            return s
        # Fallback: BACnet enum name
        try:
            return str(EngineeringUnits(code))
        except (ValueError, KeyError):
            return str(code)

class ScheduleSerializer:
    """Serialize/deserialize BACnet Schedule properties to/from plain Python dicts."""

    @staticmethod
    def _serialize_anyatomic(value):
        """Convert an AnyAtomic value to a serializable dict."""
        if value is None:
            return None
        if isinstance(value, Null):
            return {'t': 'null'}
        if isinstance(value, AnyAtomic):
            try:
                vtype = value.get_value_type()
                decoded = value.get_value()
                if vtype is Real:
                    return {'t': 'real', 'v': float(decoded)}
                elif vtype is Unsigned:
                    return {'t': 'unsigned', 'v': int(decoded)}
                elif vtype is Enumerated:
                    return {'t': 'enum', 'v': int(decoded)}
                elif vtype is Null:
                    return {'t': 'null'}
                elif vtype is Boolean:
                    return {'t': 'bool', 'v': bool(decoded)}
            except Exception:
                pass
        # Fallback for already-decoded atomic types
        if isinstance(value, Real):
            return {'t': 'real', 'v': float(value)}
        if isinstance(value, (Unsigned, BacnetUnsigned)):
            return {'t': 'unsigned', 'v': int(value)}
        if isinstance(value, BinaryPV):
            return {'t': 'enum', 'v': int(value)}
        return None

    @staticmethod
    def _deserialize_anyatomic(data):
        """Convert a serialized dict back to a BACnet atomic type."""
        if data is None:
            return None
        t = data.get('t')
        if t == 'real':
            return Real(data['v'])
        elif t == 'unsigned':
            return BacnetUnsigned(data['v'])
        elif t == 'enum':
            return BinaryPV(data['v'])
        elif t == 'bool':
            return Boolean(data['v'])
        elif t == 'null':
            # Null is a special singleton in bacpypes3 — wrap via TagList
            return AnyAtomic(Null.encode(Null))
        return None

    @staticmethod
    def _serialize_time(t):
        """Serialize a BACnet Time to a tuple (h, m, s, cs)."""
        return (int(t[0]), int(t[1]), int(t[2]), int(t[3]))

    @staticmethod
    def serialize_weekly_schedule(weekly):
        """Serialize weeklySchedule (ArrayOf(DailySchedule, 7)) to list of lists."""
        if weekly is None:
            return None
        result = []
        for daily in weekly:
            day_entries = []
            if daily.daySchedule:
                for tv in daily.daySchedule:
                    entry = {
                        'time': ScheduleSerializer._serialize_time(tv.time),
                        'value': ScheduleSerializer._serialize_anyatomic(tv.value),
                    }
                    day_entries.append(entry)
            result.append(day_entries)
        return result

    @staticmethod
    def deserialize_weekly_schedule(data):
        """Deserialize list of lists back to ArrayOf(DailySchedule, 7)."""
        if data is None:
            return None
        days = []
        for day_entries in data:
            time_values = []
            for entry in day_entries:
                t = entry['time']
                val = ScheduleSerializer._deserialize_anyatomic(entry['value'])
                tv = TimeValue(
                    time=BacnetTime((t[0], t[1], t[2], t[3])),
                    value=val,
                )
                time_values.append(tv)
            days.append(DailySchedule(daySchedule=time_values))
        return days

    @staticmethod
    def _serialize_calendar_entry(ce):
        """Serialize a CalendarEntry Choice."""
        if ce.date is not None:
            return {'type': 'date', 'value': tuple(int(x) for x in ce.date)}
        elif ce.dateRange is not None:
            return {
                'type': 'dateRange',
                'start': tuple(int(x) for x in ce.dateRange.startDate),
                'end': tuple(int(x) for x in ce.dateRange.endDate),
            }
        elif ce.weekNDay is not None:
            return {'type': 'weekNDay', 'value': list(ce.weekNDay)}
        return None

    @staticmethod
    def _deserialize_calendar_entry(data):
        """Deserialize a CalendarEntry."""
        t = data['type']
        if t == 'date':
            return CalendarEntry(date=Date(tuple(data['value'])))
        elif t == 'dateRange':
            return CalendarEntry(dateRange=DateRange(
                startDate=Date(tuple(data['start'])),
                endDate=Date(tuple(data['end'])),
            ))
        elif t == 'weekNDay':
            return CalendarEntry(weekNDay=WeekNDay(bytes(data['value'])))
        return None

    @staticmethod
    def serialize_exception_schedule(exceptions):
        """Serialize exceptionSchedule to list of dicts."""
        if exceptions is None:
            return None
        result = []
        for se in exceptions:
            entry = {'eventPriority': int(se.eventPriority)}
            # Period
            if se.period and se.period.calendarEntry is not None:
                entry['period'] = {
                    'type': 'calendarEntry',
                    'entry': ScheduleSerializer._serialize_calendar_entry(se.period.calendarEntry),
                }
            elif se.period and se.period.calendarReference is not None:
                entry['period'] = {
                    'type': 'calendarReference',
                    'ref': str(se.period.calendarReference),
                }
            # TimeValues
            tvs = []
            if se.listOfTimeValues:
                for tv in se.listOfTimeValues:
                    tvs.append({
                        'time': ScheduleSerializer._serialize_time(tv.time),
                        'value': ScheduleSerializer._serialize_anyatomic(tv.value),
                    })
            entry['timeValues'] = tvs
            result.append(entry)
        return result

    @staticmethod
    def deserialize_exception_schedule(data):
        """Deserialize list of dicts back to list of SpecialEvent."""
        if data is None:
            return None
        result = []
        for entry in data:
            # Period
            period_data = entry.get('period', {})
            if period_data.get('type') == 'calendarEntry':
                ce = ScheduleSerializer._deserialize_calendar_entry(period_data['entry'])
                period = SpecialEventPeriod(calendarEntry=ce)
            elif period_data.get('type') == 'calendarReference':
                period = SpecialEventPeriod(
                    calendarReference=ObjectIdentifier(period_data['ref'])
                )
            else:
                continue
            # TimeValues
            time_values = []
            for tv_data in entry.get('timeValues', []):
                t = tv_data['time']
                val = ScheduleSerializer._deserialize_anyatomic(tv_data['value'])
                time_values.append(TimeValue(
                    time=BacnetTime((t[0], t[1], t[2], t[3])),
                    value=val,
                ))
            result.append(SpecialEvent(
                period=period,
                listOfTimeValues=time_values,
                eventPriority=entry['eventPriority'],
            ))
        return result


class NotifyMixin:
    """Mixin that fires a callback on external BACnet writes to presentValue."""
    _write_callback: Optional[Callable[[str, Any, int], None]] = None

    def set_write_callback(self, cb):
        self._write_callback = cb

    async def write_property(self, attr, value, index=None, priority=None):
        # Normalize attr: BACnet stack passes PropertyIdentifier(int), not str
        if isinstance(attr, int):
            attr = self._property_identifier_class(attr).attr
        await super().write_property(attr, value, index, priority)
        if attr == "presentValue" and priority is not None and self._write_callback:
            self._write_callback(self.objectName, value, priority)

# Read-only objects (local versions with dynamic propertyList/statusFlags)
class CustomAnalogValue(AnalogValueObject): pass
class CustomBinaryValue(BinaryValueObject): pass
class CustomMultiStateValue(MultiStateValueObject): pass

# Writable/Commandable objects (Commandable handles priorityArray + recalculating)
class WritableAnalogValue(NotifyMixin, AnalogValueObjectCmd): pass
class WritableBinaryValue(NotifyMixin, BinaryValueObjectCmd): pass
class WritableMultiStateValue(NotifyMixin, Commandable, MultiStateValueObject): pass

class PersistentScheduleObject(ScheduleObject):
    """ScheduleObject subclass that notifies when schedule config changes.

    Also fixes a bacpypes3 issue where _Object.write_property() bypasses
    the local Object.__setattr__(), which means _property_monitors are
    never fired for BACnet WriteProperty requests. This override
    explicitly fires the monitors after a successful write for
    schedule-related properties.
    """
    _persistence_callback: Optional[Callable] = None
    _lock_references: bool = False

    # Properties that need monitor notification on BACnet writes
    _SCHEDULE_PROPS = frozenset(("weeklySchedule", "exceptionSchedule", "scheduleDefault"))

    # Properties protected when _lock_references is True
    _REFERENCE_PROPS = frozenset(("listOfObjectPropertyReferences",))

    def set_persistence_callback(self, cb):
        self._persistence_callback = cb
        # Register property monitors for schedule-modifiable properties
        for prop in self._SCHEDULE_PROPS:
            self._property_monitors[prop].append(self._on_schedule_changed)

    def _on_schedule_changed(self, old_value, new_value):
        if self._persistence_callback:
            self._persistence_callback()

    def present_value_changed(self, old_value, new_value):
        """Override to properly schedule async write_property on targets.

        The base ScheduleObject.present_value_changed() calls obj.write_property()
        synchronously, but write_property is async (coroutine). The returned
        coroutine is never awaited, so writes to targets silently fail.
        This override uses asyncio.ensure_future() to schedule the coroutine.
        """
        if not self._app:
            return
        obj_prop_refs = self.listOfObjectPropertyReferences
        if not obj_prop_refs:
            return
        atomic_value = new_value.get_value()
        for obj_prop_ref in obj_prop_refs:
            if obj_prop_ref.deviceIdentifier:
                continue
            obj = self._app.get_object_id(obj_prop_ref.objectIdentifier)
            if not obj:
                continue
            try:
                coro = obj.write_property(
                    obj_prop_ref.propertyIdentifier,
                    atomic_value,
                    index=obj_prop_ref.propertyArrayIndex,
                    priority=self.priorityForWriting,
                )
                asyncio.ensure_future(coro)
            except Exception:
                pass

    def interpret_schedule(self):
        """Override to handle eval() returning None (bacpypes3 bug).

        bacpypes3's interpret_schedule unpacks eval() without checking for None,
        which crashes when the date is outside effectivePeriod.
        """
        if self.reliability != Reliability.noFaultDetected:
            return
        if self._app and self._app.device_object:
            current_date = self._app.device_object.localDate
            current_time = self._app.device_object.localTime
        else:
            current_date = Date.now()
            current_time = BacnetTime.now()
        result = self.eval(current_date, current_time)
        if result is None:
            return
        current_value, next_transition = result
        self.presentValue = current_value
        try:
            from bacpypes3.local.schedule import datetime_to_time
            transition_time = datetime_to_time(current_date, next_transition)
            self._interpret_schedule_handle = asyncio.get_running_loop().call_at(
                transition_time, self.interpret_schedule
            )
        except RuntimeError:
            self._interpret_schedule_handle = None

    async def write_property(self, attr, value, index=None, priority=None):
        """Override to fire _property_monitors after BACnet writes.

        Fixes two bacpypes3 issues:
        1. _Object.write_property bypasses local Object.__setattr__, so
           _property_monitors are never fired for BACnet WriteProperty requests.
        2. YABE may write invalid effectivePeriod (endDate < startDate),
           which causes eval() to return None.
        """
        if isinstance(attr, int):
            attr = self._property_identifier_class(attr).attr

        if self._lock_references and attr in self._REFERENCE_PROPS:
            from bacpypes3.errors import PropertyError
            raise PropertyError(errorCode="writeAccessDenied")

        is_schedule_prop = attr in self._SCHEDULE_PROPS or attr == 'effectivePeriod'

        await super().write_property(attr, value, index, priority)

        # Fix invalid effectivePeriod (e.g. YABE writing endDate < startDate)
        if attr == 'effectivePeriod':
            ep = self.effectivePeriod
            if ep and tuple(ep.startDate[:3]) > tuple(ep.endDate[:3]):
                self.effectivePeriod = DateRange(
                    startDate=Date((0, 1, 1, 255)),
                    endDate=Date((254, 12, 31, 255)),
                )

        # Fire monitors for schedule properties (bypassed by _Object.write_property)
        if is_schedule_prop:
            new_value = getattr(self, attr, None)
            for fn in self._property_monitors.get(attr, []):
                try:
                    fn(None, new_value)
                except Exception:
                    pass


# --- TRENDING ---

class TrendingEntry:
    """Descriptor for a recorded BACnet trending object (local or remote)."""

    def __init__(self, key: str, task_key: str, device_id: str,
                 obj_type: str, instance: int, name: str, address: str = None,
                 description: str = '', labels=None):
        self.key = key           # composite key (unique)
        self.task_key = task_key
        self.device_id = device_id   # 'local' or str(numeric_device_id)
        self.obj_type = obj_type     # e.g. 'analog-value'
        self.instance = int(instance)
        self.name = name         # display name
        self.address = address   # IP address for remote objects, None for local
        self.description = description or ''
        self.labels = labels or []   # state text labels for binary/MSV
        self.cov_active = False      # True when a COV subscription is active (remote only)

    @property
    def is_local(self) -> bool:
        return self.device_id == 'local'

    @staticmethod
    def make_key(task_key: str, device_id: str, obj_type: str, instance) -> str:
        return f"{task_key}:{device_id}:{obj_type}:{instance}"


class BACnetTrendingStore:
    """Thread-safe in-memory time-series store for BACnet object trending.

    Keyed by composite string: "{task}:{device}:{type}:{instance}".
    Supports both local objects (property monitor + poll) and remote (poll only).
    Old points (> max_age) are evicted inline on each push — no memory leak.
    """
    POLL_INTERVAL       = 15        # seconds between automatic polls
    DEFAULT_MAX_AGE     = 7 * 86400 # 7 days default retention
    ANALOG_DEDUP_DIGITS = 1         # decimal places for float deduplication (None = disabled)

    def __init__(self, max_age: float = None):
        self._max_age = max_age if max_age is not None else self.DEFAULT_MAX_AGE
        self._data: dict        = {}  # key → deque[(timestamp_float, value)]
        self._entries: dict     = {}  # key → TrendingEntry
        self._poll_errors: dict = {}  # key → bool
        self._dirty_since: dict = {}  # key → timestamp of first push since last save
        self._lock = threading.Lock()

    def record(self, entry: TrendingEntry):
        with self._lock:
            self._entries[entry.key] = entry
            if entry.key not in self._data:
                self._data[entry.key] = deque()

    def unrecord(self, key: str):
        """Remove from recording set. History is kept until explicitly purged."""
        with self._lock:
            self._entries.pop(key, None)
            self._poll_errors.pop(key, None)
            self._dirty_since.pop(key, None)

    def purge(self, key: str):
        """Clear all stored data points for this key."""
        with self._lock:
            if key in self._data:
                self._data[key].clear()
            self._poll_errors.pop(key, None)
            self._dirty_since.pop(key, None)

    def set_poll_error(self, key: str, has_error: bool):
        with self._lock:
            self._poll_errors[key] = has_error

    def has_poll_error(self, key: str) -> bool:
        with self._lock:
            return self._poll_errors.get(key, False)

    def push(self, key: str, value):
        now = time.time()
        cutoff = now - self._max_age
        with self._lock:
            if key not in self._data:
                self._data[key] = deque()
            dq = self._data[key]
            if dq:
                last_val = dq[-1][1]
                if isinstance(value, float) and isinstance(last_val, float):
                    digits = self.ANALOG_DEDUP_DIGITS
                    if digits is not None:
                        if round(value, digits) == round(last_val, digits):
                            return
                    elif value == last_val:
                        return
                elif value == last_val:
                    return
            dq.append((now, value))
            while dq and dq[0][0] < cutoff:
                dq.popleft()
            if key not in self._dirty_since:
                self._dirty_since[key] = now

    def get_history(self, key: str, since: float = 0.0) -> list:
        with self._lock:
            dq = self._data.get(key, deque())
            if since:
                return [(t, v) for t, v in dq if t >= since]
            return list(dq)

    def last_point(self, key: str):
        """Return (timestamp, value) of the most recent data point, or None."""
        with self._lock:
            dq = self._data.get(key)
            return dq[-1] if dq else None

    def point_count(self, key: str) -> int:
        with self._lock:
            return len(self._data.get(key, []))

    def is_recorded(self, key: str) -> bool:
        return key in self._entries

    def get_entries(self) -> dict:
        with self._lock:
            return dict(self._entries)  # shallow copy for safe iteration

    def total_points(self) -> int:
        with self._lock:
            return sum(len(dq) for dq in self._data.values())

    def mark_saved(self, key: str):
        """Clear the dirty flag after a successful pickle save."""
        with self._lock:
            self._dirty_since.pop(key, None)

    def get_due_for_save(self, min_age: float = 900.0) -> list:
        """Return list of (key, entry) pairs where data is dirty and min_age s have elapsed."""
        now = time.time()
        result = []
        with self._lock:
            for key, ts in list(self._dirty_since.items()):
                if now - ts >= min_age:
                    entry = self._entries.get(key)
                    if entry:
                        result.append((key, entry))
        return result

    def load_data(self, key: str, points: list):
        """Load stored data points into the deque, respecting max_age."""
        if not points:
            return
        cutoff = time.time() - self._max_age
        with self._lock:
            if key not in self._data:
                self._data[key] = deque()
            dq = self._data[key]
            for pt in points:
                try:
                    t, v = pt[0], pt[1]
                    if t >= cutoff:
                        dq.append((t, v))
                except (IndexError, TypeError):
                    pass
            sorted_pts = sorted(dq, key=lambda x: x[0])
            self._data[key] = deque(sorted_pts)



# --- ENGINE ---

class BACnetServerEngine(Application):
    """
    Application BACnet pure.
    N'initialise PAS le réseau. C'est le rôle du Service qui fera le bind().
    """

    def __init__(self, device_info: DeviceObject, interface_ref):
        # Init Application standard avec le device
        super().__init__(device=device_info)

        # FIX V13.0: Stockage explicite pour éviter AttributeError 'NoneType'
        self.localDevice = device_info

        self.interface_ref = weakref.ref(interface_ref)
        self.log = interface_ref.logger
        self._objects_map = {}
        self._write_acl: Optional[list] = None

        self.log.info(f"[BACnet] Engine initialized (Logic Only).")

    def set_write_acl(self, networks):
        """Set the list of IP networks allowed to write. None = no restriction."""
        if networks is None:
            self._write_acl = None
            return
        import ipaddress
        self._write_acl = [ipaddress.ip_network(n, strict=False) for n in networks]

    async def do_WritePropertyRequest(self, apdu):
        """Override to enforce write ACL based on source IP."""
        if self._write_acl is not None:
            import ipaddress
            try:
                src_ip = ipaddress.ip_address(apdu.pduSource.addrTuple[0])
                if not any(src_ip in net for net in self._write_acl):
                    self.log.warning(f"[BACnet] Write rejected from {src_ip} (not in ACL)")
                    from bacpypes3.errors import ExecutionError
                    raise ExecutionError(errorClass="security", errorCode="writeAccessDenied")
            except (AttributeError, TypeError, IndexError):
                pass
        await super().do_WritePropertyRequest(apdu)

    def add_app_object(self, obj):
        self.add_object(obj)
        self._objects_map[obj.objectName] = obj

    def get_app_object(self, name: str):
        return self._objects_map.get(name)

    def set_system_status(self, status: DeviceStatus):
        self.localDevice.systemStatus = status

# --- SERVICE THREAD ---

class BACnetService(threading.Thread):
    def __init__(self,
                 interface,
                 device_id: int,
                 ip_address: str,
                 port: int,
                 vendor_id: int,
                 vendor_name: str,
                 model_name: str,
                 config_objects: List[dict],
                 app_watchdog_timeout: int,
                 fdr_config: Optional[dict] = None,
                 bind_to_all_interfaces: bool = False):

        super().__init__(name="BACnetServiceThread", daemon=True)
        self.interface = interface
        self.logger = interface.logger

        self.device_id = device_id
        self.ip_address = ip_address
        self.port = port
        self.vendor_id = vendor_id
        self.vendor_name = vendor_name
        self.model_name = model_name
        self.config_objects = config_objects
        self.app_watchdog_timeout = app_watchdog_timeout
        self.fdr_config = fdr_config
        self.bind_to_all_interfaces = bind_to_all_interfaces

        self.loop = None
        self.engine: Optional[BACnetServerEngine] = None
        self.transport = None
        self.asap = None  # ApplicationServiceAccessPoint
        self.nsap = None  # NetworkServiceAccessPoint
        self.nse = None   # NetworkServiceElement

        self.ready_event = threading.Event()
        self.stop_event = asyncio.Event()
        self._stopping = threading.Event()  # cross-thread shutdown signal (set before call_soon_threadsafe)
        self.fatal_error: Optional[Exception] = None
        self.last_alive_timestamp = 0.0
        self.last_app_feed_timestamp = time.time()
        self.calculated_broadcast: Optional[str] = None
        self._schedule_persistence_dirty = False
        self._trending = BACnetTrendingStore()
        self._cov_tasks: dict = {}          # key → asyncio.Task (one per remote object with COV)
        self._cov_no_support: set = set()   # addresses that returned serviceNotSupported
        self._cov_pid_counter: int = 1      # monotone counter for subscriber process identifiers

    def run(self):
        self.last_alive_timestamp = time.time()
        try:
            self.loop = asyncio.new_event_loop()
            asyncio.set_event_loop(self.loop)
            self.loop.run_until_complete(self._main_task())
        except Exception as e:
            self.fatal_error = e
            self.logger.critical(f"[BACnet] Fatal Thread Crash: {e}", exc_info=True)
            try: self.interface.on_server_error(e)
            except: pass
        finally:
            if self.loop: self.loop.close()
            self.logger.info("[BACnet] Thread stopped.")

    async def _main_task(self):
        tasks = []
        try:
            self.logger.info(f"[BACnet] Initializing V{SPEC_VERSION} (Manual Stack)...")

            # 1. Device Info (using bacpypes3.local.device.DeviceObject which
            #    provides dynamic objectList, protocolServicesSupported,
            #    deviceAddressBinding, localDate/localTime, and sensible defaults)
            local_device = DeviceObject(
                objectName=self.model_name,
                objectIdentifier=ObjectIdentifier(f"device,{self.device_id}"),
                maxApduLengthAccepted=MAX_APDU_LENGTH,
                segmentationSupported="segmentedBoth",
                vendorIdentifier=self.vendor_id,
                vendorName=self.vendor_name,
                modelName=self.model_name,
                firmwareRevision=SPEC_VERSION,
                applicationSoftwareVersion=SPEC_VERSION,
            )

            # 2. Binding Address
            if self.bind_to_all_interfaces:
                bind_str = f"{DEFAULT_BIND_ALL_ADDRESS}:{self.port}"
                self.logger.info(f"[BACnet] Hybrid Mode: Explicit bind to {bind_str}")
            else:
                bind_str = f"{self.ip_address}:{self.port}"

            # Broadcast Calculation
            if "/" in self.ip_address:
                try:
                    iface = ipaddress.IPv4Interface(self.ip_address)
                    self.calculated_broadcast = str(iface.network.broadcast_address)
                    self.logger.info(f"[BACnet] Broadcast Target: {self.calculated_broadcast}")
                except: pass

            # 3. APPLICATION INITIALIZATION (BACpypes3 moderne)
            # Architecture: Application -> ASAP -> NSAP -> LinkLayer
            try:
                # A. Créer l'Application (Engine) et enregistrer le device
                self.engine = BACnetServerEngine(local_device, self.interface)
                if self.interface._write_acl:
                    self.engine.set_write_acl(self.interface._write_acl)
                self.engine.add_object(local_device)

                # B. Créer ApplicationServiceAccessPoint (ASAP)
                # ASAP nécessite un device object et un cache
                device_info_cache = DeviceInfoCache()
                self.asap = ApplicationServiceAccessPoint(local_device, device_info_cache)

                # C. Créer NetworkServiceAccessPoint (NSAP)
                self.nsap = NetworkServiceAccessPoint()

                # D. Créer NetworkServiceElement (NSE) et le lier au NSAP
                self.nse = NetworkServiceElement()
                bind(self.nse, self.nsap)  # ASE -> SAP binding

                # E. Lier Application -> ASAP -> NSAP
                bind(self.engine, self.asap, self.nsap)

                # F. Créer la couche de liaison (Link Layer)
                local_address = IPv4Address(bind_str)

                # Choisir le bon Link Layer selon la config
                if self.fdr_config:
                    # Mode Foreign Device - utiliser ForeignLinkLayer
                    # ForeignLinkLayer.__init__ ne prend que local_address.
                    # L'enregistrement FDR se fait via .register() après le montage de la stack.
                    self._fdr_bbmd_address = IPv4Address(f"{self.fdr_config['address']}:{self.port}")
                    self._fdr_ttl = self.fdr_config.get('ttl', 300)
                    self.transport = ForeignLinkLayer(local_address)
                    self.logger.info(f"[BACnet] Using ForeignLinkLayer with BBMD {self.fdr_config['address']}")
                else:
                    # Mode Normal - utiliser NormalLinkLayer
                    self.transport = NormalLinkLayer(local_address)
                    self.logger.info(f"[BACnet] Using NormalLinkLayer at {bind_str}")

                # G. Lier le Link Layer au NSAP (via la méthode spéciale nsap.bind())
                # IMPORTANT: C'est nsap.bind(), pas comm.bind() !
                self.nsap.bind(self.transport, address=local_address)

                self.logger.info(f"[BACnet] Stack bound successfully")

            except Exception as e:
                raise RuntimeError(f"Transport Init Failed: {e}")

            # 4. Objects & Persistence
            persistence_data = self.interface.on_persistence_load()
            # Load persisted instance ID mapping before instantiation
            if persistence_data and '_instance_map' in persistence_data:
                self._load_instance_map(persistence_data['_instance_map'])
            self._instantiate_objects()
            if persistence_data:
                await self._apply_persistence(persistence_data)

            # Save persistence immediately to capture new/updated instance map
            self.interface._queue_schedule_persistence()

            # FDR: enregistrement auprès du BBMD (après montage complet de la stack)
            if self.fdr_config and hasattr(self, '_fdr_bbmd_address'):
                self.transport.register(self._fdr_bbmd_address, self._fdr_ttl)
                self.logger.info(f"[BACnet] FDR registered with BBMD {self._fdr_bbmd_address} (TTL={self._fdr_ttl}s)")

            self.ready_event.set()
            self.logger.info(f"[BACnet] Server Ready.")

            # I-Am
            await self._send_manual_iam()
            # Plus besoin de _fdr_maintenance_task()

            tasks = await self._run_supervised()

        except Exception as e:
            self.fatal_error = e
            self.ready_event.set()
            raise e
        finally:
            await self._stop_all_cov_tasks_async()
            for task in tasks:
                if not task.done(): task.cancel()
            if tasks: await asyncio.gather(*tasks, return_exceptions=True)
            # Cancel any remaining pending tasks (e.g. from _run_in_loop cross-thread calls)
            for task in asyncio.all_tasks(asyncio.get_event_loop()):
                if task is not asyncio.current_task() and not task.done():
                    task.cancel()
            await asyncio.sleep(0.1)
            # Fermer dans l'ordre inverse de la création
            if self.transport:
                try: self.transport.close()
                except: pass
            if self.nsap:
                try: self.nsap.close()
                except: pass
            if self.asap:
                try: self.asap.close()
                except: pass
            if self.engine:
                try: self.engine.close()
                except: pass

    async def _send_manual_iam(self):
        if not self.engine: return

        dev = self.engine.localDevice
        if not dev: return

        iam = IAmRequest(
            iAmDeviceIdentifier=dev.objectIdentifier,
            maxAPDULengthAccepted=dev.maxApduLengthAccepted,
            segmentationSupported=dev.segmentationSupported,
            vendorID=dev.vendorIdentifier
        )

        if self.calculated_broadcast:
            target = IPv4Address(f"{self.calculated_broadcast}:{self.port}")
            iam.pduDestination = target
        else:
            iam.pduDestination = IPv4Address(f"{DEFAULT_BROADCAST_ADDRESS}:{self.port}")

        await self.engine.request(iam)

    def _create_base_kwargs(self, name: str, desc: str) -> dict:
        """Create common kwargs for all BACnet objects.
        Note: statusFlags is now a dynamic property from local.object.Object.
        """
        return {
            'objectName': name,
            'description': desc,
            'eventState': EventState.normal,
            'reliability': Reliability.noFaultDetected,
            'outOfService': False
        }

    def _create_analog_value_object(self, cfg: dict, mapper: UnitMapper):
        """Create an Analog Value object from configuration."""
        name = cfg['name']
        writable = cfg['writable']
        unit_enum = mapper.get_bacnet_unit(cfg.get('units'))
        initial = cfg.get('initial_value') or 0.0

        cls = WritableAnalogValue if writable else CustomAnalogValue
        kwargs = self._create_base_kwargs(name, cfg.get('description', ''))
        kwargs.update({
            'objectIdentifier': ObjectIdentifier(f"analogValue,{self._get_instance_id('AV', name, cfg.get('start_instance_id', 0))}"),
            'units': unit_enum,
            'presentValue': initial,
        })

        obj = cls(**kwargs)
        if writable:
            obj.set_write_callback(self._on_external_write_callback)
        return obj

    def _create_binary_value_object(self, cfg: dict):
        """Create a Binary Value object from configuration."""
        name = cfg['name']
        writable = cfg['writable']
        initial = cfg.get('initial_value') or 0

        cls = WritableBinaryValue if writable else CustomBinaryValue
        kwargs = self._create_base_kwargs(name, cfg.get('description', ''))
        kwargs.update({
            'objectIdentifier': ObjectIdentifier(f"binaryValue,{self._get_instance_id('BV', name, cfg.get('start_instance_id', 0))}"),
            'activeText': cfg.get('active_text', 'On'),
            'inactiveText': cfg.get('inactive_text', 'Off'),
            'presentValue': initial,
        })

        obj = cls(**kwargs)
        if writable:
            obj.set_write_callback(self._on_external_write_callback)
        return obj

    def _create_multistate_value_object(self, cfg: dict):
        """Create a Multi-State Value object from configuration."""
        name = cfg['name']
        writable = cfg['writable']
        states = cfg.get('state_texts', ['State1'])
        initial = cfg.get('initial_value') or 1

        cls = WritableMultiStateValue if writable else CustomMultiStateValue
        kwargs = self._create_base_kwargs(name, cfg.get('description', ''))
        kwargs.update({
            'objectIdentifier': ObjectIdentifier(f"multiStateValue,{self._get_instance_id('MSV', name, cfg.get('start_instance_id', 0))}"),
            'numberOfStates': len(states),
            'stateText': [CharacterString(s) for s in states],
            'presentValue': initial,
        })

        obj = cls(**kwargs)
        if writable:
            obj.set_write_callback(self._on_external_write_callback)
        return obj

    def _to_bacnet_atomic(self, value):
        """Convert a Python value to the appropriate BACnet atomic type."""
        if isinstance(value, bool):
            return BinaryPV(int(value))
        elif isinstance(value, float):
            return Real(value)
        elif isinstance(value, int):
            return BacnetUnsigned(value)
        return Real(float(value))

    def _create_schedule_object(self, cfg: dict):
        """Create a Schedule object from configuration with persistence support."""
        name = cfg['name']
        schedule_default = self._to_bacnet_atomic(cfg.get('schedule_default') or 0.0)
        priority = cfg.get('priority_for_writing') or 15

        # Initialize weeklySchedule with 7 empty days so the property
        # is visible to BACnet clients (it's optional in the spec but
        # clients like YABE expect it to be present)
        empty_weekly = [DailySchedule(daySchedule=[]) for _ in range(7)]

        kwargs = {
            'objectName': name,
            'objectIdentifier': ObjectIdentifier(f"schedule,{self._get_instance_id('SCHED', name, cfg.get('start_instance_id', 0))}"),
            'description': cfg.get('description', ''),
            'presentValue': schedule_default,
            'effectivePeriod': DateRange(
                startDate=Date((0, 1, 1, 255)),
                endDate=Date((254, 12, 31, 255)),
            ),
            'weeklySchedule': empty_weekly,
            'exceptionSchedule': [],
            'scheduleDefault': schedule_default,
            'priorityForWriting': priority,
            'listOfObjectPropertyReferences': [],
            'reliability': Reliability.noFaultDetected,
            'outOfService': False,
            'eventState': EventState.normal,
        }

        obj = PersistentScheduleObject(**kwargs)
        obj.set_persistence_callback(self._on_schedule_persistence_dirty)
        obj._lock_references = cfg.get('lock_references', False)
        return obj

    def _on_schedule_persistence_dirty(self):
        """Called from asyncio thread when a schedule property changes."""
        self._schedule_persistence_dirty = True

    def _resolve_schedule_references(self):
        """Resolve symbolic target names in Schedule objects to DeviceObjectPropertyReferences."""
        for cfg in self.config_objects:
            if cfg['type'] != 'SCHED':
                continue
            sched_name = cfg['name']
            target_names = cfg.get('target_names') or []
            if not target_names:
                continue

            sched_obj = self.engine.get_app_object(sched_name)
            if not sched_obj:
                continue

            refs = []
            for target_name in target_names:
                target_obj = self.engine.get_app_object(target_name)
                if not target_obj:
                    self.logger.warning(f"[BACnet] Schedule '{sched_name}': target '{target_name}' not found")
                    continue
                refs.append(DeviceObjectPropertyReference(
                    objectIdentifier=target_obj.objectIdentifier,
                    propertyIdentifier=PropertyIdentifier.presentValue,
                ))

            if refs:
                sched_obj.listOfObjectPropertyReferences = refs
                self.logger.info(f"[BACnet] Schedule '{sched_name}': {len(refs)} target(s) linked")

    def _instantiate_objects(self):
        """Instantiate all BACnet objects from configuration using factory pattern."""
        mapper = self.interface.unit_mapper

        # Factory pattern: map object type to creation method
        object_creators = {
            'AV': lambda cfg: self._create_analog_value_object(cfg, mapper),
            'BV': self._create_binary_value_object,
            'MSV': self._create_multistate_value_object,
            'SCHED': self._create_schedule_object,
        }

        for cfg in self.config_objects:
            obj_type = cfg['type']
            creator = object_creators.get(obj_type)

            if not creator:
                self.logger.warning(f"[BACnet] Unknown object type '{obj_type}', skipping")
                continue

            try:
                new_obj = creator(cfg)
                self.engine.add_app_object(new_obj)
            except Exception as e:
                self.logger.error(
                    f"[BACnet] Failed to create object '{cfg.get('name', 'unknown')}' "
                    f"of type '{obj_type}': {e}",
                    exc_info=True
                )

        # Resolve schedule target references now that all objects exist
        self._resolve_schedule_references()

    _INSTANCE_MAP_EXPIRY_DAYS = 30

    def _load_instance_map(self, raw_map: dict):
        """Load persisted instance map, handling both old and new formats.

        Old format: {name: int}
        New format: {name: {id: int, type: str, ts: float}}

        Entries older than _INSTANCE_MAP_EXPIRY_DAYS are purged.
        All non-expired entries (even for objects not declared this run)
        have their IDs reserved to prevent collisions.
        """
        self._instance_map_full = {}
        self._id_used = {}
        now = time.time()
        expiry = self._INSTANCE_MAP_EXPIRY_DAYS * 86400
        loaded = 0
        expired = 0
        for name, entry in raw_map.items():
            # Backward compat: old format stored plain int (no type info)
            if isinstance(entry, int):
                entry = {'id': entry, 'ts': now}
            iid = entry.get('id')
            ts = entry.get('ts', now)
            obj_type = entry.get('type')  # None for old format
            if iid is None:
                continue
            if (now - ts) > expiry:
                expired += 1
                continue
            self._instance_map_full[name] = {'id': iid, 'type': obj_type, 'ts': ts}
            # Reserve this ID to prevent collisions
            _type_compat = {
                'analog-value': 'AV', 'binary-value': 'BV',
                'multi-state-value': 'MSV', 'schedule': 'SCHED',
            }
            resolved = _type_compat.get(obj_type, obj_type)
            if resolved:
                self._id_used.setdefault(resolved, set()).add(iid)
            else:
                for t in ('AV', 'BV', 'MSV', 'SCHED'):
                    self._id_used.setdefault(t, set()).add(iid)
            loaded += 1
        self.logger.info(f"[BACnet] Instance map: {loaded} loaded, {expired} expired")

    def _get_instance_id(self, type_prefix, name, start_from=0):
        """Get instance ID for an object, reusing persisted ID if available."""
        if not hasattr(self, '_id_used'): self._id_used = {}
        if not hasattr(self, '_instance_map_full'): self._instance_map_full = {}
        used = self._id_used.setdefault(type_prefix, set())
        # Compat: map old persisted type strings to type prefix
        _type_compat = {
            'analog-value': 'AV', 'binary-value': 'BV',
            'multi-state-value': 'MSV', 'schedule': 'SCHED',
        }
        # Reuse persisted ID if available for this name
        entry = self._instance_map_full.get(name)
        if entry:
            persisted_type = entry.get('type')
            resolved_type = _type_compat.get(persisted_type, persisted_type)
            if resolved_type is None or resolved_type == type_prefix:
                iid = entry['id']
                used.add(iid)
                return iid
        # Find first free from start_from
        idx = start_from
        while idx in used:
            idx += 1
        used.add(idx)
        return idx

    async def _get_persistence_snapshot_async(self) -> dict:
        """Must be called from the BACnet event loop thread.
        Returns plain Python types (float/int/str) suitable for pickle/json."""
        data = {}
        for name, obj in self.engine._objects_map.items():
            if isinstance(obj, (WritableAnalogValue, WritableBinaryValue, WritableMultiStateValue)):
                if hasattr(obj, 'priorityArray') and obj.priorityArray:
                    obj_priorities = {}
                    for i, p_val in enumerate(obj.priorityArray):
                        if p_val.null is not None: continue
                        val = None
                        if isinstance(obj, WritableAnalogValue) and p_val.real is not None: val = float(p_val.real)
                        elif isinstance(obj, WritableBinaryValue) and p_val.binaryPV is not None: val = int(p_val.binaryPV)
                        elif isinstance(obj, WritableMultiStateValue) and p_val.unsigned is not None: val = int(p_val.unsigned)
                        if val is not None: obj_priorities[str(i+1)] = val
                    if obj_priorities: data[str(name)] = obj_priorities

            elif isinstance(obj, PersistentScheduleObject):
                sched_data = {'_type': 'SCHED'}
                try:
                    sched_data['weeklySchedule'] = ScheduleSerializer.serialize_weekly_schedule(obj.weeklySchedule)
                    sched_data['exceptionSchedule'] = ScheduleSerializer.serialize_exception_schedule(obj.exceptionSchedule)
                    sched_data['scheduleDefault'] = ScheduleSerializer._serialize_anyatomic(obj.scheduleDefault)
                except Exception as e:
                    self.logger.error(f"[BACnet] Schedule snapshot error for '{name}': {e}")
                data[str(name)] = sched_data

        # Save name → {id, type, ts} mapping for stable IDs across restarts
        # All values must be plain Python types (str/int/float) for pickle
        now = time.time()
        instance_map = {}
        # Carry over non-expired entries for objects not declared this run
        if hasattr(self, '_instance_map_full'):
            active_names = set(str(n) for n in self.engine._objects_map.keys())
            for entry_name, entry in self._instance_map_full.items():
                if str(entry_name) not in active_names:
                    instance_map[str(entry_name)] = entry
        # Type mapping for objectIdentifier str to type prefix
        # str(objectIdentifier[0]) uses kebab-case (e.g. "analog-value")
        type_map = {
            'analog-value': 'AV', 'binary-value': 'BV',
            'multi-state-value': 'MSV', 'schedule': 'SCHED',
        }
        # Active objects: update ts to now
        for name, obj in self.engine._objects_map.items():
            obj_type_str = str(obj.objectIdentifier[0])
            instance_map[str(name)] = {
                'id': int(obj.objectIdentifier[1]),
                'type': type_map.get(obj_type_str, obj_type_str),
                'ts': now,
            }
        data['_instance_map'] = instance_map

        return data

    def get_persistence_snapshot(self) -> dict:
        """Thread-safe persistence snapshot. Can be called from any thread."""
        return self._run_in_loop(self._get_persistence_snapshot_async()) or {}

    def _cast_to_present_value_type(self, obj, value):
        """Cast a plain Python value to the BACnet type expected by obj.presentValue."""
        pv_type = obj._elements.get("presentValue")
        if pv_type is not None and not isinstance(value, pv_type):
            return pv_type(pv_type.cast(value))
        return value

    async def _apply_persistence(self, data: dict):
        self.logger.info(f"[BACnet] Applying persistence: {len(data)} objects")
        for name, obj_data in data.items():
            if name.startswith('_'):
                continue
            obj = self.engine.get_app_object(name)
            if not obj:
                self.logger.warning(f"[BACnet] Persistence: object '{name}' not found, skipping")
                continue

            # Schedule objects have a '_type': 'SCHED' marker
            if isinstance(obj_data, dict) and obj_data.get('_type') == 'SCHED':
                try:
                    self._apply_schedule_persistence(obj, name, obj_data)
                except Exception as e:
                    self.logger.error(f"[BACnet] Persistence: failed to restore schedule '{name}': {e}", exc_info=True)
                continue

            # AV/BV/MSV: obj_data is a dict of {priority_str: value}
            for priority_str, value in obj_data.items():
                try:
                    priority = int(priority_str)
                    if 1 <= priority <= 16:
                        typed_value = self._cast_to_present_value_type(obj, value)
                        await obj.write_property('presentValue', typed_value, priority=priority)
                        self.logger.info(f"[BACnet] Persistence: {name} = {value} @priority {priority}")
                except Exception as e:
                    self.logger.error(f"[BACnet] Persistence: failed to restore {name}@{priority_str}: {e}", exc_info=True)

    def _apply_schedule_persistence(self, obj, name, sched_data):
        """Restore schedule properties from persistence data (runs in asyncio thread)."""
        ws = sched_data.get('weeklySchedule')
        if ws is not None:
            days = ScheduleSerializer.deserialize_weekly_schedule(ws)
            if days:
                obj.weeklySchedule = days
                self.logger.info(f"[BACnet] Persistence: {name} weeklySchedule restored")

        es = sched_data.get('exceptionSchedule')
        if es is not None:
            events = ScheduleSerializer.deserialize_exception_schedule(es)
            if events:
                obj.exceptionSchedule = events
                self.logger.info(f"[BACnet] Persistence: {name} exceptionSchedule restored ({len(events)} events)")

        sd = sched_data.get('scheduleDefault')
        if sd is not None:
            val = ScheduleSerializer._deserialize_anyatomic(sd)
            if val is not None:
                obj.scheduleDefault = val
                self.logger.info(f"[BACnet] Persistence: {name} scheduleDefault restored")

    def _on_external_write_callback(self, name, value, priority):
        """Buffer external writes for processing outside the event loop.
        Called from within the asyncio event loop — must NOT block."""
        self.interface._queue_external_write(name, value, priority)

    async def _cpu_yield_task(self):
        """Background task that updates alive timestamp and yields CPU."""
        while not self.stop_event.is_set():
            self.last_alive_timestamp = time.time()
            try:
                await asyncio.sleep(CPU_YIELD_INTERVAL)
            except asyncio.CancelledError: break

    async def _buffer_consumer_task(self):
        """Background task that processes pending object updates from the interface.
        Also handles schedule persistence dirty flag."""
        while not self.stop_event.is_set():
            try:
                updates = self.interface.pop_pending_updates()
                if updates:
                    for name, data in updates.items():
                        obj = self.engine.get_app_object(name)
                        if not obj: continue
                        value, is_error, is_manual, is_alarm = data
                        # statusFlags is now a dynamic property computed from
                        # eventState, reliability, outOfService — just set the sources
                        obj.reliability = RELIABILITY_NO_SENSOR if is_error else RELIABILITY_NO_FAULT
                        obj.eventState = EventState.offnormal if is_alarm else EventState.normal
                        if value is not None:
                            if isinstance(obj, (WritableAnalogValue, WritableBinaryValue, WritableMultiStateValue)):
                                obj.relinquishDefault = value
                            else:
                                obj.presentValue = value

                # Check if a schedule property changed and trigger persistence
                if self._schedule_persistence_dirty:
                    self._schedule_persistence_dirty = False
                    self.interface._queue_schedule_persistence()

                await asyncio.sleep(BUFFER_CONSUMER_INTERVAL)
            except asyncio.CancelledError: break
            except Exception as e:
                self.logger.error(f"[BACnet] Buffer Error: {e}")
                await asyncio.sleep(BUFFER_ERROR_RETRY_INTERVAL)

    async def _watchdog_monitor_task(self):
        """Background task that monitors application health via watchdog feed.

        Implements a 'dead man's switch' pattern - if the application stops
        feeding the watchdog, the device systemStatus is set to nonOperational.
        """
        if self.app_watchdog_timeout <= 0: return
        while not self.stop_event.is_set():
            try:
                elapsed = time.time() - self.last_app_feed_timestamp
                if elapsed > self.app_watchdog_timeout:
                    if self.engine.localDevice.systemStatus != DeviceStatus.nonOperational:
                        self.logger.warning(f"[BACnet] DEAD MAN SWITCH! App frozen {elapsed:.1f}s.")
                        self.engine.localDevice.systemStatus = DeviceStatus.nonOperational
                else:
                    if self.engine.localDevice.systemStatus == DeviceStatus.nonOperational:
                        self.logger.info("[BACnet] App alive. System Operational.")
                        self.engine.localDevice.systemStatus = DeviceStatus.operational
                await asyncio.sleep(WATCHDOG_CHECK_INTERVAL)
            except asyncio.CancelledError: break

    async def _loop_monitor_task(self):
        """Background task that monitors asyncio event loop responsiveness.

        Sleeps for LOOP_MONITOR_INTERVAL and measures the actual elapsed time.
        If the event loop is blocked by synchronous code, the actual elapsed time
        will be significantly larger than the requested sleep, which is logged as a warning.
        """
        while not self.stop_event.is_set():
            try:
                t0 = time.monotonic()
                await asyncio.sleep(LOOP_MONITOR_INTERVAL)
                elapsed = time.monotonic() - t0
                jitter = elapsed - LOOP_MONITOR_INTERVAL
                if jitter > LOOP_MONITOR_WARN_THRESHOLD:
                    self.logger.warning(
                        f"[BACnet] LOOP STALL detected: sleep({LOOP_MONITOR_INTERVAL}s) "
                        f"took {elapsed:.3f}s (jitter +{jitter:.3f}s)"
                    )
            except asyncio.CancelledError:
                break

    async def _iam_maintenance_task(self):
        """Background task that periodically broadcasts I-Am messages.

        Sends I-Am announcements at regular intervals to ensure the device
        remains visible on the BACnet network.
        """
        while not self.stop_event.is_set():
            try:
                await asyncio.sleep(IAM_BROADCAST_INTERVAL)
                await self._send_manual_iam()
            except asyncio.CancelledError: break
            except Exception as e:
                self.logger.warning(f"[BACnet] I-Am broadcast error: {e}")

    async def _run_supervised(self) -> list:
        """Run background tasks with supervision: log errors and restart crashed tasks.

        Returns the task list for cleanup in the finally block.
        """
        task_factories = {
            'cpu_yield': self._cpu_yield_task,
            'buffer_consumer': self._buffer_consumer_task,
            'watchdog_monitor': self._watchdog_monitor_task,
            'iam_maintenance': self._iam_maintenance_task,
            'loop_monitor': self._loop_monitor_task,
            'trending_poll': self._trending_poll_task,
        }

        running = {}
        for name, factory in task_factories.items():
            running[name] = asyncio.create_task(factory(), name=name)

        stop_task = asyncio.create_task(self.stop_event.wait(), name='stop')

        while not self.stop_event.is_set():
            all_tasks = list(running.values()) + [stop_task]
            done, _ = await asyncio.wait(all_tasks, return_when=asyncio.FIRST_COMPLETED)

            if stop_task in done:
                break

            for task in done:
                task_name = task.get_name()
                exc = task.exception() if not task.cancelled() else None
                if exc:
                    self.logger.error(f"[BACnet] Task '{task_name}' crashed: {exc}", exc_info=exc)
                else:
                    self.logger.warning(f"[BACnet] Task '{task_name}' ended unexpectedly")

                # Restart the crashed task
                factory = task_factories.get(task_name)
                if factory:
                    self.logger.info(f"[BACnet] Restarting task '{task_name}'")
                    await asyncio.sleep(0.5)
                    running[task_name] = asyncio.create_task(factory(), name=task_name)

        stop_task.cancel()
        return list(running.values()) + [stop_task]

    def stop(self):
        self._stopping.set()
        if self.loop: self.loop.call_soon_threadsafe(self.stop_event.set)

    def feed_app_watchdog(self):
        self.last_app_feed_timestamp = time.time()

    def _run_in_loop(self, coro, timeout=CROSS_THREAD_READ_TIMEOUT):
        """Execute a coroutine in the BACnet event loop from another thread.
        Returns the result or None on timeout/error/shutdown.

        Shutdown safety:
        - _stopping (threading.Event) is set first in stop(), before call_soon_threadsafe.
          This allows cross-thread early exit before the loop sees the stop signal.
        - coro.close() is called when the coroutine is not submitted, to prevent
          "coroutine was never awaited" RuntimeWarnings.
        - future.cancel() is wrapped in try-except because the loop may already be
          closed when TimeoutError fires at shutdown, causing RuntimeError.
        """
        if not self.loop or not self.loop.is_running() or self._stopping.is_set():
            coro.close()
            return None
        try:
            future = asyncio.run_coroutine_threadsafe(coro, self.loop)
            return future.result(timeout=timeout)
        except concurrent.futures.TimeoutError:
            try:
                future.cancel()
            except RuntimeError:
                pass  # Event loop already closed during shutdown
            self.logger.warning("[BACnet] _run_in_loop: timeout (%.1fs) for %s" % (timeout, coro.__qualname__))
            return None
        except Exception as e:
            self.logger.warning("[BACnet] _run_in_loop: %s: %s" % (type(e).__name__, e))
            return None

    async def _get_value_safe(self, name: str):
        obj = self.engine.get_app_object(name)
        if obj is None:
            return None
        pv = obj.presentValue
        # ScheduleObject presentValue is AnyAtomic — unwrap to native Python type
        if isinstance(pv, AnyAtomic):
            pv = pv.get_value()
        # Convert BACnet primitives to native Python types
        if isinstance(pv, Real):
            return float(pv)
        if isinstance(pv, BacnetUnsigned):
            return int(pv)
        if isinstance(pv, BinaryPV):
            return bool(pv)
        return pv

    # --- Trending ---

    async def _trending_poll_task(self):
        """Poll all recorded objects every POLL_INTERVAL seconds.
        Handles both local objects (via engine) and remote objects (BACnet read).
        await asyncio.sleep(0) between each object ensures the event loop stays
        responsive for protocol traffic even when polling many local objects
        (_get_value_safe has no internal await and would otherwise never yield)."""
        while not self.stop_event.is_set():
            await asyncio.sleep(BACnetTrendingStore.POLL_INTERVAL)
            for key, entry in self._trending.get_entries().items():
                if entry.cov_active:
                    await asyncio.sleep(0)
                    continue   # COV is the primary source → skip poll
                try:
                    if entry.is_local:
                        v = await self._get_value_safe(entry.name)
                    else:
                        obj_id = f"{entry.obj_type},{entry.instance}"
                        v = await self._read_property_safe(entry.address, obj_id, 'presentValue')
                    if v is not None:
                        self._trending.push(key, v)
                        self._trending.set_poll_error(key, False)
                    else:
                        self._trending.set_poll_error(key, True)
                except Exception:
                    self._trending.set_poll_error(key, True)
                await asyncio.sleep(0)  # yield between each object poll

    async def _attach_trending_monitor_async(self, entry: TrendingEntry) -> bool:
        """Attach property monitor for a local object. Push initial value."""
        obj = self.engine.get_app_object(entry.name)
        if obj is None:
            return False
        v = await self._get_value_safe(entry.name)
        if v is not None:
            self._trending.push(entry.key, v)
        if getattr(obj, '_trending_cb', None) is None:
            trending = self._trending
            key = entry.key
            def _trending_cb(old_val, new_val):
                pv = new_val
                if isinstance(pv, AnyAtomic): pv = pv.get_value()
                if isinstance(pv, Real):          pv = float(pv)
                elif isinstance(pv, BacnetUnsigned): pv = int(pv)
                elif isinstance(pv, BinaryPV):    pv = bool(pv)
                if pv is not None:
                    trending.push(key, pv)
            obj._property_monitors.setdefault('presentValue', []).append(_trending_cb)
            obj._trending_cb = _trending_cb
        return True

    async def _detach_trending_monitor_async(self, name: str) -> bool:
        """Remove property monitor from a local object."""
        obj = self.engine.get_app_object(name)
        if obj is None:
            return False
        cb = getattr(obj, '_trending_cb', None)
        if cb is not None:
            try:
                obj._property_monitors.get('presentValue', []).remove(cb)
            except ValueError:
                pass
            obj._trending_cb = None
        return True

    async def _poll_remote_object_async(self, entry: TrendingEntry) -> None:
        """Fetch current value from a remote object (initial data point on record)."""
        obj_id = f"{entry.obj_type},{entry.instance}"
        v = await self._read_property_safe(entry.address, obj_id, 'presentValue')
        if v is not None:
            self._trending.push(entry.key, v)

    async def _subscribe_cov_for_entry(self, entry: TrendingEntry) -> None:
        """Long-lived background coroutine: subscribe COV for one remote object.
        SubscriptionContextManager handles automatic renewal before the 300s lifetime expires.
        On cancellation (unrecord or shutdown), the SCM __aexit__ sends UnsubscribeCOV."""
        process_id = self._cov_pid_counter
        self._cov_pid_counter = (self._cov_pid_counter + 1) & 0x7FFFFFFF
        obj_id_str = f"{entry.obj_type},{entry.instance}"
        try:
            async with SubscriptionContextManager(
                app=self.engine,
                address=Address(entry.address),
                monitored_object_identifier=obj_id_str,
                subscriber_process_identifier=process_id,
                issue_confirmed_notifications=False,
                lifetime=300,
            ) as scm:
                entry.cov_active = True
                self.logger.info(f"[BACnet] COV active: {entry.key}")
                while True:
                    prop_id, value = await scm.get_value()
                    if value is not None and 'present' in str(prop_id).lower():
                        normalized = self._normalize_value(value)
                        if normalized is not None:
                            self._trending.push(entry.key, normalized)
                            try:
                                iface = self.interface
                                if iface:
                                    iface.on_cov_notification(entry, normalized)
                            except Exception:
                                pass
        except asyncio.CancelledError:
            entry.cov_active = False
            raise
        except Exception as e:
            self._cov_no_support.add(entry.address)
            entry.cov_active = False
            self.logger.info(f"[BACnet] COV unavailable for {entry.key}: {type(e).__name__}: {e}")

    async def _start_cov_task_async(self, entry: TrendingEntry) -> None:
        """Get initial value then start background COV subscription if supported."""
        await self._poll_remote_object_async(entry)   # initial value (existing behaviour)
        if entry.address in self._cov_no_support:
            return   # COV known as unsupported → polling only
        task = asyncio.create_task(
            self._subscribe_cov_for_entry(entry),
            name=f"cov_{entry.key}"
        )
        self._cov_tasks[entry.key] = task

    async def _stop_cov_task_async(self, key: str) -> None:
        """Cancel COV task — SCM __aexit__ sends UnsubscribeCOV cleanly."""
        task = self._cov_tasks.pop(key, None)
        if task and not task.done():
            task.cancel()
            try:
                await asyncio.wait_for(asyncio.shield(task), timeout=3.0)
            except (asyncio.CancelledError, asyncio.TimeoutError):
                pass

    async def _stop_all_cov_tasks_async(self) -> None:
        """Cancel all active COV subscriptions. Called during shutdown."""
        if not self._cov_tasks:
            return
        for task in self._cov_tasks.values():
            if not task.done():
                task.cancel()
        await asyncio.gather(*self._cov_tasks.values(), return_exceptions=True)
        self._cov_tasks.clear()
        self.logger.info("[BACnet] All COV subscriptions cancelled.")

    async def _get_object_details_safe(self, name: str) -> dict:
        obj = self.engine.get_app_object(name)
        if not obj:
            return {}
        details = {
            'presentValue': obj.presentValue, 'reliability': str(obj.reliability),
            'statusFlags': obj.statusFlags, 'description': obj.description,
            'outOfService': obj.outOfService
        }
        if hasattr(obj, 'units'): details['units'] = str(obj.units)
        if hasattr(obj, 'activeText'): details['activeText'] = obj.activeText
        if hasattr(obj, 'inactiveText'): details['inactiveText'] = obj.inactiveText
        if hasattr(obj, 'stateText'): details['stateText'] = [str(s) for s in obj.stateText]
        return details

    async def _get_priority_array_safe(self, name: str):
        obj = self.engine.get_app_object(name)
        if obj and hasattr(obj, 'priorityArray'):
            return list(obj.priorityArray)
        return None

    async def _get_schedule_data_async(self, name: str):
        """Read schedule data for a PersistentScheduleObject.
        Returns weeklySchedule, exceptionSchedule, scheduleDefault, description
        and presentValue as serializable Python types, or None if not found."""
        obj = self.engine.get_app_object(name)
        if not isinstance(obj, PersistentScheduleObject):
            return None
        pv = obj.presentValue
        if isinstance(pv, AnyAtomic):
            pv = pv.get_value()
        if isinstance(pv, Real):
            pv = float(pv)
        elif isinstance(pv, BacnetUnsigned):
            pv = int(pv)
        elif isinstance(pv, BinaryPV):
            pv = bool(pv)
        return {
            'weeklySchedule': ScheduleSerializer.serialize_weekly_schedule(obj.weeklySchedule),
            'exceptionSchedule': ScheduleSerializer.serialize_exception_schedule(obj.exceptionSchedule),
            'scheduleDefault': ScheduleSerializer._serialize_anyatomic(obj.scheduleDefault),
            'description': str(obj.description) if obj.description else '',
            'presentValue': pv,
        }

    async def _get_schedule_links_async(self, name: str):
        """Return the listOfObjectPropertyReferences of a local Schedule object,
        enriched with live data (name, description, presentValue, unit, reliability,
        outOfService) from the linked local objects.
        Returns a list of dicts, or None if the schedule is not found."""
        obj = self.engine.get_app_object(name)
        if not isinstance(obj, PersistentScheduleObject):
            return None
        refs = obj.listOfObjectPropertyReferences or []
        result = []
        for ref in refs:
            oid = ref.objectIdentifier
            obj_type = str(oid[0])
            obj_instance = int(oid[1])
            device_id = int(ref.deviceIdentifier[1]) if ref.deviceIdentifier else None
            entry = {
                'type': obj_type,
                'instance': obj_instance,
                'property': str(ref.propertyIdentifier) if ref.propertyIdentifier else 'present-value',
                'device': device_id,
                'name': '',
                'description': '',
                'value': None,
                'unit': '',
                'reliability': '',
                'outOfService': False,
            }
            linked = self.engine.get_object_id(oid)
            if linked:
                entry['name'] = str(linked.objectName) if linked.objectName else ''
                entry['description'] = str(linked.description) if linked.description else ''
                pv = linked.presentValue
                if isinstance(pv, AnyAtomic):
                    pv = pv.get_value()
                if isinstance(pv, Real):
                    entry['value'] = float(pv)
                elif isinstance(pv, BacnetUnsigned):
                    entry['value'] = int(pv)
                elif isinstance(pv, BinaryPV):
                    entry['value'] = bool(pv)
                else:
                    entry['value'] = str(pv) if pv is not None else None
                if hasattr(linked, 'units'):
                    entry['unit'] = str(linked.units)
                if hasattr(linked, 'reliability'):
                    entry['reliability'] = str(linked.reliability)
                if hasattr(linked, 'outOfService'):
                    entry['outOfService'] = bool(linked.outOfService)
            result.append(entry)
        return result

    async def _write_schedule_async(self, name: str, weekly=None, sched_default=None, exception_schedule=None) -> bool:
        """Write weeklySchedule, scheduleDefault and/or exceptionSchedule of a PersistentScheduleObject.
        Triggers persistence after the write. Returns True on success."""
        obj = self.engine.get_app_object(name)
        if not isinstance(obj, PersistentScheduleObject):
            return False
        if weekly is not None:
            days = ScheduleSerializer.deserialize_weekly_schedule(weekly)
            if days is not None:
                obj.weeklySchedule = days
        if sched_default is not None:
            val = ScheduleSerializer._deserialize_anyatomic(sched_default)
            if val is not None:
                obj.scheduleDefault = val
        if exception_schedule is not None:
            events = ScheduleSerializer.deserialize_exception_schedule(exception_schedule)
            if events is not None:
                obj.exceptionSchedule = events
        self._schedule_persistence_dirty = True
        return True

    # --- Client inspection coroutines (called from BACnetClient via _run_in_loop) ---

    async def _who_is_safe(self, low_limit=None, high_limit=None, timeout=3):
        """Broadcast Who-Is, wait for I-Am responses.
        Returns raw device list with deviceId, address and vendorID from the I-Am.
        Enrichment (objectName, vendorName) is done separately via _enrich_device_safe.
        """
        if not self.engine:
            return []
        try:
            i_ams = await self.engine.who_is(low_limit, high_limit, timeout=timeout)
        except Exception as e:
            self.logger.error(f"[BACnet] who_is error: {type(e).__name__}: {e!r}")
            return []

        devices = []
        for iam in (i_ams or []):
            try:
                dev_id = iam.iAmDeviceIdentifier[1]
                addr_str = str(iam.pduSource)
                devices.append({
                    'deviceId': dev_id,
                    'address': addr_str,
                    'vendorID': int(iam.vendorID) if iam.vendorID is not None else 0,
                    'name': '',
                    'vendor': '',
                })
            except Exception:
                continue
        return devices

    async def _enrich_device_safe(self, address, device_id, per_call_timeout=1.2):
        """Read objectName and vendorName from a remote device."""
        if not self.engine:
            return {}
        info = {}
        try:
            name = await asyncio.wait_for(
                self.engine.read_property(address, f"device,{device_id}", 'objectName'),
                timeout=per_call_timeout
            )
            if isinstance(name, ErrorRejectAbortNack):
                name = None
            info['name'] = str(name) if name is not None else ''
        except BaseException:
            info['name'] = ''
        try:
            vendor = await asyncio.wait_for(
                self.engine.read_property(address, f"device,{device_id}", 'vendorName'),
                timeout=per_call_timeout
            )
            if isinstance(vendor, ErrorRejectAbortNack):
                vendor = None
            info['vendor'] = str(vendor) if vendor is not None else ''
        except BaseException:
            info['vendor'] = ''
        return info

    def _normalize_value(self, val):
        """Convert BACnet primitives to native Python types.
        Returns None for ErrorRejectAbortNack responses."""
        if val is None or isinstance(val, ErrorRejectAbortNack):
            return None
        if isinstance(val, AnyAtomic):
            val = val.get_value()
        if isinstance(val, Real):
            return float(val)
        if isinstance(val, BacnetUnsigned):
            return int(val)
        if isinstance(val, BinaryPV):
            return bool(val)
        if isinstance(val, Boolean):
            return bool(val)
        if isinstance(val, Enumerated):
            return str(val)
        if isinstance(val, CharacterString):
            return str(val)
        try:
            if hasattr(val, '__float__'):
                return float(val)
            if hasattr(val, '__int__'):
                return int(val)
        except (TypeError, ValueError):
            pass
        return val

    async def _read_property_safe(self, address, object_id, property_name, array_index=None):
        """Read a single property from a remote BACnet object."""
        if not self.engine:
            return None
        try:
            result = await self.engine.read_property(address, object_id, property_name, array_index)
            return self._normalize_value(result)
        except BaseException as e:
            self.logger.debug(f"[BACnet] read_property error ({address}, {object_id}, {property_name}): {type(e).__name__}: {e!r}")
            return None

    async def _write_property_safe(self, address, object_id, property_name, value, priority=None):
        """Write a single property to a remote BACnet object."""
        if not self.engine:
            return False
        try:
            result = await self.engine.write_property(address, object_id, property_name, value, priority=priority)
            if isinstance(result, ErrorRejectAbortNack):
                self.logger.warning(f"[BACnet] write_property error ({address}, {object_id}): {result}")
                return False
            return True
        except BaseException as e:
            self.logger.error(f"[BACnet] write_property exception ({address}, {object_id}): {type(e).__name__}: {e!r}")
            return False

    async def _write_local_property_async(self, obj_type: str, obj_instance: int,
                                           property_name: str, value, priority=None) -> bool:
        """Write a property directly to a locally declared BACnet object (no network).
        Pass value=None to release a priority slot (writes Null).
        Automatically casts Python native types to the expected BACnet type.
        """
        if not self.engine:
            return False
        target = None
        for obj in self.engine._objects_map.values():
            try:
                oid = obj.objectIdentifier
                if str(oid[0]) == obj_type and int(oid[1]) == obj_instance:
                    target = obj
                    break
            except Exception:
                continue
        if target is None:
            self.logger.warning(f"[BACnet] write_local_property: object not found ({obj_type},{obj_instance})")
            return False
        try:
            if value is None:
                # Null() fails in bacpypes3: Null is a singleton metaclass and PriorityValue(Null())
                # raises TypeError. Directly set the priority slot to null via __setitem__,
                # which also triggers Commandable.recalculating() automatically.
                if priority is None:
                    priority = 16
                if not hasattr(target, 'priorityArray'):
                    self.logger.warning(
                        f"[BACnet] write_local_property: object not commandable ({obj_type},{obj_instance})")
                    return False
                target.priorityArray[priority - 1] = PriorityValue(null=())
                self.interface._queue_schedule_persistence()
                return True
            elif property_name == 'presentValue':
                write_val = self._cast_to_present_value_type(target, value)
            else:
                write_val = value
            await target.write_property(property_name, write_val, priority=priority)
            return True
        except Exception as e:
            self.logger.error(
                f"[BACnet] write_local_property error ({obj_type},{obj_instance}): {type(e).__name__}: {e!r}")
            return False

    async def _read_object_list_safe(self, address, device_id):
        """Read objectList from device (element-by-element for segmentation safety)."""
        if not self.engine:
            return []
        try:
            dev_obj_id = f"device,{device_id}"
            # Read objectList length first
            count = await self.engine.read_property(address, dev_obj_id, 'objectList', array_index=0)
            if isinstance(count, ErrorRejectAbortNack):
                return []
            if count is None or not isinstance(count, (int, BacnetUnsigned)):
                return []
            count = int(count)
            objects = []
            for i in range(1, count + 1):
                try:
                    obj_id = await self.engine.read_property(address, dev_obj_id, 'objectList', array_index=i)
                    if obj_id is not None and not isinstance(obj_id, ErrorRejectAbortNack):
                        obj_type = str(obj_id[0])
                        obj_instance = int(obj_id[1])
                        objects.append((obj_type, obj_instance))
                except BaseException:
                    continue
            return objects
        except BaseException as e:
            self.logger.error(f"[BACnet] read_object_list error ({address}, {device_id}): {type(e).__name__}: {e!r}", exc_info=True)
            return []

    async def _read_object_summary_safe(self, address, object_id):
        """Read common properties of a remote object (name, value, unit, description)."""
        if not self.engine:
            return {}
        result = {}
        obj_id_str = f"{object_id[0]},{object_id[1]}" if isinstance(object_id, tuple) else str(object_id)
        type_str = (str(object_id[0]) if isinstance(object_id, tuple) else str(object_id).split(',')[0]).lower()
        props = ['objectName', 'presentValue', 'units', 'description']
        for prop in props:
            try:
                val = await self.engine.read_property(address, obj_id_str, prop)
                if val is None or isinstance(val, ErrorRejectAbortNack):
                    continue
                if prop == 'units':
                    # Keep as int code for reverse unit mapping
                    try:
                        result[prop] = int(val)
                    except (TypeError, ValueError):
                        result[prop] = str(val)
                else:
                    val = self._normalize_value(val)
                    if val is not None:
                        result[prop] = val
            except BaseException:
                continue
        # Label properties for BV/MSV
        if 'binary' in type_str:
            for prop in ('activeText', 'inactiveText'):
                try:
                    val = await self.engine.read_property(address, obj_id_str, prop)
                    if val is not None and not isinstance(val, ErrorRejectAbortNack):
                        result[prop] = str(val)
                except BaseException:
                    pass
        elif 'multi-state' in type_str:
            try:
                val = await self.engine.read_property(address, obj_id_str, 'stateText')
                if val is not None and not isinstance(val, ErrorRejectAbortNack):
                    result['stateText'] = [str(s) for s in val]
            except BaseException:
                pass
        return result

    async def _read_device_info_safe(self, address, device_id):
        """Read detailed device properties."""
        if not self.engine:
            return {}
        dev_obj_id = f"device,{device_id}"
        result = {}
        props = [
            'objectName', 'vendorName', 'modelName', 'firmwareRevision',
            'protocolRevision', 'maxApduLengthAccepted', 'segmentationSupported',
        ]
        for prop in props:
            try:
                val = await self.engine.read_property(address, dev_obj_id, prop)
                val = self._normalize_value(val)
                if val is not None:
                    if isinstance(val, (int, float)):
                        result[prop] = val
                    else:
                        result[prop] = str(val)
            except BaseException:
                continue
        # Object count
        try:
            count = await self.engine.read_property(address, dev_obj_id, 'objectList', array_index=0)
            count = self._normalize_value(count)
            if count is not None:
                result['objectCount'] = int(count)
        except BaseException:
            pass
        return result

    async def _read_priority_array_remote_safe(self, address, object_id):
        """Read priority array from a remote object."""
        if not self.engine:
            return None
        obj_id_str = f"{object_id[0]},{object_id[1]}" if isinstance(object_id, tuple) else str(object_id)
        try:
            pa = await self.engine.read_property(address, obj_id_str, 'priorityArray')
            if pa is None or isinstance(pa, ErrorRejectAbortNack):
                return None
            result = []
            for pv in pa:
                if hasattr(pv, 'null') and pv.null is not None:
                    result.append(None)
                elif hasattr(pv, 'real') and pv.real is not None:
                    result.append(float(pv.real))
                elif hasattr(pv, 'binaryPV') and pv.binaryPV is not None:
                    result.append(bool(pv.binaryPV))
                elif hasattr(pv, 'unsigned') and pv.unsigned is not None:
                    result.append(int(pv.unsigned))
                else:
                    result.append(None)
            return result
        except BaseException as e:
            self.logger.debug(f"[BACnet] read_priority_array error ({address}, {object_id}): {type(e).__name__}: {e!r}")
            return None

    def inject_unit_update(self, name, unit_enum):
        async def _do():
            obj = self.engine.get_app_object(name)
            if obj and hasattr(obj, 'units') and obj.units != unit_enum:
                obj.units = unit_enum
        if self.loop: self.loop.call_soon_threadsafe(lambda: asyncio.create_task(_do()))

    def inject_description_update(self, name, desc_str):
        if not isinstance(desc_str, str):
            return
        async def _do():
            obj = self.engine.get_app_object(name)
            if obj and obj.description != desc_str:
                obj.description = desc_str
        if self.loop: self.loop.call_soon_threadsafe(lambda: asyncio.create_task(_do()))

    def inject_state_text_update(self, name, texts: list):
        if not texts or not isinstance(texts, list):
            return
        async def _do():
            obj = self.engine.get_app_object(name)
            if not obj: return
            if isinstance(obj, (BinaryValueObject, WritableBinaryValue)):
                if len(texts) >= 2:
                    if obj.inactiveText != texts[0]:
                        obj.inactiveText = texts[0]
                    if obj.activeText != texts[1]:
                        obj.activeText = texts[1]
            elif isinstance(obj, (MultiStateValueObject, WritableMultiStateValue)):
                new_texts = [CharacterString(s) for s in texts]
                if obj.numberOfStates != len(texts):
                    obj.numberOfStates = len(texts)
                if obj.stateText != new_texts:
                    obj.stateText = new_texts
        if self.loop: self.loop.call_soon_threadsafe(lambda: asyncio.create_task(_do()))


# --- INTERACTIVE INSPECTOR ---

def _rich_print(*args, **kwargs):
    """Print with Rich, forcing color even when stdout is not a real TTY."""
    from rich.console import Console
    Console(force_terminal=True).print(*args, **kwargs)


_TYPE_MAP = {
    'AV': 'analog-value',
    'BV': 'binary-value',
    'AI': 'analog-input',
    'AO': 'analog-output',
    'BI': 'binary-input',
    'BO': 'binary-output',
    'MSV': 'multi-state-value',
}


class BACnetDevice:
    """Proxy to a specific remote BACnet device for interactive inspection."""

    def __init__(self, client, address, deviceId):
        self._client = client
        self._address = address
        self._deviceId = deviceId

    @property
    def address(self):
        return self._address

    @property
    def deviceId(self):
        return self._deviceId

    def __repr__(self):
        return f"BACnetDevice({self._address}, {self._deviceId})"

    def _run(self, coro, timeout=None):
        svc = self._client._interface._service
        if not svc:
            return None
        t = timeout or self._client._timeout
        return svc._run_in_loop(coro, timeout=t)

    def _service(self):
        return self._client._interface._service

    def _cacheKey(self, suffix):
        return (self._address, self._deviceId, suffix)

    def _getCache(self, key):
        return self._client._cache.get(key)

    def _setCache(self, key, value):
        self._client._cache[key] = value

    @staticmethod
    def _applyFilter(objects, filter):
        """Filter objects by keywords (all words must match across searchable fields).
        Each word is matched as a substring (case-insensitive) against name,
        description, type, unit and string representation of value.
        All words must be found (AND logic), each word can match any field.
        """
        if not filter:
            return objects
        words = filter.lower().split()
        if not words:
            return objects
        result = []
        for o in objects:
            searchable = ' '.join(str(o.get(f, '')) for f in ('name', 'description', 'type', 'unit', 'value')).lower()
            if all(w in searchable for w in words):
                result.append(o)
        return result

    # --- Device info ---

    def retrieveDeviceInfo(self, cached=True):
        """Read detailed device properties.
        Returns dict with name, vendor, model, firmware, etc.
        """
        key = self._cacheKey('deviceInfo')
        if cached:
            data = self._getCache(key)
            if data is not None:
                return data
        svc = self._service()
        if not svc:
            return {}
        data = self._run(svc._read_device_info_safe(self._address, self._deviceId)) or {}
        self._setCache(key, data)
        return data

    def dumpDeviceInfo(self, cached=True):
        """Display detailed device properties."""
        from prettytable import PrettyTable
        info = self.retrieveDeviceInfo(cached=cached)
        if not info:
            print(f"No info for device {self._deviceId} at {self._address}")
            return
        t = PrettyTable(['Property', 'Value'])
        t.align = 'l'
        for prop in ['objectName', 'vendorName', 'modelName', 'firmwareRevision',
                      'protocolRevision', 'maxApduLengthAccepted', 'segmentationSupported', 'objectCount']:
            if prop in info:
                t.add_row([prop, info[prop]])
        print(t)

    # --- Object listing ---

    def _unitMapper(self):
        """Access the UnitMapper from the BACnet interface."""
        return self._client._interface.unit_mapper

    def retrieveObjects(self, cached=True, filter=None):
        """List all objects of the remote device.
        Returns list of dicts with type, instance, name, value, unit, description.
        """
        key = self._cacheKey('objects')
        data = self._getCache(key) if cached else None
        if data is None:
            svc = self._service()
            if not svc:
                return []
            mapper = self._unitMapper()
            obj_list = self._run(svc._read_object_list_safe(self._address, self._deviceId), timeout=60) or []
            data = []
            for obj_type, obj_instance in obj_list:
                summary = self._run(svc._read_object_summary_safe(self._address, (obj_type, obj_instance))) or {}
                unit_code = summary.get('units')
                unit_str = mapper.get_unit_string(unit_code) if unit_code is not None else ''
                entry = {
                    'type': obj_type,
                    'instance': obj_instance,
                    'name': str(summary.get('objectName', '')),
                    'value': summary.get('presentValue', ''),
                    'unit': unit_str,
                    'description': str(summary.get('description', '')),
                }
                type_lower = obj_type.lower()
                if 'binary' in type_lower:
                    entry['labels'] = [
                        str(summary.get('inactiveText', 'Off')),
                        str(summary.get('activeText', 'On')),
                    ]
                elif 'multi-state' in type_lower and 'stateText' in summary:
                    entry['labels'] = summary['stateText']
                data.append(entry)
            self._setCache(key, data)
        return self._applyFilter(data, filter)

    def refresh(self):
        """Re-read all object values from the remote device, updating cache."""
        self.retrieveObjects(cached=False)

    def debugObjects(self, cached=True):
        """Print raw object data with Python types for debugging."""
        objects = self.retrieveObjects(cached=cached)
        for o in objects[:10]:
            v = o.get('value', '')
            print(f"  {o['type']:20s} #{o['instance']:<5} name={o['name']!r} value={v!r} type={type(v).__name__} unit={o['unit']!r} desc={o['description']!r}")
        if len(objects) > 10:
            print(f"  ... ({len(objects)} total)")

    def _dumpObjectsTable(self, objects):
        """Display a list of object dicts as PrettyTable."""
        from prettytable import PrettyTable
        if not objects:
            print("No objects found.")
            return
        t = PrettyTable(['Type', 'Instance', 'Name', 'Value', 'Unit', 'Description'])
        t.align = 'l'
        for o in objects:
            t.add_row([o['type'], o['instance'], o['name'], o['value'], o['unit'], o['description']])
        print(t.get_string(sortby='Type'))

    def dumpObjects(self, cached=True, filter=None):
        """Display all objects of the remote device."""
        self._dumpObjectsTable(self.retrieveObjects(cached=cached, filter=filter))

    # --- Specialized retrieve/dump by type ---

    def _retrieveByType(self, typeFilter, cached=True, filter=None):
        objects = self.retrieveObjects(cached=cached)
        objects = [o for o in objects if o['type'] == typeFilter]
        return self._applyFilter(objects, filter)

    def _dumpByType(self, typeFilter, cached=True, filter=None):
        self._dumpObjectsTable(self._retrieveByType(typeFilter, cached=cached, filter=filter))

    def retrieveAV(self, cached=True, filter=None):
        return self._retrieveByType(_TYPE_MAP['AV'], cached, filter)
    def retrieveBV(self, cached=True, filter=None):
        return self._retrieveByType(_TYPE_MAP['BV'], cached, filter)
    def retrieveAI(self, cached=True, filter=None):
        return self._retrieveByType(_TYPE_MAP['AI'], cached, filter)
    def retrieveAO(self, cached=True, filter=None):
        return self._retrieveByType(_TYPE_MAP['AO'], cached, filter)
    def retrieveBI(self, cached=True, filter=None):
        return self._retrieveByType(_TYPE_MAP['BI'], cached, filter)
    def retrieveBO(self, cached=True, filter=None):
        return self._retrieveByType(_TYPE_MAP['BO'], cached, filter)
    def retrieveMSV(self, cached=True, filter=None):
        return self._retrieveByType(_TYPE_MAP['MSV'], cached, filter)
    def retrieveSchedules(self, cached=True, filter=None):
        return self._retrieveByType('schedule', cached, filter)

    def dumpAV(self, cached=True, filter=None):
        self._dumpByType(_TYPE_MAP['AV'], cached, filter)
    def dumpBV(self, cached=True, filter=None):
        self._dumpByType(_TYPE_MAP['BV'], cached, filter)
    def dumpAI(self, cached=True, filter=None):
        self._dumpByType(_TYPE_MAP['AI'], cached, filter)
    def dumpAO(self, cached=True, filter=None):
        self._dumpByType(_TYPE_MAP['AO'], cached, filter)
    def dumpBI(self, cached=True, filter=None):
        self._dumpByType(_TYPE_MAP['BI'], cached, filter)
    def dumpBO(self, cached=True, filter=None):
        self._dumpByType(_TYPE_MAP['BO'], cached, filter)
    def dumpMSV(self, cached=True, filter=None):
        self._dumpByType(_TYPE_MAP['MSV'], cached, filter)
    def dumpSchedules(self, cached=True, filter=None):
        self._dumpByType('schedule', cached, filter)

    # --- Object detail ---

    # Standard BACnet properties present on all objects (read even if not in propertyList)
    _BASE_PROPS = ['objectIdentifier', 'objectName', 'objectType', 'presentValue']

    def retrieveObjectProperties(self, objectId, cached=True):
        """Read all properties of a single remote object.
        First reads propertyList to discover available properties,
        then reads each one individually.
        objectId: tuple like ('analog-value', 1)
        """
        key = (self._address, objectId, 'properties')
        if cached:
            data = self._getCache(key)
            if data is not None:
                return data
        svc = self._service()
        if not svc:
            return {}
        obj_id_str = f"{objectId[0]},{objectId[1]}" if isinstance(objectId, tuple) else str(objectId)

        # Discover available properties via propertyList
        prop_list = self._run(svc._read_property_safe(self._address, obj_id_str, 'propertyList'))
        if prop_list and not isinstance(prop_list, (str, int, float, bool)):
            # propertyList returns a list of PropertyIdentifier values
            props = list(self._BASE_PROPS)
            for p in prop_list:
                pname = str(p)
                if pname not in props:
                    props.append(pname)
        else:
            # Fallback if propertyList not supported
            props = self._BASE_PROPS + [
                'description', 'units', 'reliability', 'outOfService',
                'statusFlags', 'eventState', 'covIncrement',
            ]

        data = {}
        for prop in props:
            try:
                val = self._run(svc._read_property_safe(self._address, obj_id_str, prop))
                if val is not None:
                    data[prop] = val
            except BaseException:
                continue
        self._setCache(key, data)
        return data

    def dumpObject(self, objectId, cached=True):
        """Display all properties of a single remote object (vertical table)."""
        from prettytable import PrettyTable
        props = self.retrieveObjectProperties(objectId, cached=cached)
        if not props:
            print(f"No properties for {objectId}")
            return
        t = PrettyTable(['Property', 'Value'])
        t.align = 'l'
        t.max_width['Value'] = 80
        for k, v in props.items():
            t.add_row([k, v])
        print(t)

    # --- Direct read ---

    def read(self, objectId, propertyName='presentValue', arrayIndex=None):
        """Read a single property from a remote BACnet object (no cache)."""
        svc = self._service()
        if not svc:
            return None
        obj_id_str = f"{objectId[0]},{objectId[1]}" if isinstance(objectId, tuple) else str(objectId)
        return self._run(svc._read_property_safe(self._address, obj_id_str, propertyName, arrayIndex))

    def readAV(self, instance, propertyName='presentValue'):
        return self.read(('analog-value', instance), propertyName)
    def readBV(self, instance, propertyName='presentValue'):
        return self.read(('binary-value', instance), propertyName)
    def readAI(self, instance, propertyName='presentValue'):
        return self.read(('analog-input', instance), propertyName)
    def readAO(self, instance, propertyName='presentValue'):
        return self.read(('analog-output', instance), propertyName)
    def readBI(self, instance, propertyName='presentValue'):
        return self.read(('binary-input', instance), propertyName)
    def readBO(self, instance, propertyName='presentValue'):
        return self.read(('binary-output', instance), propertyName)
    def readMSV(self, instance, propertyName='presentValue'):
        return self.read(('multi-state-value', instance), propertyName)

    # --- Write ---

    def _effectivePriority(self, priority):
        return priority if priority is not None else self._client._priority

    def write(self, objectId, value, propertyName='presentValue', priority=None):
        """Write a single property to a remote BACnet object."""
        svc = self._service()
        if not svc:
            return False
        obj_id_str = f"{objectId[0]},{objectId[1]}" if isinstance(objectId, tuple) else str(objectId)
        return self._run(svc._write_property_safe(
            self._address, obj_id_str, propertyName, value, self._effectivePriority(priority)))

    def writeAV(self, instance, value, priority=None):
        return self.write(('analog-value', instance), float(value), priority=priority)
    def writeBV(self, instance, value, priority=None):
        return self.write(('binary-value', instance), value, priority=priority)
    def writeAO(self, instance, value, priority=None):
        return self.write(('analog-output', instance), float(value), priority=priority)
    def writeBO(self, instance, value, priority=None):
        return self.write(('binary-output', instance), value, priority=priority)
    def writeMSV(self, instance, value, priority=None):
        return self.write(('multi-state-value', instance), int(value), priority=priority)

    # --- Release (write Null) ---

    def release(self, objectId, priority=None):
        """Release a priority slot (write Null) on a remote commandable object."""
        svc = self._service()
        if not svc:
            return False
        obj_id_str = f"{objectId[0]},{objectId[1]}" if isinstance(objectId, tuple) else str(objectId)
        return self._run(svc._write_property_safe(
            self._address, obj_id_str, 'presentValue', Null(), self._effectivePriority(priority)))

    def releaseAV(self, instance, priority=None):
        return self.release(('analog-value', instance), priority)
    def releaseBV(self, instance, priority=None):
        return self.release(('binary-value', instance), priority)
    def releaseAO(self, instance, priority=None):
        return self.release(('analog-output', instance), priority)
    def releaseBO(self, instance, priority=None):
        return self.release(('binary-output', instance), priority)
    def releaseMSV(self, instance, priority=None):
        return self.release(('multi-state-value', instance), priority)

    # --- Priority array ---

    def retrievePriorityArray(self, objectId):
        """Read priority array from a remote object. Returns list of 16 slots (None = empty)."""
        svc = self._service()
        if not svc:
            return None
        return self._run(svc._read_priority_array_remote_safe(self._address, objectId))

    def dumpPriorityArray(self, objectId):
        """Display priority array of a remote object."""
        from prettytable import PrettyTable
        pa = self.retrievePriorityArray(objectId)
        if pa is None:
            print(f"No priority array for {objectId}")
            return
        t = PrettyTable(['Priority', 'Value'])
        t.align = 'l'
        for i, v in enumerate(pa):
            t.add_row([i + 1, v if v is not None else '-'])
        print(t)

    # --- Rich tree ---

    @staticmethod
    def _richType(obj_type):
        """Format BACnet object type with Rich markup string."""
        colors = {
            'analog-input': 'bold bright_blue',
            'analog-output': 'bold blue',
            'analog-value': 'bold bright_cyan',
            'binary-input': 'bold bright_magenta',
            'binary-output': 'bold magenta',
            'binary-value': 'bold bright_yellow',
            'multi-state-value': 'bold bright_green',
            'multi-state-input': 'bold green',
            'schedule': 'bold yellow',
            'device': 'bold white',
        }
        color = colors.get(obj_type, 'white')
        return f'[{color}]{obj_type}[/]'

    @staticmethod
    def _richValue(value, unit=''):
        """Format a value with Rich markup (MBIO style)."""
        from rich.markup import escape
        if value is None or value == '':
            return '[bold blue]None[/]'
        if isinstance(value, bool):
            return '[bold green]ON[/]' if value else '[bold red]OFF[/]'
        unit_str = ''
        if unit:
            unit_str = escape(str(unit))
        if isinstance(value, float):
            return f'[bold magenta]{value:.1f}{unit_str}[/]'
        if isinstance(value, int):
            return f'[bold magenta]{value}{unit_str}[/]'
        try:
            fv = float(value)
            return f'[bold magenta]{fv:.1f}{unit_str}[/]'
        except (TypeError, ValueError):
            return f'[bold]{escape(str(value))}[/]'

    @staticmethod
    def _richObjectLabel(o):
        """Build a Rich markup label for a single BACnet object entry."""
        from rich.markup import escape
        unit = str(o.get('unit', ''))
        val = BACnetDevice._richValue(o.get('value', ''), unit)
        name = escape(str(o.get('name', '')))
        instance = o.get('instance', '')
        label = f'[bright_black]#{instance}[/] [bold]{name}[/]={val}'
        desc = str(o.get('description', ''))
        if desc:
            label += f' [italic]{escape(desc)}[/]'
        return label

    def gettree(self, cached=True, filter=None):
        """Build a Rich Tree for this device."""
        from rich.tree import Tree
        from rich.markup import escape

        info = self.retrieveDeviceInfo(cached=cached)
        name = escape(str(info.get('objectName', '?')))
        vendor = escape(str(info.get('vendorName', '?')))
        model = escape(str(info.get('modelName', '')))
        obj_count = info.get('objectCount', '?')

        label = (f'[bold yellow]{self.__class__.__name__}[/]'
                 f'([bold]{name}[/]={vendor}'
                 f'{", " + model if model else ""}'
                 f', [bold cyan]{self._address}[/]'
                 f', id=[bold]{self._deviceId}[/]'
                 f', {obj_count} objects)')
        tree = Tree(label)

        objects = self.retrieveObjects(cached=cached, filter=filter)
        by_type = {}
        for o in objects:
            by_type.setdefault(o['type'], []).append(o)

        for obj_type in sorted(by_type.keys()):
            count = len(by_type[obj_type])
            type_label = f'{self._richType(obj_type)} [bright_black]({count})[/]'
            type_branch = tree.add(type_label)
            for o in sorted(by_type[obj_type], key=lambda x: x['instance']):
                type_branch.add(self._richObjectLabel(o))

        return tree

    def tree(self, cached=True, filter=None):
        """Display a Rich Tree for this device."""
        _rich_print(self.gettree(cached=cached, filter=filter))


class BACnetClient:
    """Interactive BACnet network discovery and inspection client."""

    def __init__(self, interface, timeout=3, bbmd=None, priority=16):
        self._interface = interface
        self._timeout = timeout
        self._bbmd = bbmd
        self._priority = priority
        self._cache = {}

    def __repr__(self):
        return f"BACnetClient(timeout={self._timeout}, bbmd={self._bbmd}, priority={self._priority})"

    @property
    def interface(self):
        return self._interface

    def setPriority(self, priority):
        """Set the default write priority (1-16)."""
        self._priority = priority

    def clearCache(self):
        """Clear all cached discovery/inspection data."""
        self._cache = {}

    def _run(self, coro):
        """Execute async coroutine via the BACnet service thread."""
        if not self._interface._service:
            return None
        return self._interface._service._run_in_loop(coro, timeout=self._timeout)

    # --- Device discovery ---

    def retrieveDevices(self, lowLimit=None, highLimit=None, cached=True):
        """Discover BACnet devices via Who-Is.
        Returns list of dicts with deviceId, address, name, vendor.
        """
        if cached and 'devices' in self._cache:
            return self._cache['devices']
        svc = self._interface._service
        if not svc:
            return []
        # who_is needs self._timeout seconds to collect I-Am responses,
        # so _run_in_loop must wait longer than the who_is timeout
        who_is_timeout = self._timeout
        run_timeout = who_is_timeout + 5
        devices = svc._run_in_loop(
            svc._who_is_safe(lowLimit, highLimit, timeout=who_is_timeout),
            timeout=run_timeout
        ) or []
        # Enrich each device with objectName/vendorName (separate calls)
        for dev in devices:
            try:
                info = self._run(svc._enrich_device_safe(dev['address'], dev['deviceId']))
                if info:
                    dev.update(info)
            except Exception:
                pass
        self._cache['devices'] = devices
        return devices

    def refreshDevices(self):
        """Re-discover BACnet devices, updating cache."""
        self.retrieveDevices(cached=False)

    def dumpDevices(self, lowLimit=None, highLimit=None, cached=True):
        """Display discovered devices in a PrettyTable."""
        from prettytable import PrettyTable
        devices = self.retrieveDevices(lowLimit, highLimit, cached)
        if not devices:
            print("No devices found.")
            return
        t = PrettyTable(['DeviceID', 'Address', 'Name', 'Vendor'])
        t.align = 'l'
        for d in devices:
            t.add_row([d['deviceId'], d['address'], d.get('name', ''), d.get('vendor', '')])
        print(t.get_string(sortby='DeviceID'))

    # --- Device proxy ---

    def device(self, addressOrIndex, deviceId=None):
        """Get a BACnetDevice proxy.
        - device(8001)              : by deviceId (searched in cache first, then who_is)
        - device(0)                 : by index if no matching deviceId found
        - device('192.168.0.10:47808', 100) : explicit address + deviceId
        """
        if isinstance(addressOrIndex, str):
            return BACnetDevice(self, addressOrIndex, deviceId)

        # int: try deviceId match first, then fallback to index
        if isinstance(addressOrIndex, int):
            devices = self._cache.get('devices')
            if devices is None:
                devices = self.retrieveDevices(cached=False)
            # Search by deviceId
            for d in devices:
                if d['deviceId'] == addressOrIndex:
                    return BACnetDevice(self, d['address'], d['deviceId'])
            # Fallback to index
            if addressOrIndex < len(devices):
                d = devices[addressOrIndex]
                return BACnetDevice(self, d['address'], d['deviceId'])
            return None

        return None

    # --- Self-inspection (local objects) ---

    def retrieveLocalObjects(self):
        """List all locally declared BACnet objects.
        Returns list of dicts with type, instance, name, value, unit, writable, description.
        """
        svc = self._interface._service
        if not svc or not svc.engine:
            return []
        mapper = self._interface.unit_mapper
        objects = []
        for name, obj in svc.engine._objects_map.items():
            obj_type = str(obj.objectIdentifier[0])
            obj_instance = int(obj.objectIdentifier[1])
            writable = isinstance(obj, (WritableAnalogValue, WritableBinaryValue, WritableMultiStateValue))
            pv = obj.presentValue
            # Convert BACnet types to native
            if isinstance(pv, AnyAtomic):
                pv = pv.get_value()
            if isinstance(pv, Real):
                pv = float(pv)
            elif isinstance(pv, BacnetUnsigned):
                pv = int(pv)
            elif isinstance(pv, BinaryPV):
                pv = bool(pv)
            # Unit
            unit_str = ''
            if hasattr(obj, 'units'):
                try:
                    unit_str = mapper.get_unit_string(int(obj.units))
                except (TypeError, ValueError):
                    unit_str = str(obj.units)
            active_priority = None
            if writable and hasattr(obj, 'priorityArray'):
                for i, slot in enumerate(obj.priorityArray):
                    if slot.null is None:  # slot occupied (not null)
                        active_priority = i + 1
                        break

            entry = {
                'type': obj_type,
                'instance': obj_instance,
                'name': str(name),
                'value': pv,
                'unit': unit_str,
                'writable': writable,
                'description': str(obj.description) if obj.description else '',
                'active_priority': active_priority,
            }
            type_lower = obj_type.lower()
            if 'binary' in type_lower:
                entry['labels'] = [
                    str(getattr(obj, 'inactiveText', None) or 'Off'),
                    str(getattr(obj, 'activeText', None) or 'On'),
                ]
            elif 'multi-state' in type_lower:
                try:
                    entry['labels'] = [str(s) for s in obj.stateText]
                except Exception:
                    pass
            objects.append(entry)
        return objects

    def retrieveLocalObjectProperties(self, object_id):
        """Get detailed properties of a specific local BACnet object.
        object_id format: 'type,instance' (e.g. 'analog-value,1').
        Returns a dict of properties and an optional priority_array list."""
        svc = self._interface._service
        if not svc or not svc.engine:
            return {}, None
        mapper = self._interface.unit_mapper

        target = None
        for name, obj in svc.engine._objects_map.items():
            obj_type = str(obj.objectIdentifier[0])
            obj_instance = int(obj.objectIdentifier[1])
            if f"{obj_type},{obj_instance}" == object_id:
                target = obj
                break
        if target is None:
            return {}, None

        def _read(obj, attr):
            try:
                return getattr(obj, attr)
            except Exception:
                return None

        def _to_native(v):
            if v is None:
                return None
            if isinstance(v, AnyAtomic):
                v = v.get_value()
            if isinstance(v, Real):
                return float(v)
            if isinstance(v, BacnetUnsigned):
                return int(v)
            if isinstance(v, BinaryPV):
                return bool(v)
            if isinstance(v, Boolean):
                return bool(v)
            if isinstance(v, Enumerated):
                return str(v)
            if isinstance(v, CharacterString):
                return str(v)
            return v

        props = {}

        name_val = _read(target, 'objectName')
        if name_val is not None:
            props['objectName'] = str(name_val)

        pv = _to_native(_read(target, 'presentValue'))
        if pv is not None:
            props['presentValue'] = pv

        if hasattr(target, 'units'):
            u = _read(target, 'units')
            if u is not None:
                try:
                    props['units'] = mapper.get_unit_string(int(u))
                except (TypeError, ValueError):
                    props['units'] = str(u)

        desc = _read(target, 'description')
        if desc is not None:
            props['description'] = str(desc) if desc else ''

        for attr in ('outOfService', 'eventState', 'reliability'):
            v = _read(target, attr)
            if v is not None:
                props[attr] = _to_native(v) if attr != 'outOfService' else bool(v)

        sf = _read(target, 'statusFlags')
        if sf is not None:
            try:
                props['statusFlags'] = {
                    'inAlarm': bool(sf.inAlarm),
                    'fault': bool(sf.fault),
                    'overridden': bool(sf.overridden),
                    'outOfService': bool(sf.outOfService),
                }
            except AttributeError:
                pass

        rd = _to_native(_read(target, 'relinquishDefault'))
        if rd is not None:
            props['relinquishDefault'] = rd

        # Label properties for BV/MSV
        for attr in ('activeText', 'inactiveText'):
            if hasattr(target, attr):
                v = _read(target, attr)
                if v is not None:
                    props[attr] = str(v)
        if hasattr(target, 'stateText'):
            v = _read(target, 'stateText')
            if v is not None:
                try:
                    props['stateText'] = [str(s) for s in v]
                except Exception:
                    pass

        # Priority array (commandable objects only)
        # Uses the same per-attribute deserialization as _read_priority_array_remote_safe
        # because PriorityValue has no get_value() method.
        priority_array = None
        if hasattr(target, 'priorityArray'):
            pa_raw = _read(target, 'priorityArray')
            if pa_raw is not None:
                try:
                    pa_list = []
                    for slot in pa_raw:
                        if hasattr(slot, 'null') and slot.null is not None:
                            v = None
                        elif hasattr(slot, 'real') and slot.real is not None:
                            v = float(slot.real)
                        elif hasattr(slot, 'binaryPV') and slot.binaryPV is not None:
                            v = bool(slot.binaryPV)
                        elif hasattr(slot, 'unsigned') and slot.unsigned is not None:
                            v = int(slot.unsigned)
                        else:
                            v = None
                        pa_list.append(v)
                    priority_array = pa_list
                except Exception:
                    pass

        return props, priority_array

    def retrieveLocalSchedules(self):
        """List all locally declared Schedule objects.
        Returns a list of dicts with name, instance, description, presentValue
        and scheduleDefault. Direct memory read, no asyncio required.
        """
        svc = self._interface._service
        if not svc or not svc.engine:
            return []
        result = []
        for name, obj in svc.engine._objects_map.items():
            if not isinstance(obj, PersistentScheduleObject):
                continue
            pv = obj.presentValue
            if isinstance(pv, AnyAtomic):
                pv = pv.get_value()
            if isinstance(pv, Real):
                pv = float(pv)
            elif isinstance(pv, BacnetUnsigned):
                pv = int(pv)
            elif isinstance(pv, BinaryPV):
                pv = bool(pv)
            result.append({
                'name': str(name),
                'instance': int(obj.objectIdentifier[1]),
                'description': str(obj.description) if obj.description else '',
                'presentValue': pv,
                'scheduleDefault': ScheduleSerializer._serialize_anyatomic(obj.scheduleDefault),
            })
        return result

    def getScheduleData(self, name: str):
        """Read the full schedule data for a named local Schedule object.
        Returns a dict with weeklySchedule, exceptionSchedule, scheduleDefault,
        description and presentValue, or None if the object is not found.
        """
        svc = self._interface._service
        if not svc:
            return None
        return self._run(svc._get_schedule_data_async(name))

    def getScheduleLinks(self, name: str):
        """Return the linked objects of a local Schedule (listOfObjectPropertyReferences),
        enriched with live data. Returns a list of dicts, or [] if not found."""
        svc = self._interface._service
        if not svc:
            return []
        return self._run(svc._get_schedule_links_async(name)) or []

    def writeScheduleData(self, name: str, weekly=None, sched_default=None, exception_schedule=None) -> bool:
        """Write weeklySchedule, scheduleDefault and/or exceptionSchedule of a local Schedule object.
        Returns True on success, False on failure.
        """
        svc = self._interface._service
        if not svc:
            return False
        return bool(self._run(svc._write_schedule_async(name, weekly, sched_default, exception_schedule)))

    def writeLocalProperty(self, obj_type: str, obj_instance: int,
                           property_name: str, value, priority=None) -> bool:
        """Write a property to a locally declared BACnet object. value=None releases a priority slot."""
        svc = self._interface._service
        if not svc:
            return False
        return bool(self._run(svc._write_local_property_async(
            obj_type, int(obj_instance), property_name, value, priority)))

    # --- Trending ---

    @staticmethod
    def _makeTrendingKey(task_key, device_id, obj_type, instance) -> str:
        return TrendingEntry.make_key(task_key, device_id, obj_type, instance)

    def recordObject(self, task_key: str, device_id: str, obj_type: str,
                     instance, name: str, address: str = None,
                     description: str = None, labels: list = None) -> bool:
        """Start trending for this object (local or remote).
        If description/labels are provided they are used as-is (fast path from action items).
        Otherwise they are auto-populated: local objects are read directly from the engine,
        remote objects via BACnetDevice.retrieveObjects(cached=True)."""
        svc = self._interface._service
        if not svc or not svc._trending:
            return False
        if description is None or labels is None:
            _desc = ''
            _lbl = []
            try:
                if device_id == 'local':
                    if svc.engine:
                        obj = svc.engine.get_app_object(name)
                        if obj is not None:
                            _desc = str(obj.description) if obj.description else ''
                            type_lower = str(obj.objectIdentifier[0]).lower()
                            if 'binary' in type_lower:
                                _lbl = [
                                    str(getattr(obj, 'inactiveText', None) or 'Off'),
                                    str(getattr(obj, 'activeText', None) or 'On'),
                                ]
                            elif 'multi-state' in type_lower:
                                try:
                                    _lbl = [str(s) for s in obj.stateText]
                                except Exception:
                                    pass
                else:
                    try:
                        dev = self.device(int(device_id))
                        if dev:
                            for o in (dev.retrieveObjects(cached=True) or []):
                                if (str(o.get('type', '')) == str(obj_type)
                                        and int(o.get('instance', 0)) == int(instance)):
                                    _desc = str(o.get('description', '') or '')
                                    _lbl = list(o.get('labels') or [])
                                    break
                    except (ValueError, TypeError):
                        pass
            except Exception:
                pass
            if description is None:
                description = _desc
            if labels is None:
                labels = _lbl
        key = TrendingEntry.make_key(task_key, device_id, obj_type, instance)
        entry = TrendingEntry(key, task_key, device_id, obj_type, int(instance), name, address,
                              description=description, labels=labels)
        svc._trending.record(entry)
        if entry.is_local:
            self._run(svc._attach_trending_monitor_async(entry))
        else:
            self._run(svc._start_cov_task_async(entry))
        return True

    def unrecordObject(self, task_key: str, device_id: str, obj_type: str, instance) -> bool:
        """Stop trending (history kept until explicitly purged)."""
        svc = self._interface._service
        if not svc or not svc._trending:
            return False
        key = TrendingEntry.make_key(task_key, device_id, obj_type, instance)
        entry = svc._trending.get_entries().get(key)
        svc._trending.unrecord(key)
        if entry and entry.is_local:
            self._run(svc._detach_trending_monitor_async(entry.name))
        elif entry and not entry.is_local:
            self._run(svc._stop_cov_task_async(key))
        return True

    def purgeObject(self, task_key: str, device_id: str, obj_type: str, instance) -> bool:
        """Clear all stored data points for this object."""
        svc = self._interface._service
        if not svc or not svc._trending:
            return False
        key = TrendingEntry.make_key(task_key, device_id, obj_type, instance)
        svc._trending.purge(key)
        return True

    def getTrending(self, task_key: str, device_id: str, obj_type: str,
                    instance, since: float = 0.0) -> list:
        """Return list of (timestamp, value) tuples."""
        svc = self._interface._service
        if not svc or not svc._trending:
            return []
        key = TrendingEntry.make_key(task_key, device_id, obj_type, instance)
        return svc._trending.get_history(key, since)

    def isRecorded(self, task_key: str, device_id: str, obj_type: str, instance) -> bool:
        svc = self._interface._service
        if not svc or not svc._trending:
            return False
        key = TrendingEntry.make_key(task_key, device_id, obj_type, instance)
        return svc._trending.is_recorded(key)

    def getRecordedEntries(self) -> dict:
        """Return dict of key → TrendingEntry for all recorded objects."""
        svc = self._interface._service
        if not svc or not svc._trending:
            return {}
        return svc._trending.get_entries()

    def isCovActive(self, task_key: str, device_id: str, obj_type: str, instance) -> bool:
        """Return True if a COV subscription is currently active for this remote object."""
        svc = self._interface._service
        if not svc or not svc._trending:
            return False
        key = TrendingEntry.make_key(task_key, device_id, obj_type, instance)
        entry = svc._trending.get_entries().get(key)
        return bool(entry and entry.cov_active)

    def isCovNoSupport(self, address: str) -> bool:
        """Return True if this address is known to not support COV subscriptions."""
        svc = self._interface._service
        if not svc:
            return False
        return address in svc._cov_no_support

    def getTrendingMeta(self, task_key: str, device_id: str, obj_type: str, instance) -> dict:
        """Return metadata (point_count, last_point) for one recorded object."""
        svc = self._interface._service
        if not svc or not svc._trending:
            return {}
        key = TrendingEntry.make_key(task_key, device_id, obj_type, instance)
        lp = svc._trending.last_point(key)
        return {
            'point_count': svc._trending.point_count(key),
            'last_ts': lp[0] if lp else None,
            'last_value': lp[1] if lp else None,
            'recorded': svc._trending.is_recorded(key),
            'poll_error': svc._trending.has_poll_error(key),
        }

    def getCacheData(self) -> dict:
        """Return a copy of the discovery cache for pickle persistence."""
        return dict(self._cache)

    def restoreCache(self, data: dict):
        """Merge pickled cache data — skips keys already populated.
        Devices restored from cache are marked 'stale' until confirmed live by a refresh."""
        if data:
            for k, v in data.items():
                if k not in self._cache:
                    if k == 'devices' and isinstance(v, list):
                        for dev in v:
                            if isinstance(dev, dict):
                                dev['stale'] = True
                    self._cache[k] = v

    def getTrendingStore(self):
        """Return the BACnetTrendingStore instance (or None)."""
        svc = self._interface._service
        if not svc:
            return None
        return svc._trending

    def getTrendingEntry(self, task_key: str, device_id: str, obj_type: str, instance):
        """Return TrendingEntry for one object, or None if not in store."""
        svc = self._interface._service
        if not svc or not svc._trending:
            return None
        key = TrendingEntry.make_key(task_key, device_id, obj_type, instance)
        return svc._trending.get_entries().get(key)

    def dumpLocal(self):
        """Display locally declared BACnet objects."""
        from prettytable import PrettyTable
        objects = self.retrieveLocalObjects()
        if not objects:
            print("No local objects.")
            return
        t = PrettyTable(['Type', 'Instance', 'Name', 'Value', 'Unit', 'Writable', 'Description'])
        t.align = 'l'
        for o in objects:
            t.add_row([o['type'], o['instance'], o['name'], o['value'], o.get('unit', ''), o['writable'], o['description']])
        print(t.get_string(sortby='Type'))

    # --- Rich tree ---

    def _richLocalObjectLabel(self, o):
        """Build a Rich markup label for a local BACnet object entry."""
        from rich.markup import escape
        unit = str(o.get('unit', ''))
        val = BACnetDevice._richValue(o.get('value', ''), unit)
        name = escape(str(o.get('name', '')))
        writable = o.get('writable', False)
        w = '[bold green]W[/]' if writable else '[bold red]R[/]'
        instance = o.get('instance', '')
        label = f'[bright_black]#{instance}[/]'
        label += f' [bold]{name}[/]={val}{w}'
        desc = str(o.get('description', ''))
        if desc:
            label += f'  [italic]{escape(desc)}[/]'
        return label

    def gettree(self, cached=True, filter=None):
        """Build a Rich Tree showing the BACnet network with all discovered devices."""
        from rich.tree import Tree
        from rich.markup import escape

        iface = self._interface
        healthy = iface.is_healthy()
        status = '[bold green]ONLINE[/]' if healthy else '[bold red]OFFLINE[/]'
        name = escape(str(iface.device_name))

        label = (f'[bold yellow]BACnetClient[/]'
                 f'([bold]{name}[/]'
                 f', [bold cyan]{iface.ip_address}:{iface.port}[/]'
                 f', id=[bold]{iface.device_id}[/]'
                 f', {status})')
        tree = Tree(label)

        # Local objects
        local_objects = self.retrieveLocalObjects()
        if local_objects:
            local_branch = tree.add(f'[bold green]Local Objects[/] [bright_black]({len(local_objects)})[/]')
            by_type = {}
            for o in local_objects:
                by_type.setdefault(o['type'], []).append(o)
            for obj_type in sorted(by_type.keys()):
                count = len(by_type[obj_type])
                type_label = f'{BACnetDevice._richType(obj_type)} [bright_black]({count})[/]'
                type_branch = local_branch.add(type_label)
                for o in sorted(by_type[obj_type], key=lambda x: x['instance']):
                    type_branch.add(self._richLocalObjectLabel(o))

        # Remote devices
        devices = self.retrieveDevices(cached=cached)
        if devices:
            remote_branch = tree.add(f'[bold yellow]Remote Devices[/] [bright_black]({len(devices)})[/]')
            for d in devices:
                dev = self.device(d['address'], d['deviceId'])
                if dev:
                    remote_branch.add(dev.gettree(cached=cached, filter=filter))

        return tree

    def tree(self, cached=True, filter=None):
        """Display a Rich Tree showing the BACnet network."""
        _rich_print(self.gettree(cached=cached, filter=filter))


# --- FACADE ---

class BACnetInterface:
    def __init__(self,
                 app_logger: logging.Logger,
                 device_name: str,
                 unit_mapping: Dict[str, int],
                 device_id: int,
                 ip_address: str,
                 port: int = DEFAULT_PORT,
                 vendor_id: int = DEFAULT_VENDOR_ID,
                 vendor_name: str = "PythonApp",
                 app_watchdog_timeout: int = 60,
                 bind_to_all_interfaces: bool = False):

        self.logger = app_logger
        self.unit_mapper = UnitMapper(unit_mapping)
        self.device_name=device_name
        self.device_id = device_id
        self.ip_address = ip_address
        self.port = port
        self.vendor_id = vendor_id
        self.vendor_name = vendor_name
        self.app_watchdog_timeout = app_watchdog_timeout
        self._config_objects: List[dict] = []
        self._declared_names = set()
        self._service: Optional[BACnetService] = None
        self._update_lock = threading.Lock()
        self._pending_updates: Dict[str, Tuple] = {}
        self._write_lock = threading.Lock()
        self._pending_writes: List[Tuple[str, Any, int]] = []
        self._schedule_persistence_pending = False
        self._fdr_config: Optional[dict] = None
        self._write_acl: Optional[list] = None
        self.bind_to_all_interfaces = bind_to_all_interfaces
        self._client: Optional['BACnetClient'] = None

    def declare_object(self, name: str, type: str, writable: bool = False, description: str = "", units: str = None, state_texts: List[str] = None, active_text: str = None, inactive_text: str = None, initial_value = None, **extra):
        if name in self._declared_names: raise ValueError(f"Object '{name}' already declared.")
        if type not in ['AV', 'BV', 'MSV', 'SCHED']: raise ValueError(f"Unknown type '{type}'.")
        cfg = {
            'name': name, 'type': type, 'writable': writable, 'description': description,
            'units': units, 'state_texts': state_texts, 'active_text': active_text,
            'inactive_text': inactive_text, 'initial_value': initial_value
        }
        cfg.update(extra)
        self._config_objects.append(cfg)
        self._declared_names.add(name)

    def declare_av(self, name: str, writable: bool = False, description: str = "", units: str = None, initial_value: float = None, start_instance_id: int = 0):
        """Declare an Analog Value object."""
        self.declare_object(name, 'AV', writable, description=description, units=units, initial_value=initial_value, start_instance_id=start_instance_id)

    def declare_bv(self, name: str, writable: bool = False, description: str = "", active_text: str = "On", inactive_text: str = "Off", initial_value: int = None, start_instance_id: int = 0):
        """Declare a Binary Value object."""
        self.declare_object(name, 'BV', writable, description=description, active_text=active_text, inactive_text=inactive_text, initial_value=initial_value, start_instance_id=start_instance_id)

    def declare_msv(self, name: str, writable: bool = False, description: str = "", state_texts: List[str] = None, initial_value: int = None, start_instance_id: int = 0):
        """Declare a Multi-State Value object."""
        self.declare_object(name, 'MSV', writable, description=description, state_texts=state_texts, initial_value=initial_value, start_instance_id=start_instance_id)

    def declare_schedule(self, name: str, description: str = "", target_names: List[str] = None, priority_for_writing: int = 15, schedule_default = 0.0, start_instance_id: int = 0, lock_references: bool = False):
        """Declare a Schedule object."""
        self.declare_object(name, 'SCHED', description=description,
                            target_names=target_names or [],
                            priority_for_writing=priority_for_writing,
                            schedule_default=schedule_default,
                            start_instance_id=start_instance_id,
                            lock_references=lock_references)

    def set_write_acl(self, networks: list):
        """Set IP networks allowed to write (e.g. ['192.168.1.0/24', '10.0.0.5/32']).
        None or empty list disables the ACL (all writes allowed)."""
        self._write_acl = networks or None

    def enable_fdr(self, bbmd_address: str, ttl: int = 300):
        self._fdr_config = {'address': bbmd_address, 'ttl': ttl}

    def start(self, timeout=5.0):
        if self._service and self._service.is_alive(): return
        self.logger.info(f"Starting BACnet Service (Spec V{SPEC_VERSION})...")
        self._service = BACnetService(
            interface=self, device_id=self.device_id, ip_address=self.ip_address,
            port=self.port, vendor_id=self.vendor_id, vendor_name=self.vendor_name,
            model_name=self.device_name, config_objects=self._config_objects,
            app_watchdog_timeout=self.app_watchdog_timeout, fdr_config=self._fdr_config,
            bind_to_all_interfaces=self.bind_to_all_interfaces
        )
        self._service.start()
        if not self._service.ready_event.wait(timeout=timeout):
            err = self._service.fatal_error
            self.stop()
            if err: raise RuntimeError(f"BACnet Start Failed: {err}") from err
            else: raise TimeoutError("BACnet Service timed out.")

    def stop(self):
        if self._service:
            self.logger.info("Stopping BACnet Service...")
            self._service.stop()
            self._service.join(timeout=SERVICE_STOP_TIMEOUT)
            self._service = None
            gc.collect()

    def restart(self):
        self.stop()
        time.sleep(0.5)
        self.start()

    def feed_watchdog(self):
        if self._service: self._service.feed_app_watchdog()

    def update(self, name: str, value=None, is_error=None, is_manual=None, is_alarm=None):
        if name not in self._declared_names: return
        with self._update_lock:
            self._pending_updates[name] = (value, is_error, is_manual, is_alarm)

    def pop_pending_updates(self) -> Dict[str, Tuple]:
        with self._update_lock:
            if not self._pending_updates: return {}
            data = self._pending_updates
            self._pending_updates = {}
            return data

    def _queue_external_write(self, name: str, value: Any, priority: int):
        """Queue an external write for processing outside the event loop.
        Called from within the asyncio event loop — must not block."""
        with self._write_lock:
            self._pending_writes.append((name, value, priority))

    def _queue_schedule_persistence(self):
        """Flag that schedule persistence needs to be saved.
        Called from within the asyncio event loop — must not block."""
        self._schedule_persistence_pending = True

    def process_pending_writes(self):
        """Process buffered external writes and schedule persistence. Call from the MBIOTask thread."""
        has_writes = False
        with self._write_lock:
            if self._pending_writes:
                writes = self._pending_writes
                self._pending_writes = []
                has_writes = True

        if has_writes:
            for name, value, priority in writes:
                try:
                    self.on_external_write(name, value, priority)
                except Exception as e:
                    self.logger.error(f"[BACnet] Error in external write for '{name}': {e}")

        # Save persistence if writes occurred or schedule properties changed
        if (has_writes or self._schedule_persistence_pending) and self._service:
            self._schedule_persistence_pending = False
            try:
                snapshot = self._service.get_persistence_snapshot()
                self.on_persistence_save(snapshot)
            except Exception as e:
                self.logger.error(f"[BACnet] Error saving persistence: {e}")

    def set_unit(self, name: str, unit_str: str):
        if self._service and self._service.is_alive():
            unit_enum = self.unit_mapper.get_bacnet_unit(unit_str)
            self._service.inject_unit_update(name, unit_enum)

    def set_description(self, name: str, description: str):
        if self._service and self._service.is_alive():
            self._service.inject_description_update(name, description)

    def set_state_texts(self, name: str, texts: List[str]):
        if self._service and self._service.is_alive():
            if texts and isinstance(texts, list):
                self._service.inject_state_text_update(name, texts)
            else:
                self.logger.warning(f"Invalid state texts format for {name}: {texts}")

    def is_healthy(self) -> bool:
        """Check if the BACnet service is healthy and responsive.

        Returns:
            True if service is running and responding, False otherwise.
        """
        if not self._service or not self._service.is_alive(): return False
        if self._service.fatal_error: return False
        if (time.time() - self._service.last_alive_timestamp) > HEALTH_CHECK_TIMEOUT: return False
        return True

    def get_last_error(self) -> Optional[Exception]:
        if self._service: return self._service.fatal_error
        return None

    def get_object_details(self, name: str) -> dict:
        if not self._service or not self._service.engine: return {}
        return self._service._run_in_loop(self._service._get_object_details_safe(name)) or {}

    def get_value(self, name: str) -> Any:
        if not self._service or not self._service.engine: return None
        return self._service._run_in_loop(self._service._get_value_safe(name))

    def get_priority_array(self, name: str) -> Optional[List]:
        if not self._service or not self._service.engine: return None
        return self._service._run_in_loop(self._service._get_priority_array_safe(name))

    def client(self, timeout=3, bbmd=None, priority=16):
        """Get the BACnetClient singleton for interactive discovery/inspection."""
        if self._client is None:
            self._client = BACnetClient(self, timeout=timeout, bbmd=bbmd, priority=priority)
        return self._client

    def on_persistence_load(self) -> Optional[dict]: return None
    def on_persistence_save(self, data: dict): pass
    def on_external_write(self, name: str, value: Any, priority: int): pass
    def on_server_error(self, error: Exception): pass

    def on_cov_notification(self, entry, value) -> None:
        """Called when a COV notification is received for a recorded remote object.
        Override in subclass to react to live value changes (alerting, forwarding, etc.).
        entry: TrendingEntry describing the object (key, obj_type, instance, address, ...)
        value: Python native type (float, int, bool) — already normalized.
        Default: no-op.
        """
        pass
