import io
import json
import os
import subprocess
import threading
import time
import xml.etree.ElementTree as ET
from .task import MBIOTask
from .xmlconfig import XMLConfig

from .wserver import TemporaryWebServer
from .wsmonitor_html import Html, HtmlPage
from .wsmonitor_bacnet import MBIOWsMonitorBACnetMixin
from .wsmonitor_modbusserver import MBIOWsMonitorModbusServerMixin

class MBIOWsMonitor(MBIOWsMonitorBACnetMixin, MBIOWsMonitorModbusServerMixin, MBIOTask):
    def initName(self):
        return 'wsmon'

    @property
    def wserver(self) -> TemporaryWebServer:
        return self._wserver

    def readLocalFileContent(self, fname):
        try:
            my_resources = importlib_resources.files("digimat.mbio")
            data = my_resources.joinpath(fname).read_bytes()
            return data
        except:
            pass

    def onInit(self):
        self._wserver=None

    def cb_headers_html(self, handler, rcode=200):
        """Send HTTP response headers for a text/html response."""
        handler.send_response(rcode)
        handler.send_header("Content-Type", "text/html; charset=utf-8")
        handler.end_headers()

    def cb_headers_json(self, handler, rcode=200):
        """Send HTTP response headers for an application/json response (with CORS)."""
        handler.send_response(rcode)
        handler.send_header("Content-Type", "application/json; charset=utf-8")
        handler.send_header("Access-Control-Allow-Origin", '*') # CORS pour dev
        handler.end_headers()

    def cb_write_json(self, handler, data, rcode=200):
        """Serialize data as JSON and write the full HTTP response."""
        self.cb_headers_json(handler, rcode)
        data=json.dumps(data)
        handler.wfile.write(data.encode('utf-8'))

    def cb_write_success(self, handler):
        """Write a standard success JSON response: {"success": true}."""
        data={'success': True}
        return self.cb_write_json(handler, data)

    def cb_write_failure(self, handler):
        """Write a standard failure JSON response: {"success": false} with HTTP 400."""
        data={'success': False}
        return self.cb_write_json(handler, data, 400)

    def cb_storeuserdata(self, handler, params, data=None):
        """Persist an arbitrary key/value pair in the MBIO internal storage (pickle).
        GET  /api/v1/storeuserdata?key=&lt;key&gt;&amp;value=&lt;value&gt;
        POST /api/v1/storeuserdata?key=&lt;key&gt;  body: JSON object stored as value
          key   : storage key (alphanumeric, used to retrieve data later)
          value : value to store (string via GET, or JSON body via POST)
        Returns {"success": true}. Data survives restarts.
        """

        key=params.get('key')
        value=params.get('value') or data

        # self.logger.warning(key)
        # self.logger.warning(value)

        try:
            if key and value:
                name='%s.userdata' % key
                self.pickleWrite(name, value)
                self.cb_write_success(handler)
                return True
        except:
            pass
        self.cb_write_failure(handler)

    def cb_getuserdata(self, handler, params):
        """Retrieve a previously stored key/value pair from the MBIO internal storage.
        GET /api/v1/getuserdata?key=&lt;key&gt;
          key : storage key previously used with /api/v1/storeuserdata
        Returns the stored JSON object, or {"&lt;key&gt;": &lt;value&gt;} for scalar values.
        """

        key=params.get('key')

        try:
            if key:
                name='%s.userdata' % key
                value=self.pickleRead(name)
                if isinstance(value, dict):
                    data=value
                else:
                    data={}
                    data[key]=value

                self.cb_write_json(handler, data)
                return True
        except:
            pass
        self.cb_write_failure(handler)

    def cb_gettasks(self, handler, params):
        """Retrieve all known MBIO tasks.
        GET /api/v1/gettasks
        Returns {"data": [{"key", "class", "state", "statetime", "error", "countvalues"}, ...]}
          key        : unique task identifier
          class      : Python class name
          state      : current state string (HALT, POWERON, RUN, POWEROFF, ERROR)
          statetime  : time in seconds since last state change
          error      : true if task is in error state
          countvalues: number of MBIO values managed by this task
        """

        mbio=self.getMBIO()
        items=[]
        for t in mbio.tasks.all():
            item={'key': t.key}
            item['class']=t.__class__.__name__
            item['state']=t.statestr(),
            item['statetime']=int(t.statetime())
            item['error']=t.isError()
            item['countvalues']=t.values.count()
            items.append(item)

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

    def cb_getmetrics(self, handler, params):
        """Retrieve performance metrics for all threads (tasks + gateways).
        GET /api/v1/getmetrics
        Returns {"data": [...]} with one entry per task/gateway thread.
        Fields common to all: type, key, class, state, statetime, error,
          cycle_count, cycle_hz, run_avg_ms, run_max_ms, busy_pct, cpu_share_pct,
          overrun_count, exception_count.
        Tasks also expose bacnet_* and trend_* fields when applicable.
        Gateways also expose devices_total, msg_total, msg_err_total, err_rate_pct.
        """
        mbio = self.getMBIO()
        data = []
        for t in mbio.tasks.all():
            try:
                data.append(t.metrics())
            except Exception:
                pass
        for gw in mbio.gateways.all():
            try:
                data.append(gw.metrics())
            except Exception:
                pass
        self.cb_write_json(handler, {'data': data})
        return True

    def cb_getdevicemetrics(self, handler, params):
        """Retrieve performance metrics for all Modbus devices.
        GET /api/v1/getdevicemetrics
        Returns {"data": [...]} with one entry per device.
        Fields: gateway, key, class, address, state, error, poll_count, poll_hz,
          run_avg_ms, run_max_ms, msg_total, msg_err_total, err_rate_pct.
        """
        mbio = self.getMBIO()
        data = []
        for gw in mbio.gateways.all():
            if gw is None:
                continue
            for d in gw.devices.all():
                self.microsleep()
                try:
                    data.append(d.metrics())
                except Exception:
                    pass
        self.cb_write_json(handler, {'data': data})
        return True

    def cb_getsysteminfo(self, handler, params):
        """Retrieve system-level KPIs.
        GET /api/v1/getsysteminfo
        Returns: uptime (s), tasks_total, tasks_error, values_total, values_error,
          values_manual, mbio_trend_points, bacnet_local, bacnet_remote,
          bacnet_remote_objects, bacnet_trend_entries, bacnet_trend_points,
          mbs_enabled, mbs_registers, mbs_port, sessions.
        """
        mbio = self.getMBIO()
        tasks = list(mbio.tasks.all())
        values = list(mbio.values())
        bacnet_local = 0
        bacnet_remote = 0
        for t in tasks:
            try:
                m = t.metrics()
                bacnet_local = max(bacnet_local, m.get('bacnet_local_total', 0) or 0)
                bacnet_remote = max(bacnet_remote, m.get('bacnet_remote_devices', 0) or 0)
            except Exception:
                pass
        bacnet_remote_objects = 0
        bacnet_trend_entries = 0
        bacnet_trend_points = 0
        for task in self._getBacnetTasks():
            try:
                client = task.client()
                if not client:
                    continue
                # Remote objects: sum lengths of cached object lists (key=(addr,devId,'objects'))
                bacnet_remote_objects += sum(
                    len(v) for k, v in client._cache.items()
                    if isinstance(k, tuple) and len(k) == 3 and k[2] == 'objects' and isinstance(v, list)
                )
                # Recorded entries and total trend points
                entries = client.getRecordedEntries()
                bacnet_trend_entries += len(entries)
                svc = getattr(getattr(client, '_interface', None), '_service', None)
                if svc and getattr(svc, '_trending', None):
                    bacnet_trend_points += svc._trending.total_points()
            except Exception:
                pass
        mbio_trend_points = sum(
            t.total_points() for t in tasks if callable(getattr(t, 'total_points', None))
        )
        sessions = 0
        api_tokens = 0
        try:
            if self._wserver:
                sessions = len(self._wserver.sessions_info())
                api_tokens = len(self._wserver.api_tokens_info())
        except Exception:
            pass
        rss_mb = 0
        try:
            import resource
            import sys as _sys
            ru = resource.getrusage(resource.RUSAGE_SELF)
            rss_bytes = ru.ru_maxrss
            if _sys.platform != 'darwin':
                rss_bytes *= 1024  # Linux: KB → bytes
            rss_mb = round(rss_bytes / (1024 * 1024), 1)
        except Exception:
            pass
        import threading
        active_threads = threading.active_count()
        open_fds = 0
        try:
            import os as _os
            open_fds = len(_os.listdir('/proc/self/fd'))
        except Exception:
            pass
        mbs_tasks = self._getMbsTasks()
        mbs_registers = 0
        mbs_port = 0
        for mbs_task in mbs_tasks:
            try:
                status = mbs_task.getModbusStatus()
                mbs_registers += (
                    status.get('n_hr', 0) + status.get('n_ir', 0) +
                    status.get('n_co', 0) + status.get('n_di', 0)
                )
                if not mbs_port:
                    mbs_port = status.get('port', 0) or 0
            except Exception:
                pass
        self.cb_write_json(handler, {
            'uptime': int(mbio.uptime()),
            'tasks_total': len(tasks),
            'tasks_error': sum(1 for t in tasks if getattr(t, '_error', False)),
            'values_total': len(values),
            'values_error': sum(1 for v in values if v.isError()),
            'values_manual': sum(1 for v in values if v.isManual()),
            'mbio_trend_points': mbio_trend_points,
            'mbio_trending_enabled': mbio.trendingStore is not None,
            'bacnet_local': bacnet_local,
            'bacnet_remote': bacnet_remote,
            'bacnet_remote_objects': bacnet_remote_objects,
            'bacnet_trend_entries': bacnet_trend_entries,
            'bacnet_trend_points': bacnet_trend_points,
            'mbs_enabled': bool(mbs_tasks),
            'mbs_registers': mbs_registers,
            'mbs_port': mbs_port,
            'sessions': sessions,
            'api_tokens': api_tokens,
            'rss_mb': rss_mb,
            'active_threads': active_threads,
            'open_fds': open_fds,
        })
        return True

    def cb_resettask(self, handler, params):
        """Trigger a state machine reset for a MBIO task (returns it to POWERON).
        GET /api/v1/resettask?key=&lt;taskkey&gt;
          key : unique task key (see /api/v1/gettasks)
        Returns {"success": true} or HTTP 400 if task not found.
        """

        mbio=self.getMBIO()
        key=params.get('key')
        t=mbio.task(key)
        if t:
            t.reset()
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_resetgateway(self, handler, params):
        """Trigger a state machine reset for a Modbus gateway (forces reconnection).
        GET /api/v1/resetgateway?key=&lt;gatewaykey&gt;
          key : unique gateway key (see /api/v1/getgateways)
        Returns {"success": true} or HTTP 400 if gateway not found.
        """

        mbio=self.getMBIO()
        key=params.get('key')
        g=mbio.gateway(key)
        if g:
            g.reset()
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_resetdevice(self, handler, params):
        """Trigger a state machine reset for a Modbus device.
        GET /api/v1/resetdevice?key=&lt;devicekey&gt;
          key : unique device key (see /api/v1/getalldevices)
        Returns {"success": true} or HTTP 400 if device not found.
        """

        mbio=self.getMBIO()
        key=params.get('key')
        d=mbio.device(key)
        if d:
            d.reset()
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_rebootdevice(self, handler, params):
        """Send a firmware reboot command to a device (device-specific implementation).
        GET /api/v1/rebootdevice?key=&lt;devicekey&gt;
          key : unique device key (see /api/v1/getalldevices)
        Returns {"success": true} or HTTP 400 if device not found or reboot not supported.
        Only available on devices where isRebootPossible() returns true.
        """

        mbio=self.getMBIO()
        key=params.get('key')
        d=mbio.device(key)
        if d:
            d.reboot()
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_upgradedevice(self, handler, params):
        """Trigger a firmware upgrade on a device (device-specific implementation).
        GET /api/v1/upgradedevice?key=&lt;devicekey&gt;
          key : unique device key (see /api/v1/getalldevices)
        Returns {"success": true} or HTTP 400 if device not found or upgrade not supported.
        Only available on devices where isUpgradePossible() returns true.
        """

        mbio=self.getMBIO()
        key=params.get('key')
        d=mbio.device(key)
        if d:
            d.upgrade()
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_beep(self, handler, params):
        """Trigger an audible beep on the MBIO processor (buzzer).
        GET /api/v1/beep
        No parameters. Returns {"success": true}.
        Useful for locating the physical processor in an installation.
        """

        mbio=self.getMBIO()
        mbio.beep()
        self.cb_write_success(handler)
        return True

    def cb_save(self, handler, params):
        """Force an immediate save of MBIO zone/key data to persistent storage.
        GET /api/v1/save
        No parameters. Returns {"success": true}.
        Data is normally saved automatically; this forces an immediate flush.
        """

        mbio=self.getMBIO()
        mbio.storeKeyToZoneInformation(True)
        self.cb_write_success(handler)
        return True

    def cb_setvalue(self, handler, params):
        """Set a MBIO value to manual mode with the given setpoint, or release it back to auto.
        GET /api/v1/setvalue?key=&lt;valuekey&gt;&amp;value=&lt;value|auto&gt;
          key   : unique value key
          value : numeric/boolean setpoint, or the literal string "auto" to release manual mode
        Returns {"success": true} or HTTP 400 if value not found.
        Only effective on writable values (isWritable() == true).
        """

        mbio=self.getMBIO()
        key=params.get('key')
        v=params.get('value')
        value=mbio.value(key)
        if value is not None:
            if v=="auto":
                value.auto()
            else:
                value.manual(v)
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_setvalueauto(self, handler, params):
        """Release a MBIO value from manual mode back to automatic (equivalent to value=auto).
        GET /api/v1/setvalueauto?key=&lt;valuekey&gt;
          key : unique value key
        Returns {"success": true} or HTTP 400 if value not found.
        """

        mbio=self.getMBIO()
        key=params.get('key')
        value=mbio.value(key)
        if value is not None:
            value.auto()
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_markvalue(self, handler, params):
        """Set the marked flag on a MBIO value (used to highlight values of interest).
        GET /api/v1/markvalue?key=&lt;valuekey&gt;
          key : unique value key
        Returns {"success": true} or HTTP 400 if value not found.
        Marked values are visible on /valuesmarked.
        """

        mbio=self.getMBIO()
        key=params.get('key')
        value=mbio.value(key)
        if value is not None:
            value.mark()
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_markvaluewithzone(self, handler, params):
        """Set the marked flag on all MBIO values belonging to a given zone.
        GET /api/v1/markvaluewithzone?zone=&lt;zone&gt;
          zone : zone name (see /api/v1/getzones)
        Returns {"success": true} or HTTP 400 if zone is empty or not specified.
        """

        mbio=self.getMBIO()
        zone=params.get('zone')
        if zone:
            mbio.markValuesWithZone(zone)
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_unmarkvalue(self, handler, params):
        """Clear the marked flag on a MBIO value.
        GET /api/v1/unmarkvalue?key=&lt;valuekey&gt;
          key : unique value key
        Returns {"success": true} or HTTP 400 if value not found.
        """

        mbio=self.getMBIO()
        key=params.get('key')
        value=mbio.value(key)
        if value is not None:
            value.unmark()
            self.cb_write_success(handler)
            return True
        self.cb_write_failure(handler)

    def cb_getzones(self, handler, params):
        """Retrieve all known MBIO zones with their value count and error status.
        GET /api/v1/getzones
        Returns {"data": [{"zone", "error", "#values"}, ...]}
          zone    : zone name (lowercase)
          error   : true if at least one value in this zone is in error
          #values : number of MBIO values assigned to this zone
        """

        mbio=self.getMBIO()
        items=[]

        for zone in mbio.zones():
            item={'zone': zone.lower()}
            values=mbio.valuesWithZone(zone)
            error=False

            for v in values:
                if v.isError():
                    error=True
                    break

            item['error']=error
            item['#values']=len(values)
            items.append(item)

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

    def cb_setzone(self, handler, params, data=None):
        """Assign a zone to a MBIO value or device (or clear it).
        GET  /api/v1/setzone?key=&lt;key&gt;&amp;zone=&lt;zone&gt;
        POST /api/v1/setzone  body: {"&lt;key&gt;": "&lt;zone&gt;", ...}  (bulk assignment)
          key  : unique value or device key
          zone : zone name to assign; use "none", "null" or "" to clear the zone
        POST body allows assigning multiple keys at once.
        Returns {"success": true}.
        """
        mbio=self.getMBIO()

        zone=params.get('zone')
        if zone in ['none', 'null', '']:
            zone=None
        key=params.get('key')

        if key:
            value=mbio.value(key)
            if value is not None:
                value.setZone(zone)
                self.cb_write_success(handler)
                return True

            device=mbio.device(key)
            if device is not None:
                device.setZone(zone)
                self.cb_write_success(handler)
                return True

        if data:
            # self.logger.warning(data)
            # { 'key' : 'zname' }
            for key in data.keys():
                zone=data[key]

                value=mbio.value(key)
                if value is not None:
                    value.setZone(zone)
                else:
                    device=mbio.device(key)
                    if device is not None:
                        device.setZone(zone)
            self.cb_write_success(handler)
            return True

        self.cb_write_failure(handler)

    def cb_getgateways(self, handler, params):
        """Retrieve all known Modbus TCP gateways and their status.
        GET /api/v1/getgateways
        Returns {"data": [{"key", "name", "host", "mac", "model", "class",
                            "state", "error", "countdevices", "zone"}, ...]}
          state       : OPEN or CLOSED (TCP connection status)
          countdevices: number of Modbus devices on this gateway
        """

        mbio=self.getMBIO()
        items=[]

        for g in mbio.gateways.all():
            item={'key': g.key, 'name': g.name,
                  'host': g.host, 'mac': g.MAC,
                  'model': g.model}
            item['class']=g.__class__.__name__
            state='CLOSED'
            if g.isOpen():
                state='OPEN'
            item['state']=state
            item['error']=g.isError()
            item['countdevices']=g.devices.count()
            item['zone']=g.zone
            items.append(item)

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

    def cb_getgatewaydevices(self, handler, params):
        """Retrieve all Modbus devices attached to a given gateway.
        GET /api/v1/getgatewaydevices?gateway=&lt;gatewaykey&gt;
          gateway : unique gateway key (see /api/v1/getgateways)
        Returns {"data": [{"gateway", "address", "key", "vendor", "model", "class",
                            "version", "state", "statetime", "countmsg", "countmsgerr",
                            "error", "countvalues", "ismanualvalue", "reboot", "upgrade", "zone"}, ...]}
          reboot/upgrade: true if the operation is supported by this device type
        """

        mbio=self.getMBIO()
        g=mbio.gateway(params.get('gateway'))

        items=[]
        if g is not None:
            for d in g.devices.all():
                self.microsleep()
                item={'gateway': g.key, 'address': d.address,
                      'key': d.key, 'vendor': d.vendor,
                      'model': d.model, 'state': d.statestr()}
                item['class']=d.__class__.__name__
                item['version']=None
                if d.version and d.firmware:
                    item['version']=f'{d.version}/{d.firmware}'
                elif d.version:
                    item['version']=d.version
                else:
                    item['version']=d.firmware

                item['statetime']=int(d.statetime())
                item['countmsg']=d.countMsg
                item['countmsgerr']=d.countMsgErr
                item['error']=d.isError()
                item['countvalues']=d.values.count()
                item['ismanualvalue']=d.isManualValue()
                item['countvalues']=d.values.count()
                item['reboot']=d.isRebootPossible()
                item['upgrade']=d.isUpgradePossible()
                item['zone']=d.zone
                items.append(item)

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

    def cb_getalldevices(self, handler, params):
        """Retrieve all known Modbus devices across all gateways.
        GET /api/v1/getalldevices
        Returns the same fields as /api/v1/getgatewaydevices but for all gateways.
        {"data": [{"gateway", "address", "key", "vendor", "model", "class",
                   "version", "state", "statetime", "countmsg", "countmsgerr",
                   "error", "countvalues", "ismanualvalue", "reboot", "upgrade", "zone"}, ...]}
        """

        mbio=self.getMBIO()
        items=[]
        for g in mbio.gateways.all():
            if g is not None:
                for d in g.devices.all():
                    self.microsleep()
                    item={'gateway': g.key, 'address': d.address,
                          'key': d.key, 'vendor': d.vendor,
                          'model': d.model, 'state': d.statestr()}
                    item['class']=d.__class__.__name__
                    item['version']=None
                    if d.version and d.firmware:
                        item['version']=f'{d.version}/{d.firmware}'
                    elif d.version:
                        item['version']=d.version
                    else:
                        item['version']=d.firmware

                    item['statetime']=int(d.statetime())
                    item['countmsg']=d.countMsg
                    item['countmsgerr']=d.countMsgErr
                    item['error']=d.isError()
                    item['countvalues']=d.values.count()
                    item['ismanualvalue']=d.isManualValue()
                    item['countvalues']=d.values.count()
                    item['reboot']=d.isRebootPossible()
                    item['upgrade']=d.isUpgradePossible()
                    item['zone']=d.zone
                    items.append(item)

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

    def addValueItemToDataResponse(self, items, value):
        if value is not None and value.isEnabled():
            item={'key': value.key, 'value': value.value, 'toreachvalue': None,
                    'valuestr': value.valuestr(),
                    'unit': value.unit, 'unitstr': value.unitstr(),
                    'flags': value.flags, 'age': int(value.age()), 'tag': value.tag}
            if value.isWritable():
                item['toreachvalue']=value.toReachValue
            item['class']=value.__class__.__name__
            item['error']=value.isError()
            item['writable']=value.isWritable()
            item['digital']=value.isDigital()
            item['enable']=value.isEnabled()
            item['manual']=value.isManual()
            item['marked']=value.isMarked()
            item['notifycount']=value.notifyCount
            item['description']=value.description
            item['zone']=value.zone
            item['parent']=None
            try:
                item['parent']=value.parent.parent.key
            except:
                pass
            item['zoneparent']=value.zoneParent
            item['trendcount']=value.reccount
            items.append(item)
            # self.microsleep()
        return items

    def cb_gettaskvalues(self, handler, params):
        """Retrieve all MBIO values managed by a given task.
        GET /api/v1/gettaskvalues?task=&lt;taskkey&gt;
          task : unique task key (see /api/v1/gettasks)
        Returns {"data": [&lt;value items&gt;, ...]} — same fields as /api/v1/getvalues.
        """

        mbio=self.getMBIO()
        t=mbio.task(params.get('task'))

        items=[]
        if t is not None:
            for v in t.values.all():
                self.addValueItemToDataResponse(items, v)

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

    def cb_getzonevalues(self, handler, params):
        """Retrieve all MBIO values assigned to a given zone.
        GET /api/v1/getzonevalues?zone=&lt;zone&gt;
          zone : zone name (see /api/v1/getzones)
        Returns {"data": [&lt;value items&gt;, ...]} — same fields as /api/v1/getvalues.
        """

        mbio=self.getMBIO()
        zone=params.get('zone')

        items=[]
        if zone is not None:
            for v in mbio.valuesWithZone(zone):
                self.addValueItemToDataResponse(items, v)

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

    def cb_getdevicevalues(self, handler, params):
        """Retrieve all MBIO values managed by one or more devices.
        GET /api/v1/getdevicevalues?device=&lt;devicekey&gt;[,&lt;devicekey&gt;,...]
          device : unique device key, or a comma-separated list of device keys
        Returns {"data": [&lt;value items&gt;, ...]} — same fields as /api/v1/getvalues.
        """

        mbio=self.getMBIO()

        key=params.get('device')

        items=[]
        if key is not None:
            if ',' in key:
                keys=key.split(',')
                for key in keys:
                    d=mbio.device(key)
                    if d is not None:
                        for v in d.values.all():
                            self.addValueItemToDataResponse(items, v)
            else:
                d=mbio.device(key)
                for v in d.values.all():
                    self.addValueItemToDataResponse(items, v)

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

    def cb_getvalues(self, handler, params):
        """Retrieve MBIO values with optional filters.
        GET /api/v1/getvalues[?key=&lt;key&gt;][&amp;filter=&lt;text&gt;][&amp;manual=1][&amp;error=1][&amp;marked=1]
          key    : one or more value keys (comma-separated); if omitted, return all values
          filter : text filter applied to value keys
          manual : if set, return only values currently in manual mode
          error  : if set, return only values currently in error state
          marked : if set, return only values currently marked
        Returns {"data": [{"key", "value", "valuestr", "toreachvalue", "unit", "unitstr",
                            "flags", "age", "tag", "class", "error", "writable", "digital",
                            "enable", "manual", "marked", "notifycount", "description",
                            "zone", "parent", "zoneparent"}, ...]}
        """

        mbio=self.getMBIO()

        key=params.get('key')
        manual=params.get('manual')
        error=params.get('error')
        marked=params.get('marked')

        values=[]
        if key:
            if ',' in key:
                keys=key.split(',')
                for key in keys:
                    v=mbio.value(key)
                    if v is not None:
                        values.append(v)
            else:
                v=mbio.value(key)
                if v is not None:
                    values.append(v)
        else:
            values=mbio.values(params.get('filter'))

        if manual:
            values=[v for v in values if v.isManual()]
        if error:
            values=[v for v in values if v.isError()]
        if marked:
            values=[v for v in values if v.isMarked()]

        items=[]
        for v in values:
            self.addValueItemToDataResponse(items, v)

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

    def _navHtml(self, current=None):
        """Standard main navigation links, excluding current page."""
        pages = [
            ('/dashboard', 'Dashboard'),
            ('/tasks', 'Tasks'),
            ('/gateways', 'Gateways'),
            ('/alldevices', 'Devices'),
            ('/values', 'Values'),
            ('/valuesmarked', 'Marks'),
            ('/zones', 'Zones'),
        ]
        links = ['<a href="%s">%s</a>' % (url, label) for url, label in pages if url != current]
        return ' / '.join(links)

    def _logoutHtml(self, show_logs=True, show_config=True, show_sessions=False):
        """Return button-bar extras: Config button (optional), Logs button (optional), Sessions button (optional), Logout button (if auth enabled)."""
        wserver = self._wserver
        if not wserver:
            return ''
        parts = []
        if show_config:
            parts.append('<span class="ms-2"><a href="/config" class="btn btn-sm btn-outline-secondary">Config</a></span>')
        if show_logs:
            parts.append('<span class="ms-2"><a href="/logs" class="btn btn-sm btn-outline-secondary">Logs</a></span>')
        if show_sessions and wserver._auth_enabled:
            parts.append('<span class="ms-2"><a href="/sessions" class="btn btn-sm btn-outline-secondary">Sessions</a></span>')
        if wserver._auth_enabled:
            parts.append('<span class="ms-2"><a href="/logout" class="btn btn-sm btn-outline-danger">Logout</a></span>')
        return ''.join(parts)

    def cb_dashboard(self, handler, params):
        """Performance dashboard page.
        GET /dashboard
        Shows system KPIs, thread performance metrics, device metrics, and alerts.
        """
        mbio = self.getMBIO()
        h = HtmlPage('Digimat MBIO Dashboard', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml('/dashboard'))
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + 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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0"><b>Dashboard</b></p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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" id="tab-overview-btn" data-bs-toggle="tab" data-bs-target="#tab-overview" type="button">Overview</button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button class="nav-link" id="tab-threads-btn" data-bs-toggle="tab" data-bs-target="#tab-threads" type="button">Threads</button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button class="nav-link" id="tab-gateways-btn" data-bs-toggle="tab" data-bs-target="#tab-gateways" type="button">Gateways</button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button class="nav-link" id="tab-devices-btn" data-bs-toggle="tab" data-bs-target="#tab-devices" type="button">Devices</button>
                    </li>

                    <li class="nav-item" role="presentation">
                        <button class="nav-link" id="tab-help-btn" data-bs-toggle="tab" data-bs-target="#tab-help" type="button">?</button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <button class="btn btn-sm btn-outline-secondary" id="btn-refresh">Refresh</button>
                    </li>
                    </ul>
                    <div class="tab-content border border-top-0 rounded-bottom p-3 mb-2">

                    <!-- TAB: Overview -->
                    <div class="tab-pane fade show active" id="tab-overview" role="tabpanel">
                        <div class="row g-2 mb-4">
                            <!-- OS / Process -->
                            <div class="col-md-3"><div class="card border-0 shadow-sm h-100"><div class="card-body p-3">
                                <div class="fs-4 fw-bold" id="kpi-uptime">—</div>
                                <div class="text-muted small">Uptime</div>
                            </div></div></div>
                            <div class="col-md-3"><div class="card border-0 shadow-sm h-100"><div class="card-body p-3">
                                <div class="fs-4 fw-bold" id="kpi-rss-mb">—</div>
                                <div class="text-muted small">Memory <span class="fst-italic">(RSS)</span></div>
                            </div></div></div>
                            <div class="col-md-3"><div class="card border-0 shadow-sm h-100"><div class="card-body p-3">
                                <div class="fs-4 fw-bold" id="kpi-active-threads">—</div>
                                <div class="text-muted small">Threads <span class="fst-italic">(OS)</span></div>
                            </div></div></div>
                            <div class="col-md-3"><div class="card border-0 shadow-sm h-100"><div class="card-body p-3">
                                <div class="fs-4 fw-bold" id="kpi-open-fds">—</div>
                                <div class="text-muted small">Open FDs <span class="fst-italic">(Linux)</span></div>
                            </div></div></div>
                            <!-- MBIO -->
                            <div class="col-md-3"><div class="card border-0 shadow-sm h-100"><div class="card-body p-3">
                                <div class="fs-4 fw-bold" id="kpi-threads">—</div>
                                <div class="text-muted small">Threads <span id="kpi-threads-badge"></span></div>
                            </div></div></div>
                            <div class="col-md-3"><div class="card border-0 shadow-sm h-100"><div class="card-body p-3">
                                <div class="fs-4 fw-bold" id="kpi-values">—</div>
                                <div class="text-muted small">Values <span id="kpi-values-badge"></span></div>
                            </div></div></div>
                            <div class="col-md-3 kpi-mbio-trend-card"><div class="card border-0 shadow-sm h-100"><div class="card-body p-3">
                                <div class="fs-4 fw-bold" id="kpi-mbio-trend-points">—</div>
                                <div class="text-muted small">Trend Points <span class="fst-italic">(MBIO)</span></div>
                            </div></div></div>
                            <!-- Access -->
                            <div class="col-md-3"><a href="/sessions" class="text-decoration-none text-reset d-block h-100">
                                <div class="card border-0 shadow-sm h-100" style="cursor:pointer"><div class="card-body p-3">
                                    <div class="fs-4 fw-bold" id="kpi-sessions">—</div>
                                    <div class="text-muted small">Sessions</div>
                                </div></div>
                            </a></div>
                            <div class="col-md-3"><a href="/sessions" class="text-decoration-none text-reset d-block h-100">
                                <div class="card border-0 shadow-sm h-100" style="cursor:pointer"><div class="card-body p-3">
                                    <div class="fs-4 fw-bold" id="kpi-api-tokens">—</div>
                                    <div class="text-muted small">API Tokens</div>
                                </div></div>
                            </a></div>
                            <!-- BACnet (hidden until active) -->
                            <div class="col-md-3 kpi-bacnet-card d-none"><a href="/bacnet" class="text-decoration-none text-reset d-block h-100">
                                <div class="card border-0 shadow-sm h-100" style="cursor:pointer"><div class="card-body p-3">
                                    <div class="fs-4 fw-bold" id="kpi-bacnet-local">—</div>
                                    <div class="text-muted small">BACnet Server Objects</div>
                                </div></div>
                            </a></div>
                            <div class="col-md-3 kpi-bacnet-card d-none"><a href="/bacnet" class="text-decoration-none text-reset d-block h-100">
                                <div class="card border-0 shadow-sm h-100" style="cursor:pointer"><div class="card-body p-3">
                                    <div class="fs-4 fw-bold" id="kpi-bacnet-remote">—</div>
                                    <div class="text-muted small">BACnet Client</div>
                                </div></div>
                            </a></div>
                            <div class="col-md-3 kpi-bacnet-card d-none"><a href="/bacnet" class="text-decoration-none text-reset d-block h-100">
                                <div class="card border-0 shadow-sm h-100" style="cursor:pointer"><div class="card-body p-3">
                                    <div class="fs-4 fw-bold" id="kpi-bacnet-remote-obj-line">—</div>
                                    <div class="text-muted small">BACnet Remote Objects</div>
                                </div></div>
                            </a></div>
                            <div class="col-md-3 kpi-bacnet-card d-none"><a href="/bacnettrending" class="text-decoration-none text-reset d-block h-100">
                                <div class="card border-0 shadow-sm h-100" style="cursor:pointer"><div class="card-body p-3">
                                    <div class="fs-4 fw-bold" id="kpi-bacnet-recorded">—</div>
                                    <div class="text-muted small">BACnet Trending</div>
                                </div></div>
                            </a></div>
                            <div class="col-md-3 kpi-bacnet-card d-none"><a href="/bacnettrending" class="text-decoration-none text-reset d-block h-100">
                                <div class="card border-0 shadow-sm h-100" style="cursor:pointer"><div class="card-body p-3">
                                    <div class="fs-4 fw-bold" id="kpi-bacnet-points">—</div>
                                    <div class="text-muted small">BACnet Trend Points</div>
                                </div></div>
                            </a></div>
                            <!-- Modbus Server (hidden until active) -->
                            <div class="col-md-3 kpi-modbusserver-card d-none"><a href="/modbusserver" class="text-decoration-none text-reset d-block h-100">
                                <div class="card border-0 shadow-sm h-100" style="cursor:pointer"><div class="card-body p-3">
                                    <div class="fs-4 fw-bold" id="kpi-mbs-registers">—</div>
                                    <div class="text-muted small">ModbusServer Variables
                                        <span id="kpi-mbs-port" class="badge bg-secondary ms-1"></span></div>
                                </div></div>
                            </a></div>
                        </div>
                        <h6 class="fw-semibold text-muted text-uppercase small mb-3">Top Thread Utilization</h6>
                        <div id="top-cpu-bars"></div>
                    </div>

                    <!-- TAB: Threads -->
                    <div class="tab-pane fade" id="tab-threads" role="tabpanel">
                        <table id="tbl-threads" class="display nowrap" style="width:100%">
                        <thead>
                            <tr>
                                <th title="Thread type: Task (MBIOTask subclass, dedicated thread) or Gateway (MBIOGateway, shared Modbus polling thread)">Type</th>
                                <th title="Unique identifier of this thread within the MBIO processor">Key</th>
                                <th title="Python class implementing this thread">Class</th>
                                <th title="Current state machine state. RUN=normal, ERROR=failure (auto-retry 15s), HALT=stopped, POWERON/POWEROFF=transition">State</th>
                                <th title="Actual run() call frequency in Hz, exponential moving average of the period between successive calls">Hz</th>
                                <th title="Exponential moving average (alpha=0.2) of run() execution time in ms. How long each cycle takes on average. Amber &gt;=200ms, Red &gt;=500ms">Avg ms</th>
                                <th title="Rolling peak of run() duration over the last 100 cycles (reset each window). Reveals occasional spikes hidden by the average">Max ms</th>
                                <th title="Fraction of available time spent in run(): run_ms/period_ms*100, EMA-smoothed. Measures thread load. Amber &gt;=50%, Red &gt;=80%">Busy %</th>
                                <th title="Cumulative share of MBIO uptime consumed by this thread: total_run_ms/uptime_ms*100. Long-term CPU cost indicator">CPU %</th>
                                <th title="Number of times run() exceeded its own period (overload). Non-zero means the thread cannot keep up with its scheduled rate">Overruns</th>
                                <th title="Total number of run() calls since startup">Cycles</th>
                                <th title="[BACnet tasks only] Total local BACnet objects declared (AV+BV+MSV+Schedule). Hover cell for per-type breakdown">BACnet</th>
                                <th title="[BACnet tasks only] Number of remote BACnet devices currently known in the client discovery cache">Remote</th>
                                <th title="[Trending store only] Number of value keys currently tracked (each key = one MBIOValue recorded over time)">Trend keys</th>
                                <th title="[Trending store only] Total number of trend data points held in memory across all tracked keys">Points</th>
                                <th title="[Gateway threads only] Number of Modbus devices managed by this gateway thread">Devices</th>
                                <th title="[Gateway threads only] Total Modbus messages sent by all devices on this gateway since startup">Msgs</th>
                                <th title="[Gateway threads only] Percentage of Modbus messages that resulted in an error (timeout, exception, CRC). Red if &gt;5%">Err %</th>
                            </tr>
                        </thead>
                        </table>
                    </div>

                    <!-- TAB: Gateways -->
                    <div class="tab-pane fade" id="tab-gateways" role="tabpanel">
                        <table id="tbl-gateways" class="display nowrap" style="width:100%">
                        <thead>
                            <tr>
                                <th title="Unique identifier of the Modbus TCP gateway">Gateway</th>
                                <th title="IP address or hostname of the Modbus TCP server">Host</th>
                                <th title="TCP connection state. OPEN=connected and polling, CLOSED=not connected (all devices will be offline)">State</th>
                                <th title="Number of Modbus devices configured on this gateway">Devices</th>
                                <th title="Whether any device on this gateway is currently reporting a communication error">Error</th>
                                <th title="Hardware model identifier returned by the gateway at startup">Model</th>
                                <th title="Zone assignment of this gateway (used for value grouping)">Zone</th>
                            </tr>
                        </thead>
                        </table>
                    </div>

                    <!-- TAB: Devices -->
                    <div class="tab-pane fade" id="tab-devices" role="tabpanel">
                        <table id="tbl-devices" class="display nowrap" style="width:100%">
                        <thead>
                            <tr>
                                <th title="Parent gateway this device belongs to">Gateway</th>
                                <th title="Modbus slave address (unit ID) on the gateway RS-485 bus or TCP connection">Addr</th>
                                <th title="Unique device identifier within the MBIO processor">Key</th>
                                <th title="Python driver class implementing the device-specific Modbus protocol">Class</th>
                                <th title="Device state: ONLINE=communicating, OFFLINE=unreachable, PROBE=identifying hardware, POWERON=initializing, POWEROFF=shutting down, ERROR=failure">State</th>
                                <th title="Actual polling rate of this device in Hz (EMA). All devices share the gateway thread, so each device gets a fraction of the bus time">Poll Hz</th>
                                <th title="Exponential moving average of one complete Modbus poll cycle in ms (read registers + value update). Amber &gt;=200ms, Red &gt;=500ms">Avg ms</th>
                                <th title="Rolling peak poll duration over the last 100 cycles. High values indicate occasional slow Modbus responses or bus contention">Max ms</th>
                                <th title="Number of MBIOValue objects managed by this device (data points exposed to the MBIO bus)">#Values</th>
                                <th title="Total Modbus requests sent by this device since startup">Msgs</th>
                                <th title="Percentage of Modbus requests that failed. Red if &gt;5%, row highlighted if &gt;10%. Causes: timeout, exception response, CRC error">Err %</th>
                            </tr>
                        </thead>
                        </table>
                    </div>

                    <!-- TAB: Help -->
                    <div class="tab-pane fade" id="tab-help" role="tabpanel">
                        <p class="text-muted small mb-4">
                            All metrics are collected with negligible overhead (two <code>time.time()</code> calls +
                            O(1) EMA arithmetic per cycle). Hover any column header in the other tabs for a one-line
                            reminder.
                        </p>

                        <h6 class="fw-semibold text-uppercase text-muted small mb-2 mt-3">Overview KPIs</h6>
                        <table class="table table-sm table-bordered small mb-4">
                        <thead class="table-light"><tr><th>Indicator</th><th>Definition</th></tr></thead>
                        <tbody>
                        <tr><td class="fw-semibold">Uptime</td><td>Time elapsed since the MBIO processor started (<code>time.time() - stampStart</code>).</td></tr>
                        <tr><td class="fw-semibold">Threads</td><td>Total number of MBIOTask instances currently registered. A red badge shows how many are in ERROR state.</td></tr>
                        <tr><td class="fw-semibold">Values</td><td>Total number of MBIOValue objects across all tasks and devices. Badges are clickable: <b>err</b> (red) opens <a href="/valueserror">/valueserror</a>, <b>man</b> (amber) opens <a href="/valuesmanual">/valuesmanual</a>.</td></tr>
                        <tr><td class="fw-semibold">Trend Points (MBIO store)</td><td>Total data points held in the MBIO trend store (<code>MBIOTrendingStore</code>) across all tracked value keys. Updated as an O(1) counter. Displays — if no trend store is active.</td></tr>
                        <tr><td class="fw-semibold">Sessions</td><td>Number of active HTTP sessions on the WSMonitor web server. Only meaningful when authentication is enabled. Displays — if auth is disabled or if no session is active. <b>Clickable</b> → <a href="/sessions">/sessions</a>.</td></tr>
                        <tr><td class="fw-semibold">Open FDs</td><td>Number of open file descriptors for the MBIO process, read from <code>/proc/self/fd</code> (Linux only — displays — on macOS). Includes TCP sockets (Modbus, HTTP, BACnet), pickle files, pipes, and the BACnet asyncio event loop. A steadily increasing count indicates a descriptor leak (e.g. connections not closed after use).</td></tr>
                        <tr><td class="fw-semibold">Threads (OS)</td><td>Total number of active Python threads reported by <code>threading.active_count()</code>. Includes MBIO task threads, gateway threads, HTTP worker threads, BACnet asyncio thread, and the main thread. A steadily increasing count indicates a thread leak (e.g. unclosed HTTP connections or stalled workers).</td></tr>
                        <tr><td class="fw-semibold">Memory (RSS)</td><td>Resident Set Size of the MBIO Python process in MB, sampled at each refresh via <code>resource.getrusage()</code>. Useful for detecting slow memory leaks over long runtimes. Does not include memory-mapped files or swap.</td></tr>
                        <tr><td class="fw-semibold">API Tokens</td><td>Number of static Bearer API tokens currently registered on the web server. These allow programmatic access without a session cookie. Displays — if none are configured. <b>Clickable</b> → <a href="/sessions">/sessions</a>.</td></tr>
                        <tr><td class="fw-semibold">BACnet Server Objects</td><td>Total local BACnet objects declared by the BACnet server task (AV+BV+MSV+Schedule). Displays — if no BACnet task is running. <b>Clickable</b> → <a href="/bacnet">/bacnet</a>.</td></tr>
                        <tr><td class="fw-semibold">BACnet Client</td><td>Number of remote BACnet devices currently known in the client discovery cache. Includes stale entries from previous runs until the next Refresh. The sub-line shows the total count of discovered objects across all remote devices. Displays — if no BACnet client is active. <b>Clickable</b> → <a href="/bacnet">/bacnet</a>.</td></tr>
                        <tr><td class="fw-semibold">BACnet Trending</td><td>Number of remote BACnet objects currently being recorded (COV subscriptions). Each recorded object produces one time-series in the BACnet trend store. <b>Clickable</b> → <a href="/bacnettrending">/bacnettrending</a>.</td></tr>
                        <tr><td class="fw-semibold">Trend Points</td><td>Total data points held in the BACnet trend store across all recorded objects. Displayed as k/M for readability. <b>Clickable</b> → <a href="/bacnettrending">/bacnettrending</a>.</td></tr>
                        <tr><td class="fw-semibold">Modbus Server</td><td>Total number of register entries declared by the Modbus TCP server task (HR+IR+CO+DI). The badge shows the listening port. Hidden if no Modbus server task is running. <b>Clickable</b> → <a href="/modbusserver">/modbusserver</a>.</td></tr>
                        <tr><td class="fw-semibold">Top Thread Utilization</td><td>Bar chart of the 8 most CPU-intensive threads sorted by Busy %, updated on each refresh cycle.</td></tr>
                        </tbody>
                        </table>

                        <h6 class="fw-semibold text-uppercase text-muted small mb-2 mt-3">Threads tab — common columns</h6>
                        <table class="table table-sm table-bordered small mb-4">
                        <thead class="table-light"><tr><th style="min-width:100px">Column</th><th>Definition</th><th>Thresholds</th></tr></thead>
                        <tbody>
                        <tr><td class="fw-semibold">Type</td><td><b>Task</b>: MBIOTask subclass running in its own dedicated thread (state machine HALT→POWERON→RUN→ERROR). <b>Gw</b>: MBIOGateway, a simpler polling thread that iterates over its Modbus devices every 100ms.</td><td>—</td></tr>
                        <tr><td class="fw-semibold">State</td><td>Current state machine state for tasks. For gateways: OPEN (TCP connected) or CLOSED.</td><td>ERROR = red row</td></tr>
                        <tr><td class="fw-semibold">Hz</td><td>Actual invocation frequency of <code>run()</code>, computed as an exponential moving average (EMA, α=0.2) of the period between consecutive calls. Reflects the real throughput, not the configured sleep time.</td><td>—</td></tr>
                        <tr><td class="fw-semibold">Avg ms</td><td>EMA of <code>run()</code> wall-clock duration in milliseconds. The primary indicator of how much work the thread does per cycle. If <code>run()</code> calls device I/O or waits on network responses, this captures that latency.</td><td>Amber ≥200ms · Red ≥500ms</td></tr>
                        <tr><td class="fw-semibold">Max ms</td><td>Rolling peak over the last 100 cycles, reset each window. Reveals transient spikes (e.g. DNS lookup, slow device) that are hidden by the average.</td><td>Colored same as Avg ms</td></tr>
                        <tr><td class="fw-semibold">Busy %</td><td>Ratio <code>run_ms / period_ms × 100</code>, EMA-smoothed. Measures the fraction of available time actually spent executing. A thread at 100% cannot sleep between cycles and may starve the GIL.</td><td>Amber ≥50% · Red ≥80% · Row highlight same</td></tr>
                        <tr><td class="fw-semibold">CPU %</td><td>Cumulative share of MBIO uptime consumed: <code>total_run_ms / uptime_ms × 100</code>. Long-term indicator that accounts for transient bursts at startup.</td><td>—</td></tr>
                        <tr><td class="fw-semibold">Overruns</td><td>Number of cycles where <code>run()</code> took longer than the scheduled period. A non-zero count means the thread is persistently overloaded and cannot respect its own sleep schedule.</td><td>Any value → amber badge</td></tr>
                        <tr><td class="fw-semibold">Cycles</td><td>Total <code>run()</code> call count since startup. Formatted as k/M for readability.</td><td>—</td></tr>
                        </tbody>
                        </table>

                        <h6 class="fw-semibold text-uppercase text-muted small mb-2 mt-3">Threads tab — specialized columns</h6>
                        <table class="table table-sm table-bordered small mb-4">
                        <thead class="table-light"><tr><th style="min-width:100px">Column</th><th>Applies to</th><th>Definition</th></tr></thead>
                        <tbody>
                        <tr><td class="fw-semibold">BACnet</td><td>BACnet tasks</td><td>Total local BACnet objects declared: AV (Analog Value) + BV (Binary Value) + MSV (Multi-State Value) + Schedule. Hover the cell for a per-type breakdown.</td></tr>
                        <tr><td class="fw-semibold">Remote</td><td>BACnet tasks</td><td>Number of remote BACnet devices currently stored in the client discovery cache (may include stale entries from previous runs).</td></tr>
                        <tr><td class="fw-semibold">Trend keys</td><td>Trending store</td><td>Number of MBIOValue keys currently tracked. Each key maps to a time-series deque in memory.</td></tr>
                        <tr><td class="fw-semibold">Points</td><td>Trending store</td><td>Total data points held in memory across all keys. Updated as an O(1) counter (no scan required).</td></tr>
                        <tr><td class="fw-semibold">Devices</td><td>Gateway threads</td><td>Number of MBIODevice instances managed by this gateway. All share the same polling thread and the same TCP connection.</td></tr>
                        <tr><td class="fw-semibold">Msgs</td><td>Gateway threads</td><td>Aggregated total Modbus request count across all devices on this gateway since startup.</td></tr>
                        <tr><td class="fw-semibold">Err %</td><td>Gateway threads</td><td>Aggregated error ratio: failed requests / total requests. Covers timeouts, Modbus exception responses, and CRC errors.</td></tr>
                        </tbody>
                        </table>

                        <h6 class="fw-semibold text-uppercase text-muted small mb-2 mt-3">Devices tab</h6>
                        <table class="table table-sm table-bordered small mb-4">
                        <thead class="table-light"><tr><th style="min-width:100px">Column</th><th>Definition</th><th>Thresholds</th></tr></thead>
                        <tbody>
                        <tr><td class="fw-semibold">Addr</td><td>Modbus slave address (unit ID, 1–247) identifying this device on the shared bus or TCP connection.</td><td>—</td></tr>
                        <tr><td class="fw-semibold">State</td><td>ONLINE: communicating normally. OFFLINE: unreachable, waiting before retry. PROBE: querying device identity at startup. POWERON/POWEROFF: transition. ERROR: permanent failure until reset.</td><td>—</td></tr>
                        <tr><td class="fw-semibold">Poll Hz</td><td>Actual rate at which this device's <code>run()</code> is called by the gateway thread. Since all devices on a gateway share one thread, the sum of all Poll Hz values equals the gateway loop rate.</td><td>—</td></tr>
                        <tr><td class="fw-semibold">Avg ms</td><td>EMA of one complete device poll cycle (register reads + value updates). Slow devices (high Avg ms) delay all other devices on the same gateway.</td><td>Amber ≥200ms · Red ≥500ms · Row highlight same</td></tr>
                        <tr><td class="fw-semibold">Max ms</td><td>Rolling peak over the last 100 cycles. A device with a low average but high max occasionally blocks the gateway thread (e.g. intermittent TCP retransmission).</td><td>Colored same as Avg ms</td></tr>
                        <tr><td class="fw-semibold">#Values</td><td>Number of MBIOValue objects managed by this device — the data points exposed to the MBIO bus (sensors, actuators, status registers).</td><td>—</td></tr>
                        <tr><td class="fw-semibold">Msgs</td><td>Total Modbus requests sent by this device since startup. Useful to compare activity levels across devices on the same gateway.</td><td>—</td></tr>
                        <tr><td class="fw-semibold">Err %</td><td>Error ratio for this specific device. A high value on one device while others are healthy points to a hardware or addressing problem on that device, not a network issue.</td><td>Badge if &gt;5% · Red row if &gt;10%</td></tr>
                        </tbody>
                        </table>

                        <h6 class="fw-semibold text-uppercase text-muted small mb-2 mt-3">Row color coding (all tables)</h6>
                        <table class="table table-sm table-bordered small">
                        <thead class="table-light"><tr><th>Color</th><th>Condition</th></tr></thead>
                        <tbody>
                        <tr><td><span class="badge bg-danger">Red</span></td><td>State=ERROR, Busy % &gt;80%, or Avg ms &gt;500ms</td></tr>
                        <tr><td><span class="badge bg-warning text-dark">Amber</span></td><td>Busy % &gt;50% or Avg ms &gt;200ms (without meeting the red criteria)</td></tr>
                        </tbody>
                        </table>
                    </div>

                    </div><!-- tab-content -->
                </div>
            </div>
        """
        h.write(data)

        data = """
        <script>
        $(function () {
            function fmtBadge(label, cls) {
                return '<span class="badge ' + cls + '">' + label + '</span>';
            }
            function fmtState(s) {
                var c = {'RUN': 'bg-success', 'ERROR': 'bg-danger', 'HALT': 'bg-secondary',
                         'POWERON': 'bg-primary', 'POWEROFF': 'bg-secondary',
                         'OPEN': 'bg-success', 'CLOSED': 'bg-danger',
                         'ONLINE': 'bg-success', 'OFFLINE': 'bg-danger'};
                return fmtBadge(s, c[s] || 'bg-secondary');
            }
            function fmtBar(val, max, warningAt, dangerAt) {
                var pct = max > 0 ? Math.min(100, val / max * 100) : 0;
                var cls = val >= dangerAt ? 'bg-danger' : val >= warningAt ? 'bg-warning' : 'bg-info';
                return '<div class="d-flex align-items-center gap-2">' +
                    '<div class="progress flex-grow-1" style="height:5px;min-width:50px">' +
                    '<div class="progress-bar ' + cls + '" style="width:' + pct + '%"></div></div>' +
                    '<span class="small ' + (val >= dangerAt ? 'text-danger fw-bold' : val >= warningAt ? 'text-warning fw-bold' : '') + '">' + val + '</span></div>';
            }
            function fmtK(n) {
                if (n >= 1000000) return (n/1000000).toFixed(1) + 'M';
                if (n >= 1000) return (n/1000).toFixed(1) + 'k';
                return String(n);
            }
            function fmtUptime(s) {
                if (s < 3600) return Math.floor(s/60) + 'm';
                var h = Math.floor(s/3600), m = Math.floor((s%3600)/60);
                return h + 'h ' + m + 'm';
            }
            function initHeaderTooltips(tableId) {
                document.querySelectorAll('#' + tableId + ' thead th[title]').forEach(function(el) {
                    new bootstrap.Tooltip(el, {placement: 'bottom', trigger: 'hover', container: 'body'});
                });
            }

            // ── KPI cards ────────────────────────────────────────────────
            function loadKPIs() {
                fetch('/api/v1/getsysteminfo').then(r=>r.json()).then(function(d) {
                    $('#kpi-uptime').text(fmtUptime(d.uptime));
                    $('#kpi-threads').text(d.tasks_total);
                    if (d.tasks_error > 0)
                        $('#kpi-threads-badge').html(fmtBadge(d.tasks_error + ' err', 'bg-danger'));
                    else
                        $('#kpi-threads-badge').html('');
                    $('#kpi-values').text(d.values_total);
                    var vbadge = '';
                    if (d.values_error > 0) vbadge += '<a href="/valueserror" class="badge bg-danger text-decoration-none me-1">' + d.values_error + ' err</a>';
                    if (d.values_manual > 0) vbadge += '<a href="/valuesmanual" class="badge bg-warning text-dark text-decoration-none">' + d.values_manual + ' man</a>';
                    $('#kpi-values-badge').html(vbadge);
                    $('#kpi-mbio-trend-points').text(d.mbio_trend_points > 0 ? fmtK(d.mbio_trend_points) : '—');
                    if (!d.mbio_trending_enabled) {
                        $('.kpi-mbio-trend-card').addClass('d-none');
                    }
                    $('#kpi-sessions').text(d.sessions > 0 ? d.sessions : '—');
                    $('#kpi-api-tokens').text(d.api_tokens > 0 ? d.api_tokens : '—');
                    $('#kpi-rss-mb').text(d.rss_mb > 0 ? d.rss_mb + ' MB' : '—');
                    $('#kpi-active-threads').text(d.active_threads > 0 ? d.active_threads : '—');
                    $('#kpi-open-fds').text(d.open_fds > 0 ? d.open_fds : '—');
                    $('#kpi-bacnet-local').text(d.bacnet_local > 0 ? d.bacnet_local : '—');
                    $('#kpi-bacnet-remote').text(d.bacnet_remote > 0 ? d.bacnet_remote : '—');
                    $('#kpi-bacnet-remote-obj-line').text(d.bacnet_remote_objects > 0 ? d.bacnet_remote_objects : '—');
                    $('#kpi-bacnet-recorded').text(d.bacnet_trend_entries > 0 ? d.bacnet_trend_entries : '—');
                    $('#kpi-bacnet-points').text(d.bacnet_trend_points > 0 ? fmtK(d.bacnet_trend_points) : '—');
                    var bacnetActive = d.bacnet_local > 0 || d.bacnet_remote > 0 || d.bacnet_trend_entries > 0;
                    if (bacnetActive) { $('.kpi-bacnet-card').removeClass('d-none'); }
                    if (d.mbs_enabled) {
                        $('#kpi-mbs-registers').text(d.mbs_registers > 0 ? d.mbs_registers : '—');
                        $('#kpi-mbs-port').text(d.mbs_port ? ':' + d.mbs_port : '');
                        $('.kpi-modbusserver-card').removeClass('d-none');
                    }

                }).catch(function(){});
            }

            // ── Top CPU bars ─────────────────────────────────────────────
            function loadTopCPU(metricsData) {
                var sorted = metricsData.slice().sort(function(a,b){ return b.busy_pct - a.busy_pct; }).slice(0, 8);
                var html = '';
                sorted.forEach(function(row) {
                    var busy = row.busy_pct || 0;
                    var pct = Math.min(100, busy);
                    var barCls = pct >= 80 ? 'bg-danger' : pct >= 50 ? 'bg-warning' : 'bg-info';
                    var txtCls = pct >= 80 ? 'text-danger fw-bold' : pct >= 50 ? 'text-warning fw-bold' : '';
                    html += '<div class="d-flex align-items-center gap-2 mb-2">' +
                        '<div style="min-width:120px;max-width:120px" class="text-truncate small fw-semibold" title="' + row.key + '">' + row.key + '</div>' +
                        '<div style="min-width:80px" class="text-muted small">' + row.class + '</div>' +
                        '<div class="progress flex-grow-1" style="height:14px">' +
                        '<div class="progress-bar ' + barCls + '" style="width:' + pct + '%" role="progressbar"></div></div>' +
                        '<div style="min-width:52px" class="text-end small ' + txtCls + '">' + pct.toFixed(1) + '%</div>' +
                        '</div>';
                });
                if (!html) html = '<div class="text-muted small">No data yet — wait a few seconds for the first metrics cycle.</div>';
                $('#top-cpu-bars').html(html);
            }

            // ── Threads DataTable ────────────────────────────────────────
            var tblThreads = new DataTable('#tbl-threads', {
                responsive: true, paging: false, searching: true, ordering: true, info: false,
                order: [[7, 'desc']],
                search: { smart: true, regex: false, caseInsensitive: true, boundary: false },
                ajax: { url: '/api/v1/getmetrics', dataSrc: 'data' },
                columns: [
                    { data: 'type', render: function(d) {
                        return d === 'task' ? fmtBadge('Task', 'bg-secondary') : fmtBadge('Gw', 'bg-info text-dark'); }},
                    { data: 'key', render: function(d, t, row) {
                        if (t !== 'display') return d;
                        if (row.type === 'task') return '<a href="/taskvalues?task=' + encodeURIComponent(d) + '">' + d + '</a>';
                        return '<a href="/devices?gateway=' + encodeURIComponent(d) + '">' + d + '</a>'; }},
                    { data: 'class', render: function(d, t) { return t === 'display' ? '<code class="small">' + d + '</code>' : d; }},
                    { data: 'state', render: function(d, t) { return t === 'display' ? fmtState(d) : d; }},
                    { data: 'cycle_hz' },
                    { data: 'run_avg_ms', render: function(d, t) {
                        return t === 'display' ? fmtBar(d, 500, 200, 500) : d; }},
                    { data: 'run_max_ms', render: function(d, t) {
                        if (t !== 'display') return d;
                        var c = d >= 500 ? 'text-danger' : d >= 200 ? 'text-warning' : '';
                        return '<span class="' + c + '">' + d + '</span>'; }},
                    { data: 'busy_pct', render: function(d, t) {
                        return t === 'display' ? fmtBar(d, 100, 50, 80) : d; }},
                    { data: 'cpu_share_pct', render: function(d, t) {
                        return t === 'display' ? '<span class="small">' + d + '</span>' : d; }},
                    { data: 'overrun_count', render: function(d, t) {
                        if (t !== 'display') return d;
                        return d > 0 ? fmtBadge(d, 'bg-warning text-dark') : '0'; }},
                    { data: 'cycle_count', render: function(d, t) { return t === 'display' ? fmtK(d) : d; }},
                    { data: 'bacnet_local_total', defaultContent: '',
                      render: function(d, t, row) {
                        if (t !== 'display' || d == null) return d || '';
                        var tip = 'AV:' + (row.bacnet_local_av||0) + ' BV:' + (row.bacnet_local_bv||0) +
                                  ' MSV:' + (row.bacnet_local_msv||0) + ' SCH:' + (row.bacnet_local_sch||0);
                        return '<span title="' + tip + '">' + d + '</span>'; }},
                    { data: 'bacnet_remote_devices', defaultContent: '' },
                    { data: 'trend_keys', defaultContent: '' },
                    { data: 'trend_total_points', defaultContent: '',
                      render: function(d, t) { return t === 'display' && d ? fmtK(d) : (d || ''); }},
                    { data: 'devices_total', defaultContent: '' },
                    { data: 'msg_total', defaultContent: '',
                      render: function(d, t) { return t === 'display' && d ? fmtK(d) : (d || ''); }},
                    { data: 'err_rate_pct', defaultContent: '',
                      render: function(d, t) {
                        if (t !== 'display' || d == null) return d || '';
                        return d > 5 ? fmtBadge(d + '%', 'bg-danger') : d + '%'; }},
                ],
                createdRow: function(row, data) {
                    var busy = data.busy_pct || 0;
                    var avg = data.run_avg_ms || 0;
                    if (data.state === 'ERROR' || busy > 80 || avg > 500)
                        $(row).addClass('table-danger');
                    else if (busy > 50 || avg > 200)
                        $(row).addClass('table-warning');
                },
                initComplete: function() { initHeaderTooltips('tbl-threads'); }
            });

            // ── Gateways DataTable ───────────────────────────────────────
            var tblGateways = new DataTable('#tbl-gateways', {
                responsive: true, paging: false, searching: true, ordering: true, info: false,
                search: { smart: true, regex: false, caseInsensitive: true, boundary: false },
                ajax: { url: '/api/v1/getgateways', dataSrc: 'data' },
                columns: [
                    { data: 'key', render: function(d, t) {
                        return t === 'display' ? '<a href="/devices?gateway=' + encodeURIComponent(d) + '">' + d + '</a>' : d; }},
                    { data: 'host' },
                    { data: 'state', render: function(d, t) { return t === 'display' ? fmtState(d) : d; }},
                    { data: 'countdevices' },
                    { data: 'error', render: function(d, t) { return t === 'display' ? (d ? fmtBadge('ERR', 'bg-danger') : '') : d; }},
                    { data: 'model', defaultContent: '' },
                    { data: 'zone', defaultContent: '' },
                ],
                createdRow: function(row, data) {
                    if (data.error || data.state === 'CLOSED') $(row).addClass('table-danger');
                },
                initComplete: function() { initHeaderTooltips('tbl-gateways'); }
            });

            // ── Devices DataTable ────────────────────────────────────────
            var tblDevices = new DataTable('#tbl-devices', {
                responsive: true, paging: false, searching: true, ordering: true, info: false,
                order: [[0, 'asc'], [1, 'asc']],
                search: { smart: true, regex: false, caseInsensitive: true, boundary: false },
                ajax: { url: '/api/v1/getdevicemetrics', dataSrc: 'data' },
                columns: [
                    { data: 'gateway' },
                    { data: 'address', render: function(d, t) { return t === 'display' ? '<code>' + d + '</code>' : d; }},
                    { data: 'key', render: function(d, t) {
                        return t === 'display' ? '<a href="/devicevalues?device=' + encodeURIComponent(d) + '">' + d + '</a>' : d; }},
                    { data: 'class', render: function(d, t) { return t === 'display' ? '<code class="small">' + d + '</code>' : d; }},
                    { data: 'state', render: function(d, t) { return t === 'display' ? fmtState(d) : d; }},
                    { data: 'poll_hz' },
                    { data: 'run_avg_ms', render: function(d, t) {
                        return t === 'display' ? fmtBar(d, 500, 200, 500) : d; }},
                    { data: 'run_max_ms', render: function(d, t) {
                        if (t !== 'display') return d;
                        var c = d >= 500 ? 'text-danger' : d >= 200 ? 'text-warning' : '';
                        return '<span class="' + c + '">' + d + '</span>'; }},
                    { data: 'countvalues' },
                    { data: 'msg_total', render: function(d, t) { return t === 'display' ? fmtK(d) : d; }},
                    { data: 'err_rate_pct', render: function(d, t) {
                        if (t !== 'display') return d;
                        return d > 5 ? fmtBadge(d + '%', 'bg-danger') : d + '%'; }},
                ],
                createdRow: function(row, data) {
                    var avg = data.run_avg_ms || 0;
                    var err = data.err_rate_pct || 0;
                    if (err > 10 || avg > 500) $(row).addClass('table-danger');
                    else if (avg > 200) $(row).addClass('table-warning');
                },
                initComplete: function() { initHeaderTooltips('tbl-devices'); }
            });

            // ── Auto-refresh ─────────────────────────────────────────────
            var _metricsData = [];
            function reloadAll() {
                loadKPIs();
                fetch('/api/v1/getmetrics').then(r=>r.json()).then(function(d) {
                    _metricsData = d.data || [];
                    tblThreads.ajax.reload(null, false);
                    loadTopCPU(_metricsData);
                }).catch(function(){});
                tblDevices.ajax.reload(null, false);
                tblGateways.ajax.reload(null, false);
            }


            reloadAll();
            setInterval(reloadAll, 5000);

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

            $(document).on('keydown', function(e) {
                if ($(e.target).is('input, textarea')) return;
                if (e.key === 'r') reloadAll();
                if (e.key === '?') { bootstrap.Tab.getOrCreateInstance(document.getElementById('tab-help-btn')).show(); }
            });
        });
        </script>
        """
        h.write(data)
        h.flush()

    def cb_tasks(self, handler, params):
        """Tasks overview page.
        GET /tasks
        Displays all MBIO tasks with state, age, error status and value count.
        Auto-refreshes every 2 s. Reset button per row. Click a row to open /taskvalues.
        """

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml('/tasks'))
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + 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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0"><b>Tasks</b></p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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-tasks" type="button">Tasks</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-tasks" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <th>Key</th>
                            <th>State</th>
                            <th>Age</th>
                            <th>Error</th>
                            <th>#Values</th>
                            <th>Class</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/gettasks",
                    dataSrc: "data"
                },
                columns: [
                    { data: "key" },
                    { data: "state",
                        render: function (data, type, row) {
                            if (type !== "display") return data;
                            return `<div class="d-flex justify-content-between align-items-center">
                                <span>${data}</span>
                                <button class="btn btn-sm btn-outline-secondary cell-action" data-id="${row.key}">
                                    Reset
                                </button>
                                </div>`;
                            }
                    },
                    { data: "statetime" },
                    { data: "error", render: v=>v ? 'ERR' : 'OK' },
                    { data: "countvalues" },
                    { data: "class" }
                ],
                rowCallback: function (row, data) {
                    if (data.error) {
                        $('td', row).css('background-color', 'pink');
                    }
                }
                });

                $("#items tbody").on("click", ".cell-action, .action-btn", async function (e) {
                    e.stopPropagation();
                    const row = table.row(this.closest('tr'));
                    const data = row.data();
                    try {
                        const r = await fetch(`/api/v1/resettask?key=${data.key}`, {method: "GET"});
                    } catch (err) {
                    }
                });

                $('#items').on('click','tr', function(e) {
                    var $clickedCell = $(e.target).closest('td');
                    if ($clickedCell.length) {
                        var columnIndex = $clickedCell.index();
                        if (columnIndex !== 0) {
                            var data = table.row(this).data();
                            const url = "/taskvalues?task=" + encodeURIComponent(data.key);
                            if (e.ctrlKey || e.metaKey) { window.open(url, '_blank'); } else { window.location.href = url; }
                        }
                    }
                });

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

                $(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;
                    }
                });

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

    def cb_zones(self, handler, params):
        """Zones overview page.
        GET /zones
        Displays all MBIO zones with their value count and error status.
        Click a row to open /zonevalues for that zone. Link to /zoneeditor for editing.
        """

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml('/zones'))
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + 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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0"><b>Zones</b></p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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-zones" type="button">Zones</button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <a href="/zoneeditor" class="btn btn-sm btn-outline-secondary">Edit</a>
                    </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-zones" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <th>Zone</th>
                            <th>Values</th>
                            <th>Error</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/getzones",
                    dataSrc: "data"
                },
                columns: [
                    { data: "zone" },
                    { data: "#values" },
                    { data: "error", render: v=>v ? 'ERR' : 'OK' }
                ],
                rowCallback: function (row, data) {
                    if (data.error) {
                        $('td', row).css('background-color', 'pink');
                    }
                }

                });

                $('#items').on('click','tr', function(e) {
                    var $clickedCell = $(e.target).closest('td');
                    if ($clickedCell.length && !$clickedCell.hasClass('dtr-control')) {
                        var data = table.row(this).data();
                        const url = "/zonevalues?zone=" + encodeURIComponent(data.zone);
                        if (e.ctrlKey || e.metaKey) { window.open(url, '_blank'); } else { window.location.href = url; }
                    }
                });

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

                $(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;
                    }
                });

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

    def cb_gateways(self, handler, params):
        """Gateways overview page.
        GET /gateways
        Displays all Modbus TCP gateways with host, MAC, state, device count and zone.
        Click a row to open /devices for that gateway.
        """

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml('/gateways'))
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + 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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0"><b>Gateways</b></p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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-gateways" type="button">Gateways</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-gateways" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <th data-priority="1">Key</th>
                            <th data-priority="1">Host</th>
                            <th data-priority="2">MAC</th>
                            <th data-priority="1">State</th>
                            <th data-priority="4">Error</th>
                            <th data-priority="5">#Devices</th>
                            <th data-priority="2">Class</th>
                            <th data-priority="3">Zone</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/getgateways",
                    dataSrc: "data"
                },
                columns: [
                    { data: "key" },
                    { data: "host" },
                    { data: "mac" },
                    { data: "state",
                        render: function (data, type, row) {
                            if (type !== "display") return data;
                            return `<div class="d-flex justify-content-between align-items-center">
                                <span>${data}</span>
                                <button class="btn btn-sm btn-outline-secondary cell-action" data-id="${row.key}">
                                    Reset
                                </button>
                                </div>`;
                            }
                    },
                    { data: "error", render: v=>v ? 'ERR' : 'OK' },
                    { data: "countdevices" },
                    { data: "class" },
                    { data: "zone" }
                ],
                rowCallback: function (row, data) {
                    if (data.error) {
                        $('td', row).css('background-color', 'pink');
                    }
                }
                });

                $("#items tbody").on("click", ".cell-action, .action-btn", async function (e) {
                    e.stopPropagation();
                    const row = table.row(this.closest('tr'));
                    const data = row.data();
                    try {
                        const r = await fetch(`/api/v1/resetgateway?key=${data.key}`, {method: "GET"});
                    } catch (err) {
                    }
                });

                $('#items').on('click','tr', function(e) {
                    var $clickedCell = $(e.target).closest('td');
                    if ($clickedCell.length) {
                        var columnIndex = $clickedCell.index();
                        if (columnIndex !== 0) {
                            var data = table.row(this).data();
                            const url = "/devices?gateway=" + encodeURIComponent(data.key);
                            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;
                    }
                });

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

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

    def cb_devices(self, handler, params):
        """Device list page for a given gateway.
        GET /devices?gateway=&lt;gatewaykey&gt;
          gateway : unique gateway key (see /gateways)
        Displays all Modbus devices with address, vendor, model, state, counters and zone.
        Click a row to open /devicevalues for that device. Reset/Reboot/Upgrade buttons per row.
        """

        gateway=params.get('gateway')

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + self._logoutHtml())
        h.variable('gateway', gateway)

        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">Digimat MBIO Processor Monitor</h1>
            <p class="text-secondary mb-0"><a href="/gateways">Gateways</a> / <b>{gateway}</b> / devices</p>
            </div>
            <div class="text-muted small">{mbio.version} /
            {main.nav}{bacnet.nav}
            </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>
            </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="1">Address</th>
            <th data-priority="1">Key</th>
            <th data-priority="2">Vendor</th>
            <th data-priority="2">Model</th>
            <th data-priority="2">Version</th>
            <th data-priority="4">Upgrade</th>
            <th data-priority="1">State</th>
            <th data-priority="6">Age</th>
            <th data-priority="8">Error</th>
            <th data-priority="9">#Values</th>
            <th data-priority="10">#Msg</th>
            <th data-priority="10">#MsgErr</th>
            <th data-priority="2">Class</th>
            <th data-priority="1">Zone</th>
            <th data-priority="4">Reboot</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/getgatewaydevices?gateway={gateway}",
                dataSrc: "data"
            },
            columns: [
                { data: "address" },
                { data: "key" },
                { data: "vendor" },
                { data: "model" },
                { data: "version" },
                { data: "upgrade",
                    render: function (data, type, row) {
                        if (type !== "display") return data;
                        if (row.upgrade && !row.error) {
                            return `<div class="d-flex justify-content-between align-items-center">
                                    <button class="btn btn-sm btn-outline-secondary cell-action"
                                        data-field="upgrade" data-id="${row.key}">
                                    Upgrade
                                    </button>
                                    </div>`;
                        }
                        return '';
                    }
                },
                { data: "state",
                    render: function (data, type, row) {
                        if (type !== "display") return data;
                        return `<div class="d-flex justify-content-between align-items-center">
                            <span>${data}</span>
                            <button class="btn btn-sm btn-outline-secondary cell-action"
                                data-field="state" data-id="${row.key}">
                                Reset
                            </button>
                            </div>`;
                        }
                },
                { data: "statetime" },
                { data: "error", render: v=>v ? 'ERR' : 'OK' },
                { data: "countvalues" },
                { data: "countmsg" },
                { data: "countmsgerr" },
                { data: "class" },
                { data: "zone" },
                { data: "reboot",
                    render: function (data, type, row) {
                        if (type !== "display") return data;
                        if (row.reboot && !row.error) {
                            return `<div class="d-flex justify-content-between align-items-center">
                                    <button class="btn btn-sm btn-outline-secondary cell-action"
                                        data-field="reboot" data-id="${row.key}">
                                    Reboot
                                    </button>
                                    </div>`;
                        }
                        return '';
                    }
                }
            ],
            rowCallback: function (row, data) {
                if (data.error) {
                    $('td', row).css('background-color', 'pink');
                }
            }
            });

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

            $("#items tbody").on("click", ".cell-action, .action-btn", async function (e) {
                e.stopPropagation();

                const $ui = $(this);
                const $tr    = $ui.closest('tr');
                const colKey = $ui.data('field');
                const rowApi = table.row($tr);
                const row    = rowApi.data();

                if (colKey=='state') {
                    try {
                        const r = await fetch(`/api/v1/resetdevice?key=${row.key}`, {method: "GET"});
                    } catch (err) {
                    }
                } else if (colKey=='reboot') {
                    try {
                        const r = await fetch(`/api/v1/rebootdevice?key=${row.key}`, {method: "GET"});
                    } catch (err) {
                    }
                } else if (colKey=='upgrade') {
                    try {
                        const r = await fetch(`/api/v1/upgradedevice?key=${row.key}`, {method: "GET"});
                    } catch (err) {
                    }
                }
            });

            $('#items').on('click','tr', function(e) {
                var $clickedCell = $(e.target).closest('td');
                if ($clickedCell.length) {
                    var columnIndex = $clickedCell.index();
                    if (columnIndex !== 0) {
                        var data = table.row(this).data();
                        const url = "/devicevalues?device=" + encodeURIComponent(data.key);
                        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;
                }
            });

            });
        </script>
        """

        h.write(data)
        h.flush()

    def cb_alldevices(self, handler, params):
        """All devices overview page (across all gateways).
        GET /alldevices
        Same as /devices but aggregates all gateways. Default landing page (/).
        State column shows OPEN/CLOSED. Click a row (col 1+) to open /devicevalues.
        Click the state column to reset the device. Also accessible as /monitor.
        """

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml('/alldevices'))
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + 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">Digimat MBIO Processor Monitor</h1>
            <p class="text-secondary mb-0"><a href="/gateways">Gateways</a> / <b>devices</b></p>
            </div>
            <div class="text-muted small">{mbio.version} /
            {main.nav}{bacnet.nav}
            </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-alldevices" type="button">Devices</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-alldevices" role="tabpanel">
            <table id="items" class="display nowrap" style="width:100%">
            <thead>
            <tr>
            <th data-priority="1">Gateway</th>
            <th data-priority="1">Address</th>
            <th data-priority="1">Key</th>
            <th data-priority="2">Vendor</th>
            <th data-priority="2">Model</th>
            <th data-priority="2">Version</th>
            <th data-priority="4">Upgrade</th>
            <th data-priority="1">State</th>
            <th data-priority="6">Age</th>
            <th data-priority="8">Error</th>
            <th data-priority="9">#Values</th>
            <th data-priority="10">#Msg</th>
            <th data-priority="10">#MsgErr</th>
            <th data-priority="2">Class</th>
            <th data-priority="1">Zone</th>
            <th data-priority="4">Reboot</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/getalldevices",
                dataSrc: "data"
            },
            columns: [
                { data: "gateway" },
                { data: "address" },
                { data: "key" },
                { data: "vendor" },
                { data: "model" },
                { data: "version" },
                { data: "upgrade",
                    render: function (data, type, row) {
                        if (type !== "display") return data;
                        if (row.upgrade && !row.error) {
                        return `<div class="d-flex justify-content-between align-items-center">
                                <button class="btn btn-sm btn-outline-secondary cell-action"
                                    data-field="upgrade" data-id="${row.key}">
                                Upgrade
                                </button>
                                </div>`;
                        }
                        return '';
                    }
                },
                { data: "state",
                    render: function (data, type, row) {
                        if (type !== "display") return data;
                        return `<div class="d-flex justify-content-between align-items-center">
                            <span>${data}</span>
                            <button class="btn btn-sm btn-outline-secondary cell-action"
                                data-field="state" data-id="${row.key}">
                                Reset
                            </button>
                            </div>`;
                        }
                },
                { data: "statetime" },
                { data: "error", render: v=>v ? 'ERR' : 'OK' },
                { data: "countvalues" },
                { data: "countmsg" },
                { data: "countmsgerr" },
                { data: "class" },
                { data: "zone" },
                { data: "reboot",
                    render: function (data, type, row) {
                        if (type !== "display") return data;
                        if (row.reboot && !row.error) {
                        return `<div class="d-flex justify-content-between align-items-center">
                                <button class="btn btn-sm btn-outline-secondary cell-action"
                                    data-field="reboot" data-id="${row.key}">
                                Reboot
                                </button>
                                </div>`;
                        }
                        return '';
                    }
                }
            ],
            rowCallback: function (row, data) {
                if (data.error) {
                    $('td', row).css('background-color', 'pink');
                }
            }
            });

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

            $("#items tbody").on("click", ".cell-action, .action-btn", async function (e) {
                e.stopPropagation();

                const $ui = $(this);
                const $tr    = $ui.closest('tr');
                const colKey = $ui.data('field');
                const rowApi = table.row($tr);
                const row    = rowApi.data();

                if (colKey=='state') {
                    try {
                        const r = await fetch(`/api/v1/resetdevice?key=${row.key}`, {method: "GET"});
                    } catch (err) {
                    }
                } else if (colKey=='reboot') {
                    try {
                        const r = await fetch(`/api/v1/rebootdevice?key=${row.key}`, {method: "GET"});
                    } catch (err) {
                    }
                } else if (colKey=='upgrade') {
                    try {
                        const r = await fetch(`/api/v1/upgradedevice?key=${row.key}`, {method: "GET"});
                    } catch (err) {
                    }
                }
            });

            $('#items').on('click','tr', function(e) {
                var $clickedCell = $(e.target).closest('td');
                if ($clickedCell.length) {
                    var columnIndex = $clickedCell.index();
                    if (columnIndex !== 0) {
                        var data = table.row(this).data();
                        const url = "/devicevalues?device=" + encodeURIComponent(data.key);
                        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;
                }
            });

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

        h.flush()

    def cb_taskvalues(self, handler, params):
        """MBIO values page for a given task.
        GET /taskvalues?task=&lt;taskkey&gt;
          task : unique task key (see /tasks)
        Displays all values with mark, manual toggle, setpoint input, flags, age, error, zone.
        Auto-refreshes every 5 s. [/] focus search, [Ctrl+R] refresh, [Ctrl+K]/[Ctrl+K+Shift] mark/unmark.
        """

        task=params.get('task')

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + self._logoutHtml())
        h.variable('task', task)

        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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0"><a href="/tasks">Tasks</a> / <b>{task}</b> / values</p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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 id="tab-values-btn" class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-values" type="button">Values <span id="badge-values-count" class="badge bg-secondary ms-1">0</span></button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button id="tab-plot-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-plot" type="button">Plot <span id="badge-chart-count" class="badge bg-info ms-1" style="display:none">0</span></button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <div id="plot-controls" style="display:none" class="d-flex gap-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;</button>
                            <button id="btn-mc-csv" class="btn btn-sm btn-outline-secondary" title="Export CSV">&#x2193; CSV</button>
                        </div>
                        <button id="btn-purge-selected" class="btn btn-sm btn-danger" style="display:none">Purge (0)</button>
                        <button id="btn-save-trends" class="btn btn-sm btn-outline-secondary" title="Save all trend records to disk now">Save</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-values" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <th class="dt-select dt-no-copy" style="width:2rem"></th>
                            <th data-priority="1">Key</th>
                            <th data-priority="1">Mark</th>
                            <th data-priority="1">Manual</th>
                            <th data-priority="1">Value</th>
                            <th data-priority="1">Setpoint</th>
                            <th data-priority="5">Flags</th>
                            <th data-priority="10">Age</th>
                            <th data-priority="9">#TX</th>
                            <th data-priority="6">Error</th>
                            <th data-priority="2">Tag</th>
                            <th data-priority="3">Label</th>
                            <th data-priority="2">Zone</th>
                            <th data-priority="8">Records</th>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh, [CTRL+k] mark, [CTRL+K] unmark, [SPACE] select row, [CTRL+a] select all, [CTRL+A] deselect all
                    </div>
                    </div>
                    <div class="tab-pane fade" id="tab-plot" role="tabpanel">
                    <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 valeurs&#x2026;</div>
                    </div>
                    </div>
                </div>
            </div>
        """
        h.write(data)

        url=f"/api/v1/gettaskvalues?task={task}"
        data=self.getDataTableScriptForValues(url, 5000)
        h.write(data)

        h.flush()

    def cb_zonevalues(self, handler, params):
        """MBIO values page for a given zone.
        GET /zonevalues?zone=&lt;zone&gt;
          zone : zone name (see /zones)
        Same layout and features as /taskvalues. Filtered to the specified zone.
        """

        zone=params.get('zone')

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + self._logoutHtml())
        h.variable('zone', zone)

        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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0"><a href="/zones">Zones</a> / <b>{zone}</b> / values</p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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 id="tab-values-btn" class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-values" type="button">Values <span id="badge-values-count" class="badge bg-secondary ms-1">0</span></button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button id="tab-plot-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-plot" type="button">Plot <span id="badge-chart-count" class="badge bg-info ms-1" style="display:none">0</span></button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <div id="plot-controls" style="display:none" class="d-flex gap-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;</button>
                            <button id="btn-mc-csv" class="btn btn-sm btn-outline-secondary" title="Export CSV">&#x2193; CSV</button>
                        </div>
                        <button id="btn-purge-selected" class="btn btn-sm btn-danger" style="display:none">Purge (0)</button>
                        <button id="btn-save-trends" class="btn btn-sm btn-outline-secondary" title="Save all trend records to disk now">Save</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-values" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <th class="dt-select dt-no-copy" style="width:2rem"></th>
                            <th data-priority="1">Key</th>
                            <th data-priority="1">Mark</th>
                            <th data-priority="1">Manual</th>
                            <th data-priority="1">Value</th>
                            <th data-priority="1">Setpoint</th>
                            <th data-priority="5">Flags</th>
                            <th data-priority="10">Age</th>
                            <th data-priority="9">#TX</th>
                            <th data-priority="6">Error</th>
                            <th data-priority="2">Tag</th>
                            <th data-priority="3">Label</th>
                            <th data-priority="2">Zone</th>
                            <th data-priority="8">Records</th>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh, [CTRL+k] mark, [CTRL+K] unmark, [SPACE] select row, [CTRL+a] select all, [CTRL+A] deselect all
                    </div>
                    </div>
                    <div class="tab-pane fade" id="tab-plot" role="tabpanel">
                    <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 valeurs&#x2026;</div>
                    </div>
                    </div>
                </div>
            </div>
        """
        h.write(data)

        url=f"/api/v1/getzonevalues?zone={zone}"
        data=self.getDataTableScriptForValues(url, 5000)
        h.write(data)

        h.flush()

    def cb_devicevalues(self, handler, params):
        """MBIO values page for a given device.
        GET /devicevalues?device=&lt;devicekey&gt;
          device : unique device key (see /alldevices)
        Same layout and features as /taskvalues. Filtered to the specified device.
        """

        mbio=self.getMBIO()

        device=params.get('device')
        d=mbio.device(device)
        gateway=None
        if d:
            gateway=d.gateway.key

        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + self._logoutHtml())
        h.variable('gateway', gateway)
        h.variable('device', device)
        h.variable('url', f'/devices?gateway={gateway}')

        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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0"><a href="/gateways">Gateways</a> / <a href="{url}">{gateway}</a> / <b>{device}</b> / values</p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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 id="tab-values-btn" class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-values" type="button">Values <span id="badge-values-count" class="badge bg-secondary ms-1">0</span></button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button id="tab-plot-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-plot" type="button">Plot <span id="badge-chart-count" class="badge bg-info ms-1" style="display:none">0</span></button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <div id="plot-controls" style="display:none" class="d-flex gap-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;</button>
                            <button id="btn-mc-csv" class="btn btn-sm btn-outline-secondary" title="Export CSV">&#x2193; CSV</button>
                        </div>
                        <button id="btn-purge-selected" class="btn btn-sm btn-danger" style="display:none">Purge (0)</button>
                        <button id="btn-save-trends" class="btn btn-sm btn-outline-secondary" title="Save all trend records to disk now">Save</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-values" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <th class="dt-select dt-no-copy" style="width:2rem"></th>
                            <th data-priority="1">Key</th>
                            <th data-priority="1">Mark</th>
                            <th data-priority="1">Manual</th>
                            <th data-priority="1">Value</th>
                            <th data-priority="1">Setpoint</th>
                            <th data-priority="5">Flags</th>
                            <th data-priority="10">Age</th>
                            <th data-priority="9">#TX</th>
                            <th data-priority="6">Error</th>
                            <th data-priority="2">Tag</th>
                            <th data-priority="3">Label</th>
                            <th data-priority="2">Zone</th>
                            <th data-priority="8">Records</th>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh, [CTRL+k] mark, [CTRL+K] unmark, [SPACE] select row, [CTRL+a] select all, [CTRL+A] deselect all
                    </div>
                    </div>
                    <div class="tab-pane fade" id="tab-plot" role="tabpanel">
                    <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 valeurs&#x2026;</div>
                    </div>
                    </div>
                </div>
            </div>
        """
        h.write(data)

        url=f"/api/v1/getdevicevalues?device={device}"
        data=self.getDataTableScriptForValues(url, 5000)
        h.write(data)

        h.flush()

    def getDataTableScriptForValues(self, url, timeoutReload=5000, enableMarkedView=False):
        data="""
        <style>
        #items tbody tr.row-selected td { background-color: #0d6efd !important; color: #fff !important; }
        #items tbody tr.row-selected td .badge { filter: brightness(1.15); }
        #items tbody tr.row-selected td code { color: #cfe2ff !important; }
        #items tbody tr.row-selected td a { color: #cfe2ff !important; }
        </style>
        <script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
        <script>
            $(function () {
                let isEditing = false;

                const table = new DataTable('#items', {
                    rowId: 'key',
                    responsive: true,
                    paging: false,
                    searching: true,
                    ordering: true,
                    info: false,
                    search: { smart: true, regex: false, caseInsensitive: true, boundary: false },
                    ajax: { url: "{url}", dataSrc: "data" },
                    columns: [
                        { data: null, className: 'dt-select dt-no-copy', orderable: false, searchable: false,
                          defaultContent: '',
                          render: function(v, type) {
                            if (type !== 'display') return '';
                            return '<input type="checkbox" class="row-check" style="cursor:pointer">';
                          }
                        },
                        { data: "key" },
                        { data: "marked", className: "editable",
                          render: function(value, type, row) {
                            if (type === "display") {
                                const checked = row.marked ? 'checked' : '';
                                return `<label class="form-switch"><input type="checkbox" class="toggle-enabled" data-id="${row.key}" data-field="marked" ${checked}><span class="slider"></span></label>`;
                            }
                            return value;
                          }
                        },
                        { data: "manual", className: "editable",
                          render: function(value, type, row) {
                            if (type === "display") {
                                if (row.writable && !row.error) {
                                    const checked = row.manual ? 'checked' : '';
                                    return `<label class="form-switch"><input type="checkbox" class="toggle-enabled" data-id="${row.key}" data-field="manual" ${checked}><span class="slider"></span></label>`;
                                }
                                return row.manual ? "MAN" : "AUTO";
                            }
                            return value;
                          }
                        },
                        { data: "valuestr", className: "text-end" },
                        { data: "toreachvalue", className: "p0 editable text-start",
                          render: function(value, type, row) {
                            if (type === "display") {
                                if (row.manual && row.writable) {
                                    if (row.digital) {
                                        const checked = row.toreachvalue ? 'checked' : '';
                                        return `<label class="form-switch"><input type="checkbox" class="toggle-enabled" data-id="${row.key}" data-field="toreachvalue" ${checked}><span class="slider"></span></label>`;
                                    } else {
                                        return `<input type="number" class="form-control form-control-sm edit-input" data-id="${row.key}" data-field="toreachvalue" value="${value ?? ''}">`;
                                    }
                                }
                            }
                            return value;
                          }
                        },
                        { data: "flags" },
                        { data: "notifycount" },
                        { data: "age", render: v=>v<3600 ? v : '' },
                        { data: "error", render: v=>v ? 'ERR' : 'OK' },
                        { data: "tag" },
                        { data: "description" },
                        { data: "zone" },
                        { data: 'trendcount', title: 'Records',
                          render: function(v, type) {
                            if (type === 'display') return v ? String(v) : '';
                            return v || 0;
                          }
                        }
                    ],
                    rowCallback: function (row, data) {
                        if (!data.enable) {
                            $('td', row).css('background-color', 'lightgray');
                        } else if (data.error) {
                            $('td', row).css('background-color', 'pink');
                        } else if (data.manual) {
                            $('td', row).css('background-color', 'lightgreen');
                        } else if (data.marked) {
                            $('td', row).css('background-color', 'lightyellow');
                        } else {
                            $('td', row).css('background-color', 'white');
                        }
                    }
                });

                async function reloadRow(table, row) {
                    try {
                        data=row.data();
                        var r = await fetch(`/api/v1/getvalues?key=${data.key}`, {method: "GET"}).then(r=>r.json());
                        var newdata=r.data[0];
                        row.data(newdata);
                        row.invalidate();
                        table.draw(false);
                        return true;
                    } catch (_) {}
                }

                async function markRow(row) {
                    try {
                        data=row.data();
                        await fetch(`/api/v1/markvalue?key=${data.key}`, {method: "GET"}).then(r=>r.json());
                    } catch (_) {}
                }

                async function unmarkRow(row) {
                    try {
                        data=row.data();
                        await fetch(`/api/v1/unmarkvalue?key=${data.key}`, {method: "GET"}).then(r=>r.json());
                    } catch (_) {}
                }

                const enableMarkedView={enablemarkedview};

                let pollingId = setInterval(function() {
                    if (isEditing) return;
                    table.ajax.reload(null, false);
                }, {timeout});

                $('#items tbody').on('focus', 'input', function () { isEditing=true; });
                $('#items tbody').on('blur', 'input', function () { isEditing=false; });

                // === Selection state ===
                var selectedKeys = new Set();

                function updateSelectionUI() {
                    var n = selectedKeys.size;
                    $('#badge-chart-count').text(n).toggle(n > 0);
                    $('#btn-purge-selected').toggle(n > 0).text('Purge (' + n + ')');
                }

                function toggleRowSelection(tr) {
                    var d = table.row(tr).data();
                    if (!d) return;
                    var key = d.key;
                    var $chk = $(tr).find('.row-check');
                    if (selectedKeys.has(key)) {
                        selectedKeys.delete(key);
                        $chk.prop('checked', false);
                        $(tr).removeClass('row-selected');
                    } else {
                        selectedKeys.add(key);
                        $chk.prop('checked', true);
                        $(tr).addClass('row-selected');
                    }
                    updateSelectionUI();
                }

                // Track mousedown target so drag-release outside an input doesn't trigger navigation
                var _rowMousedownTarget = null;
                $('#items').on('mousedown', 'tbody tr', function(e) {
                    _rowMousedownTarget = e.target;
                });

                // Click on row — checkbox col → toggle selection, data cols → navigate
                $('#items').on('click', 'tbody tr', function(e) {
                    var $td = $(e.target).closest('td');
                    if ($td.hasClass('dt-select')) {
                        toggleRowSelection(this);
                        return;
                    }
                    // Leave DataTables responsive expand/collapse control alone
                    if ($td.hasClass('dtr-control')) return;
                    // Block navigation if mouseup OR mousedown was on an interactive element
                    var $interactive = 'input,button,a,label,select,textarea';
                    if ($(e.target).closest($interactive).length) return;
                    if ($(_rowMousedownTarget).closest($interactive).length) return;
                    var d = table.row(this).data();
                    if (d) {
                        var navUrl = '/valueproperties?key=' + encodeURIComponent(d.key);
                        if (e.ctrlKey || e.metaKey) window.open(navUrl, '_blank');
                        else window.location.href = navUrl;
                    }
                });

                // Purge selected trends
                $('#btn-purge-selected').on('click', function() {
                    if (!selectedKeys.size) return;
                    if (!confirm('Purger les trends de ' + selectedKeys.size + ' valeur(s) ?')) return;
                    var keys = Array.from(selectedKeys);
                    var pending = keys.length;
                    keys.forEach(function(k) {
                        fetch('/api/v1/raztrend?key=' + encodeURIComponent(k))
                            .finally(function() { if (--pending === 0) table.ajax.reload(null, false); });
                    });
                });

                // Save all dirty trends to disk
                $('#btn-save-trends').on('click', function() {
                    var $btn = $(this).prop('disabled', true).text('Saving…');
                    fetch('/api/v1/savetrends').finally(function() {
                        $btn.prop('disabled', false).text('Save');
                    });
                });

                // DataTable draw → update badge + resync checkboxes + row highlight
                table.on('draw', function() {
                    var n = table.rows({ search: 'applied' }).count();
                    $('#badge-values-count').text(n);
                    table.rows().every(function() {
                        var d = this.data();
                        var tr = this.node();
                        if (d && tr) {
                            if (selectedKeys.has(d.key)) {
                                $(tr).find('.row-check').prop('checked', true);
                                $(tr).addClass('row-selected');
                            } else {
                                $(tr).removeClass('row-selected');
                            }
                        }
                    });
                });

                // Toggle controls
                $("#items tbody").on("change", ".toggle-enabled", async function (e) {
                    e.stopPropagation();
                    const $ui = $(this);
                    if ($ui.data('committing')) return;
                    $ui.data('committing', true);
                    const $tr    = $ui.closest('tr');
                    const colKey = $ui.data('field');
                    const enabled = $ui.is(':checked');
                    const rowApi = table.row($tr);
                    const row    = rowApi.data();
                    try {
                        var res;
                        var digital=row.digital;
                        if (colKey=="toreachvalue") {
                            if (enabled) {
                                res = await fetch(`/api/v1/setvalue?key=${row.key}&value=1`, {method: "GET"});
                            } else {
                                res = await fetch(`/api/v1/setvalue?key=${row.key}&value=0`, {method: "GET"});
                            }
                            if (enableMarkedView) {
                                var rows=table.rows({ search: 'applied' });
                                var count=0;
                                rows.every(function () {
                                    if (count<MAXVALUESATONCE) {
                                        data=this.data();
                                        if (data.digital==digital && data.writable && !data.error) {
                                            if (enabled) { fetch(`/api/v1/setvalue?key=${data.key}&value=1`, {method: "GET"}); }
                                            else { fetch(`/api/v1/setvalue?key=${data.key}&value=0`, {method: "GET"}); }
                                            reloadRow(table, this);
                                            count++;
                                        }
                                    }
                                });
                            }
                        } else if (colKey=="marked") {
                            if (enabled) {
                                res = await fetch(`/api/v1/markvalue?key=${row.key}`, {method: "GET"});
                            } else {
                                res = await fetch(`/api/v1/unmarkvalue?key=${row.key}`, {method: "GET"});
                            }
                        } else {
                            if (enabled) {
                                var v=row.toreachvalue;
                                if (v === null) v=row.value;
                                res = await fetch(`/api/v1/setvalue?key=${row.key}&value=${v}`, {method: "GET"});
                            } else {
                                res = await fetch(`/api/v1/setvalueauto?key=${row.key}`, {method: "GET"});
                            }
                            if (enableMarkedView) {
                                var rows=table.rows({ search: 'applied' });
                                var count=0;
                                rows.every(function () {
                                    if (count<MAXVALUESATONCE) {
                                        data=this.data();
                                        if (data.digital==digital && data.writable && !data.error) {
                                            if (enabled) {
                                                var v=data.toreachvalue;
                                                if (v === null) v=data.value;
                                                fetch(`/api/v1/setvalue?key=${data.key}&value=${v}`, {method: "GET"});
                                            } else {
                                                fetch(`/api/v1/setvalueauto?key=${data.key}`, {method: "GET"});
                                            }
                                            reloadRow(table, this);
                                            count++;
                                        }
                                    }
                                });
                            }
                        }
                        reloadRow(table, rowApi);
                        if (!res.ok) throw new Error();
                    } catch (_) {
                        console.log("value set error!");
                    } finally {
                        $ui.data('committing', false);
                    }
                });

                $('#items tbody').on('keydown', 'input.edit-input', function (e) {
                    if (e.key === 'Enter') { e.preventDefault(); this.blur(); }
                });

                $('#items tbody').on('change', 'input.edit-input', async function () {
                    const $ui = $(this);
                    if ($ui.data('committing')) return;
                    $ui.data('committing', true);
                    const colKey = $ui.data('field');
                    const $tr    = $ui.closest('tr');
                    const rowApi = table.row($tr);
                    const row    = rowApi.data();
                    const original = row[colKey];
                    let newVal = $ui.val().trim();
                    if (String(original) === String(newVal)) { $ui.data('committing', false); return; }
                    try {
                        const res = await fetch(`/api/v1/setvalue?key=${row.key}&value=${newVal}`, {method: 'GET'});
                        row[colKey] = newVal;
                        rowApi.data(row).draw(false);
                        if (enableMarkedView) {
                            var rows=table.rows({ search: 'applied' });
                            var count=0;
                            rows.every(function () {
                                if (count<MAXVALUESATONCE) {
                                    data=this.data();
                                    if (data.writable && data.manual && !data.error && !data.digital) {
                                        fetch(`/api/v1/setvalue?key=${data.key}&value=${newVal}`, {method: "GET"});
                                        reloadRow(table, this);
                                        count++;
                                    }
                                }
                            });
                        }
                    } catch (err) {
                    } finally {
                        $ui.data('committing', false);
                    }
                });

                const MAXVALUESATONCE=128;

                $(document).on('keydown', function (e) {
                    const $target=$(e.target);
                    if (e.key === ' ' && !$target.is('input,textarea,select')) {
                        e.preventDefault();
                        var $hovered = $('#items tbody tr:hover');
                        if ($hovered.length) toggleRowSelection($hovered[0]);
                        return;
                    }
                    if (e.key === 'c' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
                        if ($target.is('input, textarea, select')) return;
                        if (window.getSelection && window.getSelection().toString()) return;
                        e.preventDefault();
                        var sel = selectedKeys.size > 0
                            ? function(idx, d) { return selectedKeys.has(d.key); }
                            : {search: 'applied'};
                        copyDataTableToClipboard(table, {selector: sel});
                        return;
                    }
                    if (e.key.toLowerCase() === 'a' && (e.ctrlKey || e.metaKey) && !e.altKey) {
                        if ($target.is('input,textarea,select')) return;
                        e.preventDefault();
                        if (e.shiftKey) {
                            // Deselect all visible
                            table.rows({ search: 'applied' }).every(function() {
                                var d = this.data(); var tr = this.node();
                                if (!d || !tr) return;
                                selectedKeys.delete(d.key);
                                $(tr).find('.row-check').prop('checked', false);
                                $(tr).removeClass('row-selected');
                            });
                        } else {
                            // Select all visible
                            table.rows({ search: 'applied' }).every(function() {
                                var d = this.data(); var tr = this.node();
                                if (!d || !tr) return;
                                selectedKeys.add(d.key);
                                $(tr).find('.row-check').prop('checked', true);
                                $(tr).addClass('row-selected');
                            });
                        }
                        updateSelectionUI();
                        return;
                    }
                    if (e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === 'r') {
                        e.preventDefault();
                        var rows=table.rows({ search: 'applied' });
                        if (rows.count()>0) {
                            var count=0;
                            rows.every(function () {
                                if (count<MAXVALUESATONCE) { reloadRow(table, this); count++; }
                            });
                        }
                    } else if (e.ctrlKey && !e.metaKey && !e.altKey && e.key === 'k') {
                        e.preventDefault();
                        var rows = selectedKeys.size > 0
                            ? table.rows(function(idx, d) { return selectedKeys.has(d.key); })
                            : table.rows({ search: 'applied' });
                        var count=0;
                        rows.every(function () {
                            if (count<MAXVALUESATONCE) {
                                data=this.data();
                                if (!data.marked) { markRow(this); reloadRow(table, this); count++; }
                            }
                        });
                    } else if (e.ctrlKey && !e.metaKey && !e.altKey && e.key === 'K') {
                        e.preventDefault();
                        var rows = selectedKeys.size > 0
                            ? table.rows(function(idx, d) { return selectedKeys.has(d.key); })
                            : table.rows({ search: 'applied' });
                        var count=0;
                        rows.every(function () {
                            if (count<MAXVALUESATONCE) {
                                data=this.data();
                                if (data.marked) { unmarkRow(this); reloadRow(table, this); count++; }
                            }
                        });
                    }
                });

                // === Multi-trend chart ===
                var multiChart = null;
                var mcRangeMs = 86400 * 1000;
                var mcRefreshTimer = null;
                var MC_COLORS = ['#0d6efd','#dc3545','#198754','#fd7e14','#6610f2',
                                 '#20c997','#0dcaf0','#6c757d','#e83e8c','#17a2b8'];

                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);
                        }
                        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([]);
                        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';
                            c.beginPath(); c.arc(cx, yPx, 4, 0, 2*Math.PI);
                            c.fillStyle = col; c.strokeStyle = '#fff'; c.lineWidth = 2;
                            c.fill(); c.stroke();
                            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);
                        });
                        var d = new Date(xVal);
                        var timeLabel = mcRangeMs > 86400000 ? d.toLocaleString() : d.toLocaleTimeString();
                        pill(timeLabel, cx, bot-12);
                        c.restore();
                    }
                };

                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 refreshMultiChart() {
                    if (!multiChart || !selectedKeys.size) {
                        if (multiChart) { multiChart.data.datasets = []; multiChart.update('none'); }
                        var infoEl = document.getElementById('chart-info');
                        if (infoEl) infoEl.textContent = 'S\u00e9lectionner des valeurs\u2026';
                        return;
                    }
                    var since = (Date.now() - mcRangeMs) / 1000;
                    var keys = Array.from(selectedKeys);
                    var pending = keys.length;
                    var datasets = new Array(keys.length);
                    keys.forEach(function(key, i) {
                        var apiUrl = '/api/v1/gettrenddata?key=' + encodeURIComponent(key)
                                  + '&since=' + since + '&max=500';
                        fetch(apiUrl).then(function(r) { return r.json(); })
                            .then(function(resp) {
                                var pts = (resp.data || []).map(function(p) { return {x: p.x, y: p.y}; });
                                var col = MC_COLORS[i % MC_COLORS.length];
                                var rowData = table ? table.rows().data().toArray().find(function(d) { return d.key === key; }) : null;
                                var lbl = (rowData && rowData.description) ? key + ' \u2014 ' + rowData.description : key;
                                datasets[i] = {
                                    label: lbl, data: pts, borderColor: col,
                                    backgroundColor: col + '22', borderWidth: 1.5,
                                    pointRadius: 0, stepped: 'before', fill: 'origin', yAxisID: 'y0'
                                };
                                if (--pending === 0) applyMcData(datasets, keys);
                            })
                            .catch(function() { if (--pending === 0) applyMcData(datasets, keys); });
                    });
                }

                function applyMcData(datasets, keys) {
                    if (!multiChart) return;
                    multiChart.data.datasets = datasets.filter(Boolean);
                    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 = keys.length + ' s\u00e9ries \u00b7 ' + totalPts + ' pts';
                }

                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);

                $('[data-bs-toggle="tab"]').on('shown.bs.tab', function() {
                    var isPlot = $(this).data('bs-target') === '#tab-plot';
                    $('#plot-controls').toggle(isPlot);
                    if (isPlot) {
                        initMultiChart();
                        resizeMultiChart();
                        refreshMultiChart();
                        clearInterval(mcRefreshTimer);
                        mcRefreshTimer = setInterval(function() {
                            if (!$('#tab-plot').hasClass('active')) { clearInterval(mcRefreshTimer); return; }
                            if (multiChart && !multiChart._zoom) refreshMultiChart();
                        }, 5000);
                    } else {
                        clearInterval(mcRefreshTimer);
                    }
                });

                $('#chart-range-btns').on('click', '[data-range]', function() {
                    $('#chart-range-btns .btn').removeClass('active');
                    $(this).addClass('active');
                    mcRangeMs = parseInt($(this).data('range')) * 1000;
                    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 = 'trend_multiselect.csv'; a.click();
                });

            });
        </script>
        """

        data=data.replace('{url}', url)
        enable='false'
        if enableMarkedView:
            enable='true'
        data=data.replace('{enablemarkedview}', enable)
        data=data.replace('{timeout}', str(timeoutReload))

        return data

    def cb_values(self, handler, params):
        """All MBIO values page (entire system).
        GET /values
        Displays all values across all tasks and devices. Same layout as /taskvalues.
        Links to /valuesmanual (manual), /valueserror (errors), /valuesmarked (marked).
        """

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml('/values'))
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + 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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0">MBIO / <b>Values</b></p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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 id="tab-values-btn" class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-values" type="button">Values <span id="badge-values-count" class="badge bg-secondary ms-1">0</span></button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button id="tab-plot-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-plot" type="button">Plot <span id="badge-chart-count" class="badge bg-info ms-1" style="display:none">0</span></button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <div id="plot-controls" style="display:none" class="d-flex gap-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;</button>
                            <button id="btn-mc-csv" class="btn btn-sm btn-outline-secondary" title="Export CSV">&#x2193; CSV</button>
                        </div>
                        <button id="btn-purge-selected" class="btn btn-sm btn-danger" style="display:none">Purge (0)</button>
                        <button id="btn-save-trends" class="btn btn-sm btn-outline-secondary" title="Save all trend records to disk now">Save</button>
                        <a href="/valuesmanual" class="btn btn-sm btn-outline-secondary">Manuals</a>
                        <a href="/valueserror" class="btn btn-sm btn-outline-secondary">Errors</a>
                        <a href="/valuesmarked" class="btn btn-sm btn-outline-secondary">Marked</a>
                    </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-values" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <th class="dt-select dt-no-copy" style="width:2rem"></th>
                            <th data-priority="1">Key</th>
                            <th data-priority="1">Mark</th>
                            <th data-priority="1">Manual</th>
                            <th data-priority="1">Value</th>
                            <th data-priority="1">Setpoint</th>
                            <th data-priority="5">Flags</th>
                            <th data-priority="10">Age</th>
                            <th data-priority="9">#TX</th>
                            <th data-priority="6">Error</th>
                            <th data-priority="2">Tag</th>
                            <th data-priority="3">Label</th>
                            <th data-priority="2">Zone</th>
                            <th data-priority="8">Records</th>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh, [CTRL+k] mark, [CTRL+K] unmark, [SPACE] select row, [CTRL+a] select all, [CTRL+A] deselect all
                    </div>
                    </div>
                    <div class="tab-pane fade" id="tab-plot" role="tabpanel">
                    <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 valeurs&#x2026;</div>
                    </div>
                    </div>
                </div>
            </div>
        """
        h.write(data)

        data=self.getDataTableScriptForValues('/api/v1/getvalues', 10000)
        h.write(data)

        h.flush()

    def cb_valuesmanual(self, handler, params):
        """MBIO values currently in manual mode.
        GET /valuesmanual
        Filtered view of /values showing only values with manual=true.
        Setpoint editing enabled. Marked view active (Ctrl+R refreshes all visible setpoints).
        """

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + 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">Digimat MBIO Processor Monitor</h1>
                            <p class="text-secondary mb-0">MBIO / <a href="/values">Values</a> / <b>Manuals</b></p>
                        </div>
                        <div class="text-muted small">{mbio.version} /
                            {main.nav}{bacnet.nav}
                        </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 id="tab-values-btn" class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-values" type="button">Manuals <span id="badge-values-count" class="badge bg-secondary ms-1">0</span></button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button id="tab-plot-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-plot" type="button">Plot <span id="badge-chart-count" class="badge bg-info ms-1" style="display:none">0</span></button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <div id="plot-controls" style="display:none" class="d-flex gap-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;</button>
                            <button id="btn-mc-csv" class="btn btn-sm btn-outline-secondary" title="Export CSV">&#x2193; CSV</button>
                        </div>
                        <button id="btn-purge-selected" class="btn btn-sm btn-danger" style="display:none">Purge (0)</button>
                        <button id="btn-save-trends" class="btn btn-sm btn-outline-secondary" title="Save all trend records to disk now">Save</button>
                        <a href="/values" class="btn btn-sm btn-outline-secondary">Values</a>
                        <a href="/valueserror" class="btn btn-sm btn-outline-secondary">Errors</a>
                        <a href="/valuesmarked" class="btn btn-sm btn-outline-secondary">Marked</a>
                    </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-values" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                        <thead>
                            <tr>
                            <th class="dt-select dt-no-copy" style="width:2rem"></th>
                            <th data-priority="1">Key</th>
                            <th data-priority="1">Mark</th>
                            <th data-priority="1">Manual</th>
                            <th data-priority="1">Value</th>
                            <th data-priority="1">Setpoint</th>
                            <th data-priority="5">Flags</th>
                            <th data-priority="10">Age</th>
                            <th data-priority="9">#TX</th>
                            <th data-priority="6">Error</th>
                            <th data-priority="2">Tag</th>
                            <th data-priority="3">Label</th>
                            <th data-priority="2">Zone</th>
                            <th data-priority="8">Records</th>
                            </tr>
                        </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh, [CTRL+k] mark, [CTRL+K] unmark, [SPACE] select row, [CTRL+a] select all, [CTRL+A] deselect all
                    </div>
                    </div>
                    <div class="tab-pane fade" id="tab-plot" role="tabpanel">
                    <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 valeurs&#x2026;</div>
                    </div>
                    </div>
                </div>
            </div>
        """
        h.write(data)

        data=self.getDataTableScriptForValues('/api/v1/getvalues?manual=1', 5000)
        h.write(data)

        h.flush()

    def cb_valueserror(self, handler, params):
        """MBIO values currently in error state.
        GET /valueserror
        Filtered view of /values showing only values with error=true.
        """

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + 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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0">MBIO / <a href="/values">Values</a> / <b>Errors</b></p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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 id="tab-values-btn" class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-values" type="button">Errors <span id="badge-values-count" class="badge bg-secondary ms-1">0</span></button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button id="tab-plot-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-plot" type="button">Plot <span id="badge-chart-count" class="badge bg-info ms-1" style="display:none">0</span></button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <div id="plot-controls" style="display:none" class="d-flex gap-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;</button>
                            <button id="btn-mc-csv" class="btn btn-sm btn-outline-secondary" title="Export CSV">&#x2193; CSV</button>
                        </div>
                        <button id="btn-purge-selected" class="btn btn-sm btn-danger" style="display:none">Purge (0)</button>
                        <button id="btn-save-trends" class="btn btn-sm btn-outline-secondary" title="Save all trend records to disk now">Save</button>
                        <a href="/values" class="btn btn-sm btn-outline-secondary">Values</a>
                        <a href="/valuesmanual" class="btn btn-sm btn-outline-secondary">Manuals</a>
                        <a href="/valuesmarked" class="btn btn-sm btn-outline-secondary">Marked</a>
                    </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-values" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <th class="dt-select dt-no-copy" style="width:2rem"></th>
                            <th data-priority="1">Key</th>
                            <th data-priority="1">Mark</th>
                            <th data-priority="1">Manual</th>
                            <th data-priority="1">Value</th>
                            <th data-priority="1">Setpoint</th>
                            <th data-priority="5">Flags</th>
                            <th data-priority="10">Age</th>
                            <th data-priority="9">#TX</th>
                            <th data-priority="6">Error</th>
                            <th data-priority="2">Tag</th>
                            <th data-priority="3">Label</th>
                            <th data-priority="2">Zone</th>
                            <th data-priority="8">Records</th>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh, [CTRL+k] mark, [CTRL+K] unmark, [SPACE] select row, [CTRL+a] select all, [CTRL+A] deselect all
                    </div>
                    </div>
                    <div class="tab-pane fade" id="tab-plot" role="tabpanel">
                    <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 valeurs&#x2026;</div>
                    </div>
                    </div>
                </div>
            </div>
        """
        h.write(data)

        data=self.getDataTableScriptForValues('/api/v1/getvalues?error=1', 5000)
        h.write(data)

        h.flush()

    def cb_valuesmarked(self, handler, params):
        """MBIO values currently marked (favourites / points of interest).
        GET /valuesmarked
        Filtered view of /values showing only values with marked=true.
        Setpoint editing and bulk operations enabled (Ctrl+R refreshes setpoints, Ctrl+K marks).
        """

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml('/valuesmarked'))
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + 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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0">MBIO / <a href="/values">Values</a> / <b>Marked</b></p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                    </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 id="tab-values-btn" class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-values" type="button">Marked <span id="badge-values-count" class="badge bg-secondary ms-1">0</span></button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button id="tab-plot-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-plot" type="button">Plot <span id="badge-chart-count" class="badge bg-info ms-1" style="display:none">0</span></button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <div id="plot-controls" style="display:none" class="d-flex gap-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;</button>
                            <button id="btn-mc-csv" class="btn btn-sm btn-outline-secondary" title="Export CSV">&#x2193; CSV</button>
                        </div>
                        <button id="btn-purge-selected" class="btn btn-sm btn-danger" style="display:none">Purge (0)</button>
                        <button id="btn-save-trends" class="btn btn-sm btn-outline-secondary" title="Save all trend records to disk now">Save</button>
                        <a href="/values" class="btn btn-sm btn-outline-secondary">Values</a>
                        <a href="/valuesmanual" class="btn btn-sm btn-outline-secondary">Manuals</a>
                        <a href="/valueserror" class="btn btn-sm btn-outline-secondary">Errors</a>
                    </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-values" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <th class="dt-select dt-no-copy" style="width:2rem"></th>
                            <th data-priority="1">Key</th>
                            <th data-priority="1">Mark</th>
                            <th data-priority="1">Manual</th>
                            <th data-priority="1">Value</th>
                            <th data-priority="1">Setpoint</th>
                            <th data-priority="5">Flags</th>
                            <th data-priority="10">Age</th>
                            <th data-priority="9">#TX</th>
                            <th data-priority="6">Error</th>
                            <th data-priority="2">Tag</th>
                            <th data-priority="3">Label</th>
                            <th data-priority="2">Zone</th>
                            <th data-priority="8">Records</th>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh, [CTRL+k] mark, [CTRL+K] unmark, [SPACE] select row, [CTRL+a] select all, [CTRL+A] deselect all
                    </div>
                    </div>
                    <div class="tab-pane fade" id="tab-plot" role="tabpanel">
                    <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 valeurs&#x2026;</div>
                    </div>
                    </div>
                </div>
            </div>
        """
        h.write(data)

        data=self.getDataTableScriptForValues('/api/v1/getvalues?marked=1', 5000, True)
        h.write(data)

        h.flush()

    def cb_gettrenddata(self, handler, params):
        """Return sampled trend history for a single MBIO value key.
        GET /api/v1/gettrenddata?key=&lt;key&gt;&amp;since=&lt;epoch&gt;&amp;max=&lt;n&gt;
          key   : unique value key
          since : lower bound (Unix timestamp seconds, float, default 0)
          max   : max points returned (default 500)
        Returns {"data": [{"x": ms, "y": v, "flags": f, "unit": u}, ...]}
        x is in milliseconds (Chart.js timeline).
        """
        mbio=self.getMBIO()
        key=params.get('key')
        if not key:
            self.cb_write_failure(handler)
            return
        store=mbio.trendingStore
        if not store:
            self.cb_write_json(handler, {'data': []})
            return
        try:
            since=float(params.get('since', 0))
        except (TypeError, ValueError):
            since=0.0
        try:
            max_pts=int(params.get('max', 500))
        except (TypeError, ValueError):
            max_pts=500
        try:
            until=float(params.get('until', 0))
        except (TypeError, ValueError):
            until=0.0
        points=store.get_history_sampled(key, max_points=max_pts, since=since, until=until or None)
        data=[{'x': p[0]*1000, 'y': p[1], 'flags': p[2] if len(p)>2 else None,
               'unit': p[3] if len(p)>3 else ''} for p in points]
        self.cb_write_json(handler, {'data': data})

    def cb_getvalueproperties(self, handler, params):
        """Return full properties of a MBIO value as a JSON dict.
        GET /api/v1/getvalueproperties?key=&lt;key&gt;
          key : unique value key
        Returns a dict with key, name, class, value, flags, zone, trend_count, etc.
        HTTP 400 if key not found.
        """
        mbio=self.getMBIO()
        key=params.get('key')
        value=mbio.value(key) if key else None
        if value is None:
            self.cb_write_failure(handler)
            return
        store=mbio.trendingStore
        tinfo=store.info(key) if store else {}
        item={
            'key': value.key,
            'name': getattr(value, 'name', None),
            'class': value.__class__.__name__,
            'value': value.value,
            'valuestr': value.valuestr(),
            'unit': value.unit,
            'unitstr': value.unitstr(),
            'flags': value.flags,
            'age': int(value.age()),
            'zone': value.zone,
            'tag': value.tag,
            'description': value.description,
            'isDigital': value.isDigital(),
            'isMultistate': value.isMultistate() if hasattr(value, 'isMultistate') else False,
            'isWritable': value.isWritable(),
            'isEnabled': value.isEnabled(),
            'isError': value.isError(),
            'isManual': value.isManual(),
            'isMarked': value.isMarked(),
            'isCommissionable': value.isCommissionable() if hasattr(value, 'isCommissionable') else False,
            'isRemoteManual': value.isRemoteManual() if hasattr(value, 'isRemoteManual') else False,
            'isInverted': value.isInverted() if hasattr(value, 'isInverted') else False,
            'resolution': getattr(value, 'resolution', None),
            'notifycount': value.notifyCount,
            'toReachValue': value.toReachValue if value.isWritable() else None,
            'parent': None,
            'zoneparent': value.zoneParent,
            'reccount': value.reccount,
            'trend_first': tinfo.get('firstTimestamp'),
            'trend_last': tinfo.get('lastTimestamp'),
            'trend_dirty': tinfo.get('dirty', False),
        }
        try:
            item['parent']=value.parent.parent.key
        except:
            pass
        self.cb_write_json(handler, item)

    def cb_getvalueconfig(self, handler, params):
        """Return config and persistentConfig entries for a MBIO value.
        GET /api/v1/getvalueconfig?key=&lt;key&gt;
          key : unique value key
        Returns {"data": [{"property": name, "value": str, "persistent": bool}, ...]}
        HTTP 400 if key not found.
        """
        mbio=self.getMBIO()
        key=params.get('key')
        value=mbio.value(key) if key else None
        if value is None:
            self.cb_write_failure(handler)
            return
        items=[]
        seen=set()
        pc=getattr(value, 'persistentConfig', None)
        if pc:
            try:
                for name, v in pc.all().items():
                    items.append({'property': name, 'value': str(v), 'persistent': True})
                    seen.add(name)
            except:
                pass
        cfg=getattr(value, 'config', None)
        if cfg:
            try:
                for name, v in cfg.all().items():
                    if name not in seen:
                        items.append({'property': name, 'value': str(v), 'persistent': False})
            except:
                pass
        self.cb_write_json(handler, {'data': items})

    def cb_raztrend(self, handler, params):
        """Purge trend history for a MBIO value (keeps key active).
        GET /api/v1/raztrend?key=&lt;key&gt;
          key : unique value key
        Returns {"success": true}.
        """
        mbio=self.getMBIO()
        key=params.get('key')
        if not key:
            self.cb_write_failure(handler)
            return
        store=mbio.trendingStore
        if store:
            store.purge(key)
        self.cb_write_success(handler)

    def cb_gettrendstoreinfo(self, handler, params):
        """Return global trend store statistics.
        GET /api/v1/gettrendstoreinfo
        Returns {"total": N, "keys": K, "dirty": D}
        """
        store=self.getMBIO().trendingStore
        if store:
            self.cb_write_json(handler, {
                'total': store.total_points(),
                'keys':  len(store.keys()),
                'dirty': len(store._dirty_since),
            })
        else:
            self.cb_write_json(handler, {'total': 0, 'keys': 0, 'dirty': 0})

    def cb_savetrends(self, handler, params):
        """Force immediate save of all dirty trend records to disk.
        GET /api/v1/savetrends
        Returns {"success": true}.
        """
        store=self.getMBIO().trendingStore
        if store:
            store.save()
        self.cb_write_success(handler)

    def cb_valueproperties(self, handler, params):
        """MBIO value detail page (Plot + Config + Properties tabs).
        GET /valueproperties?key=&lt;key&gt;
          key : unique value key
        Displays a single-value trend chart (Tab Plot), config table (Tab Config),
        and live properties table (Tab Properties) with auto-refresh every 5 s.
        """
        mbio=self.getMBIO()
        key=params.get('key', '')

        value=mbio.value(key)
        parent_url=''
        parent_label=''
        try:
            parent_key=value.parent.parent.key
            parent_class=value.parent.parent.__class__.__name__
            if 'Device' in parent_class or 'Modbus' in parent_class:
                parent_url=f'/devicevalues?device={parent_key}'
            else:
                parent_url=f'/taskvalues?task={parent_key}'
            parent_label=parent_key
        except:
            pass

        h=HtmlPage('Value 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() + self._modbusNavHtml() + self._logoutHtml())
        h.variable('valuekey', key)
        h.variable('parent.url', parent_url)
        h.variable('parent.label', parent_label)

        val_label=''
        if value is not None:
            val_label=value.description or ''
            if val_label and val_label == key:
                val_label=''

        h.variable('value.label', val_label)

        data="""
            <style>
            @keyframes flash-yellow { 0%{background-color:#fff3cd;} 100%{background-color:transparent;} }
            #props-table tbody tr.value-changed { animation: flash-yellow 1.5s ease-out; }
            </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">Value Properties</h1>
            <p class="text-secondary mb-0">
                <a href="/values">Values</a> /
                <a href="{parent.url}">{parent.label}</a> /
                <b class="font-monospace">{valuekey}</b>
                <span class="text-muted ms-2">{value.label}</span>
            </p>
            </div>
            <div class="text-muted small">{mbio.version} /
            {main.nav}{bacnet.nav}
            </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 id="tab-plot-btn" class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-plot" type="button">Plot <span id="badge-trend-count" class="badge bg-info text-dark ms-1" style="display:none">0</span></button>
            </li>
            <li class="nav-item" role="presentation">
            <button id="tab-config-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-config" type="button">Config</button>
            </li>
            <li class="nav-item" role="presentation">
            <button id="tab-props-btn" class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-props" type="button">Properties</button>
            </li>
            <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center flex-wrap">
            <span class="badge bg-secondary font-monospace" id="badge-live-value" style="font-size:.85rem"></span>
            <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-live" class="btn btn-sm btn-success" title="Retour vue live" style="display:none">&#x25B6; Live</button>
            <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="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-plot" role="tabpanel">
            <div id="plot-wrapper" style="position:relative;height:360px"><canvas id="plot-chart"></canvas></div>
            <div class="text-muted small mt-1" id="plot-info"></div>
            </div>
            <div class="tab-pane fade" id="tab-config" role="tabpanel">
            <table id="config-table" class="display nowrap" style="width:100%%">
            <thead><tr>
            <th data-priority="1">Property</th>
            <th data-priority="1">Value</th>
            <th data-priority="2">Type</th>
            </tr></thead>
            </table>
            </div>
            <div class="tab-pane fade" id="tab-props" role="tabpanel">
            <table id="props-table" 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>
            </div>
            </div>
        """
        h.write(data)

        data="""
        <script>
        $(function () {
            var valueKey = {valuekey_js};
            var rangeMs = 86400 * 1000;
            var chart = null;
            var refreshTimer = null;
            var prevPropsValues = {};
            var configLoaded = false;
            var currentToReachValue = null;
            var loadedMinMs = null;   // earliest timestamp in current dataset (ms)
            var loadedMaxMs = null;   // latest timestamp in current dataset (ms)
            var isPanned = false;     // user has panned away from live view
            var panFetchTimer = null;
            var _ignoreViewChange = false; // guard: suppress callbacks during resetZoom
            var measureAnchor = null; // {xMs, y} — measurement cursor anchor

            // === Helpers ===
            function chartInterpY(c, xVal) {
                var ds = c.data.datasets[0];
                if (!ds || !ds.data.length) return null;
                var pts = ds.data;
                if (xVal <= pts[0].x) return pts[0].y;
                if (xVal >= pts[pts.length-1].x) return pts[pts.length-1].y;
                for (var i = 0; i < pts.length-1; i++) {
                    if (pts[i].x <= xVal && pts[i+1].x >= xVal) return pts[i].y;
                }
                return null;
            }
            function fmtDt(ms) {
                var s = ms < 0 ? '-' : '+';
                var abs = Math.abs(ms);
                if (abs < 60000)      return s + (abs/1000).toFixed(0) + 's';
                if (abs < 3600000)  { var m=Math.floor(abs/60000), sc=Math.floor((abs%60000)/1000); return s+m+'min'+(sc?' '+sc+'s':''); }
                if (abs < 86400000) { var h=Math.floor(abs/3600000), mn=Math.floor((abs%3600000)/60000); return s+h+'h'+(mn?' '+mn+'min':''); }
                var d=Math.floor(abs/86400000), h2=Math.floor((abs%86400000)/3600000); return s+d+'j'+(h2?' '+h2+'h':'');
            }
            function fmtVal(v) {
                return (typeof v === 'number' && !Number.isInteger(v)) ? v.toFixed(2) : String(v);
            }

            // === Crosshair plugin ===
            // Amber-fill badge (same style as anchor Y badge): amber bg, dark text
            function _drawDimBadge(ctx2, text, cx, cy) {
                ctx2.font = 'bold 10px system-ui,sans-serif';
                var tw = ctx2.measureText(text).width;
                var pw = tw + 10, ph = 16, r = 3;
                var bx = cx - pw/2, by = cy - ph/2;
                ctx2.fillStyle = '#f59e0b';
                ctx2.beginPath(); ctx2.roundRect(bx, by, pw, ph, r); ctx2.fill();
                ctx2.fillStyle = 'rgba(15,23,42,0.92)'; ctx2.textAlign = 'center'; ctx2.textBaseline = 'middle';
                ctx2.fillText(text, cx, cy);
            }
            var mbioCrosshairPlugin = {
                id: 'mbioCrosshair',
                afterEvent: function(c, args) {
                    var e = args.event;
                    if (e.type === 'mousemove') { c._ch_x = e.x; args.changed = true; }
                    else if (e.type === 'mouseout') { c._ch_x = null; args.changed = true; }
                },
                afterDraw: function(c) {
                    var area = c.chartArea;
                    var ctx2 = c.ctx;
                    ctx2.save();

                    // Cursor state
                    var cx    = c._ch_x;
                    var inArea = (cx !== null && cx >= area.left && cx <= area.right);
                    var xVal  = inArea ? c.scales.x.getValueForPixel(cx) : null;
                    var cyv   = inArea ? chartInterpY(c, xVal) : null;
                    var cyPx  = (cyv !== null) ? Math.max(area.top, Math.min(area.bottom, c.scales.y.getPixelForValue(cyv))) : null;

                    // ── Anchor ──────────────────────────────────────────────────────
                    if (measureAnchor) {
                        var axPx = c.scales.x.getPixelForValue(measureAnchor.xMs);
                        var ayPx = (measureAnchor.y !== null)
                            ? Math.max(area.top, Math.min(area.bottom, c.scales.y.getPixelForValue(measureAnchor.y)))
                            : null;

                        // Vertical anchor line (solid amber)
                        ctx2.strokeStyle = '#f59e0b'; ctx2.lineWidth = 1.5; ctx2.setLineDash([]);
                        ctx2.beginPath(); ctx2.moveTo(axPx, area.top); ctx2.lineTo(axPx, area.bottom); ctx2.stroke();

                        if (ayPx !== null) {
                            // Horizontal anchor line — same style as vertical (solid amber)
                            ctx2.strokeStyle = '#f59e0b'; ctx2.lineWidth = 1.5; ctx2.setLineDash([]);
                            ctx2.beginPath(); ctx2.moveTo(area.left, ayPx); ctx2.lineTo(area.right, ayPx); ctx2.stroke();

                            // Y-value badge on left edge of horizontal anchor line
                            var ayStr = fmtVal(measureAnchor.y);
                            ctx2.font = '10px system-ui,sans-serif';
                            var ayW = ctx2.measureText(ayStr).width + 8;
                            ctx2.fillStyle = '#f59e0b';
                            ctx2.beginPath(); ctx2.roundRect(area.left, ayPx - 8, ayW, 16, 3); ctx2.fill();
                            ctx2.fillStyle = 'rgba(15,23,42,0.9)'; ctx2.textAlign = 'left'; ctx2.textBaseline = 'middle';
                            ctx2.fillText(ayStr, area.left + 4, ayPx);

                            // Anchor dot on curve
                            ctx2.beginPath(); ctx2.arc(axPx, ayPx, 5, 0, 2*Math.PI);
                            ctx2.fillStyle = '#f59e0b'; ctx2.strokeStyle = '#fff'; ctx2.lineWidth = 2;
                            ctx2.fill(); ctx2.stroke();
                        }

                        // ── Measurement spans (when cursor is also in area) ──────
                        if (inArea && cyPx !== null && ayPx !== null) {
                            var dt = xVal - measureAnchor.xMs;
                            var dv = cyv - measureAnchor.y;
                            var AMBER = '#f59e0b';

                            // Diagonal connecting line — same amber style as anchor lines
                            ctx2.strokeStyle = 'rgba(245,158,11,0.5)'; ctx2.lineWidth = 1.5; ctx2.setLineDash([]);
                            ctx2.beginPath(); ctx2.moveTo(axPx, ayPx); ctx2.lineTo(cx, cyPx); ctx2.stroke();

                            // ── Δt horizontal dimension line (top of chart) ────────
                            var dimY  = area.top + 16;
                            var xL    = Math.min(axPx, cx), xR = Math.max(axPx, cx);
                            var midDX = (axPx + cx) / 2;
                            if (xR - xL > 24) {
                                ctx2.strokeStyle = AMBER; ctx2.lineWidth = 1;
                                ctx2.beginPath(); ctx2.moveTo(xL, dimY); ctx2.lineTo(xR, dimY); ctx2.stroke();
                                [xL, xR].forEach(function(x) {
                                    ctx2.beginPath(); ctx2.moveTo(x, dimY - 5); ctx2.lineTo(x, dimY + 5); ctx2.stroke();
                                });
                                [[xL, 1], [xR, -1]].forEach(function(p) {
                                    ctx2.fillStyle = AMBER; ctx2.beginPath();
                                    ctx2.moveTo(p[0] + p[1]*7, dimY);
                                    ctx2.lineTo(p[0] + p[1]*2, dimY - 3);
                                    ctx2.lineTo(p[0] + p[1]*2, dimY + 3);
                                    ctx2.closePath(); ctx2.fill();
                                });
                                var dtBx = Math.max(area.left + 30, Math.min(area.right - 30, midDX));
                                _drawDimBadge(ctx2, fmtDt(dt), dtBx, dimY);
                            }

                            // ── ΔY vertical dimension line (right edge of chart) ──
                            var dimX  = area.right - 20;
                            var yT    = Math.min(ayPx, cyPx), yB = Math.max(ayPx, cyPx);
                            var midDY = (ayPx + cyPx) / 2;
                            if (yB - yT > 20) {
                                ctx2.strokeStyle = AMBER; ctx2.lineWidth = 1;
                                ctx2.beginPath(); ctx2.moveTo(dimX, yT); ctx2.lineTo(dimX, yB); ctx2.stroke();
                                [yT, yB].forEach(function(y) {
                                    ctx2.beginPath(); ctx2.moveTo(dimX - 5, y); ctx2.lineTo(dimX + 5, y); ctx2.stroke();
                                });
                                [[yT, 1], [yB, -1]].forEach(function(p) {
                                    ctx2.fillStyle = AMBER; ctx2.beginPath();
                                    ctx2.moveTo(dimX, p[0] + p[1]*7);
                                    ctx2.lineTo(dimX - 3, p[0] + p[1]*2);
                                    ctx2.lineTo(dimX + 3, p[0] + p[1]*2);
                                    ctx2.closePath(); ctx2.fill();
                                });
                                var dvStr = (dv >= 0 ? '+' : '') + fmtVal(dv);
                                var dyBy = Math.max(area.top + 10, Math.min(area.bottom - 10, midDY));
                                _drawDimBadge(ctx2, dvStr, dimX, dyBy);
                            }
                        }
                    }

                    // ── Cursor crosshair ────────────────────────────────────────────
                    if (!inArea || !c.data.datasets.length) { ctx2.restore(); return; }

                    // Vertical cursor line
                    ctx2.strokeStyle = 'rgba(80,80,80,0.65)'; ctx2.lineWidth = 1; ctx2.setLineDash([5, 4]);
                    ctx2.beginPath(); ctx2.moveTo(cx, area.top); ctx2.lineTo(cx, area.bottom); ctx2.stroke();

                    // Horizontal cursor line at curve Y
                    if (cyPx !== null) {
                        ctx2.strokeStyle = 'rgba(80,80,80,0.45)'; ctx2.lineWidth = 1; ctx2.setLineDash([4, 4]);
                        ctx2.beginPath(); ctx2.moveTo(area.left, cyPx); ctx2.lineTo(area.right, cyPx); ctx2.stroke();
                    }
                    ctx2.setLineDash([]);

                    // Cursor dot on curve
                    if (cyPx !== null) {
                        var ds0 = c.data.datasets[0];
                        ctx2.beginPath(); ctx2.arc(cx, cyPx, 4, 0, 2*Math.PI);
                        ctx2.fillStyle = ds0.borderColor || '#0d6efd';
                        ctx2.strokeStyle = '#fff'; ctx2.lineWidth = 2;
                        ctx2.fill(); ctx2.stroke();

                        // Cursor Y badge (right of horizontal line) — hidden when anchor active (dY line takes right edge)
                        if (!measureAnchor) {
                            var valStr = fmtVal(cyv);
                            ctx2.font = '11px system-ui,sans-serif';
                            var tw = ctx2.measureText(valStr).width + 12;
                            var px2 = area.right - tw - 2;
                            var py2 = Math.max(area.top + 10, Math.min(area.bottom - 10, cyPx));
                            ctx2.fillStyle = 'rgba(15,23,42,0.82)';
                            ctx2.fillRect(px2, py2 - 10, tw, 20);
                            ctx2.fillStyle = '#e2e8f0'; ctx2.textAlign = 'left'; ctx2.textBaseline = 'middle';
                            ctx2.fillText(valStr, px2 + 6, py2);
                        }
                    }

                    // Time badge on cursor vertical line (bottom)
                    var d = new Date(xVal);
                    var tlbl = rangeMs > 86400000 ? d.toLocaleString() : d.toLocaleTimeString();
                    ctx2.font = '11px system-ui,sans-serif';
                    var tw2 = ctx2.measureText(tlbl).width + 12;
                    var bx2 = Math.max(area.left, Math.min(area.right - tw2, cx - tw2/2));
                    ctx2.fillStyle = 'rgba(15,23,42,0.82)';
                    ctx2.fillRect(bx2, area.bottom - 22, tw2, 18);
                    ctx2.fillStyle = '#e2e8f0'; ctx2.textAlign = 'left'; ctx2.textBaseline = 'middle';
                    ctx2.fillText(tlbl, bx2 + 6, area.bottom - 13);
                    ctx2.restore();
                }
            };

            function onViewChanged() {
                if (!chart || _ignoreViewChange) return;
                isPanned = true;
                $('#btn-live').show();
                if (panFetchTimer) clearTimeout(panFetchTimer);
                panFetchTimer = setTimeout(function() {
                    var viewMin = chart.scales.x.min;
                    var viewMax = chart.scales.x.max;
                    if (viewMin == null || viewMax == null) return;
                    var margin = (viewMax - viewMin) * 0.5;
                    var needMin = viewMin - margin;
                    var needMax = viewMax + margin;
                    if (loadedMinMs === null || needMin < loadedMinMs || needMax > loadedMaxMs) {
                        var fetchSince = loadedMinMs !== null ? Math.min(needMin, loadedMinMs) : needMin;
                        var fetchUntil = loadedMaxMs !== null ? Math.max(needMax, loadedMaxMs) : needMax;
                        loadChart(fetchSince, fetchUntil);
                    }
                }, 300);
            }

            function initChart() {
                if (chart) return;
                var ctx = document.getElementById('plot-chart').getContext('2d');
                chart = new Chart(ctx, {
                    type: 'line',
                    data: { datasets: [
                        {
                            label: valueKey,
                            data: [],
                            borderColor: '#0d6efd',
                            backgroundColor: '#0d6efd22',
                            borderWidth: 1.5,
                            pointRadius: 0,
                            stepped: 'before',
                            fill: 'origin',
                            order: 1
                        },
                        {
                            label: 'Setpoint',
                            data: [],
                            borderColor: '#f59e0b',
                            backgroundColor: 'transparent',
                            borderWidth: 1.5,
                            borderDash: [6, 4],
                            pointRadius: 0,
                            stepped: false,
                            fill: false,
                            hidden: true,
                            order: 2
                        },
                        {
                            type: 'scatter',
                            label: 'Flag changes',
                            data: [],
                            backgroundColor: [],
                            borderColor: '#fff',
                            borderWidth: 1,
                            pointStyle: 'rectRot',
                            pointRadius: 7,
                            pointHoverRadius: 9,
                            hidden: true,
                            order: 0,
                            showLine: false
                        }
                    ] },
                    plugins: [mbioCrosshairPlugin],
                    options: {
                        animation: false, responsive: true, maintainAspectRatio: false,
                        scales: {
                            x: { type: 'linear', grid: { color: '#e9ecef' },
                                 ticks: { callback: function(v, index, ticks) {
                                     var d = new Date(v);
                                     var t = d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
                                     if (ticks.length >= 2) {
                                         var d0 = new Date(ticks[0].value);
                                         var d1 = new Date(ticks[ticks.length - 1].value);
                                         if (d0.toDateString() !== d1.toDateString())
                                             return [t, d.toLocaleDateString([], {day: '2-digit', month: '2-digit'})];
                                     }
                                     return t;
                                 }, maxTicksLimit: 8 } },
                            y: { type: 'linear', grid: { color: '#e9ecef' } }
                        },
                        plugins: {
                            legend: { display: true, position: 'top',
                                labels: { filter: function(item) { return !item.hidden; },
                                          boxWidth: 12, font: { size: 11 } } },
                            tooltip: {
                                filter: function(item) { return item.datasetIndex === 2; },
                                callbacks: {
                                    title: function(items) {
                                        return items.length ? new Date(items[0].parsed.x).toLocaleString() : '';
                                    },
                                    label: function(item) {
                                        return '\u25C6 ' + flagLabel(item.raw.f);
                                    }
                                }
                            },
                            zoom: {
                                pan: { enabled: true, mode: 'x', onPanComplete: onViewChanged },
                                zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x',
                                        onZoomComplete: onViewChanged }
                            }
                        }
                    }
                });

                // === Measurement cursor interactions ===
                var canvas = document.getElementById('plot-chart');
                var _mdX = null;
                canvas.addEventListener('mousedown', function(e) { _mdX = e.clientX; });
                function placeMeasureAnchor() {
                    if (!chart || chart._ch_x == null) return;
                    var area = chart.chartArea;
                    if (chart._ch_x < area.left || chart._ch_x > area.right) return;
                    var xMs = chart.scales.x.getValueForPixel(chart._ch_x);
                    var y   = chartInterpY(chart, xMs);
                    measureAnchor = {xMs: xMs, y: y};
                    chart.update('none');
                }
                canvas.addEventListener('click', function(e) {
                    // Ignore if mouse moved significantly (drag-zoom)
                    if (_mdX !== null && Math.abs(e.clientX - _mdX) > 5) return;
                    placeMeasureAnchor();
                });
                $(document).on('keydown.measure', function(e) {
                    if ($(e.target).is('input,textarea,select')) return;
                    if (e.key === 'Escape') {
                        if (measureAnchor) { measureAnchor = null; if (chart) chart.update('none'); }
                    } else if (e.key === ' ') {
                        e.preventDefault();
                        placeMeasureAnchor();
                    }
                });
            }

            function updateSetpointLine() {
                if (!chart) return;
                var ds = chart.data.datasets[1];
                var mainPts = chart.data.datasets[0].data;
                if (currentToReachValue !== null && currentToReachValue !== undefined && mainPts.length >= 2) {
                    ds.data = [{x: mainPts[0].x, y: currentToReachValue},
                               {x: mainPts[mainPts.length - 1].x, y: currentToReachValue}];
                    ds.hidden = false;
                    ds.label = 'Setpoint: ' + currentToReachValue;
                } else {
                    ds.data = [];
                    ds.hidden = true;
                }
            }

            function loadChart(sinceMs, untilMs) {
                if (!chart) initChart();
                if (sinceMs === undefined || sinceMs === null) sinceMs = Date.now() - rangeMs;
                if (untilMs === undefined || untilMs === null) untilMs = Date.now();
                var since = sinceMs / 1000;
                var until = untilMs / 1000;
                var url = '/api/v1/gettrenddata?key=' + encodeURIComponent(valueKey)
                        + '&since=' + since + '&until=' + until + '&max=1000';
                // Flags are strings (e.g. 'E', 'M', '*R'). Only operational chars matter.
                var FLAG_OPS = [
                    ['E', 'Erreur',         '#dc3545'],
                    ['M', 'Manuel',         '#198754'],
                    ['*', 'Override',       '#fd7e14'],
                    ['R', 'Remote Manuel',  '#0dcaf0'],
                    ['C', 'Commissionné',   '#6f42c1'],
                    ['X', 'Désactivé',      '#adb5bd'],
                ];
                function sigFlags(f) {
                    var sig = '';
                    if (f) FLAG_OPS.forEach(function(d) { if (f.indexOf(d[0]) >= 0) sig += d[0]; });
                    return sig;
                }
                function flagColor(f) {
                    for (var i = 0; i < FLAG_OPS.length; i++) {
                        if (f && f.indexOf(FLAG_OPS[i][0]) >= 0) return FLAG_OPS[i][2];
                    }
                    return '#6c757d';
                }
                function flagLabel(f) {
                    var names = [];
                    FLAG_OPS.forEach(function(d) { if (f && f.indexOf(d[0]) >= 0) names.push(d[1]); });
                    return names.length ? names.join(', ') : 'Normal';
                }

                fetch(url).then(function(r) { return r.json(); })
                    .then(function(resp) {
                        loadedMinMs = sinceMs;
                        loadedMaxMs = untilMs;
                        var pts = (resp.data || []).map(function(p) { return {x: p.x, y: p.y}; });
                        chart.data.datasets[0].data = pts;
                        // Flag change markers (string-based flags: E=error, M=manual, *=override, R=remote, C=commissionné, X=disabled)
                        var fmPts = [], fmColors = [], prevSig = null;
                        (resp.data || []).forEach(function(p) {
                            var f = (p.flags !== null && p.flags !== undefined) ? String(p.flags) : '';
                            var sig = sigFlags(f);
                            if (prevSig !== null && prevSig !== sig) {
                                fmPts.push({x: p.x, y: p.y, f: sig});
                                fmColors.push(flagColor(sig));
                            }
                            prevSig = sig;
                        });
                        var ds2 = chart.data.datasets[2];
                        ds2.data = fmPts;
                        ds2.backgroundColor = fmColors;
                        ds2.hidden = (fmPts.length === 0);
                        updateSetpointLine();
                        chart.update('none');
                        var countBadge = document.getElementById('badge-trend-count');
                        if (countBadge) {
                            if (pts.length > 0) { countBadge.textContent = pts.length; countBadge.style.display = ''; }
                            else { countBadge.style.display = 'none'; }
                        }
                        var infoEl = document.getElementById('plot-info');
                        if (infoEl && pts.length) {
                            var vals = pts.map(function(p) { return p.y; });
                            var mn = Math.min.apply(null, vals), mx = Math.max.apply(null, vals);
                            var avg = vals.reduce(function(a,b){return a+b;}, 0) / vals.length;
                            infoEl.textContent = pts.length + ' pts · '
                                + new Date(pts[0].x).toLocaleString() + ' → '
                                + new Date(pts[pts.length-1].x).toLocaleString()
                                + ' · min=' + mn.toFixed ? mn.toFixed(2) : mn
                                + ' · max=' + mx.toFixed ? mx.toFixed(2) : mx
                                + ' · avg=' + avg.toFixed(2);
                        } else if (infoEl) {
                            infoEl.textContent = 'Aucune donn\u00e9e';
                        }
                    });
            }

            function loadProperties() {
                fetch('/api/v1/getvalueproperties?key=' + encodeURIComponent(valueKey))
                    .then(function(r) { return r.json(); })
                    .then(function(item) {
                        // Live value badge
                        var badge = document.getElementById('badge-live-value');
                        if (badge) badge.textContent = item.valuestr || String(item.value);
                        // Setpoint line
                        currentToReachValue = (item.toReachValue !== null && item.toReachValue !== undefined) ? item.toReachValue : null;
                        updateSetpointLine();
                        if (chart) chart.update('none');
                        // Props table
                        var rows = [];
                        var keys = Object.keys(item);
                        keys.forEach(function(k) {
                            if (k.startsWith('trend_')) return;
                            rows.push({property: k, value: String(item[k] !== null && item[k] !== undefined ? item[k] : '')});
                        });
                        // Trend timestamps (formatted, excluded from auto-loop above)
                        if (item.trend_first) rows.push({property: 'trend_first', value: new Date(item.trend_first*1000).toLocaleString()});
                        if (item.trend_last) rows.push({property: 'trend_last', value: new Date(item.trend_last*1000).toLocaleString()});
                        if (item.trend_dirty !== undefined) rows.push({property: 'trend_dirty', value: String(item.trend_dirty)});
                        if (propsTable) {
                            propsTable.clear().rows.add(rows).draw(false);
                        } else {
                            initPropsTable(rows);
                        }
                    });
            }

            var propsTable = null;
            function initPropsTable(rows) {
                propsTable = new DataTable('#props-table', {
                    responsive: true, paging: false, searching: false, ordering: true, info: false,
                    order: [[0, 'asc']],
                    data: rows,
                    columns: [
                        { data: 'property', render: function(v) { return '<code>' + v + '</code>'; } },
                        { data: 'value', orderable: false }
                    ]
                });
            }

            var configTable = null;
            function loadConfig() {
                if (configLoaded) return;
                fetch('/api/v1/getvalueconfig?key=' + encodeURIComponent(valueKey))
                    .then(function(r) { return r.json(); })
                    .then(function(resp) {
                        configLoaded = true;
                        var rows = resp.data || [];
                        configTable = new DataTable('#config-table', {
                            responsive: true, paging: false, searching: false, ordering: false, info: false,
                            data: rows,
                            columns: [
                                { data: 'property', render: function(v) { return '<code>' + v + '</code>'; } },
                                { data: 'value' },
                                { data: 'persistent', render: function(v) {
                                    return v ? '<span class="badge bg-warning text-dark">P</span>' : '';
                                }}
                            ]
                        });
                    });
            }

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

            // Initial load
            initChart();
            resizePlotChart();
            loadChart();
            loadProperties();

            // Auto-refresh
            refreshTimer = setInterval(function() {
                if ($('#tab-plot').hasClass('active') && chart && !isPanned) loadChart();
                loadProperties();
            }, 5000);

            $('#tab-config-btn').on('shown.bs.tab', function() { loadConfig(); });

            function returnToLive() {
                isPanned = false;
                loadedMinMs = null;
                loadedMaxMs = null;
                $('#btn-live').hide();
                _ignoreViewChange = true;
                if (chart && chart.resetZoom) chart.resetZoom();
                _ignoreViewChange = false;
                loadChart();
            }

            // Range buttons
            $('#range-group').on('click', '[data-range]', function() {
                $('#range-group .btn').removeClass('active');
                $(this).addClass('active');
                rangeMs = parseInt($(this).data('range')) * 1000;
                returnToLive();
            });

            $('#btn-live').on('click', returnToLive);

            $('#btn-reset-zoom').on('click', returnToLive);

            $('#btn-refresh').on('click', function() {
                if (isPanned) {
                    // Refresh the current panned view
                    if (loadedMinMs !== null) loadChart(loadedMinMs, loadedMaxMs);
                    else loadChart();
                } else {
                    loadChart();
                }
                loadProperties();
            });

            $('#btn-purge-trend').on('click', function() {
                if (!confirm('Purger l\\'historique de cette valeur ?')) return;
                fetch('/api/v1/raztrend?key=' + encodeURIComponent(valueKey))
                    .then(function() { loadChart(); loadProperties(); });
            });

            $('#btn-export-csv').on('click', function() {
                if (!chart || !chart.data.datasets[0].data.length) return;
                var ds = chart.data.datasets[0];
                var lines = ['timestamp_iso,timestamp_ms,value'];
                ds.data.forEach(function(p) {
                    lines.push([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 = 'trend_' + valueKey.replace(/[^a-z0-9]/gi,'_') + '.csv'; a.click();
            });
        });
        </script>
        """
        h.variable('valuekey_js', json.dumps(key))
        h.write(data)
        h.flush()

    def cb_zoneeditor(self, handler, params):
        """Interactive zone editor page.
        GET /zoneeditor
        Drag-and-drop interface for assigning MBIO values and devices to zones.
        Changes are sent via POST /api/v1/setzone and persisted immediately.
        """

        h=Html(None, handler)
        h.readFile('ws-zoneeditor.html')
        h.flush()

    def cb_script_zoneeditor(self, handler, params):
        """Zone editor JavaScript bundle (served as text/javascript).
        GET /script/zoneeditor.js
        Loaded by /zoneeditor. Contains all client-side drag-and-drop logic.
        """

        h=Html(None, handler)
        h.readFile('ws-script-zoneeditor.js')
        h.flush()

    def cb_help(self, handler, params):
        """API reference page — searchable, filterable list of all registered endpoints.
        GET /help
        Endpoints are grouped by category: Pages, Data, Actions, BACnet, System.
        Live text filter ([/] shortcut) matches route, summary and full docstring.
        Click a card to expand its full docstring. ↗ icon opens the route directly.
        """

        mbio=self.getMBIO()
        h=HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + self._logoutHtml())

        # ----------------------------------------------------------------
        # Build endpoint data
        # ----------------------------------------------------------------
        get_cbs = self._wserver._callbacks.get('GET', {})
        post_cbs = self._wserver._callbacks.get('POST', {})
        all_routes = sorted(set(list(get_cbs.keys()) + list(post_cbs.keys())))

        def _categorize(route):
            r = route.lower()
            if 'bacnet' in r:
                return 'bacnet'
            if route in ('/logs', '/help', '/zoneeditor') or route.startswith('/script/'):
                return 'system'
            if route.startswith('/api/v1/get'):
                return 'data'
            if route.startswith('/api/v1/'):
                return 'actions'
            return 'pages'

        def _unescape(s):
            return (s.replace('&lt;', '<').replace('&gt;', '>')
                     .replace('&amp;', '&').replace('&quot;', '"'))

        endpoints = []
        for route in all_routes:
            if route == '/':
                continue
            methods = []
            doc = ''
            if route in get_cbs:
                methods.append('GET')
                doc = (get_cbs[route].__doc__ or '').strip()
            if route in post_cbs:
                methods.append('POST')
                if not doc:
                    doc = (post_cbs[route].__doc__ or '').strip()

            lines = [l.strip() for l in doc.splitlines()] if doc else []
            while lines and not lines[0]:
                lines.pop(0)
            while lines and not lines[-1]:
                lines.pop()

            summary = lines[0] if lines else ''
            detail_lines = lines[1:] if len(lines) > 1 else []
            while detail_lines and not detail_lines[0]:
                detail_lines.pop(0)
            detail = '\n'.join(detail_lines)

            search = _unescape(route + ' ' + summary + ' ' + detail).lower()

            endpoints.append({
                'route': route,
                'methods': methods,
                'category': _categorize(route),
                'summary': summary,
                'detail': detail,
                'search': search,
            })

        ep_json = json.dumps(endpoints)

        # ----------------------------------------------------------------
        # HTML
        # ----------------------------------------------------------------
        data = """
            <style>
            .ep-card { cursor: pointer; transition: box-shadow .15s; }
            .ep-card:hover { box-shadow: 0 .15rem .6rem rgba(0,0,0,.08); }
            .ep-card.ep-open { border-color: #0d6efd !important; }
            .ep-detail pre { font-size: .8rem; color: #333; }
            #ep-search:focus { box-shadow: 0 0 0 .2rem rgba(13,110,253,.2); }
            </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-4">
                <div>
                    <h1 class="h3 mb-1">Digimat MBIO Processor Monitor</h1>
                    <p class="text-secondary mb-0">API Reference</p>
                </div>
                <div class="text-muted small">{mbio.version} /
                    <a href="/tasks">Tasks</a> /
                    <a href="/values">Values</a>{bacnet.nav}
                </div>
            </div>
            <div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
                <input id="ep-search" type="text" class="form-control form-control-sm" data-search-focus
                       placeholder="Filter endpoints... [/ or Ctrl+F]" style="max-width:300px">
                <div id="cat-filters" class="d-flex gap-2 flex-wrap">
                    <button class="btn btn-sm btn-primary active" data-cat="">
                        All <span class="badge bg-light text-primary ms-1" id="cnt-all">0</span>
                    </button>
                    <button class="btn btn-sm btn-outline-secondary" data-cat="pages">
                        Pages <span class="badge text-bg-light ms-1" id="cnt-pages">0</span>
                    </button>
                    <button class="btn btn-sm btn-outline-secondary" data-cat="data">
                        Data <span class="badge text-bg-light ms-1" id="cnt-data">0</span>
                    </button>
                    <button class="btn btn-sm btn-outline-secondary" data-cat="actions">
                        Actions <span class="badge text-bg-light ms-1" id="cnt-actions">0</span>
                    </button>
                    <button class="btn btn-sm btn-outline-secondary" data-cat="bacnet">
                        BACnet <span class="badge text-bg-light ms-1" id="cnt-bacnet">0</span>
                    </button>
                    <button class="btn btn-sm btn-outline-secondary" data-cat="system">
                        System <span class="badge text-bg-light ms-1" id="cnt-system">0</span>
                    </button>
                </div>
                <span class="ms-auto text-muted small" id="ep-count"></span>
            </div>
            <div id="ep-list"></div>
            </div>
            </div>
        """
        h.write(data)

        script = """
        <script>
        $(function() {
            var endpoints = EPJSON;

            var CAT_BADGE = {
                pages:   'bg-primary',
                data:    'bg-success',
                actions: 'bg-warning text-dark',
                bacnet:  'bg-info text-dark',
                system:  'bg-secondary'
            };
            var CAT_LABEL = {
                pages:'Pages', data:'Data', actions:'Actions', bacnet:'BACnet', system:'System'
            };

            function renderEp(ep) {
                var mHtml = ep.methods.map(function(m) {
                    return '<span class="badge ' + (m==='GET'?'bg-success':'bg-primary') + ' me-1">' + m + '</span>';
                }).join('');
                var cb = CAT_BADGE[ep.category]||'bg-secondary';
                var cl = CAT_LABEL[ep.category]||ep.category;
                var detailHtml = ep.detail
                    ? '<div class="ep-detail d-none border-top px-3 py-2" style="background:#f8f9fa">'
                      + '<pre style="white-space:pre-wrap;margin:0">' + ep.detail + '</pre></div>'
                    : '';
                var toggleBtn = ep.detail
                    ? '<button class="btn btn-link btn-sm p-0 ep-toggle text-muted" style="line-height:1">&#9660;</button>'
                    : '<span style="display:inline-block;width:1.1rem"></span>';
                var safeSearch = ep.search.replace(/&/g,'&amp;').replace(/"/g,'&quot;');
                return '<div class="ep-card border rounded mb-2"'
                    + ' data-cat="'+ep.category+'"'
                    + ' data-search="'+safeSearch+'">'
                    + '<div class="d-flex align-items-center gap-3 p-3">'
                    + '<div style="min-width:85px;flex-shrink:0">'+mHtml+'</div>'
                    + '<div style="flex:1;min-width:0">'
                    + '<div class="d-flex align-items-center gap-2 flex-wrap">'
                    + '<code class="fw-semibold text-dark">'+ep.route+'</code>'
                    + '<span class="badge '+cb+' small">'+cl+'</span>'
                    + '</div>'
                    + (ep.summary ? '<div class="text-muted small mt-1">'+ep.summary+'</div>' : '')
                    + '</div>'
                    + '<div class="d-flex align-items-center gap-2 flex-shrink-0">'
                    + toggleBtn
                    + '<a href="'+ep.route+'" class="btn btn-link btn-sm p-0 text-primary"'
                    + ' title="Open" onclick="event.stopPropagation()" style="line-height:1;font-size:1.1rem">&#8599;</a>'
                    + '</div>'
                    + '</div>'
                    + detailHtml
                    + '</div>';
            }

            var $list = $('#ep-list');
            endpoints.forEach(function(ep) { $list.append(renderEp(ep)); });

            // Counts
            var counts = {};
            endpoints.forEach(function(ep) {
                counts['all'] = (counts['all']||0) + 1;
                counts[ep.category] = (counts[ep.category]||0) + 1;
            });
            $('#cnt-all').text(counts['all']||0);
            ['pages','data','actions','bacnet','system'].forEach(function(c) {
                $('#cnt-'+c).text(counts[c]||0);
            });
            $('#ep-count').text((counts['all']||0) + ' endpoints');

            function applyFilter() {
                var search = $('#ep-search').val().toLowerCase().trim();
                var cat = $('#cat-filters .btn.btn-primary').data('cat');
                var visible = 0;
                $('.ep-card').each(function() {
                    var $el = $(this);
                    var ok = (!cat || $el.data('cat')===cat)
                          && (!search || $el.data('search').indexOf(search)!==-1);
                    $el.toggle(ok);
                    if (ok) visible++;
                });
                $('#ep-count').text(visible + ' endpoint' + (visible!==1?'s':''));
            }

            $('#ep-search').on('input', applyFilter);

            $('#cat-filters .btn').on('click', function() {
                $('#cat-filters .btn').removeClass('btn-primary').addClass('btn-outline-secondary');
                $(this).removeClass('btn-outline-secondary').addClass('btn-primary');
                applyFilter();
            });

            // Expand / collapse
            $('#ep-list').on('click', '.ep-card', function(e) {
                if ($(e.target).closest('a').length) return;
                var $d = $(this).find('.ep-detail');
                if (!$d.length) return;
                $d.toggleClass('d-none');
                $(this).toggleClass('ep-open', !$d.hasClass('d-none'));
                $(this).find('.ep-toggle').html($d.hasClass('d-none') ? '&#9660;' : '&#9650;');
            });

        });
        </script>
        """
        script = script.replace('EPJSON', ep_json)
        h.write(script)
        h.flush()

    def cb_getxmlconfig(self, handler, params):
        """Return the XML config file content, pretty-printed when possible.
        GET /api/v1/getxmlconfig
        Returns {"content": "&lt;xml...&gt;", "filename": "config.xml", "path": "/abs/path/config.xml"}
        Content is reformatted with ET.indent (2-space indentation); falls back to raw content on parse error.
        Returns {"success": false} if file not found.
        """
        mbio = self.getMBIO()
        fpath = getattr(mbio, '_xmlConfigFilePath', None)
        if not fpath:
            return self.cb_write_failure(handler)
        try:
            with open(fpath, 'r', encoding='utf-8', errors='replace') as f:
                content = f.read()
            try:
                tree = ET.parse(fpath)
                ET.indent(tree, space='  ')
                buf = io.BytesIO()
                tree.write(buf, encoding='utf-8', xml_declaration=True)
                content = buf.getvalue().decode('utf-8')
            except Exception:
                pass
            self.cb_write_json(handler, {
                'content': content,
                'filename': os.path.basename(fpath),
                'path': str(fpath),
            })
        except (OSError, IOError):
            self.cb_write_failure(handler)
        return True

    def cb_getxmlconfigdoc(self, handler, params):
        """Return the XML config companion documentation file content.
        GET /api/v1/getxmlconfigdoc
        Reads config.xml.txt in the module directory (same location as wsmonitor.py).
        Returns {"content": "...", "filename": "config.xml.txt", "path": "/abs/path/config.xml.txt"}
        or {"success": false, "path": "..."} if file not found.
        """
        doc_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.xml.txt')
        self.logger.debug('getxmlconfigdoc: trying %s', doc_path)
        try:
            with open(doc_path, 'r', encoding='utf-8', errors='replace') as f:
                content = f.read()
            self.cb_write_json(handler, {
                'content': content,
                'filename': os.path.basename(doc_path),
                'path': doc_path,
            })
        except (OSError, IOError):
            self.logger.warning('getxmlconfigdoc: not found at %s', doc_path)
            self.cb_write_json(handler, {'success': False, 'path': doc_path})
        return True

    def cb_config(self, handler, params):
        """XML configuration viewer with collapsible tree and documentation reference.
        GET /config
        Tab 1 — Configuration XML: interactive tree with fold/unfold per element (atom-one-dark theme).
        Tab 2 — Documentation: companion config.xml.txt reference file.
        Refresh reloads the active tab. Update triggers mbio --update (cloud sync + restart).
        """
        mbio = self.getMBIO()
        h = HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml('/config'))
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + self._logoutHtml(show_config=False))

        data = """
        <style>
        #xml-tree {
            font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
            font-size: .82rem; line-height: 1.55;
            background: #282c34; color: #abb2bf;
            padding: 1.25rem 1.5rem;
            border-radius: 0 0 4px 4px;
            max-height: 75vh; overflow: auto;
        }
        .xl { white-space: pre; }
        .xn { color: #e06c75; }
        .xb { color: #abb2bf; }
        .xa { color: #d19a66; }
        .xv { color: #98c379; }
        .xt { color: #abb2bf; }
        .xm { color: #5c6370; font-style: italic; }
        .xi { color: #c678dd; }
        .xd { color: #e5c07b; }
        .xg {
            color: #61afef; cursor: pointer; user-select: none;
            display: inline-block; width: 1em; text-align: center;
            font-size: .7rem; vertical-align: middle;
        }
        .xg:hover { color: #528bff; }
        .xg0 { color: transparent; cursor: default; pointer-events: none; }
        #doc-view {
            font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
            font-size: .82rem; line-height: 1.55;
            background: #282c34; color: #abb2bf;
            border-radius: 0 0 4px 4px;
            max-height: 75vh; overflow: auto;
            padding: 1.25rem .5rem 1.25rem 0;
        }
        #tab-xml.tab-pane, #tab-doc.tab-pane { overflow: hidden; }
        .xhit { background: rgba(255,215,0,.13); }
        .xhit-cur { background: rgba(255,215,0,.32) !important; box-shadow: inset 3px 0 0 #ffd700; }
        #xml-tree.filter-mode .xl:not(.xhit) { display: none; }
        mark.xmark { background: #4a3f00; color: #ffd700; border-radius: 2px; padding: 0 1px; font-weight: inherit; outline: 1px solid #6b5900; }
        mark.xmark-cur { background: #7a5f00; color: #ffe066; outline-color: #a07a00; }
        .ln { display:inline-block; min-width:3.2em; text-align:right; padding-right:.6em; margin-right:.2em; color:#4b5263; border-right:1px solid #3e4451; user-select:none; font-size:.78rem; flex-shrink:0; }
        .doc-line { display:flex; align-items:baseline; }
        .doc-line .lc { white-space:pre-wrap; flex:1; min-width: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">Digimat MBIO Processor Monitor</h1>
                        <p class="text-secondary mb-0"><b>Configuration</b>
                            &mdash; <span id="xml-filename" class="font-monospace text-muted small"></span>
                        </p>
                    </div>
                    <div class="text-muted small">{mbio.version} /
                        {main.nav}{bacnet.nav}
                    </div>
                </div>
                <ul class="nav nav-tabs mt-3 mb-0 d-flex align-items-center flex-wrap" id="cfgTabs" role="tablist">
                    <li class="nav-item" role="presentation">
                        <button class="nav-link active" id="tab-xml-btn"
                                data-bs-toggle="tab" data-bs-target="#tab-xml"
                                type="button" role="tab">Configuration XML</button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button class="nav-link" id="tab-doc-btn"
                                data-bs-toggle="tab" data-bs-target="#tab-doc"
                                type="button" role="tab">Documentation</button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex align-items-center flex-wrap gap-2">
                        <input id="tab-search" type="text" class="form-control form-control-sm" data-search-focus
                               placeholder="Search… (/ or Ctrl+F, Esc to clear)" style="width:220px" autocomplete="off">
                        <div class="btn-group btn-group-sm" role="group">
                            <input type="radio" class="btn-check" name="smode" id="smode-hl" value="hl" checked autocomplete="off">
                            <label class="btn btn-outline-secondary" for="smode-hl">Highlight</label>
                            <input type="radio" class="btn-check" name="smode" id="smode-fi" value="fi" autocomplete="off">
                            <label class="btn btn-outline-secondary" for="smode-fi">Filter</label>
                        </div>
                        <span id="search-count" class="text-muted small"></span>
                        <button id="btn-refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
                        <button id="btn-update" class="btn btn-sm btn-outline-warning">Update</button>
                    </li>
                </ul>
                <div class="tab-content border border-top-0 overflow-hidden mb-2">
                    <div class="tab-pane fade show active" id="tab-xml" role="tabpanel">
                        <div id="xml-error" class="alert alert-danger rounded-0 mb-0 d-none">Failed to load configuration file.</div>
                        <div id="xml-tree"></div>
                    </div>
                    <div class="tab-pane fade" id="tab-doc" role="tabpanel">
                        <div id="doc-error" class="alert alert-warning rounded-0 mb-0 d-none">
                            Documentation file not found &mdash; <span id="doc-path" class="font-monospace small"></span>
                        </div>
                        <div id="doc-view"></div>
                    </div>
                </div>
            </div>
        </div>
        """
        h.write(data)

        data = """
        <script>
        $(function() {

            // ── XML tree renderer ───────────────────────────────────────────
            var _uid = 0;
            var _lnum = 0;

            function lnHtml() { return '<span class="ln">' + (++_lnum) + '</span>'; }

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

            function renderAttrs(attrs) {
                var out = '';
                for (var i = 0; i < attrs.length; i++) {
                    out += ' <span class="xa">' + esc(attrs[i].name) + '</span>'
                         + '<span class="xb">=</span>'
                         + '<span class="xv">&quot;' + esc(attrs[i].value) + '&quot;</span>';
                }
                return out;
            }

            function renderNode(node, depth) {
                var pad = '', i;
                for (i = 0; i < depth; i++) pad += '  ';
                var ghost = '<span class="xg xg0"> </span>';

                if (node.nodeType === 8)  // Comment
                    return '<div class="xl">' + lnHtml() + ghost + pad
                        + '<span class="xm">&lt;!-- ' + esc(node.nodeValue.trim()) + ' --&gt;</span></div>';

                if (node.nodeType === 7)  // Processing instruction
                    return '<div class="xl">' + lnHtml() + ghost + pad
                        + '<span class="xi">&lt;?' + esc(node.target) + ' ' + esc(node.data) + '?&gt;</span></div>';

                if (node.nodeType === 3) {  // Text
                    var t = node.nodeValue.trim();
                    return t ? '<div class="xl">' + lnHtml() + ghost + pad + '<span class="xt">' + esc(t) + '</span></div>' : '';
                }

                if (node.nodeType === 4)  // CDATA
                    return '<div class="xl">' + lnHtml() + ghost + pad
                        + '<span class="xd">&lt;![CDATA[' + esc(node.nodeValue) + ']]&gt;</span></div>';

                if (node.nodeType !== 1) return '';

                var name    = esc(node.nodeName);
                var attrs   = renderAttrs(node.attributes);
                var openTag = '<span class="xb">&lt;</span><span class="xn">' + name + '</span>' + attrs;

                // Collect non-blank children
                var kids = [];
                for (i = 0; i < node.childNodes.length; i++) {
                    var c = node.childNodes[i];
                    if (c.nodeType === 3 && !c.nodeValue.trim()) continue;
                    kids.push(c);
                }

                // Self-closing
                if (!kids.length)
                    return '<div class="xl">' + lnHtml() + ghost + pad + openTag + '<span class="xb"> /&gt;</span></div>';

                // Inline: single short text child
                if (kids.length === 1 && kids[0].nodeType === 3 && kids[0].nodeValue.trim().length <= 80)
                    return '<div class="xl">' + lnHtml() + ghost + pad
                        + openTag + '<span class="xb">&gt;</span>'
                        + '<span class="xt">' + esc(kids[0].nodeValue.trim()) + '</span>'
                        + '<span class="xb">&lt;/</span><span class="xn">' + name + '</span><span class="xb">&gt;</span>'
                        + '</div>';

                // Foldable element
                var id = 'x' + (++_uid);
                var openLn = lnHtml();   // capture BEFORE recursing into children
                var childHtml = '';
                for (i = 0; i < kids.length; i++) childHtml += renderNode(kids[i], depth + 1);

                return '<div>'
                    + '<div class="xl">'
                    +   openLn
                    +   '<span class="xg" data-id="' + id + '">&#9660;</span>'
                    +   pad + openTag + '<span class="xb">&gt;</span>'
                    +   '<span id="' + id + 'f" style="display:none">'
                    +     '<span class="xb">&hellip;&lt;/</span><span class="xn">' + name + '</span><span class="xb">&gt;</span>'
                    +   '</span>'
                    + '</div>'
                    + '<div id="' + id + 'c">' + childHtml + '</div>'
                    + '<div id="' + id + 'e" class="xl">' + lnHtml() + ghost + pad
                    +   '<span class="xb">&lt;/</span><span class="xn">' + name + '</span><span class="xb">&gt;</span>'
                    + '</div>'
                    + '</div>';
            }

            // ── Load XML ────────────────────────────────────────────────────
            var container = document.getElementById('xml-tree');

            function loadConfig() {
                container.innerHTML = '<span style="color:#5c6370;font-family:monospace">Loading\u2026</span>';
                fetch('/api/v1/getxmlconfig')
                    .then(function(r) { return r.json(); })
                    .then(function(data) {
                        if (!data.content) {
                            $('#xml-error').removeClass('d-none');
                            container.innerHTML = '';
                            return;
                        }
                        $('#xml-error').addClass('d-none');
                        $('#xml-filename').text(data.filename || '');
                        _uid = 0; _lnum = 0;
                        var parser = new DOMParser();
                        var doc = parser.parseFromString(data.content, 'application/xml');
                        if (doc.querySelector('parsererror')) {
                            container.innerHTML = '<pre style="color:#abb2bf;margin:0;white-space:pre-wrap">'
                                                + esc(data.content) + '</pre>';
                            return;
                        }
                        var html = '';
                        for (var i = 0; i < doc.childNodes.length; i++) html += renderNode(doc.childNodes[i], 0);
                        container.innerHTML = html;
                        applySearch();
                    })
                    .catch(function() { $('#xml-error').removeClass('d-none'); container.innerHTML = ''; });
            }

            // ── Fold / unfold ───────────────────────────────────────────────
            $(container).on('click', '.xg:not(.xg0)', function() {
                var id = $(this).data('id');
                var $c = $('#' + id + 'c'), $e = $('#' + id + 'e'), $f = $('#' + id + 'f');
                if ($c.is(':visible')) {
                    $c.hide(); $e.hide(); $f.show(); $(this).html('&#9658;');
                } else {
                    $c.show(); $e.show(); $f.hide(); $(this).html('&#9660;');
                }
            });

            // ── Search / Filter ─────────────────────────────────────────────
            var xmlMatches = [], xmlMatchIdx = -1;
            var docOriginalText = null;

            function escHl(s) {
                return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
            }

            function hlText(text, term) {
                if (!term) return escHl(text);
                var out = '', lower = text.toLowerCase(), pos = 0, idx;
                while ((idx = lower.indexOf(term, pos)) !== -1) {
                    out += escHl(text.slice(pos, idx));
                    out += '<mark class="xmark">' + escHl(text.slice(idx, idx + term.length)) + '</mark>';
                    pos = idx + term.length;
                }
                return out + escHl(text.slice(pos));
            }

            function applySearchXml(term, mode) {
                $(container).removeClass('filter-mode').find('.xl').removeClass('xhit xhit-cur');
                xmlMatches = []; xmlMatchIdx = -1;
                if (!term) { $('#search-count').text(''); return; }
                $(container).find('.xl').each(function() {
                    var rowText = $(this).clone().find('.ln').remove().end().text().toLowerCase();
                    if (rowText.indexOf(term) !== -1) {
                        $(this).addClass('xhit');
                        xmlMatches.push(this);
                    }
                });
                if (mode === 'fi') $(container).addClass('filter-mode');
                if (xmlMatches.length) {
                    xmlMatchIdx = 0;
                    $(xmlMatches[0]).addClass('xhit-cur');
                    xmlMatches[0].scrollIntoView({block: 'nearest'});
                }
                $('#search-count').text(xmlMatches.length + ' match' + (xmlMatches.length !== 1 ? 'es' : ''));
            }

            function renderDocLines(lines, term) {
                var out = '';
                lines.forEach(function(l, i) {
                    var lnSpan = '<span class="ln">' + (i + 1) + '</span>';
                    var content = term ? hlText(l, term) : escHl(l);
                    out += '<div class="doc-line">' + lnSpan + '<span class="lc">' + content + '</span></div>';
                });
                return out;
            }

            function applySearchDoc(term, mode) {
                var el = document.getElementById('doc-view');
                if (!el || docOriginalText === null) return;
                var lines = docOriginalText.split('\\n');
                if (!term) {
                    el.innerHTML = renderDocLines(lines, '');
                    $('#search-count').text('');
                    return;
                }
                if (mode === 'fi') {
                    var matched = lines.filter(function(l) { return l.toLowerCase().indexOf(term) !== -1; });
                    el.innerHTML = renderDocLines(matched, term);
                    $('#search-count').text(matched.length + ' line' + (matched.length !== 1 ? 's' : ''));
                } else {
                    el.innerHTML = renderDocLines(lines, term);
                    var marks = el.querySelectorAll('mark.xmark');
                    if (marks.length) { marks[0].classList.add('xmark-cur'); marks[0].scrollIntoView({block: 'nearest'}); }
                    $('#search-count').text(marks.length + ' match' + (marks.length !== 1 ? 'es' : ''));
                }
            }

            function applySearch() {
                var term = $('#tab-search').val().trim().toLowerCase();
                var mode = $('input[name="smode"]:checked').val();
                if ($('#tab-xml-btn').hasClass('active')) applySearchXml(term, mode);
                else applySearchDoc(term, mode);
            }

            function navigateNext(dir) {
                if ($('#tab-xml-btn').hasClass('active')) {
                    if (!xmlMatches.length) return;
                    $(xmlMatches[xmlMatchIdx]).removeClass('xhit-cur');
                    xmlMatchIdx = (xmlMatchIdx + dir + xmlMatches.length) % xmlMatches.length;
                    $(xmlMatches[xmlMatchIdx]).addClass('xhit-cur');
                    xmlMatches[xmlMatchIdx].scrollIntoView({block: 'nearest'});
                } else {
                    var el = document.getElementById('doc-view');
                    if (!el) return;
                    var marks = el.querySelectorAll('mark.xmark');
                    if (!marks.length) return;
                    var cur = el.querySelector('mark.xmark-cur');
                    var idx = Array.prototype.indexOf.call(marks, cur);
                    if (cur) cur.classList.remove('xmark-cur');
                    idx = ((idx + dir) + marks.length) % marks.length;
                    marks[idx].classList.add('xmark-cur');
                    marks[idx].scrollIntoView({block: 'nearest'});
                }
            }

            $('#tab-search').on('input', applySearch);
            $('input[name="smode"]').on('change', applySearch);

            $(document).on('keydown', function(e) {
                if (!$(e.target).is('input,textarea,select')) return;
                if (e.key === 'Escape') { $('#tab-search').val('').blur(); applySearch(); }
                else if (e.key === 'Enter') { e.preventDefault(); navigateNext(e.shiftKey ? -1 : 1); }
            });

            // ── Load documentation ──────────────────────────────────────────
            var docLoaded = false;

            function loadDoc(force) {
                if (docLoaded && !force) return;
                $('#doc-view').html('<span style="color:#6c757d">Loading\u2026</span>');
                fetch('/api/v1/getxmlconfigdoc')
                    .then(function(r) { return r.json(); })
                    .then(function(data) {
                        if (!data.content) {
                            $('#doc-path').text(data.path || '');
                            $('#doc-error').removeClass('d-none');
                            $('#doc-view').empty();
                            return;
                        }
                        $('#doc-error').addClass('d-none');
                        docOriginalText = data.content;
                        docLoaded = true;
                        applySearch();
                    })
                    .catch(function() {
                        $('#doc-error').removeClass('d-none');
                        $('#doc-view').empty();
                    });
            }

            $('#tab-doc-btn').on('shown.bs.tab', function() { loadDoc(); });

            $('[data-bs-toggle="tab"]').on('shown.bs.tab', function() {
                $('#search-count').text('');
                applySearch();
            });

            // ── Toolbar ─────────────────────────────────────────────────────
            $('#btn-refresh').on('click', function() {
                if ($('#tab-xml-btn').hasClass('active')) loadConfig();
                else loadDoc(true);
            });

            $('#btn-update').on('click', function() {
                if (!confirm('Download latest config from cloud and restart MBIO?')) return;
                fetch('/api/v1/processorreload', {method: 'GET'})
                    .then(function(r) { return r.json(); })
                    .then(function(resp) {
                        showToast(resp.success ? 'Update command sent' : 'Update failed',
                                  resp.success ? 'success' : 'danger');
                    })
                    .catch(function() { showToast('Update failed', 'danger'); });
            });

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

    def cb_processorstop(self, handler, params):
        """Stop the MBIO processor.
        GET /api/v1/processorstop
        Calls mbio.halt() to stop the processor. Returns {"success": true}.
        """

        mbio=self.getMBIO()
        mbio.halt()

        try:
            subprocess.Popen(
                ['/usr/local/bin/mbio', '--kill'],
                start_new_session=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            self.cb_write_success(handler)
        except Exception:
            self.cb_write_failure(handler)
        return True

    def cb_processorreload(self, handler, params):
        """Reload the MBIO processor (runs /usr/local/bin/mbio --update in background).
        GET /api/v1/processorreload
        Returns {"success": true} immediately; the command is launched detached.
        """
        try:
            subprocess.Popen(
                ['/usr/local/bin/mbio', '--update'],
                start_new_session=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            self.cb_write_success(handler)
        except Exception:
            self.cb_write_failure(handler)
        return True

    def cb_processorupgrade(self, handler, params):
        """Upgrade the MBIO processor (runs /usr/local/bin/mbio --upgrade in background).
        GET /api/v1/processorupgrade
        Returns {"success": true} immediately; the command is launched detached.
        """
        try:
            subprocess.Popen(
                ['/usr/local/bin/mbio', '--upgrade'],
                start_new_session=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            self.cb_write_success(handler)
        except Exception:
            self.cb_write_failure(handler)
        return True

    def cb_sioscanner(self, handler, params):
        """Run a SIO network scan.
        GET /api/v1/sioscanner
        Optional query parameter: network (CIDR or IP prefix, default None)
        Launches the scan in a background thread; returns {"success": true} immediately.
        Scan progress is visible in the live log stream.
        """
        network = params.get('network') or None
        mbio = self.getMBIO()
        try:
            scanner = mbio.sioscanner(network)
            if scanner is None:
                return self.cb_write_failure(handler)

            def _run():
                try:
                    scanner.scan()
                except Exception as e:
                    self.logger.error('SIOScanner error: %s', e)

            threading.Thread(target=_run, daemon=True).start()
            self.cb_write_success(handler)
        except Exception:
            self.cb_write_failure(handler)
        return True

    def cb_razcpuconfig(self, handler, params, data=None):
        """Reset MBIO CPU configuration values.
        POST /api/v1/razcpuconfig
        Body: {"confirmationcode": "<code>", "keyfilter": "<filter>"}
          confirmationcode : validation code required by the processor (empty string = no code)
          keyfilter        : optional key prefix/pattern to restrict the reset scope (empty = all)
        Returns {"success": true} if mbio.RAZCPUCONFIG() returns True, {"success": false} otherwise.
        """
        if data is None:
            data = {}
        confirmationcode = data.get('confirmationcode', '') or ''
        keyfilter = data.get('keyfilter', '') or ''
        mbio = self.getMBIO()
        try:
            result = mbio.RAZCPUCONFIG(confirmationcode, keyfilter)
            if result:
                self.cb_write_success(handler)
            else:
                self.cb_write_failure(handler)
        except Exception:
            self.cb_write_failure(handler)
        return True

    def cb_razmbiottrendstore(self, handler, params, data=None):
        """POST /api/v1/razmbiottrendstore — purge MBIO TrendStore (YESIAMSURE required)."""
        if data is None:
            data = {}
        if data.get('confirmationcode', '') != 'YESIAMSURE':
            self.cb_write_failure(handler)
            return True
        try:
            store = self.getMBIO().trendingStore
            if store:
                store.raz()
                self.cb_write_success(handler)
            else:
                self.cb_write_failure(handler)
        except Exception:
            self.cb_write_failure(handler)
        return True

    def cb_logs(self, handler, params):
        """Live log viewer (SSE stream)"""

        mbio = self.getMBIO()
        h = HtmlPage('Digimat MBIO Processor Monitor', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + self._logoutHtml(show_logs=False))

        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">Digimat MBIO Processor Monitor</h1>
                            <p class="text-secondary mb-0"><b>Logs</b></p>
                        </div>
                        <div class="text-muted small">{mbio.version} /
                            {main.nav}{bacnet.nav}
                        </div>
                    </div>
                    <ul class="nav nav-tabs mt-3 mb-0 d-flex align-items-center flex-wrap" role="tablist">
                        <li class="nav-item" role="presentation">
                            <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-logs" type="button">Logs</button>
                        </li>
                        <li class="nav-item" role="presentation">
                            <button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-maintenance" type="button">Maintenance</button>
                        </li>
                        <li class="nav-item ms-auto pb-1 d-flex align-items-center flex-wrap gap-2">
                            <select id="level-filter" class="form-select form-select-sm" style="width:auto">
                                <option value="">All levels</option>
                                <option value="DEBUG">DEBUG+</option>
                                <option value="INFO">INFO+</option>
                                <option value="WARNING">WARNING+</option>
                                <option value="ERROR">ERROR+</option>
                                <option value="CRITICAL">CRITICAL</option>
                            </select>
                            <input id="log-search" type="text" class="form-control form-control-sm" data-search-focus
                                   placeholder="Filter... [/ or Ctrl+F]" style="width:180px">
                            <button id="btn-pause" class="btn btn-sm btn-outline-secondary">Pause</button>
                            <button id="btn-clear" class="btn btn-sm btn-outline-warning">Clear</button>
                            <span id="conn-badge" class="badge bg-secondary">Connecting</span>
                        </li>
                    </ul>
                    <div class="tab-content border border-top-0 rounded-bottom mb-2">
                        <div class="tab-pane fade show active p-3" id="tab-logs" role="tabpanel">
                            <div id="log-container"
                                 style="background:#0d1117;color:#d4d4d4;font-family:monospace;font-size:0.8rem;
                                        height:65vh;overflow-y:auto;padding:10px;border-radius:4px;"></div>
                            <div class="text-muted small mt-2">
                                <span id="log-count">0</span> entries &nbsp;|&nbsp; [Ctrl+F] search
                            </div>
                        </div>
                        <div class="tab-pane fade" id="tab-maintenance" role="tabpanel">
                            <div class="list-group list-group-flush">
                                <div class="list-group-item d-flex align-items-center gap-3 py-3">
                                    <div class="flex-grow-1">
                                        <div class="fw-semibold">API Reference</div>
                                        <div class="text-muted small">Browse all available REST API endpoints</div>
                                    </div>
                                    <a href="/help" class="btn btn-sm btn-outline-secondary">Open</a>
                                </div>
                                <div class="list-group-item d-flex align-items-center gap-3 py-3">
                                    <div class="flex-grow-1">
                                        <div class="fw-semibold">Sessions</div>
                                        <div class="text-muted small">Manage active browser sessions and API tokens</div>
                                    </div>
                                    <a href="/sessions" class="btn btn-sm btn-outline-secondary">Open</a>
                                </div>
                                <div class="list-group-item d-flex align-items-center gap-3 py-3">
                                    <div class="flex-grow-1">
                                        <div class="fw-semibold">Beep</div>
                                        <div class="text-muted small">Send an audio test signal to the processor</div>
                                    </div>
                                    <button id="btn-beep" class="btn btn-sm btn-outline-secondary">Beep</button>
                                </div>
                                <div class="list-group-item d-flex align-items-center gap-3 py-3">
                                    <div class="flex-grow-1">
                                        <div class="fw-semibold">SIO Scanner</div>
                                        <div class="text-muted small">Scan the network for SIO devices</div>
                                    </div>
                                    <button id="btn-sioscanner" class="btn btn-sm btn-outline-secondary">SIOScanner</button>
                                </div>
                                <div class="list-group-item d-flex align-items-center gap-3 py-3">
                                    <div class="flex-grow-1">
                                        <div class="fw-semibold">Upgrade</div>
                                        <div class="text-muted small">Upgrade the MBIO package to the latest version and restart</div>
                                    </div>
                                    <button id="btn-upgrade" class="btn btn-sm btn-outline-warning">Upgrade</button>
                                </div>
                                <div class="list-group-item d-flex align-items-center gap-3 py-3">
                                    <div class="flex-grow-1">
                                        <div class="fw-semibold text-danger">RAZ CPUConfig</div>
                                        <div class="text-muted small">Reset CPU configuration to factory defaults — <span class="text-danger fw-semibold">irreversible</span></div>
                                    </div>
                                    <button id="btn-razcpuconfig" class="btn btn-sm btn-danger">RAZ CPUConfig</button>
                                </div>
                                <div class="list-group-item d-flex align-items-center gap-3 py-3">
                                    <div class="flex-grow-1">
                                        <div class="fw-semibold text-danger">RAZ MBIO TrendStore</div>
                                        <div class="text-muted small">
                                            Purge all MBIO trend data — <span class="text-danger fw-semibold">irreversible</span>
                                        </div>
                                    </div>
                                    <button id="btn-razmbiottrendstore" class="btn btn-sm btn-danger">RAZ</button>
                                </div>
                                <div class="list-group-item d-flex align-items-center gap-3 py-3">
                                    <div class="flex-grow-1">
                                        <div class="fw-semibold text-danger">RAZ BACnet TrendStore</div>
                                        <div class="text-muted small">
                                            Purge all BACnet trend data — <span class="text-danger fw-semibold">irreversible</span>
                                        </div>
                                    </div>
                                    <button id="btn-razbacnettrendstore" class="btn btn-sm btn-danger">RAZ</button>
                                </div>
                                <div class="list-group-item d-flex align-items-center gap-3 py-3">
                                    <div class="flex-grow-1">
                                        <div class="fw-semibold text-danger">Stop</div>
                                        <div class="text-muted small">Halt the MBIO processor immediately</div>
                                    </div>
                                    <button id="btn-stop" class="btn btn-sm btn-danger">Stop</button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
            <div class="modal fade" id="action-dialog" tabindex="-1" aria-hidden="true">
                <div class="modal-dialog">
                    <div class="modal-content">
                        <div class="modal-header">
                            <h5 class="modal-title" id="action-dialog-title">Action</h5>
                            <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
                        </div>
                        <div class="modal-body" id="action-dialog-body"></div>
                        <div class="modal-footer">
                            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
                            <button type="button" class="btn btn-primary" id="action-dialog-ok">Confirm</button>
                        </div>
                    </div>
                </div>
            </div>
        """
        h.write(data)

        data = """
        <script>
        $(function() {
            var paused = false;
            var autoScroll = true;
            var logCount = 0;
            var es = null;
            var LEVEL_ORDER = {DEBUG:0,INFO:1,WARNING:2,ERROR:3,CRITICAL:4};
            var LEVEL_COLORS = {DEBUG:'#6a9fb5',INFO:'#90c060',WARNING:'#f0a830',ERROR:'#c04040',CRITICAL:'#ff2020'};

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

            function fmtTs(ts) {
                var d = new Date(ts * 1000);
                return [d.getHours(), d.getMinutes(), d.getSeconds()]
                    .map(function(v){ return String(v).padStart(2,'0'); }).join(':')
                    + '.' + String(d.getMilliseconds()).padStart(3,'0');
            }

            function isVisible(level, text) {
                var fLevel = $('#level-filter').val();
                var fText  = $('#log-search').val().toLowerCase();
                if (fLevel) {
                    var ord = LEVEL_ORDER[level] !== undefined ? LEVEL_ORDER[level] : -1;
                    var flt = LEVEL_ORDER[fLevel] !== undefined ? LEVEL_ORDER[fLevel] : -1;
                    if (ord < flt) return false;
                }
                if (fText && text.indexOf(fText) === -1) return false;
                return true;
            }

            function appendEntry(entry) {
                var level = entry.level || 'INFO';
                var color = LEVEL_COLORS[level] || '#d4d4d4';
                var ts    = fmtTs(entry.ts);
                var name  = entry.name || '';
                var msg   = entry.msg  || '';
                var text  = (level + ' ' + name + ' ' + msg).toLowerCase();

                var $line = $('<div class="ll"></div>')
                    .attr('data-level', level)
                    .attr('data-text', text)
                    .css('display', isVisible(level, text) ? '' : 'none')
                    .html('<span style="color:#555">' + ts + '</span> '
                        + '<span style="color:' + color + ';font-weight:bold;display:inline-block;min-width:5em">'
                        + level + '</span>'
                        + '<span style="color:#777"> ' + escHtml(name) + '</span> '
                        + '<span style="color:' + color + '">' + escHtml(msg).replace(/\\n/g, '<br>') + '</span>');

                var $c = $('#log-container');
                $c.append($line);
                logCount++;

                var $lines = $c.find('.ll');
                if ($lines.length > 500) $lines.first().remove();
                if (autoScroll && isVisible(level, text)) $c.scrollTop($c[0].scrollHeight);
                $('#log-count').text(logCount);
            }

            function applyFilter() {
                $('#log-container .ll').each(function() {
                    var $l = $(this);
                    $l.toggle(isVisible($l.attr('data-level'), $l.attr('data-text')));
                });
            }

            function connect() {
                if (es) { try { es.close(); } catch(_) {} }
                es = new EventSource('/api/v1/logstream');
                es.onopen = function() {
                    $('#conn-badge').text('Connected').removeClass().addClass('badge bg-success');
                };
                es.onmessage = function(e) {
                    try { if (!paused) appendEntry(JSON.parse(e.data)); } catch(_) {}
                };
                es.onerror = function() {
                    $('#conn-badge').text('Reconnecting').removeClass().addClass('badge bg-warning text-dark');
                };
            }

            connect();

            $('#btn-pause').on('click', function() {
                paused = !paused;
                $(this).text(paused ? 'Resume' : 'Pause')
                       .toggleClass('btn-outline-warning btn-outline-secondary');
            });

            $('#btn-clear').on('click', function() {
                $('#log-container').empty();
                logCount = 0;
                $('#log-count').text('0');
            });

            // Generic reusable dialog for actions requiring user-supplied arguments.
            // config: {title, fields:[{id,label,placeholder,type}], danger, confirmLabel, onConfirm}
            function openActionDialog(config) {
                $('#action-dialog-title').text(config.title || 'Action');
                var body = '';
                (config.fields || []).forEach(function(f) {
                    body += '<div class="mb-3">'
                        + '<label class="form-label small mb-1">' + escHtml(f.label || '') + '</label>'
                        + '<input type="' + (f.type || 'text') + '" id="' + f.id
                        + '" class="form-control form-control-sm" placeholder="'
                        + escHtml(f.placeholder || '') + '" autocomplete="off">'
                        + '</div>';
                });
                $('#action-dialog-body').html(body);
                var $ok = $('#action-dialog-ok');
                $ok.removeClass('btn-danger btn-primary')
                   .addClass(config.danger ? 'btn-danger' : 'btn-primary')
                   .text(config.confirmLabel || 'Confirm');
                $ok.off('click').on('click', function() {
                    var values = {};
                    (config.fields || []).forEach(function(f) { values[f.id] = $('#' + f.id).val(); });
                    bootstrap.Modal.getInstance(document.getElementById('action-dialog')).hide();
                    if (config.onConfirm) config.onConfirm(values);
                });
                // Focus first field after modal is shown
                $('#action-dialog').one('shown.bs.modal', function() {
                    var first = (config.fields || [])[0];
                    if (first) $('#' + first.id).focus();
                });
                new bootstrap.Modal(document.getElementById('action-dialog')).show();
            }

            $('#btn-beep').on('click', function() {
                fetch('/api/v1/beep', {method: 'GET'}).catch(function() {});
            });

            $('#btn-sioscanner').on('click', function() {
                fetch('/api/v1/sioscanner', {method: 'GET'})
                    .then(function(r) { return r.json(); })
                    .then(function(resp) {
                        appendEntry({ts: Date.now()/1000, level: resp.success ? 'INFO' : 'ERROR',
                                     name:'monitor', msg: resp.success ? 'SIOScanner started.' : 'SIOScanner failed.'});
                    })
                    .catch(function() {});
            });

            $('#btn-razcpuconfig').on('click', function() {
                openActionDialog({
                    title: 'RAZ CPUConfig',
                    danger: true,
                    confirmLabel: 'Execute',
                    fields: [
                        {id: 'raz-keyfilter',   label: 'Value Key Filter',
                         placeholder: '(empty = all values)'},
                        {id: 'raz-confirmcode', label: 'Confirmation Code',
                         placeholder: '', type: 'password'}
                    ],
                    onConfirm: function(values) {
                        fetch('/api/v1/razcpuconfig', {
                            method: 'POST',
                            headers: {'Content-Type': 'application/json'},
                            body: JSON.stringify({
                                keyfilter:       values['raz-keyfilter']   || '',
                                confirmationcode: values['raz-confirmcode'] || ''
                            })
                        })
                        .then(function(r) { return r.json(); })
                        .then(function(resp) {
                            appendEntry({ts: Date.now()/1000,
                                level: resp.success ? 'WARNING' : 'ERROR',
                                name: 'monitor',
                                msg:  resp.success ? 'RAZ CPUConfig done.' : 'RAZ CPUConfig failed.'});
                        })
                        .catch(function() {});
                    }
                });
            });

            $('#btn-upgrade').on('click', function() {
                if (!confirm('Upgrade the MBIO processor?')) return;
                fetch('/api/v1/processorupgrade', {method: 'GET'})
                    .then(function(r) { return r.json(); })
                    .then(function(resp) {
                        appendEntry({ts: Date.now()/1000, level: resp.success ? 'WARNING' : 'ERROR',
                                     name:'monitor', msg: resp.success ? 'Upgrade command sent.' : 'Upgrade failed.'});
                    })
                    .catch(function() {});
            });

            $('#btn-razmbiottrendstore').on('click', function() {
                openActionDialog({
                    title: 'RAZ MBIO TrendStore',
                    danger: true,
                    confirmLabel: 'Purge',
                    fields: [
                        {id: 'razmbio-confirmcode', label: 'Confirmation Code',
                         placeholder: '', type: 'password'}
                    ],
                    onConfirm: function(values) {
                        fetch('/api/v1/razmbiottrendstore', {
                            method: 'POST',
                            headers: {'Content-Type': 'application/json'},
                            body: JSON.stringify({confirmationcode: values['razmbio-confirmcode'] || ''})
                        })
                        .then(function(r) { return r.json(); })
                        .then(function(resp) {
                            appendEntry({ts: Date.now()/1000,
                                level: resp.success ? 'WARNING' : 'ERROR',
                                name: 'monitor',
                                msg:  resp.success ? 'RAZ MBIO TrendStore done.' : 'RAZ MBIO TrendStore failed (wrong code or no store).'});
                        }).catch(function() {});
                    }
                });
            });

            $('#btn-razbacnettrendstore').on('click', function() {
                openActionDialog({
                    title: 'RAZ BACnet TrendStore',
                    danger: true,
                    confirmLabel: 'Purge',
                    fields: [
                        {id: 'razbacnet-confirmcode', label: 'Confirmation Code',
                         placeholder: '', type: 'password'}
                    ],
                    onConfirm: function(values) {
                        fetch('/api/v1/razbacnettrendstore', {
                            method: 'POST',
                            headers: {'Content-Type': 'application/json'},
                            body: JSON.stringify({confirmationcode: values['razbacnet-confirmcode'] || ''})
                        })
                        .then(function(r) { return r.json(); })
                        .then(function(resp) {
                            appendEntry({ts: Date.now()/1000,
                                level: resp.success ? 'WARNING' : 'ERROR',
                                name: 'monitor',
                                msg:  resp.success ? 'RAZ BACnet TrendStore done.' : 'RAZ BACnet TrendStore failed (wrong code or no BACnet).'});
                        }).catch(function() {});
                    }
                });
            });

            $('#btn-stop').on('click', function() {
                if (!confirm('Stop the MBIO processor?')) return;
                fetch('/api/v1/processorstop', {method: 'GET'})
                    .then(function(r) { return r.json(); })
                    .then(function(resp) {
                        if (resp.success) {
                            appendEntry({ts: Date.now()/1000, level:'WARNING',
                                         name:'monitor', msg:'Stop command sent.'});
                        }
                    })
                    .catch(function() {});
            });

            $('#level-filter, #log-search').on('change input', applyFilter);

            $('#log-container').on('scroll', function() {
                var $c = $(this);
                autoScroll = ($c[0].scrollTop + $c[0].clientHeight >= $c[0].scrollHeight - 5);
            });

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

    def cb_getsessions(self, handler, params):
        """Return active sessions and registered API tokens.
        GET /api/v1/getsessions
        Returns:
          {
            "sessions": [{"token": "...", "ip": "...", "created": epoch, "expiry": epoch, "current": bool}],
            "api_tokens": ["tok1", "tok2", ...]
          }
        Sessions are only populated when authentication is enabled.
        The "current" flag marks the session used by this request.
        """
        wserver = self._wserver
        if not wserver:
            return self.cb_write_json(handler, {'sessions': [], 'api_tokens': []})
        current_token = handler._get_session_token()
        sessions = []
        if wserver._auth_enabled:
            for s in wserver.sessions_info():
                s['current'] = (s['token'] == current_token)
                sessions.append(s)
        api_tokens = wserver.api_tokens_info()
        self.cb_write_json(handler, {'sessions': sessions, 'api_tokens': api_tokens})
        return True

    def cb_closesession(self, handler, params, data=None):
        """Revoke an active session by its token.
        POST /api/v1/closesession
        Body: {"token": "<full_session_token>"}
        Returns {"success": true}. Revoking an unknown or already-expired token also returns success.
        """
        wserver = self._wserver
        if not wserver:
            return self.cb_write_failure(handler)
        payload = data or {}
        token = payload.get('token', '')
        if token:
            wserver.revoke_session(token)
            self._save_sessions()
            self.cb_write_success(handler)
        else:
            self.cb_write_failure(handler)
        return True

    def cb_revokeapitoken(self, handler, params, data=None):
        """Remove a registered API Bearer token.
        POST /api/v1/revokeapitoken
        Body: {"token": "<api_token>"}
        Returns {"success": true}. Revoking an unknown token also returns success.
        """
        wserver = self._wserver
        if not wserver:
            return self.cb_write_failure(handler)
        payload = data or {}
        token = payload.get('token', '')
        if token:
            wserver.remove_api_token(token)
            self._save_api_tokens()
            self.cb_write_success(handler)
        else:
            self.cb_write_failure(handler)
        return True

    def cb_generateapitoken(self, handler, params, data=None):
        """Generate a new random API Bearer token and register it.
        POST /api/v1/generateapitoken
        Returns {"success": true, "token": "<full_token>"}.
        The full token is returned only once; subsequent calls to getsessions show a truncated version.
        """
        import secrets
        wserver = self._wserver
        if not wserver:
            return self.cb_write_failure(handler)
        token = secrets.token_hex(32)
        wserver.add_api_token(token)
        self._save_api_tokens()
        self.cb_write_json(handler, {'success': True, 'token': token})
        return True

    def cb_sessions(self, handler, params):
        """Active sessions and API tokens management page.
        GET /sessions
        Shows all active browser sessions (IP, created, expiry, current flag) and
        registered API Bearer tokens. Requires authentication to be enabled.
        Each session can be closed with a confirmation dialog; the current session
        is highlighted and closing it redirects to /login.
        API tokens can be revoked individually.
        Page auto-refreshes every 10 s.
        """
        mbio = self.getMBIO()
        h = HtmlPage('Sessions — Digimat MBIO', handler)
        h.variable('mbio.version', mbio.version)
        h.variable('main.nav', self._navHtml())
        h.variable('bacnet.nav', self._bacnetNavHtml() + self._modbusNavHtml() + self._logoutHtml(show_logs=True, show_sessions=False))

        data = """
        <div class="container-fluid py-4">
            <div class="mb-4 d-flex align-items-center justify-content-between flex-wrap gap-2">
                <div>
                    <h1 class="h3 mb-1">Digimat MBIO Processor Monitor</h1>
                    <p class="text-secondary mb-0"><b>Sessions &amp; API Tokens</b></p>
                </div>
                <div class="text-muted small">{mbio.version} /
                    {main.nav}{bacnet.nav}
                </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-sessions" type="button">
                        Sessions&nbsp;<span id="sess-count" class="badge bg-secondary">0</span>
                    </button>
                </li>
                <li class="nav-item" role="presentation">
                    <button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-tokens" type="button">
                        API Tokens&nbsp;<span id="tok-count" class="badge bg-secondary">0</span>
                    </button>
                </li>
                <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                    <button class="btn btn-sm btn-success" id="btn-generate-token">Generate Token</button>
                    <span id="refresh-badge" class="text-muted small">Loading…</span>
                </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-sessions" role="tabpanel">
                    <table class="table table-hover mb-0" id="sess-table">
                        <thead class="table-light">
                            <tr>
                                <th>Token</th>
                                <th>IP</th>
                                <th>Created</th>
                                <th>Expires in</th>
                                <th></th>
                            </tr>
                        </thead>
                        <tbody id="sess-body">
                            <tr><td colspan="5" class="text-muted text-center py-3">Loading…</td></tr>
                        </tbody>
                    </table>
                </div>
                <div class="tab-pane fade" id="tab-tokens" role="tabpanel">
                    <table class="table table-hover mb-0" id="tok-table">
                        <thead class="table-light">
                            <tr>
                                <th>Token</th>
                                <th></th>
                            </tr>
                        </thead>
                        <tbody id="tok-body">
                            <tr><td colspan="2" class="text-muted text-center py-3">Loading…</td></tr>
                        </tbody>
                    </table>
                </div>
            </div>
        </div>

        <!-- Modal: new token display (shown once after generation) -->
        <div class="modal fade" id="modal-token-generated" tabindex="-1" aria-labelledby="modal-token-label" aria-hidden="true">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title" id="modal-token-label">New API Token</h5>
                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                    </div>
                    <div class="modal-body">
                        <p class="text-warning small mb-2">
                            <b>Copy this token now</b> — it will not be shown again in full.
                        </p>
                        <div class="input-group">
                            <input type="text" id="generated-token-value" class="form-control font-monospace small" readonly>
                            <button class="btn btn-outline-secondary" id="btn-copy-token" type="button">Copy</button>
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Done</button>
                    </div>
                </div>
            </div>
        </div>
        """
        h.write(data)

        data = """
        <script>
        $(function() {
            var refreshInterval = 10000;
            var timer = null;

            function fmtTs(epoch) {
                if (!epoch) return '—';
                var d = new Date(epoch * 1000);
                return d.toLocaleString();
            }

            function fmtRemaining(expiry) {
                var sec = Math.max(0, Math.round(expiry - Date.now() / 1000));
                if (sec <= 0) return '<span class="text-danger">Expired</span>';
                var h = Math.floor(sec / 3600);
                var m = Math.floor((sec % 3600) / 60);
                var s = sec % 60;
                if (h > 0)  return h + 'h ' + m + 'm';
                if (m > 0)  return m + 'm ' + s + 's';
                return s + 's';
            }

            function truncToken(tok) {
                if (!tok) return '—';
                return tok.substring(0, 12) + '&hellip;' + tok.substring(tok.length - 4);
            }

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

            function loadSessions() {
                $.getJSON('/api/v1/getsessions', function(data) {
                    renderSessions(data.sessions || []);
                    renderTokens(data.api_tokens || []);
                    $('#refresh-badge').text('Updated ' + new Date().toLocaleTimeString());
                }).fail(function() {
                    $('#refresh-badge').text('Error loading data');
                });
            }

            function renderSessions(sessions) {
                $('#sess-count').text(sessions.length);
                if (sessions.length === 0) {
                    $('#sess-body').html('<tr><td colspan="5" class="text-muted text-center py-3">No active sessions</td></tr>');
                    return;
                }
                var html = '';
                sessions.forEach(function(s) {
                    var isCurrent = s.current;
                    var rowClass = isCurrent ? ' class="table-primary"' : '';
                    var badge = isCurrent ? ' <span class="badge bg-primary ms-1">current</span>' : '';
                    var closeBtn = isCurrent
                        ? ''
                        : '<button class="btn btn-sm btn-outline-danger sess-close" data-token="' + escHtml(s.token) + '">Close</button>';
                    html += '<tr' + rowClass + '>';
                    html += '<td class="font-monospace small">' + truncToken(s.token) + badge + '</td>';
                    html += '<td>' + escHtml(s.ip || '—') + '</td>';
                    html += '<td class="small">' + fmtTs(s.created) + '</td>';
                    html += '<td>' + fmtRemaining(s.expiry) + '</td>';
                    html += '<td class="text-end">' + closeBtn + '</td>';
                    html += '</tr>';
                });
                $('#sess-body').html(html);
            }

            function renderTokens(tokens) {
                $('#tok-count').text(tokens.length);
                if (tokens.length === 0) {
                    $('#tok-body').html('<tr><td colspan="2" class="text-muted text-center py-3">No API tokens</td></tr>');
                    return;
                }
                var html = '';
                tokens.forEach(function(tok) {
                    html += '<tr>';
                    html += '<td class="font-monospace small">' + truncToken(tok) + '</td>';
                    html += '<td class="text-end"><button class="btn btn-sm btn-outline-danger tok-revoke" data-token="' + escHtml(tok) + '">Revoke</button></td>';
                    html += '</tr>';
                });
                $('#tok-body').html(html);
            }

            $(document).on('click', '.sess-close', function() {
                var tok = $(this).data('token');
                $.ajax({
                    url: '/api/v1/closesession',
                    method: 'POST',
                    contentType: 'application/json',
                    data: JSON.stringify({token: tok}),
                    success: function() { loadSessions(); }
                });
            });

            $(document).on('click', '.tok-revoke', function() {
                var tok = $(this).data('token');
                if (!confirm('Revoke this API token?')) return;
                $.ajax({
                    url: '/api/v1/revokeapitoken',
                    method: 'POST',
                    contentType: 'application/json',
                    data: JSON.stringify({token: tok}),
                    success: function() { loadSessions(); }
                });
            });

            $('#btn-generate-token').on('click', function() {
                $.ajax({
                    url: '/api/v1/generateapitoken',
                    method: 'POST',
                    contentType: 'application/json',
                    data: JSON.stringify({}),
                    success: function(resp) {
                        if (resp && resp.token) {
                            $('#generated-token-value').val(resp.token);
                            $('#btn-copy-token').text('Copy').removeClass('btn-success').addClass('btn-outline-secondary');
                            var modal = new bootstrap.Modal(document.getElementById('modal-token-generated'));
                            modal.show();
                        }
                    },
                    error: function() {
                        alert('Failed to generate token.');
                    }
                });
            });

            $('#btn-copy-token').on('click', function() {
                var val = $('#generated-token-value').val();
                navigator.clipboard.writeText(val).then(function() {
                    $('#btn-copy-token').text('Copied!').removeClass('btn-outline-secondary').addClass('btn-success');
                    setTimeout(function() {
                        $('#btn-copy-token').text('Copy').removeClass('btn-success').addClass('btn-outline-secondary');
                    }, 2000);
                });
            });

            $('#modal-token-generated').on('hidden.bs.modal', function() {
                loadSessions();
            });

            loadSessions();
            timer = setInterval(loadSessions, refreshInterval);
        });
        </script>
        """
        h.write(data)
        h.flush()

    def onLoad(self, xml: XMLConfig):
        mbio=self.getMBIO()
        port=xml.getInt('port', 443)
        password=xml.get('password', 'mbi0')
        matrix=xml.getBool('matrix', True)
        ssl=xml.getBool('ssl', True)
        words=xml.get('words')

        if words:
            word=words.split(',')

        # deny reserved ports
        if port in [8000, 8443]:
            port=443
        # interface=mbio.interface
        interface='0.0.0.0'
        self._hostcpu=mbio.resolveIpAliasToIpAddress('cpu')
        if self._hostcpu=='cpu':
            self._hostcpu='localhost'

        ws=TemporaryWebServer('/tmp/wsmonitor', port=port, host=interface, logger=self.logger)
        if ws:
            if matrix:
                ws.enable_matrix(words)
            if password:
                ws.set_password(password)
            if ssl:
                ws.declare_certificate(['/etc/sysconfig/digimat/credentials', '/var/run/digimat', '/tmp'])

            self._wserver=ws
            ws.attach_logger(self.logger)
            ws.registerGetCallback('/api/v1/getmetrics', self.cb_getmetrics)
            ws.registerGetCallback('/api/v1/getdevicemetrics', self.cb_getdevicemetrics)
            ws.registerGetCallback('/api/v1/getsysteminfo', self.cb_getsysteminfo)
            ws.registerGetCallback('/dashboard', self.cb_dashboard)
            ws.registerGetCallback('/api/v1/gettasks', self.cb_gettasks)
            ws.registerGetCallback('/api/v1/getzones', self.cb_getzones)
            ws.registerGetPostCallback('/api/v1/setzone', self.cb_setzone)
            ws.registerGetCallback('/api/v1/getgateways', self.cb_getgateways)
            ws.registerGetCallback('/api/v1/getgatewaydevices', self.cb_getgatewaydevices)
            ws.registerGetCallback('/api/v1/getalldevices', self.cb_getalldevices)
            ws.registerGetCallback('/api/v1/gettaskvalues', self.cb_gettaskvalues)
            ws.registerGetCallback('/api/v1/getzonevalues', self.cb_getzonevalues)
            ws.registerGetCallback('/api/v1/getdevicevalues', self.cb_getdevicevalues)
            ws.registerGetCallback('/api/v1/getvalues', self.cb_getvalues)
            ws.registerGetCallback('/api/v1/setvalue', self.cb_setvalue)
            ws.registerGetCallback('/api/v1/setvalueauto', self.cb_setvalueauto)
            ws.registerGetCallback('/api/v1/markvalue', self.cb_markvalue)
            ws.registerGetCallback('/api/v1/markvaluewithzone', self.cb_markvaluewithzone)
            ws.registerGetCallback('/api/v1/unmarkvalue', self.cb_unmarkvalue)
            ws.registerGetCallback('/api/v1/resettask', self.cb_resettask)
            ws.registerGetCallback('/api/v1/resetgateway', self.cb_resetgateway)
            ws.registerGetCallback('/api/v1/resetdevice', self.cb_resetdevice)
            ws.registerGetCallback('/api/v1/rebootdevice', self.cb_rebootdevice)
            ws.registerGetCallback('/api/v1/upgradedevice', self.cb_upgradedevice)
            ws.registerGetCallback('/api/v1/save', self.cb_save)
            ws.registerGetCallback('/api/v1/beep', self.cb_beep)
            ws.registerGetCallback('/api/v1/getuserdata', self.cb_getuserdata)
            ws.registerGetPostCallback('/api/v1/storeuserdata', self.cb_storeuserdata)

            ws.registerGetCallback('/api/v1/getbacnetdevices', self.cb_getbacnetdevices)
            ws.registerGetCallback('/api/v1/getbacnetobjects', self.cb_getbacnetobjects)
            ws.registerGetCallback('/api/v1/refreshbacnet', self.cb_refreshbacnet)
            ws.registerGetPostCallback('/api/v1/bacnetaction', self.cb_bacnetaction)
            ws.registerGetCallback('/api/v1/getbacnetobjectproperties', self.cb_getbacnetobjectproperties)
            ws.registerGetPostCallback('/api/v1/bacnetwritepriority', self.cb_bacnetwritepriority)
            ws.registerGetPostCallback('/api/v1/bacnetwriteproperty', self.cb_bacnetwriteproperty)
            ws.registerGetCallback('/api/v1/getbacnetclientlocalobjectmeta',    self.cb_getbacnetclientlocalobjectmeta)
            ws.registerPostCallback('/api/v1/setbacnetclientlocalobjectmeta',   self.cb_setbacnetclientlocalobjectmeta)
            ws.registerPostCallback('/api/v1/resetbacnetclientlocalobjectmeta', self.cb_resetbacnetclientlocalobjectmeta)

            ws.registerGetCallback('/script/zoneeditor.js', self.cb_script_zoneeditor)

            ws.registerGetCallback('/tasks', self.cb_tasks)
            ws.registerGetCallback('/zones', self.cb_zones)
            ws.registerGetCallback('/gateways', self.cb_gateways)
            ws.registerGetCallback('/devices', self.cb_devices)
            ws.registerGetCallback('/alldevices', self.cb_alldevices)
            ws.registerGetCallback('/taskvalues', self.cb_taskvalues)
            ws.registerGetCallback('/zonevalues', self.cb_zonevalues)
            ws.registerGetCallback('/devicevalues', self.cb_devicevalues)
            ws.registerGetCallback('/values', self.cb_values)
            ws.registerGetCallback('/valuesmanual', self.cb_valuesmanual)
            ws.registerGetCallback('/valueserror', self.cb_valueserror)
            ws.registerGetCallback('/valuesmarked', self.cb_valuesmarked)
            ws.registerGetCallback('/monitor', self.cb_gateways)
            ws.registerGetCallback('/', self.cb_dashboard)
            ws.registerGetCallback('/zoneeditor', self.cb_zoneeditor)
            ws.registerGetCallback('/bacnet', self.cb_bacnet)
            ws.registerGetCallback('/bacnetobjects', self.cb_bacnetobjects)
            ws.registerGetCallback('/bacnetobjectproperties', self.cb_bacnetobjectproperties)
            ws.registerGetCallback('/api/v1/getbacnetschedules', self.cb_getbacnetschedules)
            ws.registerGetCallback('/api/v1/getschedule', self.cb_getschedule)
            ws.registerGetCallback('/api/v1/getschedulelinks', self.cb_getschedulelinks)
            ws.registerPostCallback('/api/v1/writeschedule', self.cb_writeschedule)
            ws.registerGetCallback('/bacnetschedule', self.cb_bacnetschedule)
            ws.registerGetCallback('/api/v1/gettrending',        self.cb_gettrending)
            ws.registerGetCallback('/api/v1/bacnettrendingdata', self.cb_bacnettrendingdata)
            ws.registerGetCallback('/api/v1/bacnetrecord',       self.cb_bacnetrecord)
            ws.registerGetCallback('/api/v1/bacnetunrecord',     self.cb_bacnetunrecord)
            ws.registerGetCallback('/api/v1/bacnetpurge',        self.cb_bacnetpurge)
            ws.registerGetCallback('/bacnettrending',            self.cb_bacnettrending)
            ws.registerGetCallback('/api/v1/bacnetsavetrends',   self.cb_bacnetsavetrends)
            ws.registerGetCallback('/api/v1/bacnettrendpush',    self.cb_bacnettrendpush)

            ws.registerGetCallback('/api/v1/gettrenddata',          self.cb_gettrenddata)
            ws.registerGetCallback('/api/v1/getvalueproperties',     self.cb_getvalueproperties)
            ws.registerGetCallback('/api/v1/getvalueconfig',         self.cb_getvalueconfig)
            ws.registerGetCallback('/api/v1/raztrend',               self.cb_raztrend)
            ws.registerGetCallback('/api/v1/savetrends',             self.cb_savetrends)
            ws.registerGetCallback('/api/v1/gettrendstoreinfo',      self.cb_gettrendstoreinfo)
            ws.registerGetCallback('/valueproperties',               self.cb_valueproperties)

            ws.registerGetCallback('/api/v1/getxmlconfig', self.cb_getxmlconfig)
            ws.registerGetCallback('/api/v1/getxmlconfigdoc', self.cb_getxmlconfigdoc)
            ws.registerGetCallback('/api/v1/processorstop', self.cb_processorstop)
            ws.registerGetCallback('/api/v1/processorreload', self.cb_processorreload)
            ws.registerGetCallback('/api/v1/processorupgrade', self.cb_processorupgrade)
            ws.registerGetCallback('/api/v1/sioscanner', self.cb_sioscanner)
            ws.registerPostCallback('/api/v1/razcpuconfig', self.cb_razcpuconfig)
            ws.registerPostCallback('/api/v1/razmbiottrendstore', self.cb_razmbiottrendstore)
            ws.registerPostCallback('/api/v1/razbacnettrendstore', self.cb_razbacnettrendstore)
            ws.registerGetCallback('/api/v1/getsessions', self.cb_getsessions)
            ws.registerPostCallback('/api/v1/closesession', self.cb_closesession)
            ws.registerPostCallback('/api/v1/revokeapitoken', self.cb_revokeapitoken)
            ws.registerPostCallback('/api/v1/generateapitoken', self.cb_generateapitoken)
            ws.registerGetCallback('/logs', self.cb_logs)
            ws.registerGetCallback('/sessions', self.cb_sessions)
            ws.registerGetCallback('/help', self.cb_help)
            ws.registerGetCallback('/config', self.cb_config)

            ws.registerGetCallback('/api/v1/getmodbusserverinfo', self.cb_getmodbusserverinfo)
            ws.registerGetCallback('/api/v1/getmodbusserverregisters', self.cb_getmodbusserverregisters)
            ws.registerPostCallback('/api/v1/writemodbusregister', self.cb_writemodbusregister)
            ws.registerGetCallback('/modbusserver', self.cb_modbusserver)

    def _save_api_tokens(self):
        """Persist the current set of API tokens to pickle storage."""
        wserver = self._wserver
        if wserver:
            self.pickleWrite('apitoken', sorted(wserver.api_tokens_info()))

    def _save_sessions(self):
        """Persist active sessions to pickle storage."""
        wserver = self._wserver
        if wserver and wserver._auth_enabled:
            self.pickleWrite('auth_sessions', wserver.sessions_info())

    def poweron(self):
        ws=self.wserver
        if ws:
            ws.disableAutoShutdown()
            ws.linkPath('~/Dropbox/python/www')
            ws.linkPath('/usr/lib/www')
            ws.start()
            tokens = self.pickleRead('apitoken')
            if tokens:
                for token in tokens:
                    ws.add_api_token(token)
                self.logger.info('Restored %d API token(s) from persistence' % len(tokens))
            sessions = self.pickleRead('auth_sessions')
            if sessions:
                n = ws.restore_sessions(sessions)
                if n:
                    self.logger.info('Restored %d session(s) from persistence' % n)
            ws.set_session_changed_callback(self._save_sessions)
        return True

    def poweroff(self):
        self._trending_on_poweroff()
        self._save_sessions()
        ws=self.wserver
        if ws:
            ws.stop()
        return True

    def run(self):
        self._trending_on_run()
        return 0.1


if __name__ == "__main__":
    pass
