beez miniBMS documentation

Command reference

How commands are run

Always through the beez wrapper from the installation folder, which resolves the correct Python (venv first) and is immune to the Microsoft Store stub:

C:\Dev\beez-minibms> beez tools\manage.py <command> [options]

Every command accepts --db PATH to target a non-default central database; you will normally never need it. -h after any command prints its own help.

Scripts in the installation root

ScriptPurpose
run.batConsole mode start. Creates the venv and installs dependencies on first run; detects a busy port 8000 and offers to stop the occupant.
update.bat [zip]The one-action update; see Updating.
onboard-customer.batInteractive onboarding wizard: drag an EDE file in, answer the prompts; wraps the onboard command.
run-simulator.batPublishes demo values over MQTT for the seeded demo tenants; development only.
stop-all.batStops the two services and every process referencing a beez-minibms folder (elevates itself, runs from a temp copy so it never locks the folder). Use before renaming or removing an installation; prints the full removal commands.
release-folder.ps1Releases and renames a locked installation folder: stops leftover services, sweeps holders by command line, path, and loaded modules, then renames to -OLD. Save a copy outside the folder (the Desktop) and right click, Run with PowerShell. Accepts -Path for non-default locations.
beez.batThe Python wrapper used by everything above.
installer\install-windows.ps1One-shot installer, service mode; see Installation.
installer\uninstall-windows.ps1Removes the two services; data stays.

manage.py commands

onboard

Full customer onboarding from an EDE file: tenant, project, folders, points, log flags, and a first user whose password is shown exactly once.

beez tools\manage.py onboard --ede EDE_SAFRAN.csv --username nico.viewer
     [--password ...] [--role viewer|operator|admin] [--tenant safran]
     [--tenant-name "Safran E and D Services Asia"] [--project slug-override]
     [--site override]

--tenant defaults to the project slug embedded in the EDE; set it to group several buildings under one customer.

create-tenant, create-project, move-project

beez tools\manage.py create-tenant --slug safran [--name "Safran E and D"]
beez tools\manage.py create-project --tenant safran --slug safra-sg-tower2 [--name ...]
beez tools\manage.py move-project --project safra-sg-nrise --to-tenant safran

move-project carries the project's points, folder, and project-scoped users along, and deletes the source tenant when it ends up empty.

create-user

beez tools\manage.py create-user --username martial.ops
     [--password ...] [--role viewer|operator|admin|superadmin]
     [--tenant safran] [--project safra-sg-nrise] [--update]

Omit --password to auto-generate a strong one (shown once). Omit --tenant for a platform superadmin; add --project to scope a user to a single building; --update allows changing an existing user (and revokes that user's existing sessions). Security rules, forced password change, disabled, session length, allowed networks, are set from Admin, Users in the console; the CLI does not carry those flags.

list

beez tools\manage.py list

The hierarchy of tenants, projects, and users at a glance.

remove-demo

beez tools\manage.py remove-demo

A fresh installation no longer seeds demo tenants or users at all, it starts with a single admin / admin account instead, so this command has nothing to do on a new install. It remains for an installation that was seeded before that change and still carries the old demo tenants and users forward through updates (updates never touch data).

delete-user, delete-project, delete-tenant

beez tools\manage.py delete-user --username del.viewer
beez tools\manage.py delete-project --project del-sg-x1 [--yes]
beez tools\manage.py delete-tenant --tenant delcust [--cascade] [--yes]

Deleting a project removes its points, history, graphics, project-scoped users and, when done from the Admin tab or the API while the platform runs, the retained broker topics too (so nothing resurrects). Tenants delete only when empty unless --cascade. Both prompt you to type the slug; --yes skips the prompt for scripts. The last superadmin can never be deleted. The Admin tab has the same actions: delete buttons on the users table and the Delete users, projects, tenants card, with typed-slug confirmation. Copy the project folder first if you need a backup; deletion is final.

restore

beez tools\manage.py restore --from C:\Dev\beez-minibms-OLD [--yes]

Replaces this installation's data (central database, tenants, config.json) with a previous installation's; whatever is currently here is backed up first to a timestamped folder. See Backup and recovery.

import-history

beez tools\manage.py import-history --from C:\Dev\beez-minibms-OLD
     [--project safra-sg-nrise]

Merges history samples from a previous installation into the projects already here. Points match by project slug, site, equipment, and point id; duplicates are ignored; safe while the platform runs. The Admin tab card does the same with a scan table and target pickers.

Sites communication (pause, resume, status)

The Sites tab lists every project connected to a bridge with its live link state as reported by the bridge itself over $beez/bridge-status topics: mode (COV or polling), notifications or read counts, last data time, and the last error. Pause takes one site out of its bridge (the bridge restarts automatically without it) while the issue, a site VPN outage for example, is being resolved; Resume puts it back. Each site has a communication log of the last 200 reports. API: GET /api/sites/status, GET /api/sites/{id}/log, POST /api/projects/{id}/pause and /resume (superadmin). Bridges read with COV by default (subscriptions plus a slow heartbeat read, renewed automatically after link loss); each bridge can be switched to classic polling in the bridges editor (Transport). The bridge's local IP is picked from the machine's detected interfaces (GET /api/net/interfaces).

Admin actions with no CLI equivalent

The Admin tab is a set of subpages, one subject at a time: Projects (bundle import, history import, deletions, display units), Bridges (field bus bridges and project connections), MQTT (connection info and topic mapping), Users (accounts and security rules), Audit (the security and configuration log, and the setpoint command log), and further pages for alarms, relations, intent, updates, AI and the Telegram bot.

Creating a bridge, on any bus

Admin, Bridges, "Add or edit a bridge". Give it a name and pick the Bus: BACnet/IP, Modbus TCP or REST/JSON. The list comes from the drivers the server actually has (GET /api/bridges returns them under bus_types), so a bus that is only planned is never offered. Only BACnet binds a local UDP port, so the local IP, transport and port fields appear for BACnet alone; the Bus column in the bridge table reads "none" for the others, which is the truth rather than a port they do not hold.

A bridge needs neither a project nor a map to be useful. A BACnet bridge started empty is the discovery instrument the BACnet browser drives; a Modbus TCP bridge started empty is what the Modbus survey on the Bus Systems page needs before it can sweep unit ids on a target. Register and REST maps arrive later, with a project's bundle.

Calculated power points re-pair automatically at start and after bundle imports; after provisioning new A, V, Temperature, or %RH points over MQTT, superadmins can trigger it with POST /api/derived/refresh.

Calculated dew point, wet bulb, and humidity ratio

Any equipment carrying exactly one Temperature point and exactly one %RH point automatically gets three calculated points: dew point, wet bulb (both degC), and humidity ratio (g/kg). Like calculated power, this is a live recompute on every new Temperature or %RH sample, not a one-time calculation, and the calculated points are never part of a project bundle, they are provisioned and computed by whichever instance the project runs on. If an equipment has more than one Temperature or more than one %RH point, nothing is paired automatically since there is no reliable way to tell which sensor belongs with which; that is the same conservative rule calculated power uses for an ambiguous current or voltage. The calculation always runs in degC regardless of what unit the source Temperature point is stored in, so a chiller reporting natively in degF still gets a correct result; humidity ratio assumes standard sea level atmospheric pressure, disclosed on the point's description, the same approximation every standard psychrometric chart makes. A point's unit, once set by an EDE or bundle import, is never overwritten by a device's own live metadata afterward; a live message only ever establishes a unit that was genuinely still unset. Re-importing the bundle remains the explicit way to correct a unit if a device's EDE entry itself was wrong.

Display units

Under Admin, Projects: any point's value can be shown in a different, convertible unit (Celsius, Fahrenheit, Kelvin; litres, cubic metres, gallons, cubic feet; and the common flow, pressure, energy, and power families) without changing what is stored or how anything is calculated, aggregated, or compared. Three levels, most specific wins: a project default per unit family, an equipment pattern rule (for example chiller* in degF while the project default is degC, the common case where most of a plant speaks Celsius but the chillers themselves run in Fahrenheit), and a point's own override, set inline from the Points tab. History, aggregates, exports, and time in range thresholds all honour the same resolved unit consistently. API: GET/PUT /api/projects/{'{'}id{'}'}/unit-prefs, GET/POST/DELETE /api/projects/{'{'}id{'}'}/unit-rules, PATCH /api/points/{'{'}id{'}'}/display-unit.

Re-importing a bundle to update a project

A new bundle zip for a project that already exists is not blocked, uploading it again refreshes that project: any point already known has its name, kind, unit, and writable flag updated from the new file, and any point not seen before is added. Nothing is duplicated. A same-named graphic is replaced with the new file; a graphic no longer in the new bundle is left on disk rather than removed. History logging selection is only touched if the new bundle gives an explicit list; the default (log everything) leaves the current selection alone. The import summary reports new, refreshed, and total point counts so a re-import's effect is visible at a glance.

Connect MQTT topics to a project

By default a topic's first segment is the project slug (slug/site/equipment/point/value). Under Admin, MQTT, a topic prefix that does not follow that can be mapped to a project directly: values arriving under the prefix route to that project, and the rest of the topic reads as site/equipment/point. The page also lists topic roots the broker has recently delivered, marked mapped or unmapped, to make picking a prefix easier. API: GET /api/mqtt/mappings, POST /api/mqtt/mappings {'{'}project_slug, topic_prefix{'}'}, DELETE /api/mqtt/mappings/{'{'}id{'}'}, and GET /api/mqtt/topics for the recently-seen list.

Audit trail

Under Admin, Audit: sign-ins (including denied attempts), user creation and rule changes, deletions, and MQTT mapping changes, each with who, when, what, and from which address. GET /api/audit?limit= for scripts; a tenant admin sees only events touching their own tenant's users, a superadmin sees everything.

Another superadmin

A superadmin can create another superadmin from Admin, Users, the same form as any other account, just with that role selected. A tenant admin cannot; it is restricted to viewer, operator, and admin within its own tenant.

Analysis workspace below the Trends chart

Everything below the main chart, time in state, time in range, the distribution view, and the setpoint histogram, shares the same three inputs as the chart above it: whichever point or points are selected in the tree on the left, the From/To period, and the Aggregate bucket. Nothing down here has its own separate period or point picker; change a selection once and every card below reflects it. The tree stays docked on the left the whole way down the page.

Time in state, time in range (Trends tab)

How long a point held a value or stayed within a range over the period above: a fan's run time, a door's open time, a setpoint's time above or below a threshold, or a multistate's time in each mode. With more than one point selected, each is broken down on its own. State breakdown takes an optional Step: left blank, it shows every exact value seen, drawn as a bar chart (right for a binary or multistate point, a handful of states); given a step size, it auto-bins a continuous reading into bands of that width across whatever the point actually reported, and draws one bar chart of totals. If the Aggregate above is set to hour, day, or similar rather than raw, each bin instead draws its own bar chart over time, one per band, matching the distribution view below. Gaps over 6 hours are not counted, so a genuine outage cannot claim an implausible duration; this is separate from, and deliberately much longer than, the 30 minute cap used for energy integration. API: GET /api/points/{'{'}id{'}'}/state-totals, GET /api/points/{'{'}id{'}'}/time-in-ranges?ranges=...&bucket=, and GET /api/points/{'{'}id{'}'}/time-in-range?lo=&hi=&bucket=.

Time in range: distribution, and distance from setpoint (Trends tab)

Two further views, both built on defining several ranges at once for the first point currently selected in the tree. Time in range: distribution lets you add any number of ranges (with an optional label) and draw either a pie of totals across the period above, or a bar chart per range broken down using the Aggregate bucket set above, so "how many hours per day was this above 26 degrees" reads straight off the bars. Distance from setpoint asks only for a target value and a step (0.5 by default): bands are generated automatically, centred on the target and running evenly outward both ways, wide enough to cover whatever the point actually reported over the period, and drawn as one bar chart with the band containing the target picked out in the darker brand green. This is the same idea as a SAUTER SVC histogram with distribution calculation, set up with two numbers instead of a full chart configuration. Every bar chart here uses the same colour as the point's own series on the chart above when it has one, and a sample matching none of the given ranges counts toward "other". API: GET /api/points/{'{'}id{'}'}/time-in-ranges?ranges=lo1:hi1,lo2:hi2,...&bucket= (bucket optional, same rule as time-in-range). Several points sharing the same unit are now drawn as one chart with grouped, side-by-side bars rather than a separate chart each; the distribution view's bucketed bars are now one stacked chart per bucket (each range a coloured segment, the whole bar reading close to 100%) instead of a separate 0-100% chart per range. time-in-ranges also takes an optional working-time filter, work_days (comma-separated weekdays, 0 is Monday) with work_start/work_end (local hours, end exclusive): everything outside that window, nights and weekends by default, is excluded from both the numerator and the denominator, so an unoccupied setback period cannot dilute a control-quality result. The Distance from setpoint card exposes this directly with day checkboxes and an hour range, on by default (Mon-Fri, 8 to 18).

Scatter plot (Trends tab)

Checks an operating pattern, the same idea SAUTER SVC scatter plots are built around: plot a heating valve's position against outdoor temperature, and if the points trace the expected curve, the control is behaving correctly; outliers stand out on their own. Uses the first two points selected in the tree, in the order picked (first is x, second is y), aggregated to the Aggregate bucket set above (a real bucket is required, raw samples from two independently-sampled points do not pair up meaningfully) and joined by matching bucket timestamp. An optional linear regression line is fit over the plotted points.

Recreate calculated points for one project (Admin, Projects)

The "Recreate calculated points" button next to the project selector in the Display units card scopes refresh_derived/refresh_psychro to a single project without touching any other project's existing pairings. A tenant admin can use it for any project they can see; the whole-platform pass (used automatically at startup, after a bundle import, and after a bridge reconnect) remains superadmin only. API: POST /api/derived/refresh?project_id= (omit or 0 for the whole platform).

Rolling back an update

Every run of update.ps1 now saves a snapshot of the code being replaced, under backups\snapshot-<version>-<timestamp>\, before touching anything (data, tenants, and config.json are never included, the same rule the update itself follows). Only the 3 most recent snapshots are kept. rollback.ps1 (double-click rollback.bat, or use Admin, Update, Roll back in the console) restores the most recent snapshot, or a specific one by name, and itself saves the version it is about to replace first, so a rollback can always be undone the same way by running it again. API: GET /api/admin/backups to list snapshots, POST /api/admin/rollback {'{'}snapshot: "name-or-blank-for-latest"{'}'} to trigger one in the background, superadmin only, mirroring how the in-browser updater itself is triggered.

Scatter plot without aggregation

The scatter plot no longer requires an Aggregate bucket. With one set (hour, day, ...), both points are averaged to that clock and joined by matching bucket, as before. Left on raw, each x reading is instead paired with whichever y reading is genuinely closest to it in time, since two independently-sampled points are never recorded at the exact same instant; a pair further apart than the Max gap field (5 minutes by default) is dropped rather than forced together. The join is a standard two-pointer merge over both time-sorted histories, linear time even for long raw periods.

Printing the Trends page as a report

The Print report button (top of the Trends tab) opens the browser's print dialog with everything currently built, the main chart plus whatever the cards below have produced, laid out for a page rather than a screen: all input controls, the tree, and the top navigation are hidden, and a header with the generation time, the period, and the points in use is added. Choose "Save as PDF" as the destination to get a file rather than paper. Nothing on the server is involved; this only affects what the browser sends to the printer, so it always reflects exactly what is currently on screen.

Carpet plot

A day-by-hour heatmap for reading a long period at a glance: one column per calendar day, one row per hour (0 at the top, 23 at the bottom), coloured by that hour's average. Built entirely from the existing hourly aggregate (GET /api/points/{'{'}id{'}'}/aggregate?bucket=hour), so no new backend data path was needed. With no Reference value given, colour runs from light to dark green across the period's own observed range; with one given (a setpoint, for example), colour instead runs blue below it through white at it to green above it, scaled by the largest deviation actually seen, so a room that ran warm and one that ran cold both show clearly against the same reference. A month or longer thins the day labels the same way SAUTER SVC's own carpet plot does, to keep them legible; a missing hour is left blank (a faint diagonal hatch) rather than guessed at.

Flow chart (Sankey)

Shows how a quantity, typically energy, distributes from a source down through sub-meters: building electricity into floor 1 and floor 2, then into individual meters, the same shape as SAUTER SVC's own Sankey example. Select up to 16 points above as nodes, then give each one a parent in the table that appears (a node left with no parent starts its own tree, so more than one separate tree in the same chart is fine). For a cumulative meter (kWh, Wh, m3) a node's value is its consumption over the period above; for a power point it is the integrated energy; for anything else it is the period's average, since summing an average would not mean anything. Link width is proportional to value, and a parent's outgoing side is always divided exactly among its children in proportion to their own values, even when real metering does not add up perfectly. Built as a custom SVG rather than a Chart.js chart type, since Sankey diagrams are not one of Chart.js's core chart types.

The Trends toolbar

The row of controls above the main chart is now two lines and stays pinned to the top of the page as the (now quite long) column of analysis cards below it scrolls, so the period, chart type, and aggregate are always reachable without scrolling back up. The first line is period and point-scope: From, To, the quick-select buttons, Load, and Compare two periods. The second is display and output: Print report, Chart type, Aggregate, and Export CSV.

One graphic per page in the printed report

Print report now requests a landscape page and gives every graphic its own page, sized to use as much of it as possible: the main chart, and each analysis card below it that has actually produced something. A card never used prints nothing and does not consume a blank page. Chart.js canvases stretch to fill the height given to them since this app turns off their aspect ratio everywhere; the carpet plot and Sankey diagram, being custom SVG/grid renderers rather than Chart.js, get their own explicit full-page sizing rules.

AI (Claude API) key, Admin

A new Admin subpage, superadmin only, for the Anthropic API key the AI tab uses. The key is write-only from the API's own perspective: once saved, no endpoint, response, or audit entry ever includes it again, not even masked or truncated; the page can only report whether one is currently configured. Saving a new key replaces the old one. API: POST /api/admin/ai/key {'{'}api_key: "..."{'}'}, GET /api/admin/ai/key/status (returns only {'{'}configured: true|false{'}'}), DELETE /api/admin/ai/key.

AI tab

A plain-language question about one project becomes a short dashboard. Claude is given the project's point list and three tools that each wrap an existing analysis function directly, on the server, against real data: hourly or coarser aggregates, time spent in given ranges, and time at each distinct value. It reasons over what it finds, then returns a short summary plus a handful of chart specifications; the browser renders those using the same endpoints the Trends tab itself calls, so anything shown here was reachable by hand too. Point access is scoped to whatever points already exist for that project, the same visibility rules as everywhere else in the console; the model cannot query a point outside the ones it was shown, whatever it is asked to do. A run that spends 6 rounds of tool calls without reaching an answer stops with a clear message rather than continuing indefinitely. API: POST /api/ai/analyze {'{'}project_id, prompt, hours{'}'}.

Points tab

Rows are lightly tinted by kind (green sensors, blue setpoints, violet statuses) so the list reads at a glance. Every column header sorts on click (click again to reverse), and the columns for tenant, project, site, equipment, point and kind carry an Excel-style value filter: the small arrow opens a checkbox list of every distinct value, pick what to show, All/None for bulk, and Clear filters in the toolbar resets everything. Group by turns the flat list into collapsible groups for tenant, project, site, equipment, point or kind; click a group header to fold or unfold it, and the search box keeps working inside whatever view is active.

The COV column shows, per point, what the bridge's subscription actually did, straight from the status feed: a blue dot means the device accepted the subscription and its task is alive but no notification has arrived yet; a green dot is the definitive confirmation, notifications are actually flowing; an amber dot means a previously accepted subscription lapsed and the bridge's heartbeat loop will renew it; a red dot means the device refused the subscribe call, with the reason in the tooltip; a hollow dot simply means there is no COV state to show (a polling project, or a bridge that has not reported since starting). The state map arrives inside the retained per-project bridge status, so it survives console restarts.

Alarms tab

The site selector and the severity cards stay at the top; below them the page is four subpages. Live is the current alarm table with the quality (ISA-18.2) figures beside it, History the raises and hours in alarm over a window, Log the maintenance and observation entries, and Rules the advanced rules and the automatic FDD opt in. Each loads when it is opened, so the page shows one subject at a time instead of six stacked down a long scroll. The same pattern now covers Trends (its six analyses), Control, Bus Systems, Maintenance and Admin.

Alarms are evaluated continuously. A supervised worker process (Phase 1a of the AI-Native roadmap) watches the live MQTT stream and re-runs the exact same rule engines as before, basic rules, sensor plausibility, advanced rules and the bridge-polled device alarms together, every 10 seconds by default, so the platform notices problems by itself instead of only when someone opens this tab. The tab is a live view of that engine, refreshed every 5 seconds while open; the severity cards (Critical, Warnings, Sensor faults, Device alarms, Info) remain clickable filters over the combined table, click the active card again to clear, and shelved alarms are excluded from the card counts by design. If the worker is offline or disabled, the tab falls back to the on-demand snapshot evaluation it always had and says so in the status line. API: GET /api/alarms/live?project_id=.

The ISA-18.2 lifecycle. A condition must persist for 30 seconds before it raises, and be absent for 60 before it clears, so alarms cannot flap; both windows are tunable in config.json under alarm_worker (min_active_s, clear_s), together with the cadence (eval_interval) and an enabled switch. A raised alarm is Active Unacknowledged (a blinking UNACK chip) until an operator acknowledges it (steady ACKED, with the operator's name). If it clears while unacknowledged it latches as Return-to-Normal Unacknowledged (RTN) rather than vanishing, so a transient fault at 3 am is still visible at 8 am; acknowledging releases it. Shelving suppresses an alarm for a chosen number of hours (30 minutes to 7 days) while it keeps being evaluated silently; on expiry it re-raises unacknowledged if still active. Acknowledge and shelve are operator actions, audited, and survive worker restarts: the worker restores its lifecycle from its own retained MQTT snapshot and reconciles against the database. Every transition is recorded in an append-only history. APIs: POST /api/alarms/ack, POST /api/alarms/shelve, POST /api/alarms/unshelve, GET /api/alarms/events?project_id=.

Alarm quality (new in 2026.07.19-13). Three mechanisms keep the alarm list honest, and a panel keeps score. When a site's field link dies, the worker raises exactly one critical communications alarm and holds everything else for that project: point evidence over a dead link is neither fresh enough to raise on nor fresh enough to clear on, so held alarms keep their lifecycle state, nothing false-clears during the outage, and every debounce restarts when the link returns (a Guideline 36 suppression in miniature that needs no topology graph; projects fed by plain MQTT with no bridge are never comm-alarmed). When five or more alarms are active on one equipment, a flood alarm collapses them: one warning naming the count and the first out, the earliest raiser, which is the best cheap cause signal a flat point list can give; the grouped rows sit folded beneath it in the table, one click away. An alarm that raised ten or more times in 24 hours gets a calm info alarm of its own naming it a bad actor, with hysteresis so the detector itself cannot chatter (it clears below six). Thresholds live in config.json under alarm_worker (flood_n, chatter_n). The Alarm quality panel on the tab scores the system the ISA-18.2 way: annunciations per day against the roughly 150 one operator can actually work, peak hour, percentage of hours in flood, median acknowledgement latency, standing alarms older than a day, and the top ten bad actors of the week. API: GET /api/alarms/kpi?project_id=&days=.

Equipment logic (new in 2026.07.19-14). Five contradiction checks run per equipment, each naming a physical impossibility an operator can verify by walking to the machine: heating and cooling commanded together; commanded on but the run status disagrees; running on near-zero current; a valve saturated above 95% that still cannot bring its supply within 2 K of setpoint (direction-aware: a saturated cooling valve matters when the temperature sits above setpoint, heating when below); and low water delta-T, under 2 K across a chilled or hot water circuit at 80% plus load, the classic fouling and bypass signature (deliberately water-only: air-side delta-T has economizer physics these checks must not pretend to understand). The checks need to know which point plays which part, so the platform guesses roles from names, conservatively: one point per role, anything ambiguous stays unassigned, because a false contradiction burns trust faster than a missed one. Every guess is admin-overridable under Admin, Alarms, Equipment logic roles: pin any slot to a point, set it to none to disable that role for that equipment, or revert to auto; manual decisions are never overridden and the live engine picks changes up instantly. Every check demands fresh values (15 minutes) for every point it reads; missing or stale evidence means no finding, never a guess. Findings arrive as warning severity in the equipment category and carry the full ISA-18.2 lifecycle like everything else. APIs: GET /api/equip-roles, PUT /api/equip-roles, DELETE /api/equip-roles.

Anomalies and drift (new in 2026.07.19-15). The label-free statistical layer: every claim is made against the point's OWN history, and every claim is deliberately humble, because "unusual for a Tuesday at 14:00" is a different statement from "wrong". Baseline deviations: each numeric sensor gets a robust profile per workday-or-weekend and hour-of-day bucket, the median and MAD-derived spread of its per-hour means over 28 days (robust on purpose: one stuck afternoon in the history cannot poison a median the way it poisons a mean); a live value scoring beyond six spreads raises an info alarm in its own anomaly category, with a ten minute raise and fifteen minute clear debounce so a transient can never be an anomaly. Per-family spread floors stop naturally flat points from alarming on noise, buckets need at least twelve samples before they count, and setpoints, binary states and alarm flags are never baselined (an operator changing a setpoint is not an anomaly). Flatline: a sensor whose timestamps stay fresh but whose value has not moved for six hours, while its own baseline proves it normally varies, is a frozen sensor, raised as a sensor fault; fresh timestamps are what distinguish it from a comms loss (the -13 hold owns that) and from stale data (the stale check owns that). Drift: Theil-Sen slopes over daily aggregates, where an outlier day cannot bend the line, watch for declining water delta-T across the circuits the equipment roles already know (the fouling and bypass signature, water-only by design) and for creeping running current on motors; both need fourteen days of history and report in plain units per week. Baselines rebuild nightly from each project's own history file; installs on the InfluxDB historian simply skip the layer for now, gracefully. Tuning lives in config.json under alarm_worker: anomaly_enabled, anomaly_sigma, flat_hours, baseline_days.

Tier 3: cause, memory, and honest prediction (new in 2026.07.19-16). Four pieces. The maintenance and observation log is the label factory: a Log button on every alarm row and an Add observation button on the tab open a twenty second form (a cause from a fixed vocabulary, a note, the action taken), and each entry can bind to the exact alarm activation it closes, key and raise time included, which is precisely the join a supervised failure model will need in two or three years; until then it is the equipment's memory. The drift detectors learned to forecast: when a delta-T decline or an amps creep trend would cross its practical limit (1.5 K of delta-T, 20% over the running median) within half a year, the finding says so in weeks, plain regression on a KPI, capped where extrapolation stops deserving belief. Proposed relationships: mining over the raise history finds equipment pairs whose alarms co-occur far beyond chance, with direction (who raises first) and typical lead time; admins confirm real plumbing or dismiss accidents, decided pairs stop being proposed, and confirmed links (equip_links) are the first edges of the topology graph that full Guideline 36 suppression will one day stand on, built from the building's own behavior. And Explain: one button on any live alarm assembles a deterministic evidence bundle (the alarm, the equipment's role snapshot with fresh values marked fresh and stale marked stale, the surrounding event timeline, confirmed relationships, the maintenance history, active drift) and asks the configured Anthropic model for at most three ranked hypotheses, each tied to specific evidence with a concrete check; the bundle itself is shown beneath the answer, so nothing arrives unverifiable. Explain needs the API key under Admin, AI and says so politely when it is missing. APIs: POST /api/maintenance, GET /api/maintenance, GET /api/alarms/coincidence, GET/POST /api/equip-links, POST /api/alarms/explain.

The digital twin (new in 2026.07.19-17). Phase 2 of the AI-native roadmap: every equipment now carries a dynamic machine passport. The passport holds the asset's Brick class, every point with its own Brick class and unit, the wire endpoint of each point (tenant/project: site/equipment/point), the role map the contradiction checks already use, confirmed feeds relationships in both directions, and the list of monitors this platform actually runs on that asset, derived truthfully from the roles and point classes rather than promised abstractly. Tagging is conservative by construction: a curated subset of Brick, evidence required, and ambiguity walks UP to an honest parent class (Temperature_Sensor, Sensor, Equipment) instead of sideways to a wrong leaf. Everything is derived at read time and every tag is overridable in the Digital twin card under Admin, Alarms; manual decisions are never overridden, mirroring the role editor. Export the whole twin as Brick Turtle or JSON; import a model in the same simple subject-per-line dialect (a full Turtle parser is a dependency this platform does not need yet, and the limitation is stated rather than hidden) to seed equipment classes and feeds links, superadmin only, audited. The AI tab gained a get_equipment_context tool that hands the model a passport before it calculates, so its reasoning is grounded in the physical layout of the plant. Schema v14 adds the two manual override tables (point_tags, equip_profiles). APIs: GET /api/twin, GET /api/twin.ttl, PUT /api/twin/point-class, PUT /api/twin/equipment-class, POST /api/twin/import-ttl. The complete reference for the alarm side of the platform, every layer, threshold, topic and knob, now lives on its own page: the Alarms reference in the navigation above.

The knowledge graph (new in 2026.07.19-18). Phase 3 of the AI-native roadmap: the digital twin becomes a queryable RDF graph, built live from the same build the twin endpoints use, so it can never drift from the twin. Vocabulary, deliberately small: equipment nodes typed with Brick classes and labelled, brick:feeds topology, brick:hasPoint to typed point nodes each carrying brick:hasUnit, beez:kind and, crucially, beez:dbId, the numeric point id every historian tool takes, plus derived brick:Site nodes with brick:hasPart. Two engines behind one front door: a dependency-free built-in engine answers SELECT over basic graph patterns with PREFIX, the a shorthand and LIMIT, everywhere, always; install rdflib on the server (one line, uncommented in requirements.txt, or pip install rdflib in the venv) and the same front door speaks full SPARQL 1.1, FILTER and all. Every answer names its engine, and the built-in engine refuses what it cannot do by telling you exactly that. The AI tab now grounds before it calculates: a query_knowledge_graph tool sits first in its toolbox with the standing instruction to resolve topology, classes and point ids from the graph FIRST and only then hand those ids to the historian, which is the specification's semantic bootstrapping delivered as behavior. Humans get the same engine: a SPARQL box inside the Digital twin card, and POST /api/graph/query (admin, audited). Try: SELECT ?e ?p ?id WHERE { ?e a brick:Chiller . ?e brick:hasPoint ?p . ?p beez:dbId ?id . }

Readable answers, wider answers, and the health views (new in 2026.07.19-19). Five quality upgrades. AI answers now render structured: numbered steps and bullets each on their own line, bold where the model marks it, in the AI tab and in Explain alike, and the model is instructed to write that way. The AI can now answer broad advisory questions ("how do we save energy in this building"): two new tools give it the whole picture in one call each, get_energy_overview (every power and energy point integrated to kWh over the window, ranked, with equipment attribution) and get_operations_overview (active alarms by severity and category, KPIs, drift with forecasts, worst anomalies, recent maintenance), the reasoning budget doubled, and the standing instruction is to gather evidence first, then give prioritized recommendations tied to it, honest about impact. The Alarms tab gained an Alarm history card: raises per day stacked by severity, hours in alarm per day (an episode counts on the day it started; shelving never splits an episode, the condition was still there), and the ten alarms that spent the most time active, over 7, 30 or 90 days (GET /api/alarms/history-stats). Bus Systems gained an always-on health chart: telegrams per second, five minutes deep, with now, one-minute average and five-minute peak chips, storm highlighting, and a top-talkers row by topic head, two dictionary writes per message on the hot path (GET /api/bus/traffic); a steady baseline is healthy, a sustained plateau far above it is a COV storm or a runaway poller and the top talkers name the suspect. And every AI analysis can now leave the screen: Print / save as PDF opens a branded, print-clean report, the question and analysis first, then every chart on its own page, nothing cut in the middle, ready for the browser's save-as-PDF.

Generative UI (new in 2026.07.19-20). Phase 4, the last rung of the AI-native roadmap, delivered as a declarative contract rather than a frontend framework. The specification suggests CopilotKit or the Vercel AI SDK; this platform is a dependency-free, build-free vanilla application, and bolting a React agent framework onto it would have traded its whole deployment discipline for a feature that a strict JSON vocabulary delivers outright. So: the model may now return a views array alongside its summary, choosing freely from a schema-validated vocabulary of charts (line, bar, stacked, area, scatter, pie, doughnut, with point-referenced series the platform fetches as real historian data, inline series only for values the model computed from tool results, second axes and threshold lines), stat card rows with warning limits, data tables, structured text, hand-drawn SVG schematics, and follow-up question chips that continue the conversation with one click. Every view is validated server-side and dropped with a visible warning when it cannot be verified; SVG passes a whitelist sanitizer twice, once on the server and once in the browser, so scripts, styles, event handlers, external references and entity tricks never reach the DOM, while shapes, paths, text, gradients and markers (enough to draw a chiller plant) pass untouched. Point-referenced series make fabrication structurally difficult: the model names ids, the platform fetches the numbers. The Print / save as PDF report carries every view kind, each on its own page. And the hybrid co-existence the specification asks for: an Ask AI panel now lives on the Trends page (it tells the model which points the operator is looking at) and on the Graphics page (it names the equipment on screen), rendering full generative views inline, so manual exploration and generated views sit side by side.

The semantic alignment (new in 2026.07.19-21, schema v15). Three layers from the semantic schema, all derived at read time so nothing migrates and nothing drifts. Systems: the topic path's second segment is a system category (air, cld, uti and friends) under the platform's own convention, so the graph now carries one brick:System collection per category with rec:includes membership, correcting the earlier brick:Site typing. Controllers: the EDE always carried each point's hosting BACnet device instance and the importer now persists it, so the graph grows brick:Controller nodes with brick:hosts edges to every point they expose; existing projects backfill by simply re-importing their original EDE, since the import is an upsert. Control loops: the CTRLont layer, proposed by logic. Every loop whose members the role map can prove (the controlled sensor, its setpoint, the actuator) is proposed as a ctrl:ControlActor with evidence and confidence; heating and cooling sides are separate actors sharing sensor and setpoint; command plus proof becomes a start_stop actor; zone loops use a stated temperature fallback; a global bms setpoint alongside matching equipment loops proposes a supervisory reset at low confidence, saying plainly that a point list cannot prove a sequence. The loop's internal logic (gains, deadbands, order) is never inferred and is marked unknown until a sequence document fills it. A Control loops table in the Digital twin card confirms or dismisses each proposal in one click, audited and reversible; dismissed loops leave the graph and the exports; confirmed and proposed loops are SPARQL-queryable and ride the Turtle export with the new rec and ctrl prefixes. APIs: GET /api/loops, POST /api/loops/review; the twin endpoints carry the loops inline.

The Guideline 36 layer (new in 2026.07.20-1). The schema's Section 8, and the payoff of the two review workflows. One: hierarchical alarm suppression walks the operator-confirmed feeds relationships, so an upstream hard fault dims its downstream consequences (full lifecycle kept, annotated and counted, never hidden; meta alarms exempt; anomalies suppressible but never suppressors; chains always point at the root because a suppressed alarm never suppresses). Two: a Trim and Respond advisor reads the supervisory reset loops the loop review kept, counts requesting member actuators from fresh historian values, applies the G36 arithmetic and clamps every recommendation inside the setpoint's own observed 30 day range, advisory only, on GET /api/g36/advisor with a card beside the loop review. Both layers cost a site nothing until its humans have confirmed topology and loops, which is exactly the incentive the reviews deserve.

Virtual sensors (new in 2026.07.20-2). The last diagnostics layer of the schema, Section 8.2: model-based sensor FDD. Nightly, on the same hourly rows the baseline builder already reads, the worker fits robust univariate lines for every relationship the semantic layer proved (supply from return, from actuator position, from amps on one equipment; downstream supply from its confirmed upstream feeder), keeping only models with enough paired hours and real skill. Live, each modelled reading is scored against its fresh independent estimates, and the deliberate univariate design is what buys discrimination: all estimates disagreeing one way convicts the sensor; one estimate disagreeing while another vouches convicts that estimate's input; a single estimate or split directions stays an honest info-grade disagreement. Findings ride the normal lifecycle as virt: keys with anomaly-grade debounce, land in the Explain timeline for free, and a maintenance log entry against one is a labelled sensor fault for the label factory. Knobs: alarm_worker.virtual_enabled and virtual_sigma (default 5). This closes the pre-autonomy roadmap; what remains ahead is shadow mode, safety envelopes and, gated on live schedule-write validation, the first bounded closed loop.

Multi-project user scope (new in 2026.07.20-3). A user can now be granted an explicit set of projects instead of the old all-or-one choice: nothing selected keeps the classic tenant-wide account, one selection behaves exactly as project users always did, and several selections give one login a portfolio (every Hyatt property under the hyatt tenant on one account, or any subset). The Create user card takes a multi-select, every existing user row gains a scope button with an inline editor, and PUT /api/users/{id}/scope replaces the memberships, revoking the target's sessions through the same token generation bump a role change uses. Isolation is enforced from one place (the membership set is resolved from the database on every request, including websocket and export tickets, and an empty set fails closed), all listings and guards filter on it, and deleting a project removes only that membership, deleting the user only when this project was their sole scope, which preserves the old single-project semantics exactly. Existing single-project users migrate automatically (schema v16). What deliberately does not change: project slugs stay globally unique, because the slug is the riza1 site-id and the first MQTT topic segment; the grouping construct for a customer's buildings is the tenant, and this feature adds the missing middle ground inside it.

The Graphics navigator (new in 2026.07.20-4). A real hotel arrives with hundreds of mimics, and a flat dropdown cannot carry that. The Graphics tab is now a navigator: pick the project, land on the main view (the overview SVG), and drill down through the architecture the names already encode. A sidebar tree derives from the semantic layer and the graphics inventory: one branch per system, equipment grouped progressively by name segments (device blocks like pau-dev-9001 bucket by their leading digit, unit runs like fcu-h-q-1 through 35 chunk into ranges), never leaving a flat list longer than two dozen entries, with a search box for going straight to a unit. Breadcrumbs track the trail and jump back anywhere. Groups open as tile boards (name, mimic indicator, live alarm badge) unless the bundle ships an authored board SVG whose stem equals the group key, in which case the authored board wins with a tile toggle beside it. Every SVG is click-navigable: elements carrying data-nav link to any equipment, group or view by name, and every data-path bound value on an overview or board is automatically clickable through to its equipment, so existing bundles gain full drill-down with zero rework. The bundle format is unchanged. Endpoint: GET /api/projects/{id}/gfx-nav.

The Points navigator (new in 2026.07.20-5). The same architecture tree that organizes the Graphics tab now organizes the point list. The Points tab gains a sidebar: one branch per project, expanding lazily into systems, groups and equipment from the same navigation model, and clicking any node filters the table to exactly that scope (a project, a system, a group like fcu-h-p-1, or one equipment), shown as a filter chip with its equipment count and a one click clear that also lives on the existing Clear filters button. Text search, column filters and group-by all keep working inside the scoped view. Every equipment row in the tree carries a small arrow that jumps straight to that equipment in the Graphics tab, so the list and the pictures are two views of one navigation. On the pictures themselves: graphics serving and the equipment mimic path were verified end to end against the Hyatt Regency Danang bundle, including a re-import over the existing project that refreshed all 396 SVGs; a project that shows only live panels simply has no graphics files yet, and one bundle re-import into its tenant fills them, since the import always refreshes the graphics folder.

Navigator polish (2026.07.20-6). Three fixes from first field use. Breadcrumbs are now the tree path, not the click history: every view's trail is recomputed from its ancestry in the navigation model, so hopping between sibling units replaces the tail instead of stacking views one behind another, and every ancestor crumb is a real jump target. The navigation model is now cached server-side: built once per project on first request and invalidated only by a bundle import or a project deletion (and naturally by a restart), so the tree serves in single-digit milliseconds after the first build; on the Hyatt Regency Danang project the measured cold build is about 80 ms and cached serves run 5 to 8 ms. The client stops rebuilding the Points and Graphics trees on incidental point-list refreshes, only when the visible project set actually changes, so open branches stay open while live values stream. And when a project has no graphics files at all, the equipment panel now says so plainly and names the remedy: import the project's bundle, and a re-import over an existing project refreshes the graphics folder without touching its data.

Maintenance, the CMMS core (new in 2026.07.20-11). The equipment list the points already define becomes a maintenance system. Recurring plans generate automatically from each equipment's Brick class using market standard intervals (AHU filters every 6 months, cooling tower basins quarterly, chiller tube cleans yearly, and so on), at bundle import for new projects and on one click for existing ones, never duplicating what exists; an hourly scheduler turns every due plan into a task. Tasks are the unit of work, born from an assigned alarm (anomaly and prediction findings arrive as alarms and assign the same way, mapping to the value and prediction origins), from a due plan, or by hand, and every task is attributed to a user: the assignee may be a viewer, because doing the work and proving it needs no operating rights, while assigning and creating need operator. The lifecycle enforces proof: photo before, photo after, or the task cannot close as done; blocked parks it with its reason (the missing part); cancelled closes it with the reason when repair is not the answer. Completed tasks land in the maintenance log automatically, feeding Explain and the label factory. Equipment documents attach per equipment, and the executed maintenance report covers any two dates with a CSV export. The AI gains get_maintenance_overview and is told to weigh maintenance in every analysis. Roles recap, confirmed: viewer sees everything and changes nothing; operator adds alarm acknowledgement and shelving, setpoint writes on Approved automation points, and maintenance assignment.

The maintenance hub (2026.07.20-12). Maintenance splits into five sub tabs: Equipment, Tasks, Recurring plans, Documents and Reports. Equipment is the hub: the same architecture tree as the other pages on the left, and one card in the middle summarising the selected equipment end to end: an editable asset record (brand, model, serial, install date, location, notes; operator saves), its recurring plans, open work, documents, every point with live value, the last executed maintenance and the recent history. Plan templates widen (fans, heat exchangers, valves, dampers), and every equipment always receives at least a yearly inspection through the fallback, at import, automatically. Sensor faults (virtual sensor findings) and critical equipment alarms now maintain themselves into tasks: a five minute reconciler opens an attributed-to-nobody task for each, never for suppressed consequences, and when the condition clears it closes only tasks automation opened and nobody touched, with the closure recorded as automatic; a task a human started or blocked stays theirs. And closing an alarm born task as done acknowledges the alarm on the operator's behalf, signed maintenance, so the loop alarm to task to proof to acknowledgement is one motion.

Guest room phone views (2026.07.20-18). A hotel room, in platform terms, is the equipment that serves it: the FCU or zone whose name already encodes the room. Rooms therefore derive automatically at bundle import (any equipment of a room serving class or carrying a room temperature role), each with its own access token. The phone page at /g/{token} generates itself from the role map, no pictures to author: headline room temperature, a clamped setpoint control (comfort band configurable under guest sp_min and sp_max, default 20 to 26) when the room's setpoint is writable, then the honest readings. Guest writes travel the platform's one and only audited write path, signed guest:{room}, rate limited to one change per two seconds. The QR code and an NFC tag carry the same link; regenerating the token in Admin, Guest room links revokes every copy, and the Print QR sheet button produces the per room handout for the hotel. Guests never receive platform accounts; the token is the credential, scoped to one room. Moods, dining and guest profiles belong to the app layer above, by design.

Modes and the write path (2026.07.20-47). With the /set priority extension signed, loops now command the plant: bind a writable OUT point, press Test and the engine freezes the current output at priority 1 before writing its own commands visibly below it; press Auto (typed loop name) to release the freeze and hand over; press Off any time, no questions asked. Fallback on fault is release, hold or preset per loop, a test ends itself at its ceiling, and after any crash the engine releases exactly what its holds ledger recorded, never more. The wire default priority is 16.

Loop objects for third parties (2026.07.20-46). Set loop_objects true on a bridge in config.json and that bridge's BACnet device serves every control engine loop as a standard, read only Loop object: any third party client can browse and read the control layer (OUT as present value, PV, SP, health as reliability, and tuning constants when the program is a single PID with literal values). Instance numbers are the platform loop ids. Writes from outside are refused.

Shadow execution (2026.07.20-45). The control worker now runs every bound loop against live values on its scan cycle: give a loop a PV point and a setpoint (local value, existing point, or the cascade parent's output) and the Control tab shows its live health, PV, SP and computed OUT, while the same values arrive in Trends as calculated points labelled shadow. The engine writes nothing to the field: outputs are recorded, never commanded, until the priority extension is signed and the mode choreography ships.

The Control Engine begins (2026.07.20-44). The Control tab authors loops in IEC 61131-3 Structured Text with a fixed function block library; saving compiles and names any error's line and column. The PID block carries an ACTION argument (1 for reverse acting cooling), clamping anti windup and bumpless transfer. Evaluate any saved loop against the built in first order plant (gain, tau, ambient) to watch a cooling process settle before a single field write exists: this release is wire silent by design, and the engine process, modes and priorities follow.

Built in checks join automatic FDD (2026.07.20-43). Setpoint deviation and the other comfort checks are generated by the platform, not by rules, so their opt in lives on the project: the Automatic FDD card on the Alarms rules panel lists the diagnosable checks with one checkbox each. Tick setpoint deviation and every such raise queues the same diagnosis a rule alarm gets, same cooldown and concurrency policy, same persisted result behind the Diagnosis action. The manual Diagnose button needs no opt in at all: it works on every alarm row, basic or rule, today.

Automatic FDD (2026.07.20-42). Tick Auto diagnose on raise when creating an alarm rule, or flip FDD auto in the rules list, and every raise of that rule queues the diagnosis by itself. The policy protecting your API bill: one automatic diagnosis per equipment per cooldown window (ai.auto_fdd_cooldown_h in config.json, default 6 hours), at most two automatic runs at once, and skipped runs name their reason in the server log. Results persist: any alarm whose rule has a stored run shows a Diagnosis action, and opening it renders the stored answer on the AI page with save as dashboard available. A failed run tells you why in place. Rules left unticked never spend a cent.

Alarm root cause, FDD (2026.07.20-41). Every alarm row carries a Diagnose action. It sends the alarm to the AI page as a normal analysis job with a purpose built prompt: walk the equipment's upstream feeds chain and compare commanded against achieved at every hop (valve and damper positions against supply and zone temperatures, setpoints against achieved values) over the alarm window, then conclude with the single most probable cause and its evidence. The chain comes from the relationships you confirm under Admin, Proposed relationships: the platform mines candidate links from alarm co occurrence, and an operator confirms the real plumbing once (chiller feeds AHU, AHU feeds VAV). Unconfirmed links are labelled as such in the answer, and a project with no confirmed topology gets an honest note instead of a guessed chain. The result behaves like any AI answer: live progress, cancel, and save as dashboard.

Analyses as jobs (2026.07.20-38). Asking the AI now returns immediately and the page follows the work live: the status line names each step as it runs, Cancel stops a run between steps, and no reverse proxy timeout can kill a long analysis anymore because no single request outlives a few milliseconds. Deep questions on slow models may run several minutes; the answer arrives when it arrives, and bzlogs("ai") in F12 carries the full step history.

Model selection (2026.07.20-35). Superadmins set the platform model under Admin, AI: List models shows what the configured key can use, or type a model identifier directly; the choice applies to the next question immediately. On the AI page every question can override it with its own picker, so a deep analysis can go to a stronger model and a routine check to a faster one, and the same question can be compared across models. Every answer and every trace line names the model that produced it.

Dashboards and printable reports (2026.07.20-34). Any AI answer can be saved under a name (Save as dashboard on the AI page) and lives in the Dashboards tab: pick it, set from and to or use the 24h, 7d, 30d pills, and every chart and view re-runs over that window; Print to PDF produces a branded report with the dashboard name, the period and the print stamp, embedded graphics showing live values and saying so. Deleting a dashboard asks for its exact name. The AI itself now reports honestly when a step times out (raise ai.request_timeout_s in config.json for very deep questions), knows the floors and rooms, and can name project graphics for its reports. For field diagnosis, open F12 and type bzlogs() for the full diagnostics ring or bzlogs("ai"), bzlogs("api"), bzlogs("ws") for one category.

Rooms first navigation (2026.07.20-33). A read only value whose equipment belongs to a room drills to the room page, not the bare equipment: the floor board, the overview and every other graphic land the operator on Room 0502 with its authored graphic, live aggregate and controls. Equipments outside any room keep the plain equipment drill. Authored graphics may also link explicitly with data-nav="room:0502" or data-nav="floor:05" to make a whole plan cell one clickable target.

Commanding from graphics (2026.07.20-32). Every bound value on every graphic is now a control when it can be: clicking a writable point as operator or above opens the command popup in place, with the point's own state words as one click commands for status points, the numeric field for setpoints and analog commands, and Release control, which writes a BACnet NULL through the audited write path so the device falls back to its next priority or its own logic. The bridge issues commands and releases at one write priority (16). Read only values keep the automatic drill to their equipment page, and the popup carries an Open equipment page link for the same jump. Room page toggles and setpoint steppers use the identical write path, so every command lands in the audit trail with user, previous value and timestamp.

Authored room and floor graphics (2026.07.20-30). Bundle graphics may be named after rooms and floors instead of equipments: a stem whose dash delimited segments contain an existing room number binds to that room (guestroom-0501 becomes the Room 0501 page graphic; guestroom-0501-air-fcu and the ltg circuit files become its named sub views, offered as a button row on the room page), and floor-N binds to its floor board with or without zero padding. Bound graphics leave Other views, render above the live room aggregate or the floor tiles, and stay live through the normal data-path bindings. Anything that matches no equipment, group, room or floor remains listed under Other views, never hidden. The Points tree additionally carries a small arrow on every equipment and room row that jumps straight to that object in Graphics.

One tree on every page (2026.07.20-29). The three lens object tree (Systems, Logical, Technical) is now the one and only navigator on Points, Trends, Graphics and Maintenance, and the Alarms Place filter uses the same component. Trends pairs the tree with its point list: pick any node (a system, a group, a floor, a room, a location or a single equipment) and the list below narrows to the points beneath it, where clicking a row still selects it for the chart with its series color, unit chip and per point chart type; the name and unit filters apply to that list, and lists longer than four hundred points ask for a narrower node instead of drowning the page. Graphics keeps its Overview entry on top, its Other views below and its alarm badges on equipment leaves; Maintenance rebuilds the hub tree and clears the detail pane whenever the project changes. The lens tabs share the page sub tab grammar, left aligned with the underline marking the active lens.

How the worker fits the architecture. It is a plain Python subprocess in the same pattern as the BACnet bridge: the platform starts it, restarts it with backoff if it exits, and logs it to data/alarm-worker.log. It opens the database strictly read-only and publishes ISA transitions over MQTT; the main application remains the only database writer, exactly as everywhere else, so the historian's single-writer discipline is untouched. One honest v1 limit: the worker follows the canonical five-segment topic convention (slug/site/equipment/point/value); projects fed through an explicit topic-prefix map keep on-demand evaluation until a later increment.

Basic rules are built in, not stored rows, and self-discover across a project rather than needing per-point setup: any point flagged as a device-native alarm reading 1; any %RH point above 75% or below 20%; any ppm point (CO2) above 1000; any equipment with exactly one sensor and one setpoint of the same convertible unit family further apart than a generous tolerance; any point that has not reported in 24 hours; any point that looks like a battery reading by unit and name, below 20%. A blanket temperature threshold is deliberately not included: 35 degC is normal on a condenser and a problem in a guest room, so a rule needing that kind of context lives in advanced rules instead, where it names a specific point. API: POST /api/alarms/run-basic?project_id=.

Sensor plausibility runs first among the basic rules: a reading that is physically impossible for what the sensor measures is reported once, as a sensor fault (its own severity and dashboard card), and never doubles as a low or high alarm. 0 %RH is a dead humidity sensor, not dry air, so it is a fault rather than a low-humidity alarm; plain % is treated differently, because 0% and 100% are exactly what a closed damper or a full valve should read, and only values outside 0 to 100 are faults there. Temperatures are checked, after unit conversion, against role-aware plausible ranges matched on the point's code and name: chilled water -10 to 30 degC, condenser water 0 to 60, hot water 5 to 98, outdoor air -45 to 55, supply air 0 to 60 (so 100 degC on an SAT, or 212 degF, is caught as the fault it is), room and return air 0 to 50, and anything unmatched gets wide hard bounds of -45 to 150 (which is also where a compressor discharge sensor correctly lands). Water roles are matched before air roles, so a CHW supply is judged as chilled water, never as supply air just because the word supply appears. CO2 outside 250 to 10000 ppm and any non-finite reading are faults too.

Advanced rules are user-created and stored, naming specific points: threshold (one point against a fixed value), deviation (a sensor against a paired setpoint, explicit rather than auto-discovered), and mismatch (two points that should agree, a run command and its status for example, flagged when they do not, or the reverse). They are created from the New rule popup and evaluated continuously with everything else; the worker picks up a new or deleted rule within a minute, or instantly via a reload hint published on save. API: GET/POST /api/alarm-rules?project_id=, DELETE /api/alarm-rules/{'{'}id{'}'}, POST /api/alarms/run-advanced?project_id=.

Alarm-point flag management lives in Admin, Alarms: candidates by name (containing alarm, alm, fault, fail, or trip) are flagged by default at import and once by the upgrade backfill, and a deliberate unflag is never overridden by a later restart. The tab shows every point with its flag, a candidate marker, a filter, and bulk actions (flag all candidates, unflag all). API: PATCH /api/points/{'{'}id{'}'}/is-alarm, GET /api/alarms/detect-candidates?project_id=, POST /api/alarms/flag-candidates?project_id= with {'{'}mode: candidates | none{'}'}. The AI tab can also answer questions about alarms directly (what is active, which category, which to treat first) using the same rule sets, via the get_active_alarms and list_alarm_rules tools.

Audit trail

The Admin, Audit page searches and paginates server-side: the search covers the action, actor, target, detail and IP, scoped to your tenant exactly as before, and the pager walks the full history instead of a fixed last-300 view. Export CSV downloads every row matching the current search (newest first, capped at 50000), and the export itself is an audited action. APIs: GET /api/audit?q=&limit=&offset=, GET /api/audit/export?q=.

Bus Systems (admin only)

A new top-level tab, visible to admins, with two sub-pages. Bus traffic is a Wireshark-flavoured window onto the platform's own conversations, and it is honest about that scope: it shows every message the server's broker subscription carries (MQTT) and every BACnet request, response, write and COV notification the selected bridge itself exchanges, mirrored over MQTT only while the page is watching. It is not a promiscuous network sniffer; for diagnosing this platform, its own traffic is exactly the traffic that matters. Pick the buses, a bridge (required for BACnet), an optional project filter, and press Play; rows stream in once a second with time, bus, direction, target, service, object or point, round-trip milliseconds, success, and a short info or error string. Statistics update live: total telegrams, messages per second over the last five seconds, failed telegrams, the top 10 controllers and targets with their failure counts, and the top 10 points by message volume.

Retention is deliberately zero. The server keeps only a bounded in-memory ring (5000 rows, overflow counted and shown); the bridge mirrors only while switched on, with its own 10 minute safety cutoff; a capture whose page stops polling for 25 seconds expires by itself, drops its buffer, and switches the bridge mirror off. Stop ends the capture but keeps what was seen so Save CSV can download it (built in the browser from memory); Clear forgets everything. Nothing is ever written to a folder on the server. APIs: POST /api/capture/start with {'{'}buses, bridge, project_id{'}'}, POST /api/capture/stop, POST /api/capture/clear, GET /api/capture/poll?since=; the bridge control topic is $beez/bridge-ctl/<name> and the mirror topic is $beez/capture/<name>.

The BACnet browser sub-page now carries two instruments. The live browser works through the platform's own bridges, from the server's network vantage, and needs nothing installed: pick a running bridge, send a Who-Is (optionally bounded to an instance range), and every answering controller lands in the devices table; a device on a routed subnet that ignores broadcasts is added manually by address and instance. Browse a device to read its identity and full object list, read any object's name, present value and unit on demand, and export the whole device as an EDE 2.3 file that the platform's own importer reads back verbatim, which makes the onboarding loop for a brand-new site: configure a bridge (Admin, Bridges), start it with no projects connected (it idles as a pure discovery bridge, a mode new in this release), Who-Is, browse, export EDE, import the EDE as a project, connect the project to the same bridge. Long operations never hold an HTTP request open: every browse action is a job the bridge answers over MQTT, the page polls, and a full EDE scan shows its progress object by object. One lock inside the bridge serializes browse work so a long scan can never trample the polling and COV traffic the bridge exists for. Browse actions are audited. APIs: POST /api/browser/start, GET /api/browser/job/{'{'}id{'}'}, GET /api/browser/job/{'{'}id{'}'}/ede.

Beside it, beez-BACnetBrowser remains the desktop field instrument: one portable exe that runs from a USB stick, binds the field laptop's own NICs and COM ports, discovers controllers with repeated Who-Is sweeps, browses and writes every object property, and exports EDE 2.3 for a device or an entire network in one file. A laptop on a strange subnet or an RS-485 segment is territory the server's bridges cannot reach, so the exe stays the right tool there; the platform integrates it by distributing it. A superadmin uploads the published single-file exe once per tool release (built with publish.cmd on Windows; stored in tools-dist/ beside the app, untouched by platform updates, content-checked for a Windows executable header, checksummed, audited), and any admin downloads it from the page, which also carries the field notes: the firewall prompt on the first Who-Is, never sharing a machine with a soft BACnet server on UDP 47808, adding routed devices by IP, and MS/TP through a USB RS-485 adapter. Its EDE exports feed straight into bundle onboarding here. APIs: GET /api/browser-tool, POST /api/browser-tool (superadmin upload), GET /api/browser-tool/download.

AI-proposed actions (staged, approval-gated writes)

Aligned with beez-fm's beez-Coach AI Control Vision: the platform now follows the same staged automation model, and operates at Stage 2, assisted, never higher. "AI outside, safety inside" is the governing rule; the AI never writes to a point directly, under any circumstance.

Every writable point carries an automation_mode, defaulting to manual on every point, existing or new, per the Vision document's own mandate that governance be native rather than retrofitted. Manual and assisted mean the AI may only describe a recommendation in words; nothing is ever created. Only when an admin explicitly sets a specific point to Approved automation (Points tab, admin only) can the AI tab's propose_action tool create anything at all for that point, and even then, all it creates is a pending row in a new proposed_actions table, carrying the proposed value, the point's value at that moment, the reasoning, and a confidence level. Nothing is written. A person with operator access or higher must explicitly approve it from the AI tab's Pending actions list before anything changes; approving routes through the exact same write path and commands audit trail as the Adjust button on the Points tab always has, so an AI-approved write and a person typing a value in by hand are indistinguishable in the audit trail once executed. Rejecting discards the proposal; nothing is written either way until an explicit approval. The automation mode is checked again at the moment of approval, not only when the proposal was created, in case a point's mode changed in between. autonomous mode is reserved in the schema for a later stage and is deliberately not selectable yet, since unsupervised execution has not been built. API: PATCH /api/points/{'{'}id{'}'}/automation-mode, GET /api/proposed-actions?project_id=&status=, POST /api/proposed-actions/{'{'}id{'}'}/approve, POST /api/proposed-actions/{'{'}id{'}'}/reject.

Two things this deliberately does not do yet: it does not write to BACnet Schedule or Calendar objects, since the platform does not even read them yet (section above on the roadmap); and it does not integrate with WhatsApp, Telegram, or any other messaging channel, since that is a separate initiative with its own technology stack (beez-Coach itself, per its own specification), not something to duplicate inside this console.

Mobile layout

The AI and Alarms tabs, and the navigation and cards every tab shares, now have a dedicated phone-width layout: a more compact top navigation, single-column forms, full-width buttons sized for a thumb rather than a cursor, and toolbars that stack instead of crowding. The wide analysis grids (Trends, Admin tables) are left as they are at this width, since compressing them further would make them harder to read, not easier; a phone is for asking a question, checking an alarm, or approving an action, not for building a Sankey diagram.

Schedules tab

BACnet Schedule (object type 17) and Calendar (type 23) objects, discovered automatically: the EDE importer, which previously skipped these rows entirely since they are not simple present-value points, now captures their name, device, and description as a metadata-only placeholder in bacnet_schedules the moment a bundle is imported. The bridge fills in the real content (Schedule: weeklySchedule, exceptionSchedule, scheduleDefault, effectivePeriod; Calendar: dateList) on its own periodic scan (every 30 minutes, far less often than point polling, since a schedule changes on the order of weeks), reusing the exact same bacnet.read(string) call already proven for every ordinary point, just against different property names. Re-importing a bundle never overwrites what the bridge has since actually scanned, INSERT OR IGNORE only ever adds what is missing. A schedule not yet reached by a live scan shows its name only; the weekly viewer renders a proper 7-day grid when the data matches the expected shape and falls back to the raw JSON otherwise, since the exact structure bacpypes3 returns for a property this complex has not been verified against a real device from this environment.

Writing a schedule follows the identical Stage 2 discipline as a point: an automation_mode column on bacnet_schedules, defaulting to manual; only a schedule an admin has explicitly put into Approved automation accepts an AI proposal at all; propose_schedule_change creates a pending row, never a write; a human with operator access or higher must explicitly approve it, checked again at the moment of approval in case the mode changed since; approving is fire-and-forget over MQTT to the bridge, confirmed by the bridge's own next scan rather than assumed. Of everything shipped in this release, the actual BACnet write call for a weeklySchedule is the single least-proven piece: every other read or write in the bridge reuses the plain presentValue string convention already exercised on real points, and there is no verified reference for the exact string or value shape BAC0's write() expects for a structured property like this one; expect this specific piece to need adjustment once tested against a real device. API: GET /api/schedules?project_id=, PATCH /api/schedules/{'{'}id{'}'}/automation-mode.

Device-native alarms

What a BACnet device itself currently reports as an active event or alarm (its own GetEventInformation summary), separate from and complementary to the platform's own basic and advanced rules: this is the device's own opinion of its state, the platform's rules reason over point values already on hand regardless of whether the device itself flags anything. The bridge polls this every 2 minutes via a request-response call (the same service a standard BACnet operator workstation sends) rather than passively listening for unsolicited notifications on the bus; request-response is a materially simpler, more provable capability to get right without a live device to test against, and reaches the same practical goal. If the installed BACpypes3 version does not expose this call, the bridge logs it once and continues normally, every other capability is unaffected. Surfaced in the Alarms tab via a Check device alarms button, and to the AI tab via get_device_alarms. API: GET /api/device-alarms?project_id=.

The AI tab, expanded

Ten tools now, up from five: get_aggregate, get_time_in_ranges, get_state_totals, get_active_alarms, list_alarm_rules, propose_action, list_points (a search over this project's points by equipment, name, or unit, for when the list given up front is not enough), get_schedules, get_device_alarms, and propose_schedule_change. The chart types the model can ask the browser to render grew from three to six: scatter (aggregate-joined or raw nearest-neighbour, exactly like the Trends tab's own), carpet (day by hour heatmap), and Sankey (flow diagram, up to the same 16 node practical limit), each reusing the identical layout and colour math already proven for the Trends tab rather than a second implementation. Every recommendation and every proposal still carries a plain-language reason grounded in real data and an honest confidence level, the same discipline as before, now extended to schedule proposals too.

Faster polling: ReadPropertyMultiple with per-controller memory

The polling bridge now reads points in ReadPropertyMultiple batches by default (20 reads per request, tunable with --rpm-chunk, 0 disables it entirely), which turns a 300-point sweep from hundreds of sequential round trips into about 15 requests, comfortably inside a 10 second interval on one controller. Every escape hatch is built in: any object a batch response did not cover is fetched with a normal single read in the same cycle, so nothing is ever lost to an imperfect answer. The part that makes this production behaviour rather than a retry loop is the capability memory: when a controller positively rejects the service (an unrecognized-service reject, or a segmentation abort), that verdict is written to bacnet-capabilities.json with the reason and timestamp, and from the very next cycle, and every restart after, that controller only ever receives the single reads it can manage. A controller that never says no but keeps failing batched reads while staying reachable one read at a time is demoted the same way after three straight cycles. A failure in our own client call (a library version mismatch) disables batching for the bridge run without ever blaming a controller, since that verdict would be wrong to persist. The memory file lives in the installation root, deliberately outside the code folders the updater mirrors, so what the bridge has learned survives updates; delete a controller's entry (or the whole file) to probe it again after a firmware upgrade, and the bridge status feed now carries each controller's rpm state (true, false, or null while still probing). The call format, the request schema, the response shape and the exception model were verified two ways in software: directly against the source of the installed BAC0 2025.09, and end to end over real BACnet/IP on loopback against a bacpypes3 device. That verification caught three real bugs before any site saw them: a plain no answer arrives wrapped in an exception whose type name reads like a rejection (now classified transient, not a permanent refusal); a stale EDE object can poison a whole batch or come back as an inline error value inside a good one (both observed live; the bridge now excludes exactly that object after its own single read fails, re-admits it the moment it answers again, and never mistakes an error for a value); and a single read of a dead object RETURNS None rather than raising, which the old sweep would have published as a value forever (a returned non-value now counts as a failed read). A segmentation abort halves the chunk (floor 4) and the working size is persisted per controller, so after a restart the first request is already the size that controller manages. The only thing software cannot verify is vendor-specific behaviour on your physical controllers, and tools/test_rpm.py closes that per controller in seconds: a read-only probe that runs one single read and one RPM chunk against a named controller, prints the raw response and the parsed values, and states exactly what the bridge would record.

Verifying and applying an update package

Any package can be checked before upload with py -3 tools\verify_package.py beez-minibms.zip: it applies the exact acceptance rule the in-browser updater enforces plus the structural rules update.ps1 depends on, and prints precisely which rule failed if one does. The in-browser path (Admin, Update: upload, then Apply) and the manual path (copy beez-minibms.zip next to update.ps1 and double-click update.bat) are the same engine; the manual path runs interactively, shows each of the seven steps as it happens, and is the right tool when the button path fails, because it makes the failing step visible on screen. Every run, either way, writes its full trace to update-debug.log next to update.ps1; the Admin Update page shows the last 40 lines of that same file, and an unattended run always ends with a single line reading FINISHED OK or FINISHED WITH ERROR. Before replacing anything, the updater snapshots the current code into backups/, and rollback.ps1 restores the most recent snapshot or a chosen one; data, tenants, uploaded graphics and config.json are never touched by update, snapshot, or rollback, and the first start on new code migrates the database schema in place automatically.