import json
import re
import time
import threading

from .wsmonitor_html import HtmlPage


class MBIOWsMonitorBACnetMixin:
    TREND_SAVE_INTERVAL  = 900.0   # s from first-dirty mark to auto-save (trend data)
    TREND_SAVE_STAGGER   = 5.0     # s between consecutive pickle writes
    TREND_SCAN_INTERVAL  = 60.0    # s between dirty-scan cycles
    TREND_INDEX_INTERVAL = 300.0   # s between periodic trend-index backups (5 min)
    BACNET_CACHE_INTERVAL = 300.0  # s between periodic BACnet cache saves (5 min)
    def _hasBacnetTasks(self):
        """Check if any MBIOTaskBacnet instances exist."""
        return len(self._getBacnetTasks()) > 0

    def _bacnetNavHtml(self):
        """Return BACnet nav link, or empty string if no BACnet tasks."""
        if self._hasBacnetTasks():
            return ' / <a href="/bacnet">BACnet</a>'
        return ''

    def _getBacnetTasks(self):
        """Return all MBIOTaskBacnet instances."""
        try:
            from .bacnet import MBIOTaskBacnet
        except ImportError:
            return []
        mbio = self.getMBIO()
        return [t for t in mbio.tasks.all() if isinstance(t, MBIOTaskBacnet)]

    def cb_getbacnetdevices(self, handler, params):
        """Retrieve BACnet devices discovered on the network.
        GET /api/v1/getbacnetdevices[?task=&lt;taskkey&gt;]
          task (optional) : filter to a specific MBIOTaskBacnet key
        Returns {"data": [{"task", "deviceId", "address", "name", "vendor"}, ...]}
        The local BACnet server appears with deviceId="local".
        """
        items = []
        task_filter = params.get('task')
        for task in self._getBacnetTasks():
            if task_filter and task.key != task_filter:
                continue
            client = task.client()
            if not client:
                continue
            # Nœud local (serveur BACnet de cette instance MBIO)
            iface = client.interface
            if iface and iface.is_healthy():
                items.append({
                    'task': task.key,
                    'deviceId': 'local',
                    'address': '%s:%s' % (iface.ip_address, iface.port),
                    'name': iface.device_name or 'Local',
                    'vendor': '',
                    'local': True,
                })
            devices = client.retrieveDevices(cached=True) or []
            for d in devices:
                items.append({
                    'task': task.key,
                    'deviceId': d.get('deviceId', ''),
                    'address': d.get('address', ''),
                    'name': d.get('name', ''),
                    'vendor': d.get('vendor', ''),
                })
        self.cb_write_json(handler, {'data': items})
        return True

    def cb_getbacnetobjects(self, handler, params):
        """Retrieve all BACnet objects of a given device.
        GET /api/v1/getbacnetobjects?task=&lt;taskkey&gt;&amp;device=&lt;deviceId&gt;
          task   : MBIOTaskBacnet key
          device : numeric deviceId or "local" for the local BACnet server
        Returns {"data": [{"deviceId", "type", "instance", "name",
                            "value", "unit", "description", "labels"}, ...]}
        "labels" is a list of state names for binary/multistate objects, or null.
        """
        task_key = params.get('task')
        device_id = params.get('device')
        if not task_key or device_id is None:
            return self.cb_write_failure(handler)

        mbio = self.getMBIO()
        task = mbio.task(task_key)
        if not task:
            return self.cb_write_failure(handler)

        client = task.client()
        if not client:
            return self.cb_write_failure(handler)

        items = []
        if device_id == 'local':
            for o in (client.retrieveLocalObjects() or []):
                items.append({
                    'deviceId': device_id,
                    'type': o.get('type', ''),
                    'instance': o.get('instance', ''),
                    'name': o.get('name', ''),
                    'value': o.get('value', ''),
                    'unit': o.get('unit', ''),
                    'writable': o.get('writable', False),
                    'description': o.get('description', ''),
                    'labels': o.get('labels'),
                    'active_priority': o.get('active_priority'),
                    'recorded': client.isRecorded(task_key, 'local', o.get('type', ''), o.get('instance', 0)),
                })
        else:
            try:
                dev = client.device(int(device_id))
            except (ValueError, TypeError):
                return self.cb_write_failure(handler)
            if dev:
                for o in (dev.retrieveObjects(cached=False) or []):
                    items.append({
                        'deviceId': device_id,
                        'type': o.get('type', ''),
                        'instance': o.get('instance', ''),
                        'name': o.get('name', ''),
                        'value': o.get('value', ''),
                        'unit': o.get('unit', ''),
                        'description': o.get('description', ''),
                        'labels': o.get('labels'),
                        'active_priority': None,
                        'recorded': client.isRecorded(task_key, device_id, o.get('type', ''), o.get('instance', 0)),
                    })

        self.cb_write_json(handler, {'data': items})
        return True

    def cb_refreshbacnet(self, handler, params):
        """Trigger a BACnet network refresh (re-scan devices or reload object cache).
        GET /api/v1/refreshbacnet[?task=&lt;taskkey&gt;][&amp;device=&lt;deviceId&gt;]
          task   (optional) : limit refresh to a specific MBIOTaskBacnet
          device (optional) : limit to a specific device (numeric deviceId); if omitted, refresh all
        After any refresh, the device cache is saved to pickle so the new state
        persists across restarts. Freshly discovered devices have no 'stale' flag.
        """
        task_key = params.get('task')
        device_id = params.get('device')
        for task in self._getBacnetTasks():
            if task_key and task.key != task_key:
                continue
            client = task.client()
            if not client:
                continue
            if device_id:
                try:
                    dev = client.device(int(device_id))
                    if dev:
                        dev.refresh()
                except (ValueError, TypeError):
                    pass
            else:
                client.refreshDevices()
        self._bacnet_cache_save()
        self.cb_write_success(handler)
        return True

    def cb_getbacnetobjectproperties(self, handler, params):
        """Retrieve all properties of a BACnet object, plus priority array if commandable.
        GET /api/v1/getbacnetobjectproperties?task=&lt;taskkey&gt;&amp;device=&lt;deviceId&gt;&amp;type=&lt;type&gt;&amp;instance=&lt;instance&gt;
          task     : MBIOTaskBacnet key
          device   : numeric deviceId or "local"
          type     : BACnet object type (e.g. analog-value, binary-input, multi-state-value)
          instance : BACnet object instance number
        Returns {"data": [{"property", "value"}, ...],
                 "priority_array": [null|float|int|bool x16] or null,
                 "commandable": bool,
                 "out_of_service": bool,
                 "labels": [str, ...] or null}
        Key properties are returned first (presentValue, units, description, statusFlags, ...).
        Priority array is provided only if the object exposes a priorityArray property.
        """
        task_key = params.get('task')
        device_id = params.get('device')
        obj_type = params.get('type')
        obj_instance = params.get('instance')
        if not task_key or device_id is None or not obj_type or obj_instance is None:
            return self.cb_write_failure(handler)

        mbio = self.getMBIO()
        task = mbio.task(task_key)
        if not task:
            return self.cb_write_failure(handler)

        client = task.client()
        if not client:
            return self.cb_write_failure(handler)

        object_id = '%s,%s' % (obj_type, obj_instance)

        if device_id == 'local':
            props, priority_array = client.retrieveLocalObjectProperties(object_id)
            props = props or {}
        else:
            try:
                dev = client.device(int(device_id))
            except (ValueError, TypeError):
                return self.cb_write_failure(handler)
            if not dev:
                return self.cb_write_failure(handler)

            props = dev.retrieveObjectProperties(object_id, cached=False) or {}

            # Certains devices n'incluent pas les propriétés texte dans propertyList.
            # Les lire explicitement si absentes (comme le fait _read_object_summary_safe).
            type_lower_check = obj_type.lower()
            if 'binary' in type_lower_check:
                for prop_name in ('activeText', 'inactiveText'):
                    if prop_name not in props:
                        try:
                            val = dev.read(object_id, prop_name)
                            if val is not None:
                                props[prop_name] = str(val)
                        except Exception:
                            pass
            elif 'multi-state' in type_lower_check:
                if 'stateText' not in props:
                    try:
                        val = dev.read(object_id, 'stateText')
                        if val is not None:
                            props['stateText'] = val
                    except Exception:
                        pass

            priority_array = None
            if 'priorityArray' in props:
                try:
                    pa = dev.retrievePriorityArray(object_id)
                    if pa is not None:
                        priority_array = pa
                except Exception:
                    pass

        def _serialize(k, v):
            # stateText AVANT le passthrough list (ArrayOf(CharacterString) peut être sous-classe de list)
            if k == 'stateText':
                try:
                    return [str(s) for s in v]
                except (TypeError, AttributeError):
                    return str(v)
            if isinstance(v, (str, int, float, bool, type(None), list, dict)):
                return v
            if k == 'statusFlags':
                try:
                    return {
                        'inAlarm': bool(v.inAlarm),
                        'fault': bool(v.fault),
                        'overridden': bool(v.overridden),
                        'outOfService': bool(v.outOfService),
                    }
                except AttributeError:
                    pass
            return str(v)

        PRIORITY_KEYS = ['presentValue', 'units', 'description', 'statusFlags',
                         'outOfService', 'eventState', 'reliability', 'relinquishDefault',
                         'inactiveText', 'activeText', 'stateText']
        items = []
        for k in PRIORITY_KEYS:
            if k in props:
                items.append({'property': k, 'value': _serialize(k, props[k])})
        for k in sorted(props.keys()):
            if k not in PRIORITY_KEYS:
                items.append({'property': k, 'value': _serialize(k, props[k])})

        # Labels for BV/MSV (pour le rendu JS)
        # Ne définir les labels que si le device expose réellement les propriétés texte
        labels = None
        type_lower = obj_type.lower()
        if 'binary' in type_lower:
            inactive = props.get('inactiveText')
            active = props.get('activeText')
            if inactive is not None or active is not None:
                labels = [
                    str(inactive) if inactive is not None else 'Off',
                    str(active) if active is not None else 'On',
                ]
        elif 'multi-state' in type_lower:
            st = props.get('stateText')
            if st is not None:
                try:
                    labels = [str(s) for s in st]
                except Exception:
                    pass

        oos = props.get('outOfService', False)

        # COV status for remote objects
        cov_status = 'local'
        if device_id != 'local':
            try:
                _inst = int(obj_instance)
                if client.isCovActive(task_key, device_id, obj_type, _inst):
                    cov_status = 'active'
                else:
                    _dev = client.device(int(device_id))
                    if _dev and client.isCovNoSupport(_dev.address):
                        cov_status = 'no_support'
                    else:
                        cov_status = 'unknown'
            except (ValueError, TypeError):
                cov_status = 'unknown'

        self.cb_write_json(handler, {
            'data': items,
            'priority_array': priority_array,
            'commandable': priority_array is not None,
            'out_of_service': bool(oos) if oos is not None else False,
            'labels': labels,
            'cov_status': cov_status,
        })
        return True

    def cb_bacnetwriteproperty(self, handler, params, data=None):
        """Write a BACnet object property directly (no priority array).
        POST /api/v1/bacnetwriteproperty
        Body: {"task": &lt;taskkey&gt;, "device": &lt;deviceId&gt;, "type": &lt;type&gt;,
               "instance": &lt;instance&gt;, "property": &lt;propertyName&gt;, "value": &lt;value&gt;}
          property : BACnet property name to write (e.g. outOfService, description, presentValue)
          value    : new property value (type must match the BACnet property)
        Typical use: toggle outOfService, update description or any writable property.
        """
        try:
            payload = json.loads(data) if isinstance(data, str) else (data or {})
        except (json.JSONDecodeError, TypeError):
            return self.cb_write_failure(handler)

        task_key = payload.get('task')
        device_id = payload.get('device')
        obj_type = payload.get('type')
        obj_instance = payload.get('instance')
        property_name = payload.get('property')
        value = payload.get('value')

        if not all([task_key, device_id, obj_type, obj_instance, property_name]):
            return self.cb_write_failure(handler)

        mbio = self.getMBIO()
        task = mbio.task(task_key)
        if not task:
            return self.cb_write_failure(handler)

        client = task.client()
        if not client:
            return self.cb_write_failure(handler)

        if str(device_id) == 'local':
            try:
                ok = client.writeLocalProperty(obj_type, int(obj_instance), property_name, value, priority=None)
            except (ValueError, TypeError):
                return self.cb_write_failure(handler)
        else:
            try:
                dev = client.device(int(device_id))
            except (ValueError, TypeError):
                return self.cb_write_failure(handler)
            if not dev:
                return self.cb_write_failure(handler)
            object_id = '%s,%s' % (obj_type, obj_instance)
            ok = dev.write(object_id, value, propertyName=property_name, priority=None)

        if ok:
            return self.cb_write_success(handler)
        return self.cb_write_failure(handler)

    def cb_bacnetwritepriority(self, handler, params, data=None):
        """Write or release a priority-array slot of a commandable BACnet object.
        POST /api/v1/bacnetwritepriority
        Body: {"task": &lt;taskkey&gt;, "device": &lt;deviceId&gt;, "type": &lt;type&gt;,
               "instance": &lt;instance&gt;, "priority": &lt;1-16&gt;, "value": &lt;value|null&gt;}
          priority : BACnet command priority 1 (highest) to 16 (lowest/default); default 8
          value    : numeric/bool value to write, or null to release (relinquish) the slot
        Common priorities: 1=Manual Life Safety, 2=Auto Life Safety, 5=Critical Equipment,
          6=Minimum On/Off, 8=Manual Operator (default), 16=relinquishDefault.
        """
        try:
            payload = json.loads(data) if isinstance(data, str) else (data or {})
        except (json.JSONDecodeError, TypeError):
            return self.cb_write_failure(handler)

        task_key = payload.get('task')
        device_id = payload.get('device')
        obj_type = payload.get('type')
        obj_instance = payload.get('instance')
        priority = payload.get('priority', 8)
        value = payload.get('value')

        if not task_key or device_id is None or not obj_type or obj_instance is None:
            return self.cb_write_failure(handler)

        mbio = self.getMBIO()
        task = mbio.task(task_key)
        if not task:
            return self.cb_write_failure(handler)

        client = task.client()
        if not client:
            return self.cb_write_failure(handler)

        try:
            priority = int(priority)
        except (ValueError, TypeError):
            return self.cb_write_failure(handler)

        if str(device_id) == 'local':
            try:
                ok = client.writeLocalProperty(obj_type, int(obj_instance), 'presentValue', value, priority=priority)
            except (ValueError, TypeError):
                return self.cb_write_failure(handler)
        else:
            try:
                dev = client.device(int(device_id))
            except (ValueError, TypeError):
                return self.cb_write_failure(handler)
            if not dev:
                return self.cb_write_failure(handler)
            object_id = '%s,%s' % (obj_type, obj_instance)
            if value is None:
                ok = dev.release(object_id, priority=priority)
            else:
                ok = dev.write(object_id, value, priority=priority)

        if ok:
            return self.cb_write_success(handler)
        return self.cb_write_failure(handler)

    def _bacnetaction_declare(self, task, device_id, items, params):
        """Handle 'declare' action on selected BACnet objects.
        Returns True to clear selection, False to keep it.
        """
        device_id=int(device_id)
        cpu = int(params.get('cpu', 0))
        startInstance = int(params.get('startInstance', 0))

        self.logger.info('BACnet declare: task=%s device=%s items=%d cpu=%d startInstance=%d' % (task.key, device_id, len(items), cpu, startInstance))

        # items: [{'type': 'analog-input', 'instance': 13062, 'name': 'r_112_1_cio_13062_0'}, ...]
        # params: {'cpu': 0, 'startInstance': 50}

        btypes={'analog-input': 'AI', 'analog-output': 'AO',
                'binary-input': 'BI', 'binary-output': 'BO',
                'analog-value': 'AV', 'binary-value': 'BV',
                'multistate-value': 'MSV',
                'multistate-input': 'MSI', 'multistate-output': 'MSO'}

        mbio=self.getMBIO()

        try:
            notifier=mbio._cpuNotifier
            if items and notifier:
                link=notifier._link
                if link:
                    mbio.beep()
                    for item in items:
                        # self.logger.debug(item)
                        btype=btypes.get(item['type'])
                        instance=int(item['instance'])
                        unit=mbio.units.getByName(item['unit'])

                        link.declareBacnetItem(cpu, device_id, btype, instance, unit, startInstance)
                        description = item.get('description') or ''
                        labels = item.get('labels') or []
                        btype_lower = btype.lower() if btype else ''
                        low = ''
                        high = ''
                        multistate = ''
                        digit = 0
                        if 'binary' in btype_lower:
                            low = labels[0] if len(labels) > 0 else ''
                            high = labels[1] if len(labels) > 1 else ''
                        elif 'multi' in btype_lower:
                            multistate = ';'.join(['X'] + list(labels)) if labels else ''
                        else:  # analog (AI, AO, AV)
                            digit = 1
                        link.declareBacnetItemLabels(cpu, device_id, btype, instance,
                                                     description, low, high, multistate, digit)
                    return True
        except:
            self.logger.exception('e')

        return False

    def _bacnetaction_record(self, task, device_id, items, params):
        """Start trending for selected BACnet objects (local or remote)."""
        client = task.client()
        if not client:
            return False
        address = None
        if device_id != 'local':
            try:
                dev = client.device(int(device_id))
                if dev is None:
                    return False
                address = dev.address
            except (ValueError, TypeError):
                return False
        for item in items:
            name = item.get('name', '')
            # Pass description/labels only when present in item (None triggers fallback lookup)
            desc = item.get('description')   # None if absent → fallback
            lbls = item.get('labels')         # None if absent → fallback
            client.recordObject(task.key, device_id,
                                item.get('type', ''), item.get('instance', 0),
                                name, address,
                                description=desc, labels=lbls)

            # Load any previously persisted data for this entry
            entry = client.getTrendingEntry(task.key, device_id,
                                            item.get('type', ''), item.get('instance', 0))
            if entry:
                self._trending_init_persistence()
                key = entry.key
                if key not in self._trend_loaded_keys:
                    self._trend_loaded_keys.add(key)
                    self._trending_load_one(entry)
        self._trend_index_save()
        return True

    def _bacnetaction_unrecord(self, task, device_id, items, params):
        """Stop trending for selected BACnet objects."""
        client = task.client()
        if not client:
            return False
        for item in items:
            entry = client.getTrendingEntry(task.key, device_id,
                                            item.get('type', ''), item.get('instance', 0))
            client.unrecordObject(task.key, device_id,
                                  item.get('type', ''), item.get('instance', 0))
            if entry:
                self._trending_init_persistence()
                self._trending_delete_one(entry)
        self._trend_index_save()
        return True

    def cb_bacnetaction(self, handler, params, data=None):
        """Execute a named action on a selection of BACnet objects.
        POST /api/v1/bacnetaction
        Body: {"action": &lt;name&gt;, "task": &lt;taskkey&gt;, "device": &lt;deviceId&gt;,
               "items": [{"type", "instance", "name", "unit"}, ...],
               "params": {&lt;action-specific params&gt;}}
        Supported actions:
          declare — declare selected objects as MBIO values via the link notifier
                    params: {"startInstance": &lt;int&gt;}  (base instance offset, default 0)
          copy    — copy selected rows to clipboard (client-side only, no server call)
          unmark  — clear the current selection (client-side only)
        """
        if not data:
            return self.cb_write_failure(handler)
        try:
            payload = json.loads(data) if isinstance(data, str) else data
        except (json.JSONDecodeError, TypeError):
            return self.cb_write_failure(handler)

        action = payload.get('action')
        task_key = payload.get('task')
        device_id = payload.get('device')
        items = payload.get('items', [])
        action_params = payload.get('params', {})

        if not action or not task_key or not items:
            return self.cb_write_failure(handler)

        mbio = self.getMBIO()
        task = mbio.task(task_key)
        if not task:
            return self.cb_write_failure(handler)

        method = getattr(self, '_bacnetaction_%s' % action, None)
        if method is None:
            return self.cb_write_failure(handler)

        result = method(task, device_id, items, action_params)
        if result:
            self.cb_write_success(handler)
        else:
            self.cb_write_failure(handler)
        return True

    def cb_bacnet(self, handler, params):
        """BACnet devices list page.
        GET /bacnet
        Lists all BACnet devices discovered by all MBIOTaskBacnet instances.
        Click a row to navigate to /bacnetobjects for that device.
        """
        mbio = self.getMBIO()
        h = HtmlPage('Digimat MBIO - BACnetDevices', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml())
        h.variable('auth.logout', self._logoutHtml())

        data = """
            <div class="page container-fluid">
            <div class="card p-4 p-lg-5">
            <div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-3">
            <div>
            <h1 class="h3 mb-1">BACnetDevices</h1>
            <p class="text-secondary mb-0"><b>BACnet</b> / devices</p>
            </div>
            <div class="text-muted small">{mbio.version} /
            {main.nav} / <a href="/bacnettrending">BACnetTrends</a>
            {auth.logout}
            </div>
            </div>
            <ul class="nav nav-tabs mt-3 mb-0 d-flex align-items-center" role="tablist">
            <li class="nav-item" role="presentation">
            <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-devices" type="button">Devices</button>
            </li>
            <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
            <button id="btn-refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
            </li>
            </ul>
            <div class="tab-content border border-top-0 rounded-bottom p-3 mb-2">
            <div class="tab-pane fade show active" id="tab-devices" role="tabpanel">
            <table id="items" class="display nowrap" style="width:100%">
            <thead>
            <tr>
            <th data-priority="2">Task</th>
            <th data-priority="1">DeviceID</th>
            <th data-priority="1">Address</th>
            <th data-priority="1">Name</th>
            <th data-priority="2">Vendor</th>
            </tr>
            </thead>
            </table>
            </div>
            </div>
            </div>
            </div>
        """
        h.write(data)

        data = """
        <script>
        $(function () {
            const table = new DataTable('#items', {
            responsive: true,
            paging: false,
            searching: true,
            ordering: true,
            info: false,
            search: { smart: true, regex: false, caseInsensitive: true, boundary: false },
            ajax: { url: "/api/v1/getbacnetdevices", dataSrc: "data" },
            columns: [
                { data: "task" },
                { data: "deviceId" },
                { data: "address" },
                { data: "name" },
                { data: "vendor" }
            ],
            columnDefs: [{
                targets: 1,
                render: function(data, type, row) {
                    if (type !== 'display') return data;
                    return row.stale
                        ? data + ' <span class="badge bg-warning text-dark ms-1 py-0 px-1" title="Données depuis cache — pas encore confirmé en ligne">cached</span>'
                        : data;
                }
            }]
            });

            setInterval(function () {
                table.ajax.reload(null, false);
            }, 5000);

            $('#btn-refresh').on('click', async function() {
                await fetch('/api/v1/refreshbacnet');
                table.ajax.reload();
            });

            $('#items').on('click', 'tr', function(e) {
                var data = table.row(this).data();
                if (data) {
                    const url = "/bacnetobjects?task=" + encodeURIComponent(data.task)
                        + "&device=" + encodeURIComponent(data.deviceId);
                    if (e.ctrlKey || e.metaKey) { window.open(url, '_blank'); } else { window.location.href = url; }
                }
            });

            $(document).on('keydown', function (e) {
                if ($(e.target).is('input, textarea')) return;
                if (e.key === 'c' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
                    e.preventDefault();
                    copyDataTableToClipboard(table);
                    return;
                }
                if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key === '/') {
                    e.preventDefault();
                    const searchInput = $('#dt-search-0.dt-input');
                    searchInput.focus();
                    searchInput.select();
                }
            });
        });
        </script>
        """
        h.write(data)
        h.flush()

    def cb_bacnetobjects(self, handler, params):
        """BACnet object list page for a given device.
        GET /bacnetobjects?task=&lt;taskkey&gt;&amp;device=&lt;deviceId&gt;
          task   : MBIOTaskBacnet key
          device : numeric deviceId or "local"
        Displays type, instance, name, current value, unit and description.
        Checkbox or SPACE = select row. CTRL+K / CTRL+SHIFT+K = select/deselect all visible.
        CTRL+C = copy selection (or all visible) to clipboard as TSV.
        Click a row (outside checkbox) to navigate to /bacnetobjectproperties.
        Action bar: Declare (via link notifier), Copy, Unmark.
        """
        mbio = self.getMBIO()
        task_key = params.get('task', '')
        device_id = params.get('device', '')

        h = HtmlPage('BACnet Objects - Device %s' % device_id, handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml())
        h.variable('auth.logout', self._logoutHtml())
        h.variable('task', task_key)
        h.variable('device', device_id)

        data = """
            <style>
            #items tbody tr.selected { background-color: #cfe2ff !important; }
            #action-bar { display: none; }
            #action-bar.active { display: inline-flex; }
            @keyframes flash-yellow {
                0%   { background-color: #fff3cd; }
                100% { background-color: transparent; }
            }
            #items tbody tr.value-changed:not(.selected) {
                animation: flash-yellow 1.5s ease-out;
            }
            #items tbody tr { cursor: pointer; }
            #items tbody td.dt-select { cursor: default; }
            #items thead th.dt-select { width: 1px; }
            </style>
            <div class="page container-fluid">
            <div class="card p-4 p-lg-5">
            <div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-3">
            <div>
            <h1 class="h3 mb-1">BACnet Objects</h1>
            <p class="text-secondary mb-0"><a href="/bacnet">BACnet</a> / device {device}</p>
            </div>
            <div class="text-muted small">{mbio.version} /
            {main.nav} / <a href="/bacnet">BACnetDevices</a> / <a href="/bacnettrending">BACnetTrends</a>
            {auth.logout}
            </div>
            </div>
            <ul class="nav nav-tabs mt-3 mb-0 d-flex align-items-center" role="tablist">
            <li class="nav-item" role="presentation">
            <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-objects" type="button">Objects</button>
            </li>
            <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
            <div id="action-bar" class="btn-group btn-group-sm">
            <button id="btn-action" type="button" class="btn btn-primary">Declare <span id="sel-count" class="badge bg-light text-primary ms-1">0</span></button>
            <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"></button>
            <ul class="dropdown-menu dropdown-menu-end">
            <li><a class="dropdown-item active" href="#" data-action="declare">Declare</a></li>
            <li><a class="dropdown-item" href="#" data-action="copy">Copy</a></li>
            <li><hr class="dropdown-divider"></li>
            <li><a class="dropdown-item" href="#" data-action="record">Record</a></li>
            <li><a class="dropdown-item" href="#" data-action="unrecord">Unrecord</a></li>
            <li><hr class="dropdown-divider"></li>
            <li><a class="dropdown-item" href="#" data-action="unmark">Unmark</a></li>
            </ul>
            </div>
            <button id="btn-refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
            </li>
            </ul>
            <div class="tab-content border border-top-0 rounded-bottom p-3 mb-2">
            <div class="tab-pane fade show active" id="tab-objects" role="tabpanel">
            <table id="items" class="display nowrap" style="width:100%%">
            <thead>
            <tr>
            <th class="dt-select" data-priority="1"><input type="checkbox" id="check-all" class="form-check-input"></th>
            <th data-priority="1">Type</th>
            <th data-priority="1">Instance</th>
            <th data-priority="1">Name</th>
            <th data-priority="1">Value</th>
            <th data-priority="3">Unit</th>
            <th data-priority="2" class="text-center">Prio</th>
            <th data-priority="4">Description</th>
            <th data-priority="2" class="dt-no-copy dt-action"></th>
            </tr>
            </thead>
            </table>
            </div>
            </div>
            </div>
            </div>

            <!-- Action params modal -->
            <div class="modal fade" id="actionModal" tabindex="-1">
            <div class="modal-dialog modal-sm modal-dialog-centered">
            <div class="modal-content">
            <div class="modal-header py-2">
                <h6 class="modal-title" id="actionModalTitle">Parameters</h6>
                <button type="button" class="btn-close btn-close-sm" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body py-2" id="actionModalBody"></div>
            <div class="modal-footer py-1">
                <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
                <button type="button" class="btn btn-sm btn-primary" id="actionModalOk">OK</button>
            </div>
            </div>
            </div>
            </div>

        """
        h.write(data)

        data = """
        <script>
        $(function () {
            const taskKey = "{task}";
            const deviceId = "{device}";
            const apiUrl = "/api/v1/getbacnetobjects?task=" + encodeURIComponent(taskKey)
                         + "&device=" + encodeURIComponent(deviceId);

            const selected = new Set();
            const prevValues = new Map();
            let currentAction = "declare";
            let table = null;
            let hoveredRow = null;

            const actionParams = {
                declare: {
                    label: "Declare",
                    params: [
                        { name: "cpu", label: "CPU", type: "number", default: 0 },
                        { name: "startInstance", label: "Start Instance", type: "number", default: 0 }
                    ]
                },
                record:   { label: "Record",   params: [] },
                unrecord: { label: "Unrecord", params: [] },
            };

            function itemKey(d) { return d.type + ":" + d.instance; }

            function valueChanged(a, b) {
                if (a === undefined) return false;
                if (typeof a === 'number' && typeof b === 'number') {
                    return Math.abs(a - b) > 0.05;
                }
                return a !== b;
            }

            function updateActionBar() {
                const n = selected.size;
                $('#sel-count').text(n);
                if (n > 0) { $('#action-bar').addClass('active'); }
                else { $('#action-bar').removeClass('active'); }
            }

            function applySelection() {
                if (!table) return;
                table.rows().every(function () {
                    const d = this.data();
                    if (!d) return;
                    const k = itemKey(d);
                    const $row = $(this.node());
                    const isSel = selected.has(k);
                    if (isSel) { $row.addClass('selected'); }
                    else { $row.removeClass('selected'); }
                    $row.find('.row-check').prop('checked', isSel);
                    const prev = prevValues.get(k);
                    if (valueChanged(prev, d.value)) {
                        $row.removeClass('value-changed');
                        void $row[0].offsetWidth;
                        $row.addClass('value-changed');
                    }
                    prevValues.set(k, d.value);
                });
            }

            table = new DataTable('#items', {
            responsive: true,
            paging: false,
            searching: true,
            ordering: true,
            info: false,
            search: { smart: true, regex: false, caseInsensitive: true, boundary: false },
            ajax: { url: apiUrl, dataSrc: "data" },
            columns: [
                { data: null, orderable: false, searchable: false, className: 'dt-select text-center',
                  render: function() { return '<input type="checkbox" class="form-check-input row-check">'; } },
                { data: "type", render: function(data, type, row) {
                    if (type !== 'display') return data;
                    return row.recorded
                        ? data + ' <span class="badge bg-danger ms-1" style="font-size:.65rem;padding:.15em .4em" title="Trending actif">rec</span>'
                        : data;
                }},
                { data: "instance" },
                { data: "name" },
                { data: "value", className: 'text-end', render: function(v, type, row) {
                    if (type !== 'display') return v === null || v === undefined ? '' : String(v);
                    const fmt = typeof v === 'number' && !Number.isInteger(v) ? v.toFixed(1) : String(v === null || v === undefined ? '' : v);
                    return '<strong>' + fmt + '</strong>';
                }},
                { data: "unit", render: function(v, type, row) {
                    if (type !== 'display') return v || '';
                    if (row.labels && row.labels.length > 0) {
                        const val = row.value;
                        let idx;
                        if (typeof val === 'boolean') { idx = val ? 1 : 0; }
                        else if (typeof val === 'number') { idx = val - 1; }
                        else { return v || ''; }
                        const lbl = row.labels[idx];
                        if (lbl !== undefined) return '<span class="fst-italic opacity-75">' + lbl + '</span>';
                    }
                    return v || '';
                }},
                { data: "active_priority", className: 'text-center', orderable: true, searchable: false,
                  render: function(v, type, row) {
                    if (type !== 'display') return v === null || v === undefined ? '' : String(v);
                    if (v === null || v === undefined) return '';
                    var cls = v <= 2 ? 'bg-danger'
                            : (v === 5 || v === 6) ? 'bg-warning text-dark'
                            : v === 8 ? 'bg-primary'
                            : v === 16 ? 'bg-secondary'
                            : 'bg-light text-dark border';
                    return '<span class="badge rounded-pill ' + cls + '" style="min-width:1.8rem">' + v + '</span>';
                }},
                { data: "description" },
                { data: null, orderable: false, searchable: false, className: 'text-center dt-action',
                  render: function(v, type, row) {
                    if (type !== 'display') return '';
                    if (row.type === 'schedule' && row.deviceId === 'local') {
                        return '<button class="btn btn-sm btn-outline-primary py-0 px-1 edit-sched-btn" style="font-size:.75rem">Edit</button>';
                    }
                    if (row.writable === true) {
                        return '<button class="btn btn-sm btn-outline-secondary py-0 px-1 edit-pa-btn" style="font-size:.75rem">Edit</button>';
                    }
                    return '';
                  }}
            ],
            drawCallback: function () { applySelection(); }
            });

            setInterval(function () {
                table.ajax.reload(null, false);
            }, 10000);

            // Suivi de la ligne sous le curseur (pour SPACE)
            $('#items').on('mouseenter', 'tbody tr', function () {
                hoveredRow = this;
            }).on('mouseleave', 'tbody tr', function () {
                hoveredRow = null;
            });

            // Case à cocher : toggle sélection
            $('#items').on('click', '.row-check', function (e) {
                e.stopPropagation();
                const $row = $(this).closest('tr');
                const d = table.row($row).data();
                if (!d) return;
                const k = itemKey(d);
                if (selected.has(k)) { selected.delete(k); $row.removeClass('selected'); $(this).prop('checked', false); }
                else { selected.add(k); $row.addClass('selected'); $(this).prop('checked', true); }
                updateActionBar();
            });

            // Bouton Edit : naviguer vers l'éditeur de schedule
            $('#items').on('click', '.edit-sched-btn', function (e) {
                e.stopPropagation();
                var d = table.row($(this).closest('tr')).data();
                if (!d) return;
                const url = '/bacnetschedule?task=' + encodeURIComponent(taskKey)
                    + '&name=' + encodeURIComponent(d.name);
                if (e.ctrlKey || e.metaKey) { window.open(url, '_blank'); } else { window.location.href = url; }
            });

            // Bouton Edit : naviguer vers Priority Array d'un objet commandable
            $('#items').on('click', '.edit-pa-btn', function (e) {
                e.stopPropagation();
                var d = table.row($(this).closest('tr')).data();
                if (!d) return;
                const url = '/bacnetobjectproperties?task=' + encodeURIComponent(taskKey)
                    + '&device=' + encodeURIComponent(deviceId)
                    + '&type=' + encodeURIComponent(d.type)
                    + '&instance=' + encodeURIComponent(d.instance)
                    + '#tab-pa';
                if (e.ctrlKey || e.metaKey) { window.open(url, '_blank'); } else { window.location.href = url; }
            });

            // Clic sur la ligne (hors checkbox et hors colonne action) : navigation vers les propriétés
            $('#items').on('click', 'td:not(.dt-select):not(.dt-action)', function (e) {
                var data = table.row($(this).closest('tr')).data();
                if (!data) return;
                const url = '/bacnetobjectproperties?task=' + encodeURIComponent(taskKey)
                    + '&device=' + encodeURIComponent(deviceId)
                    + '&type=' + encodeURIComponent(data.type)
                    + '&instance=' + encodeURIComponent(data.instance);
                if (e.ctrlKey || e.metaKey) { window.open(url, '_blank'); } else { window.location.href = url; }
            });

            // Case "select all" dans le header
            $('#check-all').on('change', function () {
                const checked = $(this).prop('checked');
                table.rows({ search: 'applied' }).every(function () {
                    const d = this.data();
                    if (!d) return;
                    const k = itemKey(d);
                    if (checked) { selected.add(k); $(this.node()).addClass('selected'); }
                    else { selected.delete(k); $(this.node()).removeClass('selected'); }
                    $(this.node()).find('.row-check').prop('checked', checked);
                });
                updateActionBar();
            });

            $('#btn-refresh').on('click', async function() {
                await fetch('/api/v1/refreshbacnet?task=' + encodeURIComponent(taskKey)
                          + '&device=' + encodeURIComponent(deviceId));
                table.ajax.reload();
            });

            // action menu
            $('.dropdown-menu').on('click', '.dropdown-item', function (e) {
                e.preventDefault();
                currentAction = $(this).data('action');
                $('.dropdown-menu .dropdown-item').removeClass('active');
                $(this).addClass('active');
                $('#btn-action').contents().first()[0].textContent = $(this).text() + ' ';
            });

            function collectSelectedItems() {
                const items = [];
                table.rows().every(function () {
                    const d = this.data();
                    if (d && selected.has(itemKey(d))) {
                    items.push({ type: d.type, instance: d.instance, name: d.name, unit: d.unit,
                                 description: d.description || '', labels: d.labels || [] });
                    }
                });
                return items;
            }

            async function executeAction(action, items, params) {
                const btn = $('#btn-action');
                btn.prop('disabled', true);
                try {
                    const resp = await fetch('/api/v1/bacnetaction', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            action: action,
                            task: taskKey,
                            device: deviceId,
                            items: items,
                            params: params || {}
                        })
                    });
                    const result = await resp.json();
                    if (result.success) {
                        if (action === 'declare') {
                            copyDataTableToClipboard(table, {
                                selector: '.selected',
                                extraColumns: [{header: 'DeviceId', getValue: function(d) { return d.deviceId !== undefined ? String(d.deviceId) : ''; }}]
                            });
                        }
                        items.forEach(function (it) {
                            selected.delete(it.type + ":" + it.instance);
                        });
                        updateActionBar();
                        table.draw(false);
                        showToast(action.charAt(0).toUpperCase() + action.slice(1) + ': ' + items.length + ' object' + (items.length !== 1 ? 's' : ''), 'success');
                    } else {
                        showToast(action.charAt(0).toUpperCase() + action.slice(1) + ' failed', 'danger');
                    }
                } catch (e) {
                    showToast('Error: ' + e.message, 'danger');
                } finally {
                    btn.prop('disabled', false);
                }
            }

            function buildModalForm(config) {
                let html = '';
                (config.params || []).forEach(function (p) {
                    const id = 'ap-' + p.name;
                    if (p.type === 'select') {
                        html += '<div class="mb-2"><label class="form-label small mb-0" for="' + id + '">' + p.label + '</label>';
                        html += '<select class="form-select form-select-sm action-param" id="' + id + '" data-name="' + p.name + '">';
                        (p.options || []).forEach(function (opt) {
                            const sel = opt == p.default ? ' selected' : '';
                            html += '<option value="' + opt + '"' + sel + '>' + opt + '</option>';
                        });
                        html += '</select></div>';
                    } else {
                        const inputType = p.type === 'number' ? 'number' : 'text';
                        html += '<div class="mb-2"><label class="form-label small mb-0" for="' + id + '">' + p.label + '</label>';
                        html += '<input type="' + inputType + '" class="form-control form-control-sm action-param" id="' + id + '" data-name="' + p.name + '" value="' + (p.default !== undefined ? p.default : '') + '"></div>';
                    }
                });
                return html;
            }

            function collectModalParams() {
                const params = {};
                $('#actionModalBody .action-param').each(function () {
                    const $el = $(this);
                    const name = $el.data('name');
                    const val = $el.val();
                    if ($el.attr('type') === 'number') {
                        params[name] = val !== '' ? Number(val) : null;
                    } else {
                        params[name] = val;
                    }
                });
                return params;
            }

            const actionModal = new bootstrap.Modal('#actionModal');

            $('#btn-action').on('click', function () {
                if (selected.size === 0) return;
                if (currentAction === 'copy') {
                    copyDataTableToClipboard(table, {
                        selector: selected.size > 0 ? '.selected' : {search: 'applied'},
                        extraColumns: [{header: 'DeviceId', getValue: function(d) { return d.deviceId !== undefined ? String(d.deviceId) : ''; }}]
                    });
                    return;
                }
                if (currentAction === 'unmark') {
                    selected.clear();
                    table.rows().every(function () {
                        $(this.node()).removeClass('selected');
                    });
                    updateActionBar();
                    return;
                }
                const config = actionParams[currentAction];
                if (config && config.params && config.params.length > 0) {
                    $('#actionModalTitle').text(config.label || currentAction);
                    $('#actionModalBody').html(buildModalForm(config));
                    actionModal.show();
                } else {
                    executeAction(currentAction, collectSelectedItems(), {});
                }
            });

            $('#actionModalOk').on('click', function () {
                const params = collectModalParams();
                actionModal.hide();
                executeAction(currentAction, collectSelectedItems(), params);
            });

            $('#actionModal').on('keydown', function (e) {
                if (e.key === 'Enter') {
                    e.preventDefault();
                    $('#actionModalOk').click();
                }
            });

            $(document).on('keydown', function (e) {
                if (e.key === ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) {
                    const tag = document.activeElement && document.activeElement.tagName;
                    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
                    if (!hoveredRow || !table) return;
                    e.preventDefault();
                    const $row = $(hoveredRow);
                    const d = table.row($row).data();
                    if (!d) return;
                    const k = itemKey(d);
                    if (selected.has(k)) {
                        selected.delete(k);
                        $row.removeClass('selected');
                        $row.find('.row-check').prop('checked', false);
                    } else {
                        selected.add(k);
                        $row.addClass('selected');
                        $row.find('.row-check').prop('checked', true);
                    }
                    updateActionBar();
                    return;
                }
                if (e.key === 'c' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
                    const tag = document.activeElement && document.activeElement.tagName;
                    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
                    e.preventDefault();
                    const sel = table.rows('.selected').count();
                    const selector = sel > 0 ? '.selected' : {search: 'applied'};
                    copyDataTableToClipboard(table, {
                        selector: selector,
                        extraColumns: [{header: 'DeviceId', getValue: function(d) { return d.deviceId !== undefined ? String(d.deviceId) : ''; }}]
                    });
                    return;
                }
                if (e.key === 'r' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
                    const tag = document.activeElement && document.activeElement.tagName;
                    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
                    if (!table || selected.size === 0) return;
                    e.preventDefault();
                    executeAction('record', collectSelectedItems(), {});
                    return;
                }
                if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key === '/') {
                    e.preventDefault();
                    const searchInput = $('#dt-search-0.dt-input');
                    searchInput.focus();
                    searchInput.select();
                    return;
                }
                if (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey) && !e.altKey) {
                    e.preventDefault();
                    if (e.shiftKey) {
                        table.rows({ search: 'applied' }).every(function () {
                            const d = this.data();
                            if (!d) return;
                            selected.delete(itemKey(d));
                            $(this.node()).removeClass('selected');
                            $(this.node()).find('.row-check').prop('checked', false);
                        });
                    } else {
                        table.rows({ search: 'applied' }).every(function () {
                            const d = this.data();
                            if (!d) return;
                            selected.add(itemKey(d));
                            $(this.node()).addClass('selected');
                            $(this.node()).find('.row-check').prop('checked', true);
                        });
                    }
                    updateActionBar();
                }
            });
        });
        </script>
        """
        h.write(data)
        h.flush()

    def cb_bacnetobjectproperties(self, handler, params):
        """BACnet object properties detail page.
        GET /bacnetobjectproperties?task=&lt;taskkey&gt;&amp;device=&lt;deviceId&gt;&amp;type=&lt;type&gt;&amp;instance=&lt;instance&gt;
          task     : MBIOTaskBacnet key
          device   : numeric deviceId or "local"
          type     : BACnet object type (e.g. analog-value, binary-input)
          instance : BACnet object instance number
        Displays all BACnet properties (key properties first) with auto-refresh every 5 s.
        Flash animation on value change. Live trend chart for presentValue (analog/digital).
        For commandable objects: priority array table with write/release per slot,
        plus a quick-force bar. Out-of-service toggle button.
        """
        mbio = self.getMBIO()
        task_key = params.get('task', '')
        device_id = params.get('device', '')
        obj_type = params.get('type', '')
        obj_instance = params.get('instance', '')

        h = HtmlPage('BACnet Object Properties', handler)
        h.body('<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>')
        h.body('<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>')
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml())
        h.variable('auth.logout', self._logoutHtml())
        h.variable('task', task_key)
        h.variable('device', device_id)
        h.variable('objtype', obj_type)
        h.variable('objinstance', obj_instance)

        # Compute initial recorded state so the badge shows without opening Trending tab first
        _is_recorded = False
        try:
            _task = mbio.task(task_key)
            if _task and hasattr(_task, 'client'):
                _client = _task.client()
                if _client:
                    _is_recorded = _client.isRecorded(
                        task_key, device_id, obj_type,
                        int(obj_instance) if obj_instance else 0)
        except Exception:
            pass
        h.variable('obj.recorded', 'true' if _is_recorded else 'false')

        data = """
            <style>
            @keyframes flash-yellow {
                0%   { background-color: #fff3cd; }
                100% { background-color: transparent; }
            }
            #props tbody tr.value-changed {
                animation: flash-yellow 1.5s ease-out;
            }
            .pa-table td { vertical-align: middle; }
            .pa-row-active { background-color: rgba(13,110,253,.06) !important; border-left: 3px solid #0d6efd; }
            .pa-row-idle { opacity: .5; }
            .pa-row-idle:hover { opacity: 1; transition: opacity .15s; }
            .pa-priority-num { min-width: 2rem; font-size: .8rem; font-weight: 600; }
            .pa-name-cell { font-size: .875rem; min-width: 11rem; }
            .pa-row-active .pa-name-cell { font-weight: 600; color: #0d6efd; }
            .pa-ctrl-cell .input-group { max-width: 200px; }
            .pa-ctrl-cell { white-space: nowrap; }
            /* Status flags pills */
            .sf-pill { font-size: .75rem; padding: .2em .6em; border-radius: 999px; margin-right: .2em;
                       display: inline-block; font-weight: 500; }
            .sf-inactive { background-color: #e9ecef; color: #adb5bd; }
            </style>
            <div class="page container-fluid">
            <div class="card p-4 p-lg-5">
            <div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-3">
            <div>
            <h1 class="h3 mb-1">BACnet Object Properties</h1>
            <p class="text-secondary mb-0">
                <a href="/bacnet">BACnet</a> /
                <a href="/bacnetobjects?task={task}&amp;device={device}">device {device}</a> /
                <b>{objtype} / {objinstance}</b><span id="obj-description"></span>
            </p>
            </div>
            <div class="text-muted small">{mbio.version} /
            {main.nav} / <a href="/bacnet">BACnetDevices</a> / <a href="/bacnettrending">BACnetTrends</a>
            {auth.logout}
            </div>
            </div>
            <ul class="nav nav-tabs mt-3 mb-0 d-flex align-items-center" role="tablist">
            <li class="nav-item" role="presentation">
            <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-props" type="button">Properties</button>
            </li>
            <li class="nav-item" id="tab-pa-li" role="presentation" style="display:none">
            <button id="tab-pa-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-pa" type="button">
            Priority Array&nbsp;<span id="badge-active-prio" class="badge ms-1" style="display:none"></span>
            </button>
            </li>
            <li class="nav-item" role="presentation">
            <button id="tab-trending-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-trending" type="button">Plot <span id="badge-trending" class="badge bg-secondary ms-1" style="font-size:.65rem">—</span></button>
            </li>
            <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center flex-wrap">
            <button id="btn-record" class="btn btn-sm btn-outline-success">Record</button>
            <div class="btn-group btn-group-sm" id="range-group">
            <button class="btn btn-outline-secondary" data-range="3600">1h</button>
            <button class="btn btn-outline-secondary" data-range="21600">6h</button>
            <button class="btn btn-outline-secondary active" data-range="86400">24h</button>
            <button class="btn btn-outline-secondary" data-range="604800">7d</button>
            </div>
            <button id="btn-reset-zoom" class="btn btn-sm btn-outline-secondary" title="Reset zoom">&#8413;</button>
            <button id="btn-export-csv" class="btn btn-sm btn-outline-secondary" title="Export CSV">&#8595; CSV</button>
            <button id="btn-purge-trend" class="btn btn-sm btn-outline-danger" title="Purge history">Purge</button>
            <button id="oos-btn" class="btn btn-sm btn-outline-secondary" title="Basculer outOfService">Out of Service</button>
            <button id="btn-refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
            </li>
            </ul>
            <div class="tab-content border border-top-0 rounded-bottom p-3 mb-2">
            <div class="tab-pane fade show active" id="tab-props" role="tabpanel">
            <table id="props" class="display nowrap" style="width:100%%">
            <thead>
            <tr>
            <th data-priority="1">Property</th>
            <th data-priority="1">Value</th>
            </tr>
            </thead>
            </table>
            </div>
            <div class="tab-pane fade" id="tab-pa" role="tabpanel">
            <table class="table table-sm pa-table mt-2">
            <thead><tr>
                <th style="width:3rem" class="text-center">#</th>
                <th>Name</th>
                <th>Value</th>
            </tr></thead>
            <tbody id="pa-body"></tbody>
            </table>
            </div>
            <div class="tab-pane fade" id="tab-trending" role="tabpanel">
            <div id="trending-wrapper" style="position:relative;height:360px">
            <canvas id="trending-chart"></canvas>
            </div>
            <div class="text-muted small mt-1" id="trending-info"></div>
            </div>
            </div>
            </div>
            </div>
        """
        h.write(data)

        data = """
        <script>
        $(function () {
            const taskKey = "{task}";
            const deviceId = "{device}";
            const objType = "{objtype}";
            const objInstance = "{objinstance}";
            const apiUrl = "/api/v1/getbacnetobjectproperties?task=" + encodeURIComponent(taskKey)
                         + "&device=" + encodeURIComponent(deviceId)
                         + "&type=" + encodeURIComponent(objType)
                         + "&instance=" + encodeURIComponent(objInstance);

            const PA_NAMES = {
                1: "Manual Life Safety",
                2: "Automatic Life Safety",
                3: "Available",
                4: "Available",
                5: "Critical Equipment Control",
                6: "Minimum On/Off",
                7: "Available",
                8: "Manual Operator",
                9: "Available",
                10: "Available",
                11: "Available",
                12: "Available",
                13: "Available",
                14: "Available",
                15: "Available",
                16: "Default (relinquishDefault)"
            };

            const prevValues = new Map();
            let oosValue = false;
            let objLabels = null;

            const isDigital    = objType.startsWith('binary-');
            const isMultiState = objType.startsWith('multi-state-');
            function toTrendValue(v) {
                if (typeof v === 'boolean') return v ? 1 : 0;
                if (typeof v === 'number')  return v;
                return null;
            }

            // Called on each DataTable redraw — pushes presentValue into live buffer for Trending tab.
            function updateTrend() {
                if (!table) return;
                const rows = table.rows().data().toArray();
                for (let i = 0; i < rows.length; i++) {
                    if (rows[i] && rows[i].property === 'presentValue') {
                        const tv = toTrendValue(rows[i].value);
                        if (tv !== null) pushLiveValue(tv);
                        break;
                    }
                }
            }

            // --- Rendus visuels spéciaux ---
            function renderStatusFlags(v) {
                if (typeof v !== 'object' || v === null) return String(v);
                const defs = [
                    { key: 'inAlarm',      label: 'inAlarm',      cls: 'bg-danger text-white' },
                    { key: 'fault',        label: 'fault',         cls: 'bg-danger text-white' },
                    { key: 'overridden',   label: 'overridden',    cls: 'bg-warning text-dark' },
                    { key: 'outOfService', label: 'outOfService',  cls: 'bg-secondary text-white' },
                ];
                return defs.map(function(d) {
                    const cls = v[d.key] === true ? 'sf-pill ' + d.cls : 'sf-pill sf-inactive';
                    return '<span class="' + cls + '">' + d.label + '</span>';
                }).join('');
            }
            function renderReliability(v) {
                const s = String(v === null || v === undefined ? '' : v);
                const ok = s === 'no-fault-detected' || s === 'no-fault' || s === '';
                return '<span class="badge ' + (ok ? 'bg-success' : 'bg-danger') + '">' + (s || 'no-fault-detected') + '</span>';
            }
            function renderEventState(v) {
                const s = String(v === null || v === undefined ? 'normal' : v);
                const ok = s === 'normal';
                return '<span class="badge ' + (ok ? 'bg-success' : 'bg-warning text-dark') + '">' + s + '</span>';
            }
            function renderOutOfService(v) {
                return v === true
                    ? '<span class="badge bg-warning text-dark">out-of-service</span>'
                    : '<span class="badge bg-success">normal</span>';
            }

            function updateOosButton(oos) {
                oosValue = oos;
                const btn = $('#oos-btn');
                if (oos) {
                    btn.removeClass('btn-outline-secondary').addClass('btn-warning');
                    btn.html('Out of Service <span class="badge bg-dark ms-1">ON</span>');
                } else {
                    btn.removeClass('btn-warning').addClass('btn-outline-secondary');
                    btn.text('Out of Service');
                }
            }

            function applyFlash() {
                if (!table) return;
                table.rows().every(function () {
                    const d = this.data();
                    if (!d) return;
                    const k = d.property;
                    const prev = prevValues.get(k);
                    const curr = d.value;
                    let changed = false;
                    if (prev !== undefined) {
                        if (typeof prev === 'number' && typeof curr === 'number') {
                            changed = Math.abs(prev - curr) > 0.001;
                        } else {
                            changed = String(prev) !== String(curr);
                        }
                    }
                    prevValues.set(k, curr);
                    if (changed) {
                        const $row = $(this.node());
                        $row.removeClass('value-changed');
                        void $row[0].offsetWidth;
                        $row.addClass('value-changed');
                    }
                });
            }

            let objName = '';
            let covStatus = '';
            let table = null;
            table = new DataTable('#props', {
                responsive: true,
                paging: false,
                searching: false,
                ordering: false,
                info: false,
                ajax: { url: apiUrl, dataSrc: function(json) {
                    if (json.labels !== undefined) objLabels = json.labels;
                    if (json.cov_status !== undefined) covStatus = json.cov_status;
                    return json.data;
                }},
                columns: [
                    { data: "property" },
                    { data: "value", render: function(v, type, row) {
                        if (type !== 'display') {
                            return v === null ? '' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
                        }
                        const prop = row.property;
                        if (prop === 'statusFlags') return renderStatusFlags(v);
                        if (prop === 'reliability') return renderReliability(v);
                        if (prop === 'eventState') return renderEventState(v);
                        if (prop === 'outOfService') return renderOutOfService(v);
                        if (v === null || v === undefined) return '<span class="text-muted">&mdash;</span>';
                        if (prop === 'presentValue') {
                            const fmt = typeof v === 'number' && !Number.isInteger(v) ? v.toFixed(3) : String(v);
                            let html = '<strong>' + fmt + '</strong>';
                            if (isRecorded) {
                                html += ' <span class="badge bg-danger ms-1" style="font-size:.65rem;padding:.15em .4em" title="Trending actif">rec</span>';
                            }
                            if (covStatus === 'active') {
                                html += ' <span class="badge bg-info ms-1" style="font-size:.65rem;padding:.15em .4em" title="COV subscription active">COV</span>';
                            } else if (covStatus === 'no_support') {
                                html += ' <span class="badge bg-secondary ms-1" style="font-size:.65rem;padding:.15em .4em" title="Device ne supporte pas COV (polling 15s)">poll</span>';
                            }
                            if (activePrioNum !== null) {
                                const cls = priorityBadgeClass(activePrioNum);
                                html += ' <span class="badge ms-1 js-goto-pa ' + cls + '"'
                                      + ' style="font-size:.7rem;cursor:pointer"'
                                      + ' title="Active priority — click to open Priority Array">P' + activePrioNum + '</span>';
                            } else if (paCommandable) {
                                html += ' <button class="btn btn-sm btn-outline-secondary py-0 px-1 ms-1 js-goto-pa"'
                                      + ' title="Edit — open Priority Array">&#9998;</button>';
                            }
                            if (objLabels && objLabels.length > 0) {
                                let lbl = null;
                                if (typeof v === 'boolean') { lbl = objLabels[v ? 1 : 0]; }
                                else if (typeof v === 'number') { lbl = objLabels[Math.round(v) - 1]; }
                                if (lbl !== undefined && lbl !== null)
                                    html += '<br><small class="text-secondary fst-italic">' + lbl + '</small>';
                            }
                            return html;
                        }
                        if (prop === 'stateText' && Array.isArray(v)) {
                            return v.map(function(s, i) {
                                return '<span class="badge bg-light text-dark border me-1 mb-1">' + (i + 1) + ' &nbsp;' + s + '</span>';
                            }).join('');
                        }
                        if (prop === 'activeText') return '<span class="badge bg-success">' + String(v) + '</span>';
                        if (prop === 'inactiveText') return '<span class="badge bg-secondary">' + String(v) + '</span>';
                        if (typeof v === 'number' && !Number.isInteger(v)) return v.toFixed(3);
                        if (Array.isArray(v)) return v.join(', ');
                        if (typeof v === 'object') return JSON.stringify(v);
                        return String(v);
                    }}
                ],
                drawCallback: function () {
                    applyFlash();
                    updateTrend();
                    if (!table) return;
                    let desc = '';
                    let name = '';
                    table.rows().every(function () {
                        const d = this.data();
                        if (!d) return;
                        if (d.property === 'description' && d.value) desc = String(d.value);
                        if (d.property === 'objectName' && d.value) name = String(d.value);
                    });
                    $('#obj-description').text(desc ? ' / ' + desc : '');
                    objName = name;
                    // Schedule edit button — inject after presentValue cell content
                    if (objType === 'schedule' && objName) {
                        const href = '/bacnetschedule?task=' + encodeURIComponent(taskKey)
                                   + '&name=' + encodeURIComponent(objName);
                        table.rows().every(function () {
                            const d = this.data();
                            if (d && d.property === 'presentValue') {
                                const $td = $(this.node()).find('td').last();
                                $td.find('.js-sched-edit').remove();
                                $td.append(' <a href="' + href + '" class="btn btn-sm btn-outline-secondary py-0 px-1 ms-1 js-sched-edit" title="Edit schedule">&#9998;</a>');
                            }
                        });
                    }
                }
            });

            function isUserEditing() {
                return $('input:focus, select:focus, textarea:focus').length > 0;
            }

            setInterval(function () {
                if (!isUserEditing()) table.ajax.reload(null, false);
            }, 5000);

            // Click délégué sur le badge de priorité active dans la table des propriétés
            $('#props').on('click', '.js-goto-pa', function () {
                const tabBtn = document.querySelector('#tab-pa-li [data-bs-toggle="tab"]');
                if (tabBtn) bootstrap.Tab.getOrCreateInstance(tabBtn).show();
            });

            // Priority Array
            let paCommandable = false;
            let activePrioNum = null;
            let pendingTabActivation = window.location.hash;   // '#tab-pa' or '#tab-trending'

            function getActivePrio(pa) {
                for (let i = 0; i < pa.length; i++) {
                    if (pa[i] !== null && pa[i] !== undefined) return i + 1;
                }
                return null;
            }

            function updateActivePrioBadge(pa) {
                activePrioNum = pa ? getActivePrio(pa) : null;
                const $tabBadge = $('#badge-active-prio');
                if (activePrioNum !== null) {
                    const cls = priorityBadgeClass(activePrioNum);
                    $tabBadge.attr('class', 'badge ms-1 ' + cls).text('P' + activePrioNum).show();
                } else {
                    $tabBadge.hide();
                }
                // Force DataTable redraw so the presentValue badge updates
                if (table) table.rows().invalidate('data').draw(false);
            }

            function priorityBadgeClass(p) {
                if (p <= 2) return 'bg-danger';
                if (p === 5 || p === 6) return 'bg-warning text-dark';
                if (p === 8) return 'bg-primary';
                if (p === 16) return 'bg-secondary';
                return 'bg-light text-dark border';
            }

            function renderPriorityArray(pa) {
                const tbody = $('#pa-body');
                tbody.empty();
                pa.forEach(function (val, idx) {
                    const priority = idx + 1;
                    const isActive = val !== null && val !== undefined;
                    const displayVal = isActive
                        ? (typeof val === 'boolean' ? (val ? 1 : 0) : String(val))
                        : '';
                    const rowClass = isActive ? 'pa-row-active' : 'pa-row-idle';
                    const row = $('<tr id="pa-row-' + priority + '" class="' + rowClass + '">');

                    // Badge priorité
                    const badgeTd = $('<td class="text-center">');
                    badgeTd.append($('<span class="badge rounded-pill pa-priority-num ' + priorityBadgeClass(priority) + '">').text(priority));
                    row.append(badgeTd);

                    // Nom
                    row.append($('<td class="pa-name-cell">').text(PA_NAMES[priority] || ''));

                    // Input-group valeur + bouton write + bouton release
                    const ctrlTd = $('<td class="pa-ctrl-cell text-start">');
                    const ig = $('<div class="input-group input-group-sm">');
                    const input = $('<input type="text" class="form-control" id="pa-input-' + priority + '" placeholder="—">').val(displayVal);
                    const btnWrite = $('<button class="btn btn-outline-success" type="button" title="Ecrire">&#10003;</button>');
                    const btnRelease = $('<button class="btn btn-outline-danger pa-release" type="button" title="Release">&#10007;</button>');
                    if (!isActive) btnRelease.hide();
                    ig.append(input).append(btnWrite).append(btnRelease);
                    ctrlTd.append(ig);
                    row.append(ctrlTd);

                    input.on('keydown', function (e) {
                        if (e.key === 'Enter') { e.preventDefault(); btnWrite.trigger('click'); }
                    });
                    btnWrite.on('click', function () {
                        const rawVal = input.val().trim();
                        if (rawVal === '') return;
                        const numVal = parseFloat(rawVal);
                        writePriority(priority, isNaN(numVal) ? rawVal : numVal);
                    });
                    btnRelease.on('click', function () {
                        writePriority(priority, null);
                    });

                    tbody.append(row);
                });
            }

            async function writePriority(priority, value) {
                let ok = false;
                try {
                    const resp = await fetch('/api/v1/bacnetwritepriority', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            task: taskKey, device: deviceId,
                            type: objType, instance: objInstance,
                            priority: priority,
                            value: value
                        })
                    });
                    const result = await resp.json();
                    ok = result.success === true;
                } catch (err) { }
                if (value === null) {
                    showToast(ok ? 'P' + priority + ' released' : 'Release P' + priority + ' failed', ok ? 'success' : 'danger');
                } else {
                    showToast(ok ? 'P' + priority + ' \u2190 ' + value : 'Write P' + priority + ' failed', ok ? 'success' : 'danger');
                }
                table.ajax.reload(null, false);
                loadPriorityArray();
            }

            // force=true : appelé explicitement après un write, ignore le guard d'édition.
            // force=false (défaut) : refresh auto, ne touche pas la PA si l'utilisateur édite.
            function applyPriorityArrayData(json, force) {
                if (json.out_of_service !== undefined) updateOosButton(json.out_of_service);
                if (json.labels !== undefined) objLabels = json.labels;
                if (!json.commandable || !json.priority_array) {
                    $('#tab-pa-li').hide();
                    updateActivePrioBadge(null);
                    return;
                }
                $('#tab-pa-li').show();
                paCommandable = true;
                updateActivePrioBadge(json.priority_array);
                if (pendingTabActivation === '#tab-pa') {
                    pendingTabActivation = '';
                    const tabBtn = document.getElementById('tab-pa-btn');
                    if (tabBtn) bootstrap.Tab.getOrCreateInstance(tabBtn).show();
                }
                if (!force && isUserEditing()) return;
                const focusedId = document.activeElement ? document.activeElement.id : null;
                const savedVal = focusedId ? $('#' + CSS.escape(focusedId)).val() : null;
                renderPriorityArray(json.priority_array);
                if (focusedId && savedVal !== null) {
                    const $el = $('#' + CSS.escape(focusedId));
                    if ($el.length) { $el.val(savedVal).focus(); }
                }
            }

            // Piggyback sur le reload du DataTable (toutes les 5s) : la réponse contient déjà
            // priority_array, commandable et out_of_service — pas besoin d'une requête séparée.
            $('#props').on('xhr.dt', function (e, settings, json) {
                if (json) applyPriorityArrayData(json, false);
            });

            async function loadPriorityArray() {
                try {
                    const resp = await fetch(apiUrl);
                    const json = await resp.json();
                    applyPriorityArrayData(json, true);
                } catch (err) {
                    $('#pa-section').hide();
                }
            }

            loadPriorityArray();

            // --- Out of Service ---
            $('#oos-btn').on('click', async function () {
                const newOos = !oosValue;
                let ok = false;
                try {
                    const resp = await fetch('/api/v1/bacnetwriteproperty', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            task: taskKey, device: deviceId,
                            type: objType, instance: objInstance,
                            property: 'outOfService', value: newOos
                        })
                    });
                    const result = await resp.json();
                    ok = result.success === true;
                } catch (e) {}
                showToast(ok ? 'Out of Service ' + (newOos ? 'enabled' : 'disabled') : 'Out of Service write failed', ok ? 'success' : 'danger');
                table.ajax.reload(null, false);
                loadPriorityArray();
            });


            $('#btn-refresh').on('click', async function () {
                await fetch('/api/v1/refreshbacnet?task=' + encodeURIComponent(taskKey)
                          + '&device=' + encodeURIComponent(deviceId));
                table.ajax.reload();
                loadPriorityArray();
            });

            $(document).on('keydown', function (e) {
                if ($(e.target).is('input, textarea, select')) return;
                if (e.key === 'c' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
                    e.preventDefault();
                    if (table) copyDataTableToClipboard(table);
                }
            });

            // ----------------------------------------------------------------
            // Trending tab (Chart.js + zoom plugin)
            // ----------------------------------------------------------------
            let trendingChart   = null;
            let liveBuffer      = [];   // [{x: ts_ms, y: value}] — buffer JS live (2000 pts max)
            let isRecorded      = {obj.recorded};
            let rangeMs         = 86400000;
            let trendingTimer   = null;
            function buildTrendingUrl(base) {
                return base + '?task=' + encodeURIComponent(taskKey)
                            + '&device=' + encodeURIComponent(deviceId)
                            + '&type=' + encodeURIComponent(objType)
                            + '&instance=' + encodeURIComponent(objInstance);
            }

            // --- Crosshair plugin (vertical + horizontal lines, dot, axis labels) ---
            var mbioCrosshairPlugin = {
                id: 'mbioCrosshair',
                afterEvent: function(chart, args) {
                    var e = args.event;
                    if (e.type === 'mousemove') {
                        chart._ch_x = e.x; chart._ch_y = e.y; args.changed = true;
                    } else if (e.type === 'mouseout') {
                        chart._ch_x = null; args.changed = true;
                    }
                },
                afterDraw: function(chart) {
                    if (chart._ch_x == null) return;
                    var xScale = chart.scales.x, yScale = chart.scales.y;
                    var cx = chart._ch_x;
                    var top = yScale.top, bot = yScale.bottom;
                    var left = xScale.left, right = xScale.right;
                    if (cx < left || cx > right) return;

                    // Interpolate Y value at cursor X position
                    var xVal = xScale.getValueForPixel(cx);
                    var ds = chart.data.datasets[0];
                    var pts = ds ? ds.data : [];
                    var yInterp = null;
                    if (pts.length) {
                        if (xVal <= pts[0].x) {
                            yInterp = pts[0].y;
                        } else if (xVal >= pts[pts.length - 1].x) {
                            yInterp = pts[pts.length - 1].y;
                        } else {
                            for (var i = 0; i < pts.length - 1; i++) {
                                if (pts[i].x <= xVal && pts[i + 1].x >= xVal) {
                                    yInterp = pts[i].y;
                                    break;
                                }
                            }
                        }
                    }
                    if (yInterp === null) return;

                    var yPx = Math.max(top, Math.min(bot, yScale.getPixelForValue(yInterp)));
                    var c = chart.ctx;
                    c.save();

                    // Draw a pill label (dark bg, light text)
                    function pill(text, px, py) {
                        c.font = '11px system-ui,sans-serif';
                        var tw = c.measureText(text).width;
                        var pw = tw + 12, ph = 19, r = 5;
                        var bx = Math.max(left, Math.min(right - pw, px - pw / 2));
                        var by = Math.max(top, Math.min(bot - ph, py - ph / 2));
                        c.fillStyle = 'rgba(15,23,42,0.82)';
                        c.beginPath();
                        c.moveTo(bx+r, by); c.lineTo(bx+pw-r, by);
                        c.quadraticCurveTo(bx+pw, by, bx+pw, by+r);
                        c.lineTo(bx+pw, by+ph-r);
                        c.quadraticCurveTo(bx+pw, by+ph, bx+pw-r, by+ph);
                        c.lineTo(bx+r, by+ph);
                        c.quadraticCurveTo(bx, by+ph, bx, by+ph-r);
                        c.lineTo(bx, by+r);
                        c.quadraticCurveTo(bx, by, bx+r, by);
                        c.closePath(); c.fill();
                        c.fillStyle = '#e2e8f0';
                        c.textAlign = 'left'; c.textBaseline = 'middle';
                        c.fillText(text, bx + 6, by + ph / 2);
                    }

                    // Vertical dashed line
                    c.beginPath(); c.strokeStyle = 'rgba(80,80,80,0.4)';
                    c.lineWidth = 1; c.setLineDash([5, 4]);
                    c.moveTo(cx, top); c.lineTo(cx, bot); c.stroke();

                    // Horizontal dashed line at interpolated value
                    c.beginPath();
                    c.moveTo(left, yPx); c.lineTo(right, yPx); c.stroke();
                    c.setLineDash([]);

                    // Dot on curve at crosshair intersection
                    c.beginPath(); c.arc(cx, yPx, 5, 0, 2 * Math.PI);
                    c.fillStyle = '#0d6efd'; c.strokeStyle = '#fff'; c.lineWidth = 2;
                    c.fill(); c.stroke();

                    // Time label at bottom of plot area (inside, near bottom edge)
                    var d = new Date(xVal);
                    var timeLabel = rangeMs > 86400000
                        ? d.toLocaleString()
                        : d.toLocaleTimeString();
                    pill(timeLabel, cx, bot - 12);

                    // Value label at left of plot area (inside, at interpolated Y)
                    var yClamp = Math.max(top + 10, Math.min(bot - 10, yPx));
                    var valStr = typeof yInterp === 'number' && !Number.isInteger(yInterp)
                        ? yInterp.toFixed(3) : String(yInterp);
                    pill(valStr, left + c.measureText(valStr).width / 2 + 10, yClamp);

                    c.restore();
                }
            };

            function initTrendingChart() {
                const ctx = document.getElementById('trending-chart').getContext('2d');
                trendingChart = new Chart(ctx, {
                    type: 'line',
                    plugins: [mbioCrosshairPlugin],
                    data: { datasets: [{
                        label: 'Value',
                        data: [],
                        borderColor: '#0d6efd',
                        borderWidth: 1.5,
                        pointRadius: 0,
                        stepped: 'before',
                        fill: false,
                    }]},
                    options: {
                        animation: false,
                        responsive: true,
                        maintainAspectRatio: false,
                        scales: {
                            x: { type: 'linear',
                                 ticks: { callback: function(v) { return new Date(v).toLocaleTimeString(); } }},
                            y: {}
                        },
                        plugins: {
                            legend: { display: false },
                            tooltip: { enabled: false },
                            zoom: {
                                pan:  { enabled: true, mode: 'x' },
                                zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }
                            }
                        }
                    }
                });
            }

            function computeTrendingStats(pts) {
                if (!pts.length) return '';
                var vals = pts.map(function(p) { return p.y; });
                var min = Math.min.apply(null, vals).toFixed(2);
                var max = Math.max.apply(null, vals).toFixed(2);
                var avg = (vals.reduce(function(a,b){return a+b;}, 0) / vals.length).toFixed(2);
                var durMs = pts[pts.length-1].x - pts[0].x;
                var dur = durMs < 3600000
                    ? (durMs/60000).toFixed(0) + ' min'
                    : (durMs/3600000).toFixed(1) + ' h';
                return '  \u00b7  min ' + min + '  max ' + max + '  avg ' + avg + '  dur ' + dur;
            }

            function updateTrendingRecordBtn() {
                var btn = document.getElementById('btn-record');
                if (!btn) return;
                btn.textContent = isRecorded ? 'Unrecord' : 'Record';
                btn.className   = isRecorded ? 'btn btn-sm btn-danger'
                                             : 'btn btn-sm btn-outline-success';
                if (table) table.rows().invalidate('data').draw(false);
            }

            function updateTrendingChart() {
                if (!trendingChart) return;
                var since = (Date.now() - rangeMs) / 1000;
                fetch(buildTrendingUrl('/api/v1/bacnettrendingdata') + '&since=' + since)
                    .then(function(r) { return r.json(); })
                    .then(function(resp) {
                        isRecorded = resp.recorded;
                        updateTrendingBadge(resp.point_count || 0);
                        updateTrendingRecordBtn();
                        var serverPts = (resp.data || []).map(function(p) { return {x: p[0], y: p[1]}; });
                        var livePts   = liveBuffer.filter(function(p) { return p.x >= Date.now() - rangeMs; });
                        var all = serverPts.concat(livePts);
                        all.sort(function(a, b) { return a.x - b.x; });
                        trendingChart.data.datasets[0].data = all;
                        trendingChart.update('none');
                        var info = all.length + ' pts'
                            + (resp.point_count ? ' (' + resp.point_count + ' stored)' : '')
                            + ' \u00b7 ' + (isRecorded ? 'Recording \u25cf' : 'Live only')
                            + computeTrendingStats(all);
                        document.getElementById('trending-info').textContent = info;
                    });
            }

            function updateTrendingBadge(count) {
                var el = document.getElementById('badge-trending');
                if (!el) return;
                el.textContent = count > 0 ? count : '0';
                el.className = 'badge ms-1 ' + (count > 0 ? 'bg-success' : 'bg-secondary');
                el.style.fontSize = '.65rem';
            }

            function pushLiveValue(value) {
                if (value == null) return;
                liveBuffer.push({x: Date.now(), y: value});
                if (liveBuffer.length > 2000) liveBuffer.shift();
                if (isRecorded) {
                    fetch(buildTrendingUrl('/api/v1/bacnettrendpush') + '&value=' + encodeURIComponent(value));
                }
            }

            $('#btn-record').on('click', function () {
                var url = isRecorded
                    ? buildTrendingUrl('/api/v1/bacnetunrecord')
                    : buildTrendingUrl('/api/v1/bacnetrecord') + '&name=' + encodeURIComponent(objName || objType + '_' + objInstance);
                fetch(url)
                    .then(function(r) { return r.json(); })
                    .then(function(r) { isRecorded = r.recorded; updateTrendingRecordBtn(); updateTrendingChart(); });
            });

            $('#btn-purge-trend').on('click', function () {
                if (!confirm('Clear all stored trending data for this object?')) return;
                fetch(buildTrendingUrl('/api/v1/bacnetpurge'))
                    .then(function(r) { return r.json(); })
                    .then(function() { updateTrendingChart(); });
            });

            $('#range-group [data-range]').on('click', function () {
                rangeMs = parseInt($(this).data('range')) * 1000;
                $('#range-group [data-range]').removeClass('active');
                $(this).addClass('active');
                if (trendingChart) trendingChart.resetZoom();
                updateTrendingChart();
            });

            $('#btn-reset-zoom').on('click', function () {
                if (trendingChart) trendingChart.resetZoom();
            });


            $('#btn-export-csv').on('click', function () {
                var pts = trendingChart ? trendingChart.data.datasets[0].data : [];
                if (!pts.length) return;
                var rows = ['timestamp_iso,timestamp_ms,value'].concat(
                    pts.map(function(p) {
                        return new Date(p.x).toISOString() + ',' + p.x + ',' + p.y;
                    })
                );
                var blob = new Blob([rows.join('\\n')], {type: 'text/csv'});
                var a = document.createElement('a');
                a.href = URL.createObjectURL(blob);
                a.download = objType + '_' + objInstance + '_trending.csv';
                a.click();
            });

            // Initial badge fetch — lightweight (since=far future → no data, just metadata)
            fetch(buildTrendingUrl('/api/v1/bacnettrendingdata') + '&since=9999999999999')
                .then(function(r) { return r.json(); })
                .then(function(resp) { updateTrendingBadge(resp.point_count || 0); })
                .catch(function() {});

            // Activate Trending tab if hash matches (PA tab handled inside applyPriorityArrayData)
            if (pendingTabActivation === '#tab-trending') {
                pendingTabActivation = '';
                bootstrap.Tab.getOrCreateInstance(
                    document.getElementById('tab-trending-btn')).show();
            }

            function resizeTrendingChart() {
                var el = document.getElementById('trending-wrapper');
                if (!el) return;
                var top = el.getBoundingClientRect().top;
                var h = Math.max(300, window.innerHeight - top - 80);
                el.style.height = h + 'px';
                if (trendingChart) trendingChart.resize();
            }
            $(window).on('resize', resizeTrendingChart);

            document.getElementById('tab-trending-btn').addEventListener('shown.bs.tab', function () {
                resizeTrendingChart();
                if (!trendingChart) initTrendingChart();
                updateTrendingChart();
                trendingTimer = setInterval(updateTrendingChart, 5000);
            });
            document.getElementById('tab-trending-btn').addEventListener('hidden.bs.tab', function () {
                clearInterval(trendingTimer);
                trendingTimer = null;
            });

        });
        </script>
        """
        h.write(data)
        h.flush()

    # -------------------------------------------------------------------------
    # BACnet Schedule API + editor
    # -------------------------------------------------------------------------

    def cb_getbacnetschedules(self, handler, params):
        """List all locally declared Schedule objects for a BACnet task.
        GET /api/v1/getbacnetschedules?task=&lt;taskkey&gt;
        Returns {"data": [{"name", "instance", "description", "presentValue",
                            "scheduleDefault"}, ...]}
        """
        task_key = params.get('task')
        if not task_key:
            return self.cb_write_failure(handler)
        mbio = self.getMBIO()
        task = mbio.task(task_key)
        if not task:
            return self.cb_write_failure(handler)
        client = task.client()
        if not client:
            return self.cb_write_failure(handler)
        schedules = client.retrieveLocalSchedules() or []
        self.cb_write_json(handler, {'data': schedules})
        return True

    def cb_getschedule(self, handler, params):
        """Read the full data of a local Schedule object.
        GET /api/v1/getschedule?task=&lt;taskkey&gt;&amp;name=&lt;objectName&gt;
        Returns {"weeklySchedule", "exceptionSchedule", "scheduleDefault",
                 "description", "presentValue"}.
        weeklySchedule is a list of 7 day-lists (index 0 = Monday).
        Each day-list contains {"time": [h,m,s,cs], "value": {"t","v"}} entries.
        """
        task_key = params.get('task')
        name = params.get('name')
        if not task_key or not name:
            return self.cb_write_failure(handler)
        mbio = self.getMBIO()
        task = mbio.task(task_key)
        if not task:
            return self.cb_write_failure(handler)
        client = task.client()
        if not client:
            return self.cb_write_failure(handler)
        data = client.getScheduleData(name)
        if data is None:
            return self.cb_write_failure(handler)
        self.cb_write_json(handler, data)
        return True

    def cb_getschedulelinks(self, handler, params):
        """Return the listOfObjectPropertyReferences of a local Schedule, enriched with live object data.
        GET /api/v1/getschedulelinks?task=<taskkey>&name=<objectName>
        Returns {"data": [{"type", "instance", "name", "description", "value", "unit",
                           "reliability", "outOfService", "device", "property"}, ...]}.
        """
        task_key = params.get('task')
        name = params.get('name')
        if not task_key or not name:
            return self.cb_write_failure(handler)
        mbio = self.getMBIO()
        task = mbio.task(task_key)
        if not task:
            return self.cb_write_failure(handler)
        client = task.client()
        if not client:
            return self.cb_write_failure(handler)
        links = client.getScheduleLinks(name)
        self.cb_write_json(handler, {'data': links})
        return True

    def cb_writeschedule(self, handler, params, data=None):
        """Write weeklySchedule, scheduleDefault and/or exceptionSchedule of a local Schedule object.
        POST /api/v1/writeschedule
        Body: {"task": &lt;taskkey&gt;, "name": &lt;objectName&gt;,
               "weeklySchedule": [...],      (optional)
               "scheduleDefault": {...},     (optional)
               "exceptionSchedule": [...]}   (optional)
        Triggers BACnet persistence after the write.
        """
        try:
            payload = json.loads(data) if isinstance(data, str) else (data or {})
        except (json.JSONDecodeError, TypeError):
            return self.cb_write_failure(handler)
        task_key = payload.get('task')
        name = payload.get('name')
        weekly = payload.get('weeklySchedule')
        sched_default = payload.get('scheduleDefault')
        exception_schedule = payload.get('exceptionSchedule')
        if not task_key or not name:
            return self.cb_write_failure(handler)
        mbio = self.getMBIO()
        task = mbio.task(task_key)
        if not task:
            return self.cb_write_failure(handler)
        client = task.client()
        if not client:
            return self.cb_write_failure(handler)
        ok = client.writeScheduleData(name, weekly=weekly, sched_default=sched_default,
                                      exception_schedule=exception_schedule)
        if ok:
            return self.cb_write_success(handler)
        return self.cb_write_failure(handler)

    # -------------------------------------------------------------------------
    # BACnet Trending persistence
    # -------------------------------------------------------------------------

    def _trending_init_persistence(self):
        """Lazy-initialize persistence state variables (called from run() and on-demand)."""
        if not hasattr(self, '_trend_save_queue'):
            self._trend_save_queue = []
            self._trend_save_queue_lock = threading.Lock()
            self._trend_next_save_at = 0.0
            self._trend_last_scan_at = 0.0
            self._trend_loaded_keys = set()
            self._trend_orphans_cleaned = False
            self._trend_index_loaded = False
            self._trend_index_last_save = 0.0
            self._bacnet_cache_loaded = False
            self._bacnet_cache_last_save = 0.0

    def _trending_dataname(self, entry):
        """Return pickle dataname for a TrendingEntry: '{device_id}.{obj_name}.bacnettrend'."""
        safe = lambda s: re.sub(r'[^\w\-]', '_', str(s))
        return '%s.%s.bacnettrend' % (safe(entry.device_id), safe(entry.name))

    def _trending_save_one(self, entry, store):
        """Save one trend object to pickle storage.
        Uses force=True to bypass hash comparison — trend data always changes."""
        try:
            dataname = self._trending_dataname(entry)
            points = store.get_history(entry.key)
            if points and self.pickleWrite(dataname, points, force=True):
                store.mark_saved(entry.key)
                self.logger.info('Trend saved: %s (%d pts)' % (dataname, len(points)))
            elif not points:
                self.logger.debug('Trend save skipped (no data): %s' % dataname)
            else:
                self.logger.warning('Trend save failed: %s' % dataname)
        except Exception:
            self.logger.exception('_trending_save_one(%s)' % entry.key)

    def _trending_load_one(self, entry):
        """Load persisted data for one entry into its store."""
        try:
            task = self.getMBIO().task(entry.task_key)
            if not task:
                return
            client = task.client()
            if not client:
                return
            store = client.getTrendingStore()
            if not store:
                return
            dataname = self._trending_dataname(entry)
            points = self.pickleRead(dataname)
            if points:
                store.load_data(entry.key, points)
                self.logger.info('Trend loaded: %s (%d pts)' % (dataname, len(points)))
        except Exception:
            self.logger.exception('_trending_load_one(%s)' % entry.key)

    def _trending_delete_one(self, entry):
        """Delete persisted data for one entry and remove from save queue."""
        try:
            dataname = self._trending_dataname(entry)
            with self._trend_save_queue_lock:
                self._trend_save_queue = [
                    (k, e, s) for k, e, s in self._trend_save_queue
                    if k != entry.key
                ]
            self._trend_loaded_keys.discard(entry.key)
            if self.pickleDelete(dataname):
                self.logger.info('Trend deleted: %s' % dataname)
        except Exception:
            self.logger.exception('_trending_delete_one(%s)' % entry.key)

    def _trending_cleanup_orphans(self):
        """Remove pickle files for trends no longer recorded."""
        try:
            existing = set(self.pickleList('*.bacnettrend'))
            active = set()
            for task in self._getBacnetTasks():
                client = task.client()
                if not client:
                    continue
                for entry in client.getRecordedEntries().values():
                    active.add(self._trending_dataname(entry))
            for dataname in existing - active:
                self.pickleDelete(dataname)
                self.logger.info('Trend orphan deleted: %s' % dataname)
        except Exception:
            self.logger.exception('_trending_cleanup_orphans')

    # --- Trend index persistence ---

    def _trend_index_dataname(self, task_key):
        """Return pickle dataname for the trend recording index of a BACnet task."""
        safe = lambda s: re.sub(r'[^\w\-]', '_', str(s))
        return 'trend_index.%s' % safe(task_key)

    def _trend_index_save(self):
        """Save the trend recording index (list of TrendingEntry) for all BACnet tasks."""
        self._trending_init_persistence()
        try:
            for task in self._getBacnetTasks():
                client = task.client()
                if not client:
                    continue
                data = []
                for entry in client.getRecordedEntries().values():
                    data.append({
                        'task_key': entry.task_key,
                        'device_id': entry.device_id,
                        'obj_type': entry.obj_type,
                        'instance': entry.instance,
                        'name': entry.name,
                        'address': entry.address,
                    })
                dataname = self._trend_index_dataname(task.key)
                self.pickleWrite(dataname, data if data else [{'_empty': True}])
            self._trend_index_last_save = time.time()
            self.logger.info('Trend index saved')
        except Exception:
            self.logger.exception('_trend_index_save')

    def _trend_index_load(self):
        """Restore trend recording index from pickle → re-register all entries.
        Only called once all BACnet services are ready."""
        try:
            for task in self._getBacnetTasks():
                client = task.client()
                if not client or client.getTrendingStore() is None:
                    continue
                dataname = self._trend_index_dataname(task.key)
                data = self.pickleRead(dataname)
                if not data:
                    continue
                count = 0
                for d in data:
                    if d.get('_empty'):
                        continue
                    try:
                        client.recordObject(
                            d['task_key'], d['device_id'],
                            d['obj_type'], d['instance'],
                            d.get('name', ''), d.get('address'),
                        )
                        count += 1
                    except Exception:
                        self.logger.exception('_trend_index_load: entry %s' % d)
                if count:
                    self.logger.info('Trend index loaded: %d entries for %s' % (count, task.key))
        except Exception:
            self.logger.exception('_trend_index_load')
        self._trend_index_loaded = True

    # --- BACnet cache persistence ---

    def _bacnet_cache_dataname(self, task_key):
        """Return pickle dataname for the BACnet device cache of a BACnet task."""
        safe = lambda s: re.sub(r'[^\w\-]', '_', str(s))
        return 'bacnet_cache.%s' % safe(task_key)

    def _bacnet_cache_save(self):
        """Save the BACnet device discovery list for all BACnet tasks."""
        self._trending_init_persistence()
        try:
            for task in self._getBacnetTasks():
                client = task.client()
                if not client:
                    continue
                data = client.getCacheData()
                if 'devices' not in data:
                    continue
                # Strip the transient 'stale' flag before saving
                devs = [
                    {k: v for k, v in d.items() if k != 'stale'}
                    for d in data['devices'] if isinstance(d, dict)
                ]
                dataname = self._bacnet_cache_dataname(task.key)
                self.pickleWrite(dataname, {'devices': devs})
            self._bacnet_cache_last_save = time.time()
            self.logger.info('BACnet cache saved')
        except Exception:
            self.logger.exception('_bacnet_cache_save')

    def _bacnet_cache_load(self):
        """Restore BACnet device discovery cache from pickle.
        Devices are marked 'stale' (awaiting live confirmation) by restoreCache()."""
        try:
            for task in self._getBacnetTasks():
                client = task.client()
                if not client:
                    continue
                dataname = self._bacnet_cache_dataname(task.key)
                data = self.pickleRead(dataname)
                if data:
                    client.restoreCache(data)
                    n = len(data.get('devices', []))
                    self.logger.info('BACnet cache restored: %d devices for %s' % (n, task.key))
        except Exception:
            self.logger.exception('_bacnet_cache_load')
        self._bacnet_cache_loaded = True

    def _trending_on_run(self):
        """Called from run() every 100 ms.
        Sequence: cache restore → index restore → lazy data loads → orphan cleanup →
        periodic saves (index, cache) → staggered trend data writes."""
        self._trending_init_persistence()
        now = time.time()

        # 1. Restore BACnet device cache first (needs only client, not service)
        if not self._bacnet_cache_loaded:
            if any(task.client() for task in self._getBacnetTasks()):
                self._bacnet_cache_load()

        # 2. Restore trend index (re-registers entries; needs BACnet service to be up)
        if not self._trend_index_loaded:
            tasks = self._getBacnetTasks()
            if tasks and all(
                task.client() and task.client().getTrendingStore() is not None
                for task in tasks
            ):
                self._trend_index_load()

        # 3. Lazy load trend data for entries not yet loaded from pickle
        for task in self._getBacnetTasks():
            client = task.client()
            if not client:
                continue
            for key, entry in client.getRecordedEntries().items():
                if key not in self._trend_loaded_keys:
                    self._trend_loaded_keys.add(key)
                    self._trending_load_one(entry)

        # 4. One-time orphan cleanup — only after the full index is known
        if self._trend_index_loaded and not self._trend_orphans_cleaned:
            self._trend_orphans_cleaned = True
            self._trending_cleanup_orphans()

        # 5. Periodic scan for dirty trend entries due for saving
        if now - self._trend_last_scan_at >= self.TREND_SCAN_INTERVAL:
            self._trend_last_scan_at = now
            for task in self._getBacnetTasks():
                client = task.client()
                if not client:
                    continue
                store = client.getTrendingStore()
                if not store:
                    continue
                for key, entry in store.get_due_for_save(self.TREND_SAVE_INTERVAL):
                    with self._trend_save_queue_lock:
                        if not any(k == key for k, _, _ in self._trend_save_queue):
                            self._trend_save_queue.append((key, entry, store))

        # 6. Periodic saves of index and cache
        if self._trend_index_loaded and now - self._trend_index_last_save >= self.TREND_INDEX_INTERVAL:
            self._trend_index_save()
        if self._bacnet_cache_loaded and now - self._bacnet_cache_last_save >= self.BACNET_CACHE_INTERVAL:
            self._bacnet_cache_save()

        # 7. Staggered write — one trend object per TREND_SAVE_STAGGER seconds
        if self._trend_save_queue and now >= self._trend_next_save_at:
            item = None
            with self._trend_save_queue_lock:
                if self._trend_save_queue:
                    item = self._trend_save_queue.pop(0)
            if item:
                key, entry, store = item
                self._trending_save_one(entry, store)
                self._trend_next_save_at = now + self.TREND_SAVE_STAGGER

    def _trending_on_poweroff(self):
        """Flush all recorded trends, index and cache to pickle on shutdown."""
        try:
            self._trending_init_persistence()
            for task in self._getBacnetTasks():
                client = task.client()
                if not client:
                    continue
                store = client.getTrendingStore()
                if not store:
                    continue
                for entry in client.getRecordedEntries().values():
                    self._trending_save_one(entry, store)
            self._trend_index_save()
            self._bacnet_cache_save()
            self.logger.info('Trend persistence flush done')
        except Exception:
            self.logger.exception('_trending_on_poweroff')

    def _trending_force_save(self):
        """Save all recorded trends, index, and cache immediately (no stagger)."""
        try:
            self._trending_init_persistence()
            # Clear any pending queue — we're saving everything right now
            with self._trend_save_queue_lock:
                self._trend_save_queue.clear()
            count = 0
            for task in self._getBacnetTasks():
                client = task.client()
                if not client:
                    continue
                store = client.getTrendingStore()
                if not store:
                    continue
                for entry in client.getRecordedEntries().values():
                    self._trending_save_one(entry, store)
                    count += 1
            self._trend_index_save()
            self._bacnet_cache_save()
            self.logger.info('Trend force save: %d objects saved' % count)
        except Exception:
            self.logger.exception('_trending_force_save')

    def cb_bacnetsavetrends(self, handler, params):
        """Trigger manual save of all trending data.
        GET /api/v1/bacnetsavetrends
        """
        self._trending_force_save()
        self.cb_write_json(handler, {'success': True})
        return True

    def cb_bacnettrendpush(self, handler, params):
        """Push a single value into the server-side trend store for a recorded object.
        GET /api/v1/bacnettrendpush?task=X&device=Y&type=T&instance=N&value=V
        No-op (still returns success) if the object is not in record mode.
        Called from the browser Trending tab on each live DataTable refresh.
        """
        task_key  = params.get('task')
        device_id = params.get('device')
        obj_type  = params.get('type')
        instance  = params.get('instance')
        raw_value = params.get('value')
        if not all([task_key, device_id, obj_type, instance, raw_value is not None]):
            return self.cb_write_failure(handler)
        try:
            value = float(raw_value)
        except (ValueError, TypeError):
            return self.cb_write_failure(handler)
        task = self.getMBIO().task(task_key)
        if not task:
            return self.cb_write_failure(handler)
        client = task.client()
        if not client:
            return self.cb_write_failure(handler)
        if client.isRecorded(task_key, device_id, obj_type, instance):
            from .bacnetserver import TrendingEntry
            key = TrendingEntry.make_key(task_key, device_id, obj_type, instance)
            svc = client._interface._service if client._interface else None
            if svc and svc._trending:
                svc._trending.push(key, value)
        self.cb_write_json(handler, {'success': True})
        return True

    # -------------------------------------------------------------------------
    # BACnet Trending API
    # -------------------------------------------------------------------------

    def cb_bacnetrecord(self, handler, params):
        """Start recording trending for a BACnet object.
        GET /api/v1/bacnetrecord?task=X&amp;device=Y&amp;type=T&amp;instance=N&amp;name=&lt;name&gt;
        """
        task_key  = params.get('task')
        device_id = params.get('device')
        obj_type  = params.get('type')
        instance  = params.get('instance')
        name      = params.get('name', '')
        if not all([task_key, device_id, obj_type, instance]):
            return self.cb_write_failure(handler)
        task = self.getMBIO().task(task_key)
        if not task:
            return self.cb_write_failure(handler)
        client = task.client()
        if not client:
            return self.cb_write_failure(handler)
        address = None
        if device_id != 'local':
            try:
                dev = client.device(int(device_id))
                if dev:
                    address = dev.address
            except (ValueError, TypeError):
                pass
        client.recordObject(task_key, device_id, obj_type, instance, name, address)
        # Load any previously persisted data for this entry
        entry = client.getTrendingEntry(task_key, device_id, obj_type, instance)
        if entry:
            self._trending_init_persistence()
            key = entry.key
            if key not in self._trend_loaded_keys:
                self._trend_loaded_keys.add(key)
                self._trending_load_one(entry)
        self._trend_index_save()
        self.cb_write_json(handler, {'success': True, 'recorded': True})
        return True

    def cb_bacnetunrecord(self, handler, params):
        """Stop recording for a BACnet object.
        GET /api/v1/bacnetunrecord?task=X&amp;device=Y&amp;type=T&amp;instance=N
        """
        task_key  = params.get('task')
        device_id = params.get('device')
        obj_type  = params.get('type')
        instance  = params.get('instance')
        if not all([task_key, device_id, obj_type, instance]):
            return self.cb_write_failure(handler)
        task = self.getMBIO().task(task_key)
        if not task:
            return self.cb_write_failure(handler)
        client = task.client()
        if not client:
            return self.cb_write_failure(handler)
        entry = client.getTrendingEntry(task_key, device_id, obj_type, instance)
        client.unrecordObject(task_key, device_id, obj_type, instance)
        if entry:
            self._trending_init_persistence()
            self._trending_delete_one(entry)
        self._trend_index_save()
        self.cb_write_json(handler, {'success': True, 'recorded': False})
        return True

    def cb_bacnetpurge(self, handler, params):
        """Clear all stored trending data for a BACnet object (keep recording if active).
        GET /api/v1/bacnetpurge?task=X&amp;device=Y&amp;type=T&amp;instance=N
        """
        task_key  = params.get('task')
        device_id = params.get('device')
        obj_type  = params.get('type')
        instance  = params.get('instance')
        if not all([task_key, device_id, obj_type, instance]):
            return self.cb_write_failure(handler)
        task = self.getMBIO().task(task_key)
        if not task:
            return self.cb_write_failure(handler)
        client = task.client()
        if not client:
            return self.cb_write_failure(handler)
        entry = client.getTrendingEntry(task_key, device_id, obj_type, instance)
        client.purgeObject(task_key, device_id, obj_type, instance)
        if entry:
            self._trending_init_persistence()
            self._trending_delete_one(entry)
        self.cb_write_json(handler, {'success': True})
        return True

    def cb_bacnettrendingdata(self, handler, params):
        """Return trending history for a BACnet object.
        GET /api/v1/bacnettrendingdata?task=X&amp;device=Y&amp;type=T&amp;instance=N[&amp;since=&lt;epoch_s&gt;]
        Returns {"data": [[timestamp_ms, value], ...], "recorded": bool, "point_count": int}
        """
        task_key  = params.get('task')
        device_id = params.get('device')
        obj_type  = params.get('type')
        instance  = params.get('instance')
        since = float(params.get('since', 0))
        if not all([task_key, device_id, obj_type, instance]):
            return self.cb_write_failure(handler)
        task = self.getMBIO().task(task_key)
        if not task:
            return self.cb_write_failure(handler)
        client = task.client()
        if not client:
            return self.cb_write_failure(handler)
        history = client.getTrending(task_key, device_id, obj_type, instance, since)
        meta    = client.getTrendingMeta(task_key, device_id, obj_type, instance)
        self.cb_write_json(handler, {
            'data':        [[t * 1000, v] for t, v in history],
            'recorded':    meta.get('recorded', False),
            'point_count': meta.get('point_count', 0),
        })
        return True

    @staticmethod
    def _resolve_trending_label(obj_type, value, labels):
        """Return state text label for a binary or multistate value."""
        if not labels or value is None:
            return ''
        t = str(obj_type).lower()
        if 'binary' in t:
            active = bool(value) if not isinstance(value, bool) else value
            return labels[1] if (active and len(labels) > 1) else (labels[0] if labels else '')
        if 'multi-state' in t:
            try:
                idx = int(value) - 1  # MSV is 1-based
                if 0 <= idx < len(labels):
                    return labels[idx]
            except (TypeError, ValueError):
                pass
        return ''

    def cb_gettrending(self, handler, params):
        """List all recorded objects across all BACnet tasks.
        GET /api/v1/gettrending[?task=&lt;taskkey&gt;]
        Returns {"data": [{task, device, type, instance, name, description, labels,
                           points, last_ts, last_value, last_label}, ...],
                 "total_points": &lt;int&gt;}
        """
        task_filter = params.get('task')
        all_recorded = []
        total_pts = 0
        for task in self._getBacnetTasks():
            if task_filter and task.key != task_filter:
                continue
            client = task.client()
            if not client:
                continue
            for key, entry in client.getRecordedEntries().items():
                meta = client.getTrendingMeta(entry.task_key, entry.device_id,
                                              entry.obj_type, entry.instance)
                total_pts += meta.get('point_count', 0)
                lv = meta.get('last_value')
                all_recorded.append({
                    'task':        entry.task_key,
                    'device':      entry.device_id,
                    'type':        entry.obj_type,
                    'instance':    entry.instance,
                    'name':        entry.name,
                    'description': entry.description,
                    'labels':      entry.labels,
                    'points':      meta.get('point_count', 0),
                    'last_value':  lv,
                    'last_label':  self._resolve_trending_label(entry.obj_type, lv, entry.labels),
                    'poll_error':  meta.get('poll_error', False),
                    'cov_active':  entry.cov_active,
                })
        self.cb_write_json(handler, {'data': all_recorded, 'total_points': total_pts})
        return True

    # -------------------------------------------------------------------------
    # BACnet Trending page
    # -------------------------------------------------------------------------

    def cb_bacnettrending(self, handler, params):
        """BACnet Trending overview page.
        GET /bacnettrending
        Lists all recorded objects with task, device, type, instance, name, #points, last value.
        Purge and link to properties (directly on Trending tab).
        """
        mbio = self.getMBIO()
        h = HtmlPage('BACnet Trending', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml())
        h.variable('auth.logout', self._logoutHtml())
        h.style('#recorded-table tbody tr.selected { background-color: #cfe2ff !important; } #recorded-table tbody tr.selected .text-muted { color: inherit !important; }')
        h.body('<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>')
        h.body('<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>')

        data = """
            <div class="page container-fluid">
            <div class="card p-4 p-lg-5">
            <div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-3">
            <div>
            <h1 class="h3 mb-1">BACnet Trending</h1>
            <p class="text-secondary mb-0"><a href="/bacnet">BACnet</a> / BACnetTrends</p>
            </div>
            <div class="text-muted small">{mbio.version} /
            {main.nav} / <a href="/bacnet">BACnetDevices</a>
            {auth.logout}
            </div>
            </div>
            <ul class="nav nav-tabs mt-3 mb-0 d-flex align-items-center" role="tablist">
            <li class="nav-item" role="presentation">
            <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-recorded" type="button">Recorded Objects <span id="badge-rec-count" class="badge bg-secondary ms-1" style="font-size:.65rem">0</span></button>
            </li>
            <li class="nav-item" role="presentation">
            <button id="tab-chart-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-chart" type="button">Plot <span id="badge-chart-count" class="badge bg-secondary ms-1" style="font-size:.65rem">0</span></button>
            </li>
            <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
            <span class="text-muted small"><span id="pts-count">0</span> pts</span>
            <span id="sel-indicator" class="badge bg-primary" style="display:none"></span>
            <button id="btn-plot" class="btn btn-sm btn-primary" disabled title="Afficher les objets s&#xE9;lectionn&#xE9;s sur le graphique">Plot &#x2192;</button>
            <button id="btn-purge-sel" class="btn btn-sm btn-outline-warning" disabled title="Purge selected">Purge</button>
            <button id="btn-unrecord-sel" class="btn btn-sm btn-danger" disabled title="Unrecord selected">Unrecord</button>
            <button id="btn-csv-all" class="btn btn-sm btn-outline-secondary" title="Export all trending data as CSV">CSV</button>
            <button id="btn-save-trends" class="btn btn-sm btn-outline-secondary" title="Save all trends to disk now">Save</button>
            <button id="btn-refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
            </li>
            </ul>
            <div class="tab-content border border-top-0 rounded-bottom p-3 mb-2">
            <div class="tab-pane fade show active" id="tab-recorded" role="tabpanel">
            <table id="recorded-table" class="display nowrap" style="width:100%%">
            <thead>
            <tr>
            <th data-priority="1" class="dt-select dt-no-copy" style="width:2rem"><input type="checkbox" id="cb-select-all" title="Select all"></th>
            <th data-priority="1">Task</th>
            <th data-priority="2">Device</th>
            <th data-priority="2">Type</th>
            <th data-priority="2">Inst.</th>
            <th data-priority="1">Name</th>
            <th data-priority="3">Description</th>
            <th data-priority="1"># Points</th>
            <th data-priority="1">Last Value</th>
            </tr>
            </thead>
            </table>
            </div>
            <div class="tab-pane fade" id="tab-chart" role="tabpanel">
            <div class="d-flex flex-wrap gap-2 mb-2 align-items-center">
            <div class="btn-group btn-group-sm" id="chart-range-btns">
            <button class="btn btn-outline-secondary" data-range="3600">1h</button>
            <button class="btn btn-outline-secondary" data-range="21600">6h</button>
            <button class="btn btn-outline-secondary active" data-range="86400">24h</button>
            <button class="btn btn-outline-secondary" data-range="604800">7d</button>
            </div>
            <button id="btn-mc-reset-zoom" class="btn btn-sm btn-outline-secondary" title="Reset zoom">&#x22A1; Reset</button>
            <button id="btn-mc-csv" class="btn btn-sm btn-outline-secondary" title="Exporter les donn&#xE9;es du graphique en CSV">&#x2193; CSV</button>
            </div>
            <div id="chart-wrapper" style="position:relative;height:360px"><canvas id="multi-chart"></canvas></div>
            <div class="text-muted small mt-1" id="chart-info">S&#xE9;lectionner des objets dans l&#x27;onglet Recorded Objects pour tracer.</div>
            </div>
            </div>
            </div>
            </div>
        """
        h.write(data)

        data = """
        <script>
        $(function () {
            var table = null;
            var totalPts = 0;

            function loadData() {
                fetch('/api/v1/gettrending')
                    .then(function(r) { return r.json(); })
                    .then(function(resp) {
                        totalPts = resp.total_points || 0;
                        $('#pts-count').text(totalPts);
                        var recCount = (resp.data || []).length;
                        var badge = document.getElementById('badge-rec-count');
                        if (badge) {
                            badge.textContent = recCount;
                            badge.className = 'badge ms-1 ' + (recCount > 0 ? 'bg-success' : 'bg-secondary');
                            badge.style.fontSize = '.65rem';
                        }
                        if (table) {
                            table.clear().rows.add(resp.data || []).draw(false);
                            updateSelectionUI();
                        } else {
                            initTable(resp.data || []);
                        }
                    });
            }


            function buildPropsUrl(row) {
                return '/bacnetobjectproperties?task=' + encodeURIComponent(row.task)
                     + '&device=' + encodeURIComponent(row.device)
                     + '&type=' + encodeURIComponent(row.type)
                     + '&instance=' + encodeURIComponent(row.instance)
                     + '#tab-trending';
            }

            function buildParamSuffix(row) {
                return '?task=' + encodeURIComponent(row.task)
                     + '&device=' + encodeURIComponent(row.device)
                     + '&type=' + encodeURIComponent(row.type)
                     + '&instance=' + encodeURIComponent(row.instance);
            }

            function purgeRow(row) {
                fetch('/api/v1/bacnetpurge' + buildParamSuffix(row))
                    .then(function(r) { return r.json(); })
                    .then(function() { loadData(); });
            }

            function unrecordRow(row) {
                fetch('/api/v1/bacnetunrecord' + buildParamSuffix(row))
                    .then(function(r) { return r.json(); })
                    .then(function() { loadData(); });
            }

            // --- Multi-selection state ---
            var selectedKeys = new Set();
            var focusedTr = null;

            function rowKey(d) {
                return [d.task, d.device, d.type, d.instance].join(':');
            }

            function getSelectedData() {
                var result = [];
                if (!table || !selectedKeys.size) return result;
                table.rows().every(function() {
                    var d = this.data();
                    if (d && selectedKeys.has(rowKey(d))) result.push(d);
                });
                return result;
            }

            function applyRowCheckbox(tr, checked) {
                $(tr).toggleClass('selected', checked);
                $(tr).find('.row-checkbox').prop('checked', checked);
            }

            function toggleRow(tr) {
                var d = table.row(tr).data();
                if (!d) return;
                var key = rowKey(d);
                var nowChecked = !selectedKeys.has(key);
                if (nowChecked) selectedKeys.add(key); else selectedKeys.delete(key);
                applyRowCheckbox(tr, nowChecked);
                updateSelectionUI();
            }

            function updateSelectionUI() {
                var n = selectedKeys.size;
                var total = table ? table.rows().count() : 0;
                var ind = document.getElementById('sel-indicator');
                if (ind) { ind.textContent = n + ' selected'; ind.style.display = n ? '' : 'none'; }
                var cbAll = document.getElementById('cb-select-all');
                if (cbAll) {
                    cbAll.indeterminate = n > 0 && n < total;
                    cbAll.checked = n > 0 && n === total;
                }
                $('#btn-purge-sel, #btn-unrecord-sel').prop('disabled', n === 0);
                $('#btn-plot').prop('disabled', n === 0);
                var bc = document.getElementById('badge-chart-count');
                if (bc) {
                    bc.textContent = n;
                    bc.className = 'badge ms-1 ' + (n > 0 ? 'bg-primary' : 'bg-secondary');
                    bc.style.fontSize = '.65rem';
                }
            }

            function clearSelection() {
                selectedKeys.clear();
                if (table) table.rows().nodes().each(function(tr) { applyRowCheckbox(tr, false); });
                updateSelectionUI();
            }

            function initTable(data) {
                table = new DataTable('#recorded-table', {
                    responsive: true,
                    paging: false,
                    searching: true,
                    ordering: true,
                    info: false,
                    data: data,
                    columns: [
                        { data: null, orderable: false, searchable: false,
                          className: 'dt-select dt-no-copy', defaultContent: '',
                          render: function(v, type) {
                            if (type !== 'display') return '';
                            return '<input type="checkbox" class="row-checkbox" style="cursor:pointer">';
                          }
                        },
                        { data: 'task' },
                        { data: 'device' },
                        { data: 'type' },
                        { data: 'instance' },
                        { data: 'name', render: function(v, type, row) {
                            if (type !== 'display') return v || '';
                            var html = v || '';
                            if (row.poll_error) {
                                html += ' <span class="badge bg-danger ms-1" style="font-size:.65rem" title="Donn\u00e9es indisponibles (device hors-ligne ?)">&#x26A0;</span>';
                            }
                            if (row.cov_active) {
                                html += ' <span class="badge bg-info ms-1 py-0 px-1" style="font-size:.65rem" title="COV subscription active">COV</span>';
                            }
                            return html;
                        }},
                        { data: 'description', defaultContent: '', render: function(v, type) {
                            if (type !== 'display' || !v) return v || '';
                            return '<span class="text-muted">' + v + '</span>';
                        }},
                        { data: 'points', className: 'text-end' },
                        { data: 'last_value', className: 'text-end', render: function(v, type, row) {
                            if (type !== 'display') return v === null || v === undefined ? '' : String(v);
                            if (v === null || v === undefined) return '<span class="text-muted">—</span>';
                            var num = typeof v === 'number' && !Number.isInteger(v) ? v.toFixed(3) : String(v);
                            var lbl = row.last_label ? ' <span class="badge bg-light text-dark border ms-1" style="font-size:.7rem;font-weight:500">' + row.last_label + '</span>' : '';
                            return num + lbl;
                        }}
                    ],
                    createdRow: function(row, data) {
                        // Restore checkbox state after redraw
                        if (data && selectedKeys.has(rowKey(data))) applyRowCheckbox(row, true);
                        // Track hover for SPACE shortcut
                        $(row).on('mouseenter', function() { focusedTr = row; });
                        // Click on data cells (columns 2+) → navigate to properties
                        $(row).find('td:not(:first-child)').css('cursor', 'pointer').on('click', function(e) {
                            if ($(e.target).is('button,a,input') || $(e.target).closest('button,a').length) return;
                            const url = buildPropsUrl(data);
                            if (e.ctrlKey || e.metaKey) { window.open(url, '_blank'); } else { window.location.href = url; }
                        });
                    }
                });
            }

            // Checkbox cell click — toggle row selection
            $('#recorded-table').on('click', '.row-checkbox', function(e) {
                e.stopPropagation();
                toggleRow($(this).closest('tr')[0]);
            });

            // Select-all checkbox
            $('#cb-select-all').on('change', function() {
                var checked = $(this).prop('checked');
                selectedKeys.clear();
                table.rows().every(function() {
                    var d = this.data();
                    if (checked && d) selectedKeys.add(rowKey(d));
                });
                table.rows().nodes().each(function(tr) { applyRowCheckbox(tr, checked); });
                updateSelectionUI();
            });

            // SPACE → toggle focused row
            $(document).on('keydown', function(e) {
                if ($(e.target).is('input,textarea,select')) return;
                if (e.key === ' ') {
                    e.preventDefault();
                    if (focusedTr && table) toggleRow(focusedTr);
                }
            });

            // Global Purge — act on all selected rows
            $('#btn-purge-sel').on('click', function() {
                var targets = getSelectedData();
                if (!targets.length) return;
                var pending = targets.length;
                targets.forEach(function(row) {
                    fetch('/api/v1/bacnetpurge' + buildParamSuffix(row))
                        .then(function(r) { return r.json(); })
                        .then(function() { if (--pending === 0) { clearSelection(); loadData(); } });
                });
            });

            // Global Unrecord — act on all selected rows
            $('#btn-unrecord-sel').on('click', function() {
                var targets = getSelectedData();
                if (!targets.length) return;
                var pending = targets.length;
                targets.forEach(function(row) {
                    fetch('/api/v1/bacnetunrecord' + buildParamSuffix(row))
                        .then(function(r) { return r.json(); })
                        .then(function() { if (--pending === 0) { clearSelection(); loadData(); } });
                });
            });

            // Ctrl+C → copy visible rows to clipboard
            $(document).on('keydown', function(e) {
                if ($(e.target).is('input,textarea,select')) return;
                if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key === 'c') {
                    if (window.getSelection && window.getSelection().toString()) return;
                    e.preventDefault();
                    if (table) copyDataTableToClipboard(table);
                }
            });

            // Resolve state text label for binary/MSV values (mirrors server-side logic)
            function resolveTrendLabel(objType, value, labels) {
                if (!labels || !labels.length || value === null || value === undefined) return '';
                var t = String(objType).toLowerCase();
                if (t.indexOf('binary') >= 0) {
                    return value ? (labels[1] || 'On') : (labels[0] || 'Off');
                }
                if (t.indexOf('multi-state') >= 0) {
                    var idx = parseInt(value, 10) - 1;
                    if (idx >= 0 && idx < labels.length) return labels[idx];
                }
                return '';
            }

            // CSV export — fetches all trending data for all visible records and downloads one file
            function downloadAllCsv(rows) {
                if (!rows.length) { showToast('No data to export', 'danger'); return; }
                rows.sort(function(a, b) { return a[6] - b[6]; });
                var header = 'task,device,type,instance,name,description,label,timestamp_iso,timestamp_ms,value';
                function escapeCsv(v) {
                    var s = v === null || v === undefined ? '' : String(v);
                    return (s.indexOf(',') >= 0 || s.indexOf('"') >= 0 || s.indexOf('\\n') >= 0)
                        ? '"' + s.replace(/"/g, '""') + '"' : s;
                }
                var lines = [header].concat(rows.map(function(r) {
                    return r.map(escapeCsv).join(',');
                }));
                var blob = new Blob([lines.join('\\n')], {type: 'text/csv'});
                var a = document.createElement('a');
                a.href = URL.createObjectURL(blob);
                a.download = 'bacnet_trending.csv';
                a.click();
                showToast('Exported ' + rows.length + ' data points', 'success');
            }

            $('#btn-csv-all').on('click', function() {
                if (!table) return;
                var visRows = table.rows({search: 'applied'}).data().toArray();
                if (!visRows.length) { showToast('No records to export', 'danger'); return; }
                var allPts = [];
                var pending = visRows.length;
                visRows.forEach(function(row) {
                    fetch('/api/v1/bacnettrendingdata' + buildParamSuffix(row))
                        .then(function(r) { return r.json(); })
                        .then(function(resp) {
                            var labels = row.labels || [];
                            var objType = row.type || '';
                            (resp.data || []).forEach(function(p) {
                                var lbl = resolveTrendLabel(objType, p[1], labels);
                                allPts.push([
                                    row.task, row.device, row.type, row.instance, row.name,
                                    row.description || '', lbl,
                                    new Date(p[0]).toISOString(), p[0], p[1]
                                ]);
                            });
                            pending--;
                            if (pending === 0) downloadAllCsv(allPts);
                        });
                });
            });

            $('#btn-refresh').on('click', function () { loadData(); });

            $('#btn-save-trends').on('click', function() {
                var btn = $(this);
                btn.prop('disabled', true).text('Saving...');
                fetch('/api/v1/bacnetsavetrends')
                    .then(function(r) { return r.json(); })
                    .then(function() {
                        showToast('Trends saved', 'success');
                        btn.prop('disabled', false).text('Save');
                    })
                    .catch(function() { btn.prop('disabled', false).text('Save'); });
            });

            loadData();
            setInterval(loadData, 5000);

            // === Multi-Object Chart (onglet Chart) ===

            // --- Crosshair plugin multi-séries ---
            var mbioCrosshairPluginMulti = {
                id: 'mbioCrosshairMulti',
                afterEvent: function(chart, args) {
                    var e = args.event;
                    if (e.type === 'mousemove') {
                        chart._ch_x = e.x; args.changed = true;
                    } else if (e.type === 'mouseout') {
                        chart._ch_x = null; args.changed = true;
                    }
                },
                afterDraw: function(chart) {
                    if (chart._ch_x == null || !chart.data.datasets.length) return;
                    var xScale = chart.scales.x;
                    var cx = chart._ch_x;
                    var area = chart.chartArea;
                    var left = area.left, right = area.right, top = area.top, bot = area.bottom;
                    if (cx < left || cx > right) return;
                    var xVal = xScale.getValueForPixel(cx);
                    var c = chart.ctx;
                    c.save();

                    function interp(pts, xv) {
                        if (!pts || !pts.length) return null;
                        if (xv <= pts[0].x) return pts[0].y;
                        if (xv >= pts[pts.length - 1].x) return pts[pts.length - 1].y;
                        for (var i = 0; i < pts.length - 1; i++) {
                            if (pts[i].x <= xv && pts[i + 1].x >= xv) return pts[i].y;
                        }
                        return null;
                    }

                    function pill(text, px, py, bgColor) {
                        c.font = '11px system-ui,sans-serif';
                        var tw = c.measureText(text).width;
                        var pw = tw + 12, ph = 19, r = 5;
                        var bx = Math.max(left, Math.min(right - pw, px - pw / 2));
                        var by = Math.max(top, Math.min(bot - ph, py - ph / 2));
                        c.fillStyle = bgColor || 'rgba(15,23,42,0.82)';
                        c.beginPath();
                        c.moveTo(bx+r, by); c.lineTo(bx+pw-r, by);
                        c.quadraticCurveTo(bx+pw, by, bx+pw, by+r);
                        c.lineTo(bx+pw, by+ph-r);
                        c.quadraticCurveTo(bx+pw, by+ph, bx+pw-r, by+ph);
                        c.lineTo(bx+r, by+ph);
                        c.quadraticCurveTo(bx, by+ph, bx, by+ph-r);
                        c.lineTo(bx, by+r);
                        c.quadraticCurveTo(bx, by, bx+r, by);
                        c.closePath(); c.fill();
                        c.fillStyle = '#e2e8f0';
                        c.textAlign = 'left'; c.textBaseline = 'middle';
                        c.fillText(text, bx + 6, by + ph / 2);
                    }

                    // Ligne verticale
                    c.beginPath(); c.strokeStyle = 'rgba(80,80,80,0.4)';
                    c.lineWidth = 1; c.setLineDash([5, 4]);
                    c.moveTo(cx, top); c.lineTo(cx, bot); c.stroke();
                    c.setLineDash([]);

                    // Dot + pill de valeur par dataset
                    chart.data.datasets.forEach(function(ds) {
                        var yScale = chart.scales[ds.yAxisID || 'y0'];
                        if (!yScale) return;
                        var yv = interp(ds.data, xVal);
                        if (yv === null) return;
                        var yPx = Math.max(top, Math.min(bot, yScale.getPixelForValue(yv)));
                        var col = ds.borderColor || '#0d6efd';
                        // Dot
                        c.beginPath(); c.arc(cx, yPx, 4, 0, 2 * Math.PI);
                        c.fillStyle = col; c.strokeStyle = '#fff'; c.lineWidth = 2;
                        c.fill(); c.stroke();
                        // Pill de valeur — à droite du curseur si la place le permet, sinon à gauche
                        var valStr = typeof yv === 'number' && !Number.isInteger(yv)
                            ? yv.toFixed(2) : String(yv);
                        c.font = '11px system-ui,sans-serif';
                        var pw = c.measureText(valStr).width + 12;
                        var pillX = (cx + 14 + pw <= right) ? cx + 14 + pw / 2 : cx - 14 - pw / 2;
                        pill(valStr, pillX, yPx);
                    });

                    // Label de temps en bas de la zone de tracé
                    var d = new Date(xVal);
                    var timeLabel = mcRangeMs > 86400000 ? d.toLocaleString() : d.toLocaleTimeString();
                    pill(timeLabel, cx, bot - 12);

                    c.restore();
                }
            };

            var multiChart = null;
            var mcRangeMs = 86400000;
            var mcRefreshTimer = null;
            var MC_COLORS = ['#0d6efd','#dc3545','#198754','#fd7e14','#6610f2','#20c997','#0dcaf0','#6c757d'];

            function mcTypeCategory(t) {
                t = String(t).toLowerCase();
                if (t.indexOf('analog') >= 0) return 'analog';
                if (t.indexOf('binary') >= 0) return 'binary';
                if (t.indexOf('multi-state') >= 0) return 'multistate';
                return 'other';
            }

            function refreshMultiChart() {
                var sel = getSelectedData();
                var infoEl = document.getElementById('chart-info');
                if (!sel.length) {
                    if (multiChart) { multiChart.data.datasets = []; multiChart.update('none'); }
                    if (infoEl) infoEl.textContent = 'S\u00e9lectionner des objets dans l\u2019onglet Recorded Objects pour tracer.';
                    return;
                }
                var since = (Date.now() - mcRangeMs) / 1000;
                var pending = sel.length;
                var datasets = new Array(sel.length);
                var cats = [];
                sel.forEach(function(r) { var c = mcTypeCategory(r.type); if (cats.indexOf(c) < 0) cats.push(c); });
                var dual = cats.length === 2;
                sel.forEach(function(row, i) {
                    var q = '/api/v1/bacnettrendingdata?task=' + encodeURIComponent(row.task)
                          + '&device=' + encodeURIComponent(row.device)
                          + '&type=' + encodeURIComponent(row.type)
                          + '&instance=' + encodeURIComponent(row.instance)
                          + '&since=' + since;
                    fetch(q).then(function(r) { return r.json(); })
                        .then(function(resp) {
                            var pts = (resp.data || []).map(function(p) { return {x: p[0], y: p[1]}; });
                            var cat = mcTypeCategory(row.type);
                            var yid = dual ? (cats.indexOf(cat) === 0 ? 'y0' : 'y1') : 'y0';
                            var col = MC_COLORS[i % MC_COLORS.length];
                            var lbl = row.name + (row.description ? ' \u2014 ' + row.description : '');
                            datasets[i] = { label: lbl, data: pts, borderColor: col,
                                backgroundColor: col + '22', borderWidth: 1.5, pointRadius: 0,
                                stepped: 'before', fill: false, yAxisID: yid };
                            if (--pending === 0) applyMcData(datasets, cats, dual, sel);
                        }).catch(function() { if (--pending === 0) applyMcData(datasets, cats, dual, sel); });
                });
            }

            function applyMcData(datasets, cats, dual, sel) {
                if (!multiChart) return;
                multiChart.data.datasets = datasets.filter(Boolean);
                var sc = { x: multiChart.options.scales.x,
                           y0: { type: 'linear', position: 'left', grid: { color: '#e9ecef' },
                                 title: { display: dual, text: cats[0] || '' } } };
                if (dual) sc.y1 = { type: 'linear', position: 'right', grid: { drawOnChartArea: false },
                                    title: { display: true, text: cats[1] || '' } };
                multiChart.options.scales = sc;
                multiChart.update('none');
                var totalPts = datasets.reduce(function(s, ds) { return s + (ds && ds.data ? ds.data.length : 0); }, 0);
                var infoEl = document.getElementById('chart-info');
                if (infoEl) infoEl.textContent = sel.length + ' series \u00b7 ' + totalPts + ' pts total';
            }

            function initMultiChart() {
                if (multiChart) return;
                var ctx = document.getElementById('multi-chart').getContext('2d');
                multiChart = new Chart(ctx, {
                    type: 'line', data: { datasets: [] },
                    plugins: [mbioCrosshairPluginMulti],
                    options: {
                        animation: false, responsive: true, maintainAspectRatio: false,
                        scales: {
                            x: { type: 'linear', grid: { color: '#e9ecef' },
                                 ticks: { callback: function(v) { return new Date(v).toLocaleTimeString(); }, maxTicksLimit: 8 } },
                            y0: { type: 'linear', position: 'left', grid: { color: '#e9ecef' } }
                        },
                        plugins: {
                            legend: { display: true, position: 'top', labels: { boxWidth: 12, font: { size: 11 } } },
                            tooltip: { mode: 'nearest', intersect: false, callbacks: {
                                title: function(items) { return new Date(items[0].parsed.x).toLocaleString(); },
                                label: function(ctx) { return ctx.dataset.label + ': ' + ctx.parsed.y; }
                            }},
                            zoom: { pan: { enabled: true, mode: 'x' },
                                    zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' } }
                        }
                    }
                });
            }

            function resizeMultiChart() {
                var el = document.getElementById('chart-wrapper');
                if (!el) return;
                var top = el.getBoundingClientRect().top;
                var h = Math.max(300, window.innerHeight - top - 80);
                el.style.height = h + 'px';
                if (multiChart) multiChart.resize();
            }
            $(window).on('resize', resizeMultiChart);

            $('#tab-chart-btn').on('shown.bs.tab', function() {
                resizeMultiChart();
                initMultiChart();
                refreshMultiChart();
                mcRefreshTimer = setInterval(refreshMultiChart, 30000);
            });
            $('#tab-chart-btn').on('hidden.bs.tab', function() {
                clearInterval(mcRefreshTimer); mcRefreshTimer = null;
            });

            $('#chart-range-btns').on('click', '[data-range]', function() {
                mcRangeMs = parseInt($(this).data('range')) * 1000;
                $('#chart-range-btns [data-range]').removeClass('active');
                $(this).addClass('active');
                if (multiChart && multiChart.resetZoom) multiChart.resetZoom();
                refreshMultiChart();
            });

            $('#btn-mc-reset-zoom').on('click', function() {
                if (multiChart && multiChart.resetZoom) multiChart.resetZoom();
            });

            $('#btn-mc-csv').on('click', function() {
                if (!multiChart || !multiChart.data.datasets.length) return;
                var lines = ['series,timestamp_iso,timestamp_ms,value'];
                multiChart.data.datasets.forEach(function(ds) {
                    (ds.data || []).forEach(function(p) {
                        lines.push(['"' + ds.label.replace(/"/g, '""') + '"',
                                    new Date(p.x).toISOString(), p.x, p.y].join(','));
                    });
                });
                var blob = new Blob([lines.join('\\n')], {type: 'text/csv'});
                var a = document.createElement('a'); a.href = URL.createObjectURL(blob);
                a.download = 'bacnet_multichart.csv'; a.click();
                showToast('Exported ' + multiChart.data.datasets.length + ' series', 'success');
            });

            $('#btn-plot').on('click', function() {
                var chartPane = document.getElementById('tab-chart');
                if (chartPane && chartPane.classList.contains('active')) {
                    refreshMultiChart();
                } else {
                    bootstrap.Tab.getOrCreateInstance(document.getElementById('tab-chart-btn')).show();
                }
            });
        });
        </script>
        """
        h.write(data)
        h.flush()

    def cb_bacnetschedule(self, handler, params):
        """BACnet Schedule visual editor (FullCalendar).
        GET /bacnetschedule?task=&lt;taskkey&gt;&amp;name=&lt;objectName&gt;
          task : MBIOTaskBacnet key
          name : Schedule object name (local device only)
        Displays a FullCalendar timeGridWeek view on a fixed reference week
        (Mon 6 Jan 2025).  Drag-and-drop to move events, click to edit values,
        click on an empty slot to add a new event.  Save writes the result back
        to the BACnet server via POST /api/v1/writeschedule.
        """
        mbio = self.getMBIO()
        task_key = params.get('task', '')
        sched_name = params.get('name', '')

        h = HtmlPage('Schedule Editor - %s' % sched_name, handler)
        h.header('<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet">')
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml())
        h.variable('auth.logout', self._logoutHtml())
        h.variable('task', task_key)
        h.variable('schedname', sched_name)

        data = """
            <style>
            #calendar { min-height: 600px; }
            .fc-event { cursor: pointer; border-radius: 4px !important; border: none !important; }
            .fc-event-title { font-weight: 600; font-size: .8rem; }
            .fc-timegrid-slot { height: 1.8em !important; }
            .fc-timegrid-slot-minor { border-top-style: dotted !important; }
            .fc-col-header-cell { background: #f8f9fa; border-bottom: 2px solid #dee2e6 !important; }
            .fc-col-header-cell.fc-day-sat,
            .fc-col-header-cell.fc-day-sun { background: #e9ecef; }
            .fc-timegrid-col.fc-day-sat,
            .fc-timegrid-col.fc-day-sun { background: rgba(0,0,0,.018); }
            .fc-col-header-cell.mbio-col-today { background: rgba(13,110,253,.12) !important; border-bottom-color: #0d6efd !important; }
            .fc-col-header-cell.mbio-col-today .fc-col-header-cell-cushion { color: #0d6efd !important; }
            .fc-timegrid-col.mbio-col-today { background: rgba(13,110,253,.04) !important; }
            .fc-col-header-cell-cushion {
                font-size: .75rem; font-weight: 700; letter-spacing: .06em;
                text-transform: uppercase; color: #495057; text-decoration: none !important;
            }
            .fc-timegrid-axis { font-size: .72rem; color: #6c757d; }
            .fc-scrollgrid { border-radius: 6px; overflow: hidden; }
            #exc-table { border-bottom: 0; }
            #exc-table > tbody > tr:last-child > td { border-bottom: 0; }
            </style>
            <div class="page container-fluid">
            <div class="card p-4 p-lg-5">
            <div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-3">
            <div>
            <h1 class="h3 mb-1">Schedule Editor</h1>
            <p class="text-secondary mb-0">
                <a href="/bacnet">BACnet</a> /
                <a href="/bacnetobjects?task={task}&amp;device=local">device local</a> /
                <b>{schedname}</b><span id="sched-description"></span>
            </p>
            </div>
            <div class="text-muted small">{mbio.version} /
            {main.nav} / <a href="/bacnet">BACnetDevices</a> / <a href="/bacnettrending">BACnetTrends</a>
            {auth.logout}
            </div>
            </div>
            <ul class="nav nav-tabs mt-3 mb-0 d-flex align-items-center flex-wrap" id="sched-tabs">
            <li class="nav-item">
                <button class="nav-link active" id="tab-weekly-btn" data-bs-toggle="tab" data-bs-target="#tab-weekly">
                Weekly <span class="badge bg-secondary ms-1" id="badge-weekly">0</span>
                </button>
            </li>
            <li class="nav-item">
                <button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-exc">
                Exceptions <span class="badge bg-secondary ms-1" id="badge-exc">0</span>
                </button>
            </li>
            <li class="nav-item">
                <button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-links" id="tab-links-btn">
                Links <span class="badge bg-secondary ms-1" id="badge-links">—</span>
                </button>
            </li>
            <li class="nav-item ms-auto pb-1 d-flex align-items-center flex-wrap gap-2">
            <span class="small text-muted d-inline-flex align-items-center gap-1">Default: <b id="sched-default-val">&mdash;</b>
            <button id="btn-edit-default" class="btn btn-sm btn-outline-secondary py-0 px-1" title="Edit default value">&#9998;</button></span>
            <span class="small text-muted">Current: <b id="sched-current-val">&mdash;</b></span>
            <button id="btn-reload" class="btn btn-sm btn-outline-secondary">Reload</button>
            <button id="btn-cancel" class="btn btn-sm btn-outline-secondary">Cancel</button>
            <button id="btn-save" class="btn btn-sm btn-primary">Save</button>
            </li>
            </ul>
            <div class="tab-content border border-top-0 rounded-bottom p-3 mb-2">
            <!-- Tab Weekly -->
            <div class="tab-pane fade show active" id="tab-weekly">
            <div id="calendar"></div>
            </div>
            <!-- Tab Exceptions -->
            <div class="tab-pane fade" id="tab-exc">
            <div class="d-flex justify-content-end mb-2">
            <button class="btn btn-sm btn-outline-primary" id="btn-exc-add">+ Add</button>
            </div>
            <p class="text-muted small fst-italic py-1 mb-0" id="exc-empty">No exceptions defined.</p>
            <table class="table table-sm mb-0" id="exc-table" style="display:none">
            <thead class="table-light"><tr>
                <th style="width:3.5rem" class="text-center">Prio</th>
                <th>Period</th>
                <th style="width:6rem">Type</th>
                <th style="width:5rem" class="text-center">Entries</th>
                <th style="width:5.5rem"></th>
            </tr></thead>
            <tbody id="exc-tbody"></tbody>
            </table>
            </div>
            <!-- Tab Links -->
            <div class="tab-pane fade" id="tab-links">
            <table class="table table-sm table-hover mb-0" id="links-table">
            <thead class="table-light">
            <tr>
                <th>Object</th>
                <th>Name</th>
                <th>Description</th>
                <th>Value</th>
                <th>Status</th>
            </tr>
            </thead>
            <tbody id="links-tbody">
                <tr><td colspan="5" class="text-muted text-center py-3 fst-italic">Loading…</td></tr>
            </tbody>
            </table>
            </div>
            </div>
            </div>
            </div>

            <!-- Modal exception add/edit -->
            <div class="modal fade" id="excModal" tabindex="-1">
            <div class="modal-dialog modal-dialog-centered">
            <div class="modal-content">
            <div class="modal-header py-2">
                <h6 class="modal-title" id="excModalTitle">Exception</h6>
                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body">
                <div class="mb-3">
                <label class="form-label small fw-semibold mb-1">Period type</label>
                <div class="btn-group w-100" id="exc-type-group">
                    <button type="button" class="btn btn-sm btn-outline-primary exc-type-btn active" data-exctype="date">Date</button>
                    <button type="button" class="btn btn-sm btn-outline-primary exc-type-btn" data-exctype="dateRange">Date range</button>
                </div>
                </div>
                <div id="exc-date-section">
                <div class="row g-2 mb-3">
                    <div class="col-3"><label class="form-label small mb-0">Day</label>
                    <input type="number" id="exc-day" class="form-control form-control-sm" min="1" max="31" placeholder="1-31"></div>
                    <div class="col-3"><label class="form-label small mb-0">Month</label>
                    <input type="number" id="exc-month" class="form-control form-control-sm" min="1" max="12" placeholder="1-12"></div>
                    <div class="col-6"><label class="form-label small mb-0">Year <small class="text-muted">(vide=tous)</small></label>
                    <input type="number" id="exc-year" class="form-control form-control-sm" min="2000" max="2099" placeholder="any"></div>
                </div>
                </div>
                <div id="exc-range-section" style="display:none">
                <div class="row g-2 mb-3">
                    <div class="col"><label class="form-label small mb-0">From</label>
                    <input type="date" id="exc-range-start" class="form-control form-control-sm"></div>
                    <div class="col"><label class="form-label small mb-0">To</label>
                    <input type="date" id="exc-range-end" class="form-control form-control-sm"></div>
                </div>
                </div>
                <div class="mb-3">
                <label class="form-label small mb-0">Priority <small class="text-muted">(1=max, 16=min)</small></label>
                <input type="number" id="exc-priority" class="form-control form-control-sm" min="1" max="16" value="2">
                </div>
                <div>
                <div class="d-flex justify-content-between align-items-center mb-1">
                    <label class="form-label small fw-semibold mb-0">Schedule entries</label>
                    <button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="exc-add-tv">+ Row</button>
                </div>
                <table class="table table-sm table-borderless mb-0">
                <thead class="table-light"><tr>
                    <th class="py-0 small" style="width:6rem">HH:MM</th>
                    <th class="py-0 small">Value</th>
                    <th style="width:2rem"></th>
                </tr></thead>
                <tbody id="exc-tv-tbody"></tbody>
                </table>
                </div>
            </div>
            <div class="modal-footer py-1">
                <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
                <button type="button" class="btn btn-sm btn-primary" id="excModalOk">Save</button>
            </div>
            </div>
            </div>
            </div>

            <!-- Modal edition/creation d'un evenement -->
            <div class="modal fade" id="evModal" tabindex="-1">
            <div class="modal-dialog modal-sm modal-dialog-centered">
            <div class="modal-content">
            <div class="modal-header py-2">
                <h6 class="modal-title" id="evModalTitle">Event</h6>
                <button type="button" class="btn-close btn-close-sm" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body py-2">
                <div class="mb-2" id="ev-time-row">
                <label class="form-label small mb-0">Start time (HH:MM)</label>
                <input type="text" id="ev-time" class="form-control form-control-sm" placeholder="HH:MM" maxlength="5">
                </div>
                <div class="mb-2" id="ev-endtime-row">
                <label class="form-label small mb-0">End time (HH:MM, optional)</label>
                <input type="text" id="ev-endtime" class="form-control form-control-sm" placeholder="HH:MM" maxlength="5">
                </div>
                <div>
                <label class="form-label small mb-0">Value</label>
                <div id="ev-value-wrap"></div>
                </div>
            </div>
            <div class="modal-footer py-1 justify-content-between">
                <button type="button" class="btn btn-sm btn-outline-danger" id="ev-delete">Delete</button>
                <div class="d-flex gap-2">
                <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
                <button type="button" class="btn btn-sm btn-primary" id="evModalOk">OK</button>
                </div>
            </div>
            </div>
            </div>
            </div>
        """
        h.write(data)

        h.write('<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>')

        data = """
        <script>
        $(function () {
            const taskKey = "{task}";
            const schedName = "{schedname}";
            const REF_DATES = [
                '2025-01-06','2025-01-07','2025-01-08','2025-01-09',
                '2025-01-10','2025-01-11','2025-01-12'
            ];

            let schedDefault = null;
            let calendar = null;
            let _editingEvent = null;
            let _createDate = null;
            let _editingDefault = false;
            let exceptionSchedule = [];
            let _excEditIdx = -1;

            // --- Helpers ---

            function fmtTime(t) {
                return String(t[0]).padStart(2,'0') + ':' + String(t[1]).padStart(2,'0') + ':' + String(t[2]).padStart(2,'0');
            }

            function localDateStr(d) {
                return d.getFullYear() + '-'
                    + String(d.getMonth() + 1).padStart(2,'0') + '-'
                    + String(d.getDate()).padStart(2,'0');
            }

            function fmtValue(vdata) {
                if (!vdata) return '?';
                if (vdata.t === 'real') return String(parseFloat(vdata.v.toFixed(3)));
                if (vdata.t === 'unsigned') return String(vdata.v);
                if (vdata.t === 'enum' || vdata.t === 'bool') return vdata.v ? 'ON' : 'OFF';
                return '?';
            }

            function eventColor(vdata) {
                if (!vdata) return '#6c757d';
                if (vdata.t === 'enum' || vdata.t === 'bool') {
                    return vdata.v ? '#198754' : '#adb5bd';
                }
                const palette = ['#0d6efd','#dc3545','#fd7e14','#20c997','#6610f2','#d63384','#0dcaf0','#ffc107'];
                const v = typeof vdata.v === 'number' ? Math.round(Math.abs(vdata.v)) : 0;
                return palette[v % palette.length];
            }

            function buildValueData(rawVal) {
                if (!schedDefault) return null;
                const t = schedDefault.t;
                if (t === 'real') {
                    const v = parseFloat(rawVal);
                    return isNaN(v) ? null : {t: 'real', v: v};
                }
                if (t === 'unsigned') {
                    const v = parseInt(rawVal);
                    return (isNaN(v) || v < 0) ? null : {t: 'unsigned', v: v};
                }
                if (t === 'enum') {
                    return {t: 'enum', v: (rawVal === '1' || rawVal === true) ? 1 : 0};
                }
                if (t === 'bool') {
                    return {t: 'bool', v: (rawVal === '1' || rawVal === true)};
                }
                return null;
            }

            function valueInputHtml(currentVdata) {
                const t = schedDefault ? schedDefault.t : 'real';
                const v = currentVdata ? currentVdata.v : (schedDefault ? schedDefault.v : 0);
                if (t === 'real') {
                    return '<input type="number" step="0.1" id="ev-value" class="form-control form-control-sm" value="' + (v !== undefined ? v : 0) + '">';
                }
                if (t === 'unsigned') {
                    return '<input type="number" step="1" min="0" id="ev-value" class="form-control form-control-sm" value="' + (v !== undefined ? v : 0) + '">';
                }
                const sel0 = (!v || v === 0 || v === false) ? ' selected' : '';
                const sel1 = (v === 1 || v === true) ? ' selected' : '';
                return '<select id="ev-value" class="form-select form-select-sm">'
                    + '<option value="0"' + sel0 + '>OFF (0)</option>'
                    + '<option value="1"' + sel1 + '>ON (1)</option>'
                    + '</select>';
            }

            // --- Conversion weeklySchedule <-> FullCalendar events ---

            function isDefaultValue(vdata) {
                if (!schedDefault || !vdata) return false;
                return vdata.t === schedDefault.t && vdata.v === schedDefault.v;
            }

            function weeklyToEvents(weekly) {
                const events = [];
                for (let d = 0; d < 7; d++) {
                    const day = weekly[d] || [];
                    const dayEvents = [];
                    for (let i = 0; i < day.length; i++) {
                        const tv = day[i];
                        if (isDefaultValue(tv.value)) {
                            // Filler event — marks the explicit end of the previous real event
                            if (dayEvents.length > 0) {
                                const last = dayEvents[dayEvents.length - 1];
                                if (last.extendedProps.endTime === null) {
                                    last.end = REF_DATES[d] + 'T' + fmtTime(tv.time);
                                    last.extendedProps.endTime = [tv.time[0], tv.time[1], tv.time[2]];
                                }
                            }
                        } else {
                            const startStr = REF_DATES[d] + 'T' + fmtTime(tv.time);
                            dayEvents.push({
                                start: startStr,
                                end: REF_DATES[d] + 'T23:59:00',
                                title: fmtValue(tv.value),
                                color: eventColor(tv.value),
                                extendedProps: {valueData: tv.value, endTime: null}
                            });
                        }
                    }
                    // Events without explicit endTime: use next event start as visual end
                    for (let i = 0; i < dayEvents.length - 1; i++) {
                        if (dayEvents[i].extendedProps.endTime === null) {
                            dayEvents[i].end = dayEvents[i + 1].start;
                        }
                    }
                    dayEvents.forEach(function (ev) { events.push(ev); });
                }
                return events;
            }

            function eventsToWeekly() {
                const days = [[],[],[],[],[],[],[]];
                calendar.getEvents().forEach(function (ev) {
                    const start = ev.start;
                    const dateStr = localDateStr(start);
                    const dayIdx = REF_DATES.indexOf(dateStr);
                    if (dayIdx === -1) return;
                    days[dayIdx].push({
                        startSecs: start.getHours() * 3600 + start.getMinutes() * 60 + start.getSeconds(),
                        startArr: [start.getHours(), start.getMinutes(), start.getSeconds(), 0],
                        endTime: ev.extendedProps.endTime,
                        value: ev.extendedProps.valueData
                    });
                });
                const result = [[],[],[],[],[],[],[]];
                days.forEach(function (dayEvs, d) {
                    dayEvs.sort(function (a, b) { return a.startSecs - b.startSecs; });
                    dayEvs.forEach(function (ev, i) {
                        result[d].push({time: ev.startArr, value: ev.value});
                        // Insert filler at explicit end time if there's a gap before next event
                        if (ev.endTime && schedDefault) {
                            const endSecs = ev.endTime[0] * 3600 + ev.endTime[1] * 60 + (ev.endTime[2] || 0);
                            const nextSecs = (i + 1 < dayEvs.length) ? dayEvs[i + 1].startSecs : Infinity;
                            if (endSecs < nextSecs) {
                                result[d].push({
                                    time: [ev.endTime[0], ev.endTime[1], ev.endTime[2] || 0, 0],
                                    value: schedDefault
                                });
                            }
                        }
                    });
                });
                return result;
            }

            // --- FullCalendar init ---

            // Map current real-world time to the fixed reference week so the
            // built-in nowIndicator appears on the correct day-of-week column.
            function calcRefNow() {
                const now = new Date();
                const refIdx = (now.getDay() + 6) % 7;  // Mon=0 … Sun=6
                const d = new Date(REF_DATES[refIdx] + 'T00:00:00');
                d.setHours(now.getHours(), now.getMinutes(), now.getSeconds(), 0);
                return d;
            }

            function initCalendar(events) {
                const calEl = document.getElementById('calendar');
                calendar = new FullCalendar.Calendar(calEl, {
                    initialView: 'timeGridWeek',
                    initialDate: '2025-01-06',
                    firstDay: 1,
                    headerToolbar: false,
                    dayHeaderContent: function (arg) {
                        const names = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
                        return names[arg.date.getDay()];
                    },
                    nowIndicator: true,
                    now: calcRefNow(),
                    slotDuration: '00:30:00',
                    snapDuration: '00:15:00',
                    scrollTime: '06:00:00',
                    slotLabelFormat: {hour: '2-digit', minute: '2-digit', hour12: false},
                    eventTimeFormat: {hour: '2-digit', minute: '2-digit', hour12: false},
                    allDaySlot: false,
                    editable: true,
                    selectable: true,
                    height: 'auto',
                    events: events,
                    eventClick: function (info) { openEditModal(info.event); },
                    select: function (info) {
                        _createDate = info.start;
                        const ts = String(info.start.getHours()).padStart(2,'0') + ':'
                                 + String(info.start.getMinutes()).padStart(2,'0');
                        const te = String(info.end.getHours()).padStart(2,'0') + ':'
                                 + String(info.end.getMinutes()).padStart(2,'0');
                        openCreateModal(ts, te);
                        calendar.unselect();
                    },
                    eventDrop: function (info) {
                        const newDate = localDateStr(info.event.start);
                        const newDay = REF_DATES.indexOf(newDate);
                        if (newDay !== -1) info.event.setExtendedProp('day', newDay);
                        // Maintain explicit end time after drop (preserve duration)
                        if (info.event.extendedProps.endTime !== null) {
                            const end = info.event.end;
                            if (end) info.event.setExtendedProp('endTime',
                                [end.getHours(), end.getMinutes(), end.getSeconds()]);
                        }
                    },
                    eventResize: function (info) {
                        const end = info.event.end;
                        if (end) info.event.setExtendedProp('endTime',
                            [end.getHours(), end.getMinutes(), end.getSeconds()]);
                    },
                    eventsSet: function () { updateBadges(); },
                });
                calendar.render();
                setInterval(function () { calendar.setOption('now', calcRefNow()); }, 60000);
                // Highlight current day of week (fixed reference week, so use day-of-week class)
                const _days = ['sun','mon','tue','wed','thu','fri','sat'];
                const _todayClass = 'fc-day-' + _days[new Date().getDay()];
                document.querySelectorAll('.' + _todayClass).forEach(function(el) {
                    el.classList.add('mbio-col-today');
                });
            }

            // --- Modal ---

            const evModal = new bootstrap.Modal('#evModal');

            function openEditModal(event) {
                _editingEvent = event;
                _createDate = null;
                _editingDefault = false;
                $('#ev-time-row').show();
                $('#ev-endtime-row').show();
                $('#evModalTitle').text('Edit event');
                const start = event.start;
                $('#ev-time').val(String(start.getHours()).padStart(2,'0') + ':'
                               + String(start.getMinutes()).padStart(2,'0'));
                const et = event.extendedProps.endTime;
                $('#ev-endtime').val(et
                    ? String(et[0]).padStart(2,'0') + ':' + String(et[1]).padStart(2,'0')
                    : '');
                $('#ev-value-wrap').html(valueInputHtml(event.extendedProps.valueData));
                $('#ev-delete').show();
                evModal.show();
            }

            function openCreateModal(timeStr, endTimeStr) {
                _editingEvent = null;
                _editingDefault = false;
                $('#ev-time-row').show();
                $('#ev-endtime-row').show();
                $('#evModalTitle').text('New event');
                $('#ev-time').val(timeStr || '08:00');
                $('#ev-endtime').val(endTimeStr || '');
                $('#ev-value-wrap').html(valueInputHtml(null));
                $('#ev-delete').hide();
                evModal.show();
            }

            function openDefaultModal() {
                _editingEvent = null;
                _createDate = null;
                _editingDefault = true;
                $('#ev-time-row').hide();
                $('#ev-endtime-row').hide();
                $('#evModalTitle').text('Edit default value');
                $('#ev-value-wrap').html(valueInputHtml(schedDefault));
                $('#ev-delete').hide();
                evModal.show();
            }

            $('#evModalOk').on('click', function () {
                const rawVal = $('#ev-value').val();
                const vdata = buildValueData(rawVal);
                if (vdata === null) { showToast('Invalid value', 'danger'); return; }

                if (_editingDefault) {
                    schedDefault = vdata;
                    $('#sched-default-val').text(fmtValue(vdata));
                    evModal.hide();
                    _editingDefault = false;
                    return;
                }

                const tp = ($('#ev-time').val() || '00:00').split(':');
                const hh = parseInt(tp[0]) || 0, mm = parseInt(tp[1]) || 0;
                const etRaw = ($('#ev-endtime').val() || '').trim();
                let endTime = null;
                if (etRaw) {
                    const ep = etRaw.split(':');
                    const eh = parseInt(ep[0]) || 0, em = parseInt(ep[1]) || 0;
                    endTime = [eh, em, 0];
                }

                if (_editingEvent) {
                    const newStart = new Date(_editingEvent.start);
                    newStart.setHours(hh, mm, 0, 0);
                    let newEnd;
                    if (endTime) {
                        newEnd = new Date(newStart);
                        newEnd.setHours(endTime[0], endTime[1], 0, 0);
                        if (newEnd <= newStart) newEnd = new Date(newStart.getTime() + 30 * 60000);
                    } else {
                        newEnd = new Date(newStart.getTime() + 30 * 60000);
                    }
                    _editingEvent.setStart(newStart);
                    _editingEvent.setEnd(newEnd);
                    _editingEvent.setExtendedProp('valueData', vdata);
                    _editingEvent.setExtendedProp('endTime', endTime);
                    _editingEvent.setProp('title', fmtValue(vdata));
                    _editingEvent.setProp('color', eventColor(vdata));
                } else {
                    const base = _createDate || new Date('2025-01-06T00:00:00');
                    const newStart = new Date(base);
                    newStart.setHours(hh, mm, 0, 0);
                    let newEnd;
                    if (endTime) {
                        newEnd = new Date(newStart);
                        newEnd.setHours(endTime[0], endTime[1], 0, 0);
                        if (newEnd <= newStart) newEnd = new Date(newStart.getTime() + 30 * 60000);
                    } else {
                        newEnd = new Date(newStart.getTime() + 30 * 60000);
                    }
                    calendar.addEvent({
                        start: newStart, end: newEnd,
                        title: fmtValue(vdata), color: eventColor(vdata),
                        extendedProps: {valueData: vdata, endTime: endTime}
                    });
                }
                evModal.hide();
                _editingEvent = null;
            });

            $('#ev-delete').on('click', function () {
                if (_editingEvent) { _editingEvent.remove(); _editingEvent = null; }
                evModal.hide();
            });

            $('#evModal').on('keydown', function (e) {
                if (e.key === 'Enter') { e.preventDefault(); $('#evModalOk').click(); }
            });

            // --- Tab badges ---

            function updateBadges() {
                const weeklyCount = calendar ? calendar.getEvents().length : 0;
                const excCount = exceptionSchedule ? exceptionSchedule.length : 0;
                $('#badge-weekly').text(weeklyCount).toggleClass('bg-primary', weeklyCount > 0).toggleClass('bg-secondary', weeklyCount === 0);
                $('#badge-exc').text(excCount).toggleClass('bg-primary', excCount > 0).toggleClass('bg-secondary', excCount === 0);
            }

            // --- Exception Schedule ---

            const excModal = new bootstrap.Modal('#excModal');

            function fmtBacnetDate(tup) {
                // tup = [year-1900, month, day, dayofweek], 255 = wildcard
                const y = tup[0] === 255 ? null : (tup[0] + 1900);
                const m = String(tup[1]).padStart(2, '0');
                const d = String(tup[2]).padStart(2, '0');
                return d + '/' + m + (y !== null ? '/' + y : '');
            }

            function fmtPeriod(exc) {
                const p = exc.period;
                if (!p) return '?';
                if (p.type === 'calendarEntry') {
                    const e = p.entry;
                    if (!e) return '?';
                    if (e.type === 'date') return fmtBacnetDate(e.value);
                    if (e.type === 'dateRange') return fmtBacnetDate(e.start) + ' \u2192 ' + fmtBacnetDate(e.end);
                    if (e.type === 'weekNDay') return 'WeekNDay [' + e.value.join(',') + ']';
                }
                if (p.type === 'calendarReference') return 'CalRef: ' + p.ref;
                return '?';
            }

            function fmtPeriodBadge(exc) {
                const p = exc.period;
                if (!p) return '';
                if (p.type === 'calendarEntry') {
                    const e = p.entry;
                    if (e.type === 'date') return '<span class="badge bg-primary">Date</span>';
                    if (e.type === 'dateRange') return '<span class="badge bg-info text-dark">Range</span>';
                    if (e.type === 'weekNDay') return '<span class="badge bg-secondary">WeekNDay</span>';
                }
                if (p.type === 'calendarReference') return '<span class="badge bg-warning text-dark">CalRef</span>';
                return '';
            }

            function isExcEditable(exc) {
                const p = exc.period;
                if (!p || p.type !== 'calendarEntry') return false;
                return p.entry.type === 'date' || p.entry.type === 'dateRange';
            }

            function renderExceptions() {
                const tbody = $('#exc-tbody').empty();
                updateBadges();
                if (!exceptionSchedule || exceptionSchedule.length === 0) {
                    $('#exc-table').hide(); $('#exc-empty').show(); return;
                }
                $('#exc-empty').hide(); $('#exc-table').show();
                const sorted = exceptionSchedule.slice().sort(function(a, b) {
                    return a.eventPriority - b.eventPriority;
                });
                sorted.forEach(function(exc) {
                    const origIdx = exceptionSchedule.indexOf(exc);
                    const editable = isExcEditable(exc);
                    const row = $('<tr>');
                    row.append('<td class="text-center"><span class="badge bg-secondary">' + exc.eventPriority + '</span></td>');
                    row.append('<td class="small">' + fmtPeriod(exc) + '</td>');
                    row.append('<td>' + fmtPeriodBadge(exc) + '</td>');
                    row.append('<td class="text-center text-muted small">' + (exc.timeValues || []).length + '</td>');
                    const acts = $('<td class="text-end">');
                    if (editable) {
                        const btnEdit = $('<button class="btn btn-sm btn-outline-secondary py-0 me-1" title="Edit">&#9998;</button>');
                        btnEdit.on('click', function() { openExcModal(origIdx); });
                        acts.append(btnEdit);
                    }
                    const btnDel = $('<button class="btn btn-sm btn-outline-danger py-0" title="Delete">&#10007;</button>');
                    btnDel.on('click', function() {
                        exceptionSchedule.splice(origIdx, 1);
                        renderExceptions();
                    });
                    acts.append(btnDel);
                    row.append(acts);
                    tbody.append(row);
                });
            }

            function excTvValueInput(vdata) {
                if (!schedDefault) return '<input type="text" class="form-control form-control-sm exc-tv-val" value="0">';
                const t = schedDefault.t;
                const v = vdata ? vdata.v : schedDefault.v;
                if (t === 'real') return '<input type="number" step="0.1" class="form-control form-control-sm exc-tv-val" value="' + (v !== undefined ? v : 0) + '">';
                if (t === 'unsigned') return '<input type="number" step="1" min="0" class="form-control form-control-sm exc-tv-val" value="' + (v !== undefined ? v : 0) + '">';
                const sel0 = (!v || v === 0 || v === false) ? ' selected' : '';
                const sel1 = (v === 1 || v === true) ? ' selected' : '';
                return '<select class="form-select form-select-sm exc-tv-val"><option value="0"' + sel0 + '>OFF (0)</option><option value="1"' + sel1 + '>ON (1)</option></select>';
            }

            function addExcTvRow(hm, vdata) {
                const tr = $('<tr class="exc-tv-row">');
                tr.append('<td><input type="text" class="form-control form-control-sm exc-tv-time" placeholder="HH:MM" maxlength="5" value="' + (hm || '00:00') + '"></td>');
                tr.append('<td>' + excTvValueInput(vdata) + '</td>');
                const btnDel = $('<button class="btn btn-sm btn-outline-danger py-0 px-1">&#10007;</button>');
                btnDel.on('click', function() { tr.remove(); });
                tr.append($('<td>').append(btnDel));
                $('#exc-tv-tbody').append(tr);
            }

            function setExcType(type) {
                $('.exc-type-btn').removeClass('active');
                $('.exc-type-btn[data-exctype="' + type + '"]').addClass('active');
                if (type === 'date') { $('#exc-date-section').show(); $('#exc-range-section').hide(); }
                else { $('#exc-date-section').hide(); $('#exc-range-section').show(); }
            }

            function openExcModal(idx) {
                _excEditIdx = idx;
                $('#exc-tv-tbody').empty();
                if (idx === -1) {
                    $('#excModalTitle').text('Add Exception');
                    setExcType('date');
                    $('#exc-day, #exc-month, #exc-year').val('');
                    $('#exc-priority').val('2');
                    addExcTvRow('00:00', null);
                } else {
                    const exc = exceptionSchedule[idx];
                    $('#excModalTitle').text('Edit Exception');
                    $('#exc-priority').val(exc.eventPriority || 2);
                    const p = exc.period;
                    if (p && p.type === 'calendarEntry') {
                        const e = p.entry;
                        if (e.type === 'date') {
                            setExcType('date');
                            $('#exc-day').val(e.value[2]);
                            $('#exc-month').val(e.value[1]);
                            $('#exc-year').val(e.value[0] === 255 ? '' : e.value[0] + 1900);
                        } else if (e.type === 'dateRange') {
                            setExcType('dateRange');
                            function bacnetToISO(t) {
                                const y = t[0] === 255 ? new Date().getFullYear() : (t[0] + 1900);
                                return y + '-' + String(t[1]).padStart(2,'0') + '-' + String(t[2]).padStart(2,'0');
                            }
                            $('#exc-range-start').val(bacnetToISO(e.start));
                            $('#exc-range-end').val(bacnetToISO(e.end));
                        }
                    }
                    (exc.timeValues || []).forEach(function(tv) {
                        const hm = String(tv.time[0]).padStart(2,'0') + ':' + String(tv.time[1]).padStart(2,'0');
                        addExcTvRow(hm, tv.value);
                    });
                }
                excModal.show();
                setTimeout(function() { ($('#exc-date-section').is(':visible') ? $('#exc-day') : $('#exc-range-start')).focus(); }, 300);
            }

            $('#btn-exc-add').on('click', function() { openExcModal(-1); });

            $('#exc-type-group').on('click', '.exc-type-btn', function() { setExcType($(this).data('exctype')); });

            $('#exc-add-tv').on('click', function() { addExcTvRow('00:00', null); });

            $('#excModalOk').on('click', function() {
                const type = $('.exc-type-btn.active').data('exctype');
                let period;
                if (type === 'date') {
                    const day = parseInt($('#exc-day').val());
                    const month = parseInt($('#exc-month').val());
                    const yearRaw = $('#exc-year').val().trim();
                    if (isNaN(day) || day < 1 || day > 31) { showToast('Invalid day', 'danger'); return; }
                    if (isNaN(month) || month < 1 || month > 12) { showToast('Invalid month', 'danger'); return; }
                    const year = yearRaw === '' ? 255 : (parseInt(yearRaw) - 1900);
                    period = {type: 'calendarEntry', entry: {type: 'date', value: [year, month, day, 255]}};
                } else {
                    const s = $('#exc-range-start').val(), e2 = $('#exc-range-end').val();
                    if (!s || !e2) { showToast('Invalid date range', 'danger'); return; }
                    function isoToBacnet(str) {
                        const p = str.split('-');
                        return [parseInt(p[0]) - 1900, parseInt(p[1]), parseInt(p[2]), 255];
                    }
                    period = {type: 'calendarEntry', entry: {type: 'dateRange', start: isoToBacnet(s), end: isoToBacnet(e2)}};
                }
                const priority = Math.max(1, Math.min(16, parseInt($('#exc-priority').val()) || 2));
                const timeValues = [];
                $('#exc-tv-tbody .exc-tv-row').each(function() {
                    const tp = ($(this).find('.exc-tv-time').val() || '00:00').split(':');
                    const hh = parseInt(tp[0]) || 0, mm = parseInt(tp[1]) || 0;
                    const vdata = buildValueData($(this).find('.exc-tv-val').val());
                    if (vdata !== null) timeValues.push({time: [hh, mm, 0, 0], value: vdata});
                });
                const entry = {period: period, eventPriority: priority, timeValues: timeValues};
                if (_excEditIdx === -1) exceptionSchedule.push(entry);
                else exceptionSchedule[_excEditIdx] = entry;
                renderExceptions();
                excModal.hide();
            });

            // --- Load / Save ---

            async function loadSchedule() {
                try {
                    const resp = await fetch('/api/v1/getschedule?task=' + encodeURIComponent(taskKey)
                        + '&name=' + encodeURIComponent(schedName));
                    if (!resp.ok) throw new Error('HTTP ' + resp.status);
                    const d = await resp.json();
                    schedDefault = d.scheduleDefault;
                    $('#sched-default-val').text(schedDefault ? fmtValue(schedDefault) : '?');
                    $('#sched-current-val').text(
                        (d.presentValue !== null && d.presentValue !== undefined) ? String(d.presentValue) : '\u2014'
                    );
                    $('#sched-description').text(d.description ? ' / ' + d.description : '');
                    const events = weeklyToEvents(d.weeklySchedule || [[],[],[],[],[],[],[]]);
                    if (calendar) {
                        calendar.removeAllEvents();
                        events.forEach(function (ev) { calendar.addEvent(ev); });
                    } else {
                        initCalendar(events);
                    }
                    exceptionSchedule = d.exceptionSchedule || [];
                    renderExceptions();  // also calls updateBadges()
                    updateBadges();      // refresh weekly count after calendar is ready
                } catch (e) {
                    showToast('Load error: ' + e.message, 'danger');
                }
            }

            $('#btn-edit-default').on('click', function () { openDefaultModal(); });

            $('#btn-save').on('click', async function () {
                const btn = $(this).prop('disabled', true);
                try {
                    const resp = await fetch('/api/v1/writeschedule', {
                        method: 'POST',
                        headers: {'Content-Type': 'application/json'},
                        body: JSON.stringify({
                            task: taskKey,
                            name: schedName,
                            weeklySchedule: eventsToWeekly(),
                            scheduleDefault: schedDefault,
                            exceptionSchedule: exceptionSchedule
                        })
                    });
                    const result = await resp.json();
                    if (result.success) window.location.href = '/bacnetobjects?task=' + encodeURIComponent(taskKey) + '&device=local';
                    else showToast('Save error', 'danger');
                } catch (e) {
                    showToast('Error: ' + e.message, 'danger');
                } finally {
                    btn.prop('disabled', false);
                }
            });

            $('#btn-cancel').on('click', function () { window.location.href = '/bacnetobjects?task=' + encodeURIComponent(taskKey) + '&device=local'; });
            $('#btn-reload').on('click', function () { loadSchedule(); });

            // ── Links tab ──────────────────────────────────────────────────
            function escHtml(s) {
                return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
            }

            function renderLinks(links) {
                $('#badge-links').text(links.length);
                if (!links.length) {
                    $('#links-tbody').html('<tr><td colspan="5" class="text-muted text-center py-3 fst-italic">No linked objects.</td></tr>');
                    return;
                }
                var rows = '';
                links.forEach(function (o) {
                    var oidStr = o.type + ',' + o.instance;
                    var nameHtml = o.name
                        ? '<a href="/bacnetobjectproperties?task=' + encodeURIComponent(taskKey)
                          + '&device=' + encodeURIComponent(o.device !== null ? String(o.device) : 'local')
                          + '&type=' + encodeURIComponent(o.type)
                          + '&instance=' + encodeURIComponent(o.instance)
                          + '">' + escHtml(o.name) + '</a>'
                        : '<span class="text-muted">&mdash;</span>';
                    var valStr = o.value !== null && o.value !== undefined ? String(o.value) : '\u2014';
                    if (o.unit && o.unit !== 'no-units' && o.unit !== 'noUnits') valStr += ' <span class="text-muted small">' + escHtml(o.unit) + '</span>';
                    var ok = !o.reliability || o.reliability === 'no-fault-detected' || o.reliability === 'noFaultDetected';
                    var statusHtml = ok
                        ? '<span class="badge bg-success">OK</span>'
                        : '<span class="badge bg-warning text-dark">' + escHtml(o.reliability) + '</span>';
                    if (o.outOfService) statusHtml += ' <span class="badge bg-secondary ms-1">OOS</span>';
                    var device = o.device !== null ? String(o.device) : 'local';
                    rows += '<tr>'
                        + '<td class="font-monospace small text-muted">' + escHtml(oidStr) + '</td>'
                        + '<td>' + nameHtml + '</td>'
                        + '<td class="text-muted small">' + escHtml(o.description || '') + '</td>'
                        + '<td>' + valStr + '</td>'
                        + '<td>' + statusHtml + '</td>'
                        + '</tr>';
                });
                $('#links-tbody').html(rows);
            }

            async function loadLinks() {
                try {
                    const resp = await fetch('/api/v1/getschedulelinks?task=' + encodeURIComponent(taskKey)
                        + '&name=' + encodeURIComponent(schedName));
                    if (!resp.ok) throw new Error('HTTP ' + resp.status);
                    const d = await resp.json();
                    renderLinks(d.data || []);
                } catch (e) {
                    $('#links-tbody').html('<tr><td colspan="5" class="text-danger text-center py-3">' + escHtml(e.message) + '</td></tr>');
                }
            }

            $('#tab-weekly-btn').on('shown.bs.tab', function () {
                if (calendar) calendar.updateSize();
            });

            loadSchedule();
            loadLinks();
        });
        </script>
        """
        h.write(data)
        h.flush()

