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

class MBIOWsMonitor(MBIOWsMonitorBACnetMixin, 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_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
            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 = [
            ('/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_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._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._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) {
                        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._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._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._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._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 class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-taskvalues" type="button">Values</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-taskvalues" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <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>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh values, [CTRL+k] mark values, [CTRL+K] unmark values
                    </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._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 class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-zonevalues" type="button">Values</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-zonevalues" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <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>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh values, [CTRL+k] mark values, [CTRL+K] unmark values
                    </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._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 class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-devicevalues" type="button">Values</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-devicevalues" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <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>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh values, [CTRL+k] mark values, [CTRL+K] unmark values
                    </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="""
        <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: "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" }
                    ],

                    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();
                        var r = await fetch(`/api/v1/markvalue?key=${data.key}`, {method: "GET"}).then(r=>r.json());
                    } catch (_) {
                    }
                }

                async function markZone(zone) {
                    try {
						if (zone) {
							var r = await fetch(`/api/v1/markvaluewithzone?zone=${zone}`, {method: "GET"}).then(r=>r.json());
						}
                    } catch (_) {
                    }
                }

                async function unmarkRow(row) {
                    try {
                        data=row.data();
                        var r = 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});

               // Quand l'utilisateur commence à éditer un input dans le tableau
               $('#items tbody').on('focus', 'input', function () {
					isEditing=true;
                });

                // Quand il termine (blur), on “dégèle” la ligne
                $('#items tbody').on('blur', 'input', function () {
					isEditing=false;
                });

                $("#items tbody").on("change", ".toggle-enabled", async function (e) {
                    //clearInterval(pollingId);
                    e.stopPropagation();

                    const $ui = $(this);
                    if ($ui.data('committing')) return;       // garde-fou
                    $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) {
                                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"});
                                                reloadRow(table, this);
                                                count++;
                                            }
                                            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) {
                                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) {
                                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)
                                            {
                                                v=data.toreachvalue;
                                                if (v === null)
                                                    v=data.value;
                                                fetch(`/api/v1/setvalue?key=${data.key}&value=${v}`, {method: "GET"});
                                                reloadRow(table, this);
                                                count++;
                                            }
                                            else
                                            {
                                                fetch(`/api/v1/setvalueauto?key=${data.key}`, {method: "GET"});
                                                reloadRow(table, this);
                                                count++;
                                            }
                                        }
                                    }
                                });
                            }
                        }

                        reloadRow(table, rowApi);

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

                        if (!res.ok) throw new Error();
                    } catch (_) {
                        //$(this).prop("checked", !enabled);
                        console.log("value set error!");
                    } finally {
                        $ui.data('committing', false);
                    }
                });

                // ENTER => on empêche la soumission et on force le blur (ce qui déclenchera 'change')
                $('#items tbody').on('keydown', 'input.edit-input', function (e) {
                    if (e.key === 'Enter') {
                        e.preventDefault();
                        this.blur(); // provoque un 'change' si la valeur a été modifiée
                    }
                });

                // CHANGE (et donc aussi BLUR si la valeur a changé) => commit
                $('#items tbody').on('change', 'input.edit-input', async function () {
					const $ui = $(this);
					if ($ui.data('committing')) return;       // garde-fou
					$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) {
                            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;
                /*
				var activezone='';
                $('#items tbody').on('mouseenter', 'tr', function () {
					var row = table.row(this);
					var data = row.data();
					activezone=data.zone;
				});
                */

                $(document).on('keydown', function (e) {
                    const $target=$(e.target);
                    const $searchInput = $('#dt-search-0.dt-input');

                    if (e.key === 'c' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) {
                        if ($target.is('input, textarea, select')) return;
                        e.preventDefault();
                        copyDataTableToClipboard(table);
                        return;
                    }
                    if (e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === 'r') {
                        e.preventDefault();
                        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();
                        rows=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();
                        rows=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++;
                                }
                            }
                        });
                    } else if (e.ctrlKey && !e.metaKey && !e.altKey && e.key === 'z') {
                        /*
                        e.preventDefault();
						markZone(activezone);
                        rows=table.rows({ search: 'applied' });
                        if (rows.count()>0)
                        {
                            var count=0;
                            rows.every(function () {
                                if (count<MAXVALUESATONCE)
                                {
                                    reloadRow(table, this);
                                    count++;
                                }
                            });
                        }
                        */
                    }
                });

            });
        </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._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 class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-values" type="button">Values</button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <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 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>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh values, [CTRL+k] mark values, [CTRL+K] unmark values
                    </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._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 class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-valuesmanual" type="button">Manuals</button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <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-valuesmanual" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                        <thead>
                            <tr>
                            <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>
                            </tr>
                        </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh values, [CTRL+k] mark values, [CTRL+K] unmark values
                    </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._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 class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-valueserror" type="button">Errors</button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <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-valueserror" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <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>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh values, [CTRL+k] mark values, [CTRL+K] unmark values
                    </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._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 class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-valuesmarked" type="button">Marked</button>
                    </li>
                    <li class="nav-item ms-auto pb-1 d-flex gap-2 align-items-center">
                        <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-valuesmarked" role="tabpanel">
                    <table id="items" class="display nowrap" style="width:100%">
                    <thead>
                        <tr>
                            <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>
                        </tr>
                    </thead>
                    </table>
                    <div class="text-muted small mt-2">
                    [ / ] search, [CTRL+r] refresh values, [CTRL+k] mark values, [CTRL+K] unmark values
                    </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_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._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._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_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._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">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-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.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._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')
        # 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:
            ws.declare_certificate(['/etc/sysconfig/digimat/credentials', '/var/run/digimat', '/tmp'])
            self._wserver=ws
            if password:
                ws.set_password(password)
            ws.attach_logger(self.logger)
            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('/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_tasks)
            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/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.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)

    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 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))
        return True

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

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


if __name__ == "__main__":
    pass
