Architecture
Overview
Bricklogger consists of a long-running daemon that collects data, a
CLI that controls it, a web interface served by bricklogger serve
that builds on exactly the same API as the CLI, and an MCP server,
bricklogger mcp serve, through which an AI assistant configures the logger
over that same API.
graph TB
subgraph Interfaces
CLI[CLI]
WEB[Web interface<br/>bricklogger serve]
MCP[MCP server<br/>bricklogger mcp serve]
end
subgraph Core
API[HTTP/REST API<br/>localhost]
D[Daemon]
end
CLI --> API
WEB --> API
MCP --> API
API --> D
D -->|uploaded via API<br/>stored as versions| B[(Brick model)]
D -->|SPARQL| O[(Oxigraph<br/>working graph)]
D -->|reads/writes| K[config directory<br/>4 × YAML]
D -->|reads/writes| S[(SQLite<br/>runtime state)]
D -->|assignment| SOURCES[Source plugins<br/>BACnet/IP and installed plugins]
SOURCES -->|observations| D
SOURCES -->|SPARQL, read-only| O
D -->|spool, batches| DEST[(TimescaleDB)]
Components
The daemon
The daemon is the core. On startup it loads the configuration and the active version of the Brick model, evaluates the rule set into a poll plan and starts collecting. If no model exists yet, the daemon starts in an idle state: the API and status work, but nothing is polled until a model has been uploaded. The daemon:
- hands assignments to the sources (BACnet/IP and any installed source plugin) based on the rule set and receives their observations,
- writes observations to the destinations (first version: TimescaleDB),
- keeps runtime state (e.g. last poll time and error counters) in a local SQLite database in the data directory — never in the configuration files,
- exposes an HTTP/REST API, by default on localhost only, with health and status endpoints, all control and configuration operations, and a SPARQL endpoint (queries only) against the working graph.
The CLI
The CLI is the control plane and the primary user interface. It talks to the daemon over the HTTP API: status, configuration, model versions, SPARQL queries, plugin tools and start/stop of plugin instances. The commands are defined in the CLI document.
When the daemon is not running, the CLI can work directly on the configuration files with the same validation as the API. A system can therefore be set up from scratch without a running daemon (bootstrap).
Protocol tools. For every supported protocol the CLI offers commands to interact directly with the protocol — e.g. device discovery and reading of objects — for setup and troubleshooting on site. For BACnet/IP the tools build on a wrapper of bacpypes3.
The protocol commands follow the same fallback model as the configuration: when the daemon is running, the command goes through its API (so the daemon remains the only actor on the network socket, and the web interface offers the same); when the daemon is not running, the CLI talks on the network directly.
The web interface
The web interface is a separate process, started with bricklogger serve,
that talks to the daemon's HTTP API exactly as the CLI does and serves
browsers on a port of its own. It has full parity with the CLI: there is no
functionality that can only be reached through the web — or only through the
CLI. Its functionality is defined in the web document.
The MCP server
The MCP server is a third process, bricklogger mcp serve, that speaks the Model
Context Protocol to an AI assistant and talks to the daemon's HTTP API as the
CLI does, with the CLI's fallback to the files when no daemon runs. It is made
for configuration: it carries the schemas, the documentation and the plugin
catalogue as resources, and its tools map one to one onto the CLI's
configuration commands and read-only views. It uploads no models and operates
no instances. Its functionality is defined in the
MCP document.
The Brick model
The Brick model is a semantic graph describing the building: equipment, data
points and their relationships. The points' physical addresses (e.g.
BACnet device and object references) are also in the graph, following Brick's
standardised external references (ref-schema). Where Brick's reference schema
has a type for a system, the reference is written in it; where it has none — a
cloud service such as iBOS — the source plugin brings a small
vocabulary of its own. Brick's ref:TimeseriesReference says
where a point's time series is stored; it is reserved for the databases
Bricklogger writes to and is never a source's address.
Bricklogger treats the graph as read-only input: it is produced and maintained by other tools. Bricklogger never defines points outside the model and never writes to it.
Brick and RealEstateCore. Brick 1.5 models the spaces of a building —
site, building, levels, rooms and zones — with RealEstateCore (rec:Building,
rec:Level, rec:Room and its kinds, rec:HVACZone) and keeps its own
spatial classes only as deprecated aliases. Bricklogger follows: a location is
a Brick Location or a RealEstateCore space, rec:isPartOf and
rec:locatedIn are the part and location relations beside Brick's
isPartOf and hasLocation, which Brick declares as their equivalents, and
points, equipment and their relations stay Brick's. An HVAC zone within a
room is rec:isPartOf the room; a zone that spans rooms has them as parts,
rec:hasPart; both occur, and the rule set's
location selector reaches a
zone's points from its rooms either way. Bricklogger ships its own
copy of Brick — 1.5.0-rc1 at the time of writing, with the RealEstateCore
alignment and the reference schema — so validation and inference need no
network and do not depend on any library's bundled copy. The deprecated Brick
classes still validate on their own, but not mixed with RealEstateCore in one
hierarchy — a rec:Level as part of a brick:Building fails the old class's
shape — so a model chooses one vocabulary for its spaces, and a new model
chooses RealEstateCore.
Lifecycle: upload, versions and replacement
The model is delivered to the daemon as an upload through the API (from the CLI or the web interface). The daemon stores it in the data directory and reloads the active version on restart.
The model can be replaced in operation with the same semantics as a configuration reload: the new model is validated first; if it is invalid, it is rejected with an error message and the daemon continues on the running model. If it is valid, it is swapped atomically, the rule set is re-evaluated into a new poll plan, and points that no longer exist in the model leave the plan.
What validation checks. The model is validated against Brick's shapes,
and only violations on the model's own nodes reject it. Reference nodes are
exempt: what a reference must look like is the source plugin's business, and
the plugin reports what it cannot use as rejected with a reason. Values from
QUDT's vocabulary, such as units, are not verified against QUDT, because the
bundled Brick ontology does not carry QUDT's own type assertions. Warnings,
such as deprecated classes, are reported with the upload but do not reject it.
Every upload is a version. The daemon keeps every uploaded model with a timestamp and remembers which version was active when. Versions can be listed and exported, and an earlier version can be reactivated with the same validation and atomic swap as an upload — a new model that turns out to be wrong is rolled back the same way it came in.
Diff between versions. The result of an upload shows the difference to the previously active version: points added, removed or changed — e.g. moved to another piece of equipment or room, or re-addressed. The same diff can be retrieved later for any two versions. The diff is computed on what was uploaded, not on the inferred graph.
Versions carry the model, not values. Value history is the time series' job, and the graph shows at most a current snapshot.
Prerequisite: stable URIs
The history hangs on the points' URIs. A point that gets a new URI in a new version is a new point in the time series, and its history breaks. That puts a requirement on the tool that builds the model: same point, same URI, version after version.
The working graph
The daemon does not work directly on the model file. When a version is activated, it is validated against Brick's shapes and loaded into a working graph: an Oxigraph store in the data directory, where all queries — rule evaluation, status, API and the source plugins' reference lookups — run as SPARQL. The store holds four named graphs:
- the active model version, as uploaded — Bricklogger never writes to it,
- the ontology: the bundled Brick with its RealEstateCore alignment, and the vocabularies the installed source plugins declare,
- the inferred graph: everything that follows from model and ontology under OWL-RL — superclasses, inverse relations and the rest of what Brick declares,
- the value overlay, where every point carries
brick:lastKnownValuewith the latest valid value and its timestamp — the latest by timestamp, not by arrival, since a history source can deliver an older sample after a newer one — as a node withbrick:valueandbrick:timestamp.nullobservations do not change it, so the age of the timestamp shows that the point is silent or in fault.enumandbooleanare shown as text, not ordinals, so the graph can be read without metadata; the ordinal stands in while the texts are not yet known. The overlay is updated in batches from runtime state, every few seconds, and rebuilt whenever the plan changes, so a point that leaves the plan leaves the overlay. It is a current snapshot, not a history.
Inference. On activation, the full OWL-RL closure of model plus ontology
is derived with the Rust-based reasoner (reasonable), and the
inferred triples are placed in the inferred graph. The SHACL rules of Brick's
reference schema run in the same step, together with Bricklogger's own
supplementary rules and the rules of the source plugins' vocabularies, so a
reference node the model leaves untyped — Brick's own examples do — gets its
type: ref:BACnetReference, derived in either of the two BACnet vocabularies
found in models, or the reference type of an installed plugin's vocabulary;
the daemon's classification of
references relies on it. All of this
takes from seconds to minutes depending on the size of the model and happens
once per activation; the running model is unaffected until the new one is
ready. Raw SPARQL can therefore write ?p a brick:Temperature_Sensor and hit
all subclasses, and relations can be followed in both directions. Transitive
part hierarchies — "everything on floor 1" — are Bricklogger's own selector
semantics, not something Brick declares, and are expressed at query time with
property paths such as brick:isPartOf*.
Queries run over the union of the graphs. Export gives by default the model alone, as uploaded; the inferred graph and the values are opt-in. The SPARQL endpoint on the API gives read access to all of it.
The store is derived state. The truth is the version files and the runtime state; if the store is deleted, the daemon rebuilds it on the next start.
Configuration
All configuration lives in a config directory with four YAML files —
daemon.yaml, sources.yaml, destinations.yaml and rules.yaml — which
together are the source of truth. The files are human-readable and belong in
version control. The schema is defined in the
configuration document.
Write paths. Changes normally go through the daemon's API (from the CLI, the web interface or the MCP server), which validates and writes the files atomically — the configuration can therefore not become invalid that way. The files can also be edited directly.
Reload. When the files are changed outside the API, the changes take effect only when the user performs a reload through the CLI. On reload the configuration is validated as a whole; if it is invalid, it is rejected with an error message and the daemon continues on the running configuration.
Secrets. The configuration can interpolate environment variables (e.g.
${TSDB_PASSWORD}), which the daemon fills in when loading. Secrets never
appear in the files themselves. Beside the four files the config directory may
hold an env file of NAME=value lines, which the daemon and the CLI read
before they interpolate, so a command needs no exported variables; the process
environment wins over the file. The file is not part of the configuration API
and is never printed.
Point selection: the rule set
Which of the graph's points are logged — and how often — is determined by an ordered rule set in the configuration, evaluated like rules in a firewall:
- Rules are evaluated top down; the first match wins.
- A rule either logs (with a polling frequency) or excludes ("deny").
- Points that no rule matches are not logged (implicit deny).
- Points are selected with simplified selectors (e.g. Brick class, equipment, location) or raw SPARQL as an escape hatch. The selectors' vocabulary and semantics are defined in the daemon functionality.
Example
The full YAML schema is defined in the configuration document:
# rules.yaml
- name: Exclude test room
match: { location: "ex:Room_1_17" }
action: deny
- name: Ventilation temperatures
match: { class: brick:Temperature_Sensor, equipment: "ex:AHU_01" }
action: accept
method: poll
interval: 5m
- name: All energy meters
match: { class: brick:Energy_Sensor }
action: accept
method: poll
interval: 15m
# everything else is not logged (implicit deny)
Plugins: sources and destinations
Sources and destinations are plugins behind one common contract per role — and the built-in ones — BACnet/IP and TimescaleDB — follow the same contract as custom plugins. There is no privileged code path: if the contract can drive BACnet/IP, it can also drive a site-specific API.
Custom plugins are distributed as Python packages, installed in the
daemon's environment and discovered through entry points. Dependencies
and versioning thus follow the package, and the daemon finds plugins without
extra configuration. A plugin is built on the plugin SDK, bricklogger.sdk,
the one public surface of the package: the contract, the declaration, the
configuration value types and a test kit are importable from there, and
nothing else in Bricklogger is. The contract is stable within a minor
version of Bricklogger: a change to it raises the minor version, and a
plugin pins the minor version it was built for. How a plugin is installed,
written, tested and distributed is on the plugins page.
Execution model for sources
The daemon owns the plan, the plugin owns the execution. The rule set is evaluated into a plan that is split into one assignment per source instance: which points, with which collection method and which parameters. The assignment is handed over as full desired state — not as changes — together with a sink for observations and a channel for status. On reload or a new model, a new desired state is simply handed over, and the plugin works out for itself what to start, stop and leave alone.
The plugin then runs its own loops and subscriptions and pushes observations into the sink. The daemon has no scheduler: it computes, delegates and receives. How a plugin paces its work — one round per interval, a spread start, rounds skipped when one outlasts its interval — is the plugin's own: the SDK offers no loop, and the contract asks for none.
Which source owns which point
The plan can only be split into assignments once the daemon knows which source instance serves which point. The graph does not answer that: it carries the point's address — which device, which object — but not which network interface on which machine can reach that address. That is an operational condition, not a property of the building.
Therefore the plugin claims. Each source instance reads the graph's external references itself, through the read-only graph access every source has, and answers with the points it serves. The answer is decided statically from the plugin's own configuration — address, subnet, device range — without touching the network. The binding can therefore be validated and shown before the daemon starts collecting.
An instance without a network restriction claims all references of its type. On an installation with one source, the coupling thus requires no configuration.
Arbitration. An instance claims without knowing the rule set, and the daemon then decides the claims for the points the rule set accepts — the graph may well describe more than this installation collects, without status drowning in warnings. A point can carry several external references, e.g. a BACnet reference and a time-series reference; if one source claims the point through one of them, the point is covered.
| Case | Handling |
|---|---|
| Nobody claims | The point is not collected; warning in status with the point's URI |
| No installed source understands any of the point's references | As above, with a message about the reference type |
| The point has no external reference | As above, with a message about the missing reference |
| Several claim the same point | Validation error naming the point and the competing instances |
The warnings are not validation errors: one new point in the graph must not
be able to topple a running configuration — the same philosophy as for a
missing fallback. A double claim, on the other hand, is an error in the
source configuration that would mean double collection or an arbitrary
choice, and it hits the operation that creates the conflict: a reload is
rejected and the running configuration is kept; a model activation is
rejected the same way, because the fix lies in sources.yaml.
Isolation and resources
One source instance is one isolation unit: every plugin runs in its own thread with its own event loop, and the sink is thread-safe. A blocking protocol library therefore cannot slow down the other sources. The contract is transport-agnostic, so the isolation can later be tightened to separate processes without changing it.
A plugin declares the exclusive resources it claims, as opaque strings —
e.g. udp:0.0.0.0:47808 or serial:/dev/ttyUSB0. The daemon only compares
the strings and rejects collisions at validation, without knowing what a
socket is.
The protocol tools in the CLI are addressed per source instance, and the daemon routes the command to the plugin that owns the connection.
The phases of the contract
The contract follows a plugin's life in six phases: declaration, configuration binding, binding to the model (resources and claims), operation (start, assignment, acknowledgement, observations, metadata, status), protocol tools and stop. All six are defined here.
Declaration
Plugins are discovered in the entry point groups bricklogger.sources and
bricklogger.destinations; the entry point name is the type name written in
type in the configuration. The declaration is static and can be read
before any instance exists — so the CLI can list installed plugins, and the
daemon can validate the configuration without starting anything. The entry
points are read when the daemon starts, so a plugin installed or removed while
it runs is seen at its next start. A source declares:
- the type name,
- the reference types it understands, e.g.
ref:BACnetReference— so the daemon can warn about accepted points whose references no installed source understands, - a vocabulary, when the source's system has no reference type in Brick's
reference schema: the prefix and namespace of the source's own reference
type and a Turtle document with its classes, properties and SHACL rules,
shipped in the plugin's package. The daemon loads the vocabularies of every
installed source at activation together with Brick's ontology and rules, so
the reference nodes get their type and the classes their place under
ref:ExternalReferencewithout any network access, and it pre-declares the prefix. The vocabulary of the iBOS source is one, - the collection methods, with a parameter schema per method.
pollwithintervalis defined centrally by Bricklogger and opted into by the source; source-specific methods, each with its own parameters, are defined by the source itself. The daemon validatesrules.yamlagainst the schemas without knowing the methods, - the configuration schema for the instance's own settings,
- the protocol tools it offers.
A destination correspondingly declares its type name, its configuration schema and whether it stores metadata.
Schemas are written as pydantic models. The plugin gets typed objects, errors become precise, and JSON Schema can be derived automatically, so the contract stays transport-agnostic.
A plugin that cannot be loaded stops nothing. An entry point whose import
raises, or whose declaration is of the wrong role or carries another type
name than the entry point, is recorded with its error instead of ending the
load. The catalogue lists the plugin with the error where its description
would be, its configured instances are failed in status with the same error
and raise instance_failed, validation warns about them instead of checking
their settings, and every other instance runs. A configured type that no
installed plugin provides — a plugin not yet added after an upgrade, one a
container could not lay down, or a misspelt name — is treated the same way
when the daemon starts: its instances are failed with the error that the
type is not installed. A change written through the CLI, the web interface,
the API or the MCP server is refused when an instance it adds or changes
names such a type, so a misspelling is caught where it is typed.
Configuration binding
The daemon owns the binding: it fills in environment variables, validates
every instance in sources.yaml and destinations.yaml against the type's
configuration schema, and creates the instance with the validated, typed
configuration. Errors name the instance and the key. The plugin never reads
YAML itself. A setting that is a secret is marked in the schema with the JSON
Schema format password, as password and token are in the built-in
plugins; the CLI then asks for it without echo, keeps it in the env file and
refuses to write it into a configuration file.
Binding to the model
Binding happens at every plan computation — reload and model activation — and without touching the network:
- Resources. The configured instance states the exclusive resources it claims, as opaque strings. A collision between instances is a validation error.
- Claims. The instance finds the external references of its declared types in the graph itself, decides from its own configuration which of them it serves, and answers with the point URIs. The daemon hands over nothing about references: it intersects the answer with the accepted points, arbitrates as described above and computes the assignment.
Graph access for sources
A source plugin reads what it needs about its points from the graph itself: it receives a query function that runs read-only SPARQL against the working graph — model, ontology, inferred graph and value overlay — with the same semantics as the API's SPARQL endpoint. The plugin may query at any time, during claiming and in operation alike. Every model activation leads to a new binding round — claim and a full assignment — in which the plugin resolves its references again against the new model. A query is a call with a response, so the access survives a later move of plugins into separate processes.
Only sources have this access. Destinations remain pure write targets with the closed metadata the daemon offers them.
Operation
Operation is the plugin's own life in its own thread:
- Start. The plugin receives a thread-safe sink and a status channel and starts its loop.
- Assignment. The daemon hands over full desired state: per point its URI, the name of the collection method, the typed parameters, and the latest observation the daemon has recorded for the point, if any — the timestamp held in the runtime state, which survives a restart. The plugin resolves each point's reference from the graph itself. The assignment is idempotent, and the plugin works out for itself what to start, stop and leave alone. A history source resumes from the latest observation, so a restart of the daemon or of the instance refetches nothing.
- Outcome per point is reported on the status channel as a state that can
change over time:
active,unsupportedwhen the device cannot do the method, orrejectedwith a reason. It is reported initially and again on change, because whether a device supports a method is often only known when it is attempted, and it can change when a device is replaced. - The daemon owns fallback. When the plugin reports
unsupported, the daemon hands over a new full desired state in which the point carries the rule'sfallback. Without a fallback the point is not collected, with a warning.rejectedgives a warning with the reason. - Observations are pushed into the sink, singly or in batches.
- Metadata is delivered through the sink as full desired state, first when the plugin knows it — typically after reading units and state texts — and again on change.
- The status channel carries state per instance, device and point, and
warnings a source raises with a code of its own, such as
rate_limited, and withdraws again. What status shows is a separate topic; the contract only fixes that the channel exists and what it carries.
Rule semantics stay in the daemon: the plugin never sees a fallback, only the method it is asked to run. What a protocol plugin needs to know about a point — its reference, the device behind it and the device's address — it reads from the graph itself, so the plugin, not the daemon, knows the shape of its reference type.
Protocol tools
A tool is declared with a name, a description, a parameter schema as a pydantic model, and a structured, JSON-serialisable result. The CLI renders the result as a table for humans and can emit it raw for scripts, and the same result travels unchanged over the API — which is what gives the web interface parity.
A tool may declare its result a document: one JSON object meant to be kept
as a file rather than read as a table — an export, such as a point list. The
result travels over the API exactly as any other; what changes is the
presentation. The CLI writes a document as JSON, to standard output or to a
file with -o FILE, and the web interface offers it as a download named after
the instance, the tool, the values given for its parameters and the day,
instead of rendering it. The plugin declares nothing else; the flag on the
tool is enough, and both interfaces handle every document tool of every plugin
the same way.
A document tool may also say where the web interface offers it: on the
rows of another tool, with a parameter filled from a named column — the point
list on the rows of the project listing, the project number from the number
column. The web interface then puts a download on every row of that tool's
result, and one for the whole document above the rows when the document needs
no parameter, and gives the document tool no form of its own. The CLI is
untouched by the offer; the document tool remains a command with its
parameters as flags.
Execution goes through the daemon to the instance that owns the connection, and the tool runs inside the plugin's own loop, so the socket keeps one actor. Without a running daemon, the CLI binds the configuration itself and runs the tool in-process. Long-running tools, such as discovery that listens for replies for several seconds, declare their duration as a parameter and return a complete result when done; there is no streaming in the first version. A tool failure is an error result with a message, never a crash of the instance.
Which tools a source offers belongs on the sources page.
Stop
Stopping is graceful with a deadline: the plugin cancels subscriptions,
closes sockets and flushes what it holds into the sink. The deadline is
stop_timeout in daemon.yaml, default 10 seconds. A plugin that misses the
deadline is abandoned and reported in status — Python cannot force a
thread to stop. That is the honest limit of thread isolation, and one more
reason process isolation can come later without changing the contract.
- Restart of one instance. A reload that changes an instance's configuration stops that instance and starts a new one; the other instances are untouched. A reload that does not touch an instance leaves it running, possibly with a new assignment.
- Crash. An uncaught exception in the plugin's loop marks the instance as failed in status with the error, and the daemon restarts it with exponential backoff, capped. The daemon emits no observations on behalf of a dead plugin — it never stamps anything itself.
- Daemon shutdown stops all instances with the same deadline and then flushes the destinations.
- Operator stop. An instance can be stopped and started from the CLI. A
stopped instance stays stopped across reload and daemon restart until it is
started again, shows as
stoppedin status and as the warninginstance_stopped.
The destination contract
Declaration and configuration binding are shared with sources. The rest mirrors the source contract, only simpler, and every destination instance runs in its own thread:
- Start. The destination connects and prepares its storage — TimescaleDB creates its tables if they are missing. A destination that cannot start is marked failed in status and retried with backoff, while its spool keeps filling. Collection never stops because a destination is down.
- Write. The destination receives a batch of observations and either succeeds or raises. On failure the batch stays in the spool, and before the retry the destination is stopped and started again, so that a connection the far end has closed is replaced rather than used again. The runner cannot tell a broken connection from any other fault, so every failed write is followed by a fresh start; a destination that then cannot start is retried with the same backoff. The instance stays failed in status, and its warning stands, until a write actually succeeds.
- Delivery is at-least-once. A batch leaves the spool only when the write succeeded, so a crash between write and acknowledgement can repeat a batch. Destinations must therefore write idempotently; for TimescaleDB a unique key on point and time makes a repeat harmless.
- Metadata is offered to destinations that declared they store it, in the same full-desired-state form. It travels through the same spool, so order is kept: the graph's part of a point's metadata precedes the point's first observation, and the plugin's part follows when known.
- Stop. The destination flushes what is in flight and closes, within
stop_timeout. Whatever is not yet written stays in the spool for the next start.
Backpressure: the spool
Destinations never slow down collection. Between the daemon and each destination instance sits a persistent spool in the data directory: an on-disk queue that observations are appended to and drained from in order, in batches. It survives a daemon restart, and it decouples the destinations from each other, so a slow one never stalls a fast one.
The spool is bounded by size and age, configurable per destination with defaults of 1 GB and 7 days. When a cap is reached, the oldest observations are dropped, and status shows the spool depth and the drop count. The settings are described in the configuration document.
Observations and metadata
Sources and destinations meet only in the daemon, and they share only two things: observations and point metadata. Both vocabularies are closed — a plugin cannot invent new types or fields. That is what makes five sources and three destinations eight plugins to maintain rather than fifteen combinations.
The observation
An observation carries point, timestamp, type and value — and nothing else. Meaning belongs to the point, not to the individual measurement: the states of an operating-mode point do not change from reading to reading, and the time series is the only thing in the system that grows without bound.
The timestamp belongs to the source. It is the source plugin that delivers the observation, and it delivers it with a timestamp; the daemon never stamps anything itself. The timestamp states when the value applies, as well as the source knows it. If the protocol carries no time — a BACnet read does not — the source stamps at reading with the machine's clock. A source that receives timestamped samples, e.g. history or an external API, passes the sample's timestamp on.
Under polling the timestamp therefore says that the value was valid at that time; how long before it became true can only be bounded by the poll interval.
Three conventions apply to all sources:
- Timestamps are always timezone-aware and stored as UTC.
- Live reads are stamped when the response is received, with the actual time — not the scheduled one. A combined read of several points thereby gets one shared timestamp.
- The daemon rejects observations with a timestamp in the future beyond a small tolerance and counts them as errors. A plugin that passes on device time must say so in its documentation.
The value type comes from this closed vocabulary:
| Type | Content | Typical source |
|---|---|---|
number |
Floating-point number | Analogue points, scaled registers |
integer |
Integer | Counters, run hours |
boolean |
True/false | Binary points, coils |
enum |
Ordinal — the texts are in metadata | Operating modes (Manual/Auto/Off) |
string |
Text | Text points |
datetime |
Point in time | Date and time points |
null |
No valid value — the reason is the value | Faulty point, point out of service, empty point |
Bit strings — status flags, alarm bitmaps — are not a type. If they are needed, they are decomposed into individual booleans in the source; otherwise every destination inherits a bit-interpretation task.
No quality marker. A source only delivers a value it can vouch for as the
point's value. If the device itself disputes the value — it reports a fault,
the point is decoupled from its input or locally overridden — the source
delivers null with a reason. The observation carries no quality marker next
to the value: a fault period appears in the time series as null
observations with a reason, and that is in itself a finding on a building
under commissioning.
The reason travels as the value of the null observation and comes from
this closed vocabulary:
| Reason | Meaning |
|---|---|
fault |
The device reports the point as faulty |
out_of_service |
The point is decoupled from its input |
overridden |
The point is locally overridden |
no_value |
The device has no value for the point |
unreachable |
No answer: timeout or device offline |
read_error |
The device answered with an error: unknown object, unknown property, access denied |
Failed reads. The last two reasons cover reads that fail without the
device saying anything about the value. Every poll attempt of a live
source yields one observation — a value or a null with a reason — so the
time series always says what the logger did, and communication reliability
can be measured directly per point. read_error almost always points to an
error in the model or in the device's setup and therefore also gives a
warning in status, like points nobody claims.
A history source copies samples the system has already recorded — the
iBOS cloud is one — and delivers nothing for a failed request: the
stretch is fetched when the next request succeeds, so the history stays
complete, and a gap in it is the building's, never the logger's. The
source's own outages show in status instead. Such a source passes the
samples' timestamps on, so an older sample can arrive after a newer one; the
daemon keeps the latest by timestamp as the last known value. It also
resumes where it left off: the assignment carries the latest observation the
daemon has recorded for each point, and the source fetches from there,
however long the pause, so a restart costs no refetch and an outage is caught
up in full. Only a point without a recorded observation is fetched its
backfill back.
If the status itself is wanted as a time series — which fault, alarm states
that do not dispute the value, out-of-service periods — the status is modelled
as its own point in the graph, e.g. BACnet's Reliability as an enum or
Out_Of_Service as a boolean. Bricklogger logs it like any other point.
Point metadata
Metadata describes the point and is delivered separately from the measurements. It is gathered from two places:
- The Brick graph supplies the semantics: name, class, equipment,
location and the unit, if modelled. The name is the point's
rdfs:label, or the last part of its URI when there is no label; class, equipment and location are given in prefixed form. The equipment is what the point is a point of, and the location is the point's nearest: the BrickLocationor RealEstateCore space it is a point of, or the location of its equipment — the same anchors as the rule set's location selector, without the selector's closure over floors and buildings. The daemon gathers this part itself every time it plans, so it precedes the plugin's part. - The source plugin supplies what only the protocol knows: the value
type, the state texts for
enum, the texts forbooleanand the protocol's own unit designation.
The plugin delivers its part through the sink once it knows it — typically after reading units and state texts — and can deliver again whenever something changes in operation, e.g. after a device has been restarted and the texts have been re-read. The form is the same as for the assignment: full desired state, not changes.
The daemon keeps the combined metadata authoritatively in its runtime state, so status and API can answer without asking the plugin.
Units. Metadata can carry two units: the graph's, if modelled, and the protocol's own. The source translates the protocol's unit into Brick's unit vocabulary (QUDT) where it can, and otherwise delivers the protocol's own designation. If both are present and disagree, the graph wins as the primary unit, and the disagreement gives a warning per point in status — on a building under commissioning it is a finding in itself, whether the error lies in the model or in the device's setup. Bricklogger never converts values: the value is delivered as read, and the protocol's unit stands beside it, so nothing is lost.
Metadata in destinations
The daemon offers metadata to the destinations. A destination that stores
it yields a time-series database that can be read on its own: 2 can be
resolved to Auto in a dashboard without Bricklogger anywhere near. How it is
stored — dimension table, columns or otherwise — is the destination plugin's
own choice.
A destination that cannot store metadata opts out in its declaration, and the daemon sends only measurements. Status shows which destinations store metadata, so it is visible whether the data can be read without Bricklogger.
Data flow
sequenceDiagram
participant G as Brick model
participant D as Daemon
participant S as Source plugin
participant T as TimescaleDB
D->>G: Load model (points + addresses)
D->>D: Evaluate rule set → plan
D->>S: Hand over assignment (points, method, parameters)
S->>G: Resolve references (read-only SPARQL)
S-->>D: Outcome per point (active / unsupported / rejected)
loop the plugin's own loop
S->>S: Read points at its own pace
S-->>D: Observations
D->>T: Write observations
end
Runtime state
The daemon's operational state — what was last polled when, the last known value per point, error counters and the like — lives in an SQLite database in the data directory. It is separate from the configuration: state can be deleted without losing the setup, and the configuration files stay clean for version control.
The data directory also holds the model versions, which are the truth about the model, the working graph, which is derived from them and from the runtime state, and the spools of the destinations.
Platform
Bricklogger runs in production on Linux. The filesystem defaults —
~/.config/bricklogger and ~/.local/share/bricklogger for the login that
runs it, /etc/bricklogger and /var/lib/bricklogger in the container — the
installation with uv tool install and systemd --user services, and the
choice of OWL-RL reasoner follow from that.