Orbit connects trusted host state to display nodes. The current V1 path reads Sub2API usage and local Codex task state in a host Agent, transports Protobuf observations over MQTT, and lets Core publish retained views for OLED, Web, and Android nodes. Core includes an authenticated React console for inspecting Agents and Nodes and managing projection routes stored in local SQLite. Deployment settings remain in YAML.
| Component | Responsibility |
|---|---|
| Agent | Collect host usage and Codex state; execute enabled typed capabilities. |
| Core + console | Validate observations, manage routes, and publish device views. The console defaults to 127.0.0.1:7620. |
| Web Node | Display its assigned view over HTTP/SSE; optionally request opening a Codex task. It has its own login and is separate from the Core console. |
| OLED / Android Node | Render the view assigned by Core on a physical display or Android widgets. |
The architecture is split into two one-way flows: state projection and typed command execution.
State flow: Observation -> DeviceView
flowchart TB
Usage["Usage Source"] -->|"typed payload"| Agent["Orbit Agent<br/>metadata / revision / expiry"]
Codex["Codex Source"] -->|"typed payload"| Agent
Agent -->|"QoS 1 / non-retained"| Observation["MQTT Observation<br/>agents/{agent_id}/observations/{type}"]
Observation --> Ingress["Core ingress<br/>identity / epoch / revision / TTL"]
AgentState["MQTT AgentState<br/>retained / current agent_epoch"] -->|"epoch prerequisite"| Ingress
Ingress --> Canonical[("Canonical state<br/>latest snapshot per agent/type")]
Canonical --> Projector["Projection<br/>route / privacy / profile"]
Console["Authenticated Core Console"] --> SQLite[("SQLite routes")]
SQLite --> Route["Projection Route<br/>node_id -> profile + inputs"] -->|"routing policy"| Projector
NodeState["MQTT NodeState<br/>retained / epoch + product"] -->|"node prerequisite"| Projector
Projector -->|"QoS 1 / retained"| View["MQTT DeviceView<br/>nodes/{node_id}/view"]
View -->|"usage-oled-128x32"| OLED["OLED Node<br/>local pixel rendering"]
View -->|"overview-web"| Web["Web Node<br/>cached view + HTTP/SSE"]
View -->|"overview-android"| Android["Android Node<br/>home screen widgets"]
classDef host fill:#eaf2ff,stroke:#2563eb,color:#172033
classDef topic fill:#fff4e8,stroke:#c2410c,color:#172033
classDef core fill:#eaf8ef,stroke:#15803d,color:#172033
classDef node fill:#f3efff,stroke:#7c3aed,color:#172033
class Usage,Codex,Agent host
class Observation,AgentState,NodeState,View topic
class Ingress,Canonical,Projector,Route core
class OLED,Web,Android node
Command flow: Intent -> CommandResult
flowchart TB
subgraph Request["1 - Request and route"]
direction LR
Web["Web Node<br/>fresh cached view"] -->|"TTL 20s"| Intent["MQTT Intent<br/>nodes/{node_id}/intents"]
Intent --> Router["Core validation + routing<br/>epoch / revision / session"]
Context["Current Core context<br/>Route + Codex state"] --> Router
Router -->|"typed / TTL <= 30s"| Command["MQTT Command<br/>agents/{agent_id}/commands"]
end
subgraph Execute["2 - Validate and execute"]
direction LR
Agent["Orbit Agent<br/>target / TTL / UUID / dedup"] --> Capability["OpenCodexSession<br/>typed Capability"]
Capability --> Result["Build final result<br/>intent_ref + status"]
end
subgraph Observe["3 - Observe result"]
direction LR
ResultTopic["MQTT CommandResult<br/>agents/{agent_id}/results"] --> Observer["Test/admin subscriber<br/>current endpoint"]
ResultTopic -. "planned" .-> Feedback["Core -> Web feedback<br/>not implemented"]
end
Command --> Agent
Result --> ResultTopic
classDef host fill:#eaf2ff,stroke:#2563eb,color:#172033
classDef topic fill:#fff4e8,stroke:#c2410c,color:#172033
classDef core fill:#eaf8ef,stroke:#15803d,color:#172033
classDef node fill:#f3efff,stroke:#7c3aed,color:#172033
classDef planned fill:#f7f7f8,stroke:#64748b,stroke-dasharray:5 4,color:#475569
class Agent,Capability,Result host
class Intent,Command,ResultTopic topic
class Router,Context core
class Web,Observer node
class Feedback planned
style Request fill:#ffffff,stroke:#cbd5e1,color:#475569
style Execute fill:#ffffff,stroke:#cbd5e1,color:#475569
style Observe fill:#ffffff,stroke:#cbd5e1,color:#475569
Sub2API credentials and Codex databases stay on the Agent. OLED receives only
formatted cost, token, TPM, and freshness text. The web node receives the rich
usage and sanitized Codex fields selected by its Core projection. Command
execution is limited to the explicitly enabled open_codex_session capability;
the Agent validates the session UUID and opens codex://threads/{session_id}
locally. There is no arbitrary URL or shell command payload.
- Go 1.27 or newer (go.mod declares go 1.27.0).
- Node.js 22.12+ and pnpm 11.9.0 for the Core console and Make-based Go builds/checks (CI and Docker use Node 24). These are not needed to run the compiled Core binary.
- uv and Python 3.13 or newer for the firmware project.
- A C++17 toolchain supported by PlatformIO.
- For live operation: an MQTT broker reachable by all participants, credentials and ACLs for the Agent, Core, and node; a reachable Sub2API account when that source is enabled; and a local Codex home when Codex is enabled.
- For hardware operation: a YD-ESP32-S3, an SSD1306 128x32 I2C OLED, and a USB data connection.
Check the local tools before installing dependencies:
go version
node --version
pnpm --version
uv --version
python3 --versionThe root Makefile installs pinned Buf and protoc-gen-go binaries under the ignored .tools/bin/ directory when protocol targets first need them. The OLED project keeps its pinned Python and PlatformIO dependencies in nodes/display/models/oled-128x32/variants/yd-esp32-s3/uv.lock.
From the repository root, download Go dependencies and run the Go build:
go mod download
make build-go # build the console, then compile all Go packages
make build-core # write a standalone binary to dist/orbit-coreInstall the firmware environment separately:
cd nodes/display/models/oled-128x32/variants/yd-esp32-s3
uv syncuv sync creates the ignored .venv/. PlatformIO may download its board and library packages on the first firmware build, so the build host needs network access.
There are four independent YAML contracts. Copy the examples to the ignored local paths before starting anything:
cp configs/agent.example.yaml configs/agent.local.yaml
cp configs/core.example.yaml configs/core.local.yaml
cp configs/web.example.yaml configs/web.local.yaml
cp nodes/display/models/oled-128x32/variants/yd-esp32-s3/config.example.yaml \
nodes/display/models/oled-128x32/variants/yd-esp32-s3/config.local.yamlThe host examples resolve relative secret paths from the directory containing the YAML file. Create the referenced credential files under configs/secrets/ (one value per file; do not commit them):
configs/secrets/agent-client.pem
configs/secrets/agent-client.key
configs/secrets/agent-mqtt-username
configs/secrets/agent-mqtt-password
configs/secrets/core-client.pem
configs/secrets/core-client.key
configs/secrets/core-mqtt-username
configs/secrets/core-mqtt-password
configs/secrets/web-client.pem
configs/secrets/web-client.key
configs/secrets/web-mqtt-username
configs/secrets/web-mqtt-password
configs/secrets/sub2api-email
configs/secrets/sub2api-password
Update the local YAML values for the selected broker and account:
- agent.local.yaml: agent.id and agent.host_label, MQTT URL/TLS files, and the settings for each enabled source. Sub2API uses HTTPS endpoints and secret files; Codex uses codex_home (or the local CODEX_HOME/default) and its polling, filtering, and privacy settings. Enable capabilities.open_codex_session to let approved Web intents open a local Codex task.
- core.local.yaml: core.id, MQTT URL/TLS files,
console.password, optional console listen/database paths, andobservation_policies. Manage routes in the Core console; YAMLprojection_routesis deprecated and only seeds a database on its first initialization. - web.local.yaml: node.id, its MQTT credentials, the local HTTP listen address,
and
web.auth.passwordplusweb.auth.session_ttlfor the browser login.
After starting the services, log into the Core console and create a route for
each display node. The route's node ID must equal the node configuration's
node.id; each input agent_id must equal the Agent's resolved ID (agent.id
when explicitly set). The current Core example starts without routes.
Host configuration is strict and is validated before a process connects. IDs
must match [a-z0-9][a-z0-9_-]{0,63}. Agent host_label, Core console password,
MQTT credentials, and all enabled source values are required. Configure an observation policy for each type used
by the routes; an empty route set is valid. Durations
use Go syntax such as 30s, 10s, and 2m; Sub2API requires USD and each source's
observation TTL must be at least its poll interval.
The Codex source reads local projection databases in read-only mode. Display
names and project names are omitted unless their privacy flags are explicitly
enabled. include_display_name is a deliberate opt-in because the value may
come from a Codex title or first-user-message fallback.
TLS is enabled by default. With TLS enabled, use an mqtts:// URL. Leave
mqtt.tls.ca_file empty to use the operating system trust store, or set it to
a CA file for a private CA or a root not trusted by the host. Client certificate
and key must be supplied together if the broker requires them. To intentionally
use a plaintext broker, set
mqtt.tls.enabled: false, change the URL to mqtt://, and leave all TLS file
fields empty. The clients never downgrade automatically after a TLS failure.
Keep host YAML and secret files local. They are ignored by Git, but a file being ignored does not make its contents safe to share in logs or commits.
Edit config.local.yaml with the physical node ID, firmware version, Wi-Fi credentials, MQTT host/port/credentials, the broker CA, and optional display brightness. The node schema is in config.schema.yaml.
Node Wi-Fi and MQTT values are embedded into the firmware at build time. The node config file must therefore remain private and must not be committed. The default TLS CA points at the checked-in example asset assets/DigiCertGlobalRootG2.crt; replace it with the root certificate for the actual broker. A TLS-enabled build fails if its CA file cannot be read.
The hardware wiring expected by this variant is:
| Signal | Connection |
|---|---|
| Board | YD-ESP32-S3 (esp32-s3-devkitc-1) |
| OLED | SSD1306, 128x32, 3.3 V, I2C address 0x3C |
| SDA | GPIO5 |
| SCL | GPIO6 |
Set console.password in the existing Core YAML, then run make dev-core and
open http://127.0.0.1:5173 for the Vite development console with hot updates.
The compiled Core serves its embedded console at http://127.0.0.1:7620.
The console has separate pages:
| Page | Contents |
|---|---|
Overview (/) |
Summary and configured data flow. |
Agents (/agents) |
Discovered hosts, versions, source health, and observation freshness. |
Nodes (/nodes) |
Discovered devices, product types, and associated routes. |
Routes (/routes) |
Create, edit, and delete projection routes. |
System (/system) |
Core identity, storage, session, and observation policies. |
State refreshes every 5 seconds. Discovery is based on MQTT participant state; Presence is not implemented, so a discovered participant is not proof that it is currently online.
console:
listen: 127.0.0.1:7620
database: data/core.sqlite
password: replace-with-your-passwordThe password is required. The login page creates a 24-hour HttpOnly session Cookie; logout invalidates the session, and Core restart requires a new login. The Core frontend stores neither passwords nor tokens in local storage. Basic Auth is not accepted. Use HTTPS or an SSH tunnel for remote access.
Keep Core identity, MQTT/TLS credentials, observation policies, NTP, logging,
and console settings in YAML. SQLite stores projection routes and their
revision; it does not replace the deployment configuration. Relative database
paths resolve from the Core YAML directory, so the default local configuration
uses configs/data/core.sqlite.
Each Node has one route, selecting a profile and at most one source Agent per observation type:
| Profile | Inputs | Target |
|---|---|---|
usage-oled-128x32 |
Usage | OLED display |
overview-web |
Usage and/or Codex | Web Node |
overview-android |
Usage and/or Codex | Android widgets |
Agents and Nodes can be configured before discovery. Keep the corresponding
observation_policies in YAML. Saving a rule immediately updates runtime
routing; revision checks prevent one browser from overwriting another's edits.
Core retries failed view delivery and sends an expired clearing view when a
route change or deletion would otherwise leave old data on the device.
Legacy YAML projection_routes is imported only when SQLite is first
initialized. Later YAML route edits have no effect, even after all rules have
been deleted in the console. Manage subsequent changes through the console.
Discovery and Canonical State are rebuilt from MQTT after restart; SQLite is
not a history of online devices or observations.
web/core-console is an independent React +
TypeScript project using Vite, Tailwind CSS, and pnpm, with its own dependencies
and type checks.
make dev-core # start Core + Vite together; no frontend production build
make dev-core-api # start only the Go backend with its YAML
make dev-console # start only Vite; /api proxies to Core at 127.0.0.1:7620
make build-console # install locked frontend dependencies and build assets
make build-core # embed frontend assets into dist/orbit-core
make build-go # build frontend and compile all Go packagesmake build-core, Docker builds, and release CI build the frontend before Go
embeds its assets. The resulting binary needs no Node.js, pnpm, or external
static files at runtime. Before using go build or go test directly, run
make build-console; without built assets the console entry point returns 503.
The Vite development server uses port 5173 and the same Core login. make dev-core
starts both processes with a fixed terminal header for the frontend URL and
service status, plus tagged Core/Vite logs below. Either service exiting stops
the other and its subprocesses. Redirected output and make dev use plain logs;
set ORBIT_DEV_PLAIN=1 to request plain logs explicitly.
Edit React/TypeScript/CSS files to see hot updates without
restarting Core. Stop them with Ctrl-C. Go changes still require a restart.
For separate terminals, run make dev-core-api and make dev-console.
Port 7620 serves the backend and any previously embedded assets, so use port
5173 to see frontend changes. Vite fails if its port is occupied rather than
silently selecting another port.
Run these from the repository root. A passing check exits with status 0; any non-zero status is a failure even if some earlier packages passed.
make test-go # build console; run Go unit and in-memory integration tests
make test-web # Web Node browser-state tests
pnpm --dir web/core-console check
pnpm --dir web/core-console format:check
go test ./internal/agent ./internal/integration
go test -race ./internal/agent ./internal/integration
go test ./internal/sources/codex
env ORBIT_CODEX_LIVE_TEST=1 go test ./internal/sources/codex -run '^TestLiveSmoke$' -count=1
make test-node # YAML tests and PlatformIO native firmware tests
make proto-lint # Buf schema lint; installs the pinned Buf if needed
make generate # regenerate Go bindings under gen/go/
make build-go # compile all Go packages
make build-node # generate nanopb and compile the YD-ESP32-S3 firmware
make verify # fmt-check, lint, all tests, protocol checks, and buildsBuild the three service images with the version from .env:
cp .env.example .env
docker compose buildThe images are tagged as
registry.cn-heyuan.aliyuncs.com/leo03w/orbit-{agent,core,web}:vX.Y.Z.
make release bumps the patch version in .env by default, updates the tracked
.env.example, creates a chore: release vX.Y.Z commit, and adds an annotated
tag. Use make release V=v1.2.3 to choose an explicit version. The release
command requires a clean Git worktree and does not build or push images.
Without V, make release increments the last component (for example,
v0.1.8 → v0.1.9). Both env files must contain one version=vX.Y.Z entry;
other settings, comments and blank lines are preserved.
Pushing a v* tag triggers the release workflow: signed GHCR
images, a release-signed Android APK, and a signed ESP32-S3 firmware archive.
The GitHub Release includes image addresses/digests and downloadable artifacts.
Configure the Android signing secrets before the first tag push. Public ESP32-S3
firmware uses example network settings; connected devices need a private build.
Deploy the published images on a Linux host with the local configuration files:
docker compose --env-file .env -f deploy/docker-compose.yml pull
docker compose --env-file .env -f deploy/docker-compose.yml up -dThe deployment binds the Core console to host 127.0.0.1:7620 and publishes
Web Node on port 7621. Set these values in the respective YAML files:
- Core:
console.listen: 0.0.0.0:7620,console.database: /app/data/core.sqlite, and aconsole.password. - Web Node:
web.listen: 0.0.0.0:7621andweb.auth.password.
Files under configs/ are mounted read-only and must be readable by UID 65532.
The orbit-core-data volume persists SQLite across container replacements;
retain it during upgrades and back it up along with the YAML and secrets.
Expose the Core console through an HTTPS reverse proxy to 127.0.0.1:7620,
or access it over an SSH tunnel. Core frontend assets are included in its image;
there is no separate frontend service to deploy.
make test-go uses an in-memory MQTT broker, an httptest Sub2API server, and Codex fixtures. It proves source selection, initial AgentState ordering, independent revisions/health, and privacy bounds, but does not use the local YAML, a real broker, real Sub2API credentials, local Codex files, or hardware.
The TestLiveSmoke command is a read-only local Codex adapter check. It passes
only when the current user's Codex projections contain at least one session;
it does not publish MQTT or expose session contents in the test output.
make verify is accepted only when the command exits with status 0 after every stage (format check, Go vet/tests, Buf lint, node tests, and both builds). A passing automated run still does not validate a real Broker, TLS certificate chain, ACL, Sub2API account, Wi-Fi connection, or physical OLED.
make test-node runs the Python config-codegen tests and the native PlatformIO tests for view freshness, revision ordering, dirty-region calculation, and Protobuf codec behavior. The native target generates nanopb bindings into the ignored src/generated-proto/ directory.
make build-node deliberately builds with config.example.yaml, so it checks firmware compilation and code generation without embedding local credentials. It does not prove Wi-Fi, broker TLS/ACL, Sub2API, or OLED behavior.
Generated Go code is owned by Buf and must not be edited manually. If protocol sources change, run make generate and then rerun make proto-lint and the relevant tests.
The live host path needs a reachable broker and the configured secret files. Start Core first so it is subscribed before observations arrive. Use separate terminals when inspecting logs:
Agent, Core, and Web Node also accept an optional top-level ntp section with
server, sync_interval, and timeout. When omitted, it defaults to
ntp.aliyun.com, 10m, and 2s. Each process queries NTP once before connecting
to MQTT and refreshes the offset in the background. A failed query keeps the
previous offset (or the system clock before the first successful query), logs a
warning, and does not stop the service. Deployments must allow outbound UDP port
123 to the configured server. An Alibaba Cloud VPC deployment can override the
server with ntp.cloud.aliyuncs.com.
make dev-core CORE_CONFIG=configs/core.local.yaml
make dev-agent AGENT_CONFIG=configs/agent.local.yamlmake dev is the equivalent parallel launch:
make dev AGENT_CONFIG=configs/agent.local.yaml \
CORE_CONFIG=configs/core.local.yamlInstall the Agent as a per-user launchd service after preparing the local Agent configuration:
make install-agent AGENT_CONFIG=configs/agent.local.yamlThe command builds a standalone binary under
~/Library/Application Support/Orbit/bin/, writes
~/Library/LaunchAgents/com.leo.orbit.agent.plist, and starts the service. The
job starts at login, is restarted by launchd if it exits, and writes stdout and
stderr under ~/Library/Logs/Orbit/. The plist stores the absolute path to the
selected YAML file, so keep that file and its relative secret files in place.
Running make install-agent again is the update path: it builds and validates
the replacement before stopping the current service, then reloads launchd. If
activation fails, the previous binary and plist are restored.
Stop the background service before running an Agent from the repository:
make stop-agent
make dev-agent AGENT_CONFIG=configs/agent.local.yamlmake stop-agent is idempotent and leaves the installed files in place. Run
make install-agent to update or resume the background service. To remove the
launchd job and installed binary:
make uninstall-agentUninstalling preserves ~/Library/Logs/Orbit/ for diagnostics and never
removes the source YAML or secret files.
Start the web node in a third terminal:
make dev-web WEB_CONFIG=configs/web.local.yamlThe startup log prints its local URL (127.0.0.1:8080 in the example). The page requires the configured single password, then keeps an expiring browser session in local storage and one SSE connection open. It updates whenever a new retained DeviceView is accepted. Its MQTT credential needs publish access to its own NodeState and Intent topics and subscribe access to its own DeviceView topic.
make dev-web serves nodes/web/static directly from disk. Saving an HTML,
CSS, or JavaScript file in that directory automatically reloads connected
browsers; no Web Node restart or manual page refresh is needed. Go code changes
still require restarting the process. Running the binary without -static-dir
continues to serve the embedded production assets.
Use the following browser settings to compare the Web node with the current device in landscape orientation:
| Setting | Value |
|---|---|
| Responsive viewport | 1000 x 450 px |
| Browser zoom | 125% |
| Android system minimum width | 320 dp |
Compare the layout with the device browser in fullscreen mode so browser chrome does not reduce the available page height.
Each service exits non-zero on a configuration, TLS, authentication, ACL, or initial MQTT connection error. A successful startup emits an "orbit core started" or "orbit agent started" log message after the MQTT connection is established. With logging.level: debug, the live acceptance log sequence also includes "usage observation published" from Agent, "usage observation accepted" and "device view published" from Core, and "node state accepted" after the node connects.
After startup, each enabled source is polled immediately and then at its own configured interval. A successful live chain has these messages in the broker:
| Topic | Publisher | Consumer | Retained |
|---|---|---|---|
| orbit/v1/agents/{agent_id}/state | Agent | Core | yes |
| orbit/v1/agents/{agent_id}/observations/usage | Agent | Core | no |
| orbit/v1/agents/{agent_id}/observations/codex | Agent | Core -> Web projection; OLED ignores | no |
| orbit/v1/nodes/{node_id}/state | Node | Core | yes |
| orbit/v1/nodes/{node_id}/view | Core | Node | yes |
| orbit/v1/nodes/{node_id}/intents | Node | Core | no |
| orbit/v1/agents/{agent_id}/commands | Core | Agent | no |
| orbit/v1/agents/{agent_id}/results | Agent | Test/admin subscriber (Core feedback not implemented) | no |
All Go MQTT payloads use application/protobuf and QoS 1. Broker ACLs should allow each identity only the publish/subscribe rows it owns. See docs/mqtt-topics.md and docs/security.md for the contract and deployment security requirements.
- Copy
configs/agent.example.yamlto a local file, enablesources.codex, setsources.codex.codex_homewhen the projections are not under the default~/.codex, enablecapabilities.open_codex_sessionwhen Web clicks should open Codex, and disablesources.sub2api. - Start the Agent with a reachable broker:
make dev-agent AGENT_CONFIG=configs/agent.codex.local.yaml- Subscribe with a credential authorized for the Agent topic subtree and
confirm the first message is retained AgentState with Codex health
UNSPECIFIED, followed by a non-retained Codex observation and a retained healthy AgentState. Decode the Protobuf and verify metadata revision1, the configuredagent_epoch, and anexpires_atafterproduced_at. - With the default privacy settings,
display_nameandproject_namemust be empty, so no title or first-user-message fallback appears. There is no separate raw prompt/title field. Ifinclude_display_nameis enabled, its value may carry the Codex title or first-user-message fallback and must be treated as sensitive opt-in data. Source JSON, full paths, rollout paths, and PIDs never appear in the payload or logs. Logs contain source type, aggregate counts, revision, bytes, and stable error codes only.
The in-memory integration test is the deterministic Agent-side version of this sequence; the broker command additionally proves deployment TLS, credentials, ACLs, and MQTT delivery. Core consumes the observation and projects sanitized Codex data to the Web node. OLED intentionally does not consume or render Codex data in this milestone.
On the Web node, click a session row. The HTTP endpoint accepts only JSON for a
session present in the cached fresh view, then publishes a 20-second Intent.
Core revalidates the Node epoch, view revision, current Codex snapshot, and
projection route before publishing OpenCodexSession to the selected Agent.
The Agent accepts only a non-retained, at-most-30-second command containing a
lowercase UUID and invokes /usr/bin/open codex://threads/{session_id}.
Run the following from the node variant directory after uv sync:
cd nodes/display/models/oled-128x32/variants/yd-esp32-s3
make check
make build CONFIG=config.local.yamlmake build regenerates nanopb bindings and the generated configuration header before compiling. A non-zero result means either the YAML/CA file, Protobuf generation, or firmware compilation failed. To find the serial port, use the PlatformIO CLI and then pass it to the upload and monitor targets:
uv run pio device list
make upload CONFIG=config.local.yaml PORT=/dev/cu.usbmodemXXXX
make monitor PORT=/dev/cu.usbmodemXXXX BAUD=115200Use the device path reported on the current host (/dev/ttyACM0 is common on Linux). The monitor uses 115200 baud with DTR/RTS disabled.
On reset, the serial output should include Orbit display boot, an I2C scan finding 0x3C, and OLED ready. The display state labels are observable acceptance signals:
- WIFI, TIME, and MQTT identify the current connection prerequisite.
- MQTT ERR, PROTO ERR, SIZE ERR, and VIEW ERR identify a runtime error.
- A valid retained view shows the primary, secondary, and footer slots; the small marker in the top-right indicates stale data.
The full live acceptance is: the node reaches OLED ready, connects to Wi-Fi and the broker, publishes retained NodeState, receives its retained orbit/v1/nodes/{node_id}/view, and renders the three slots without an error label. The primary slot is formatted cost, the secondary slot is token count, and the footer is TPM; the integration fixture renders $12.35, 1.2M, and 4.5K, while live values depend on Sub2API. The node does not print a dedicated MQTT-success line, so confirm the NodeState/View topics in the broker or use Core debug logs (node state accepted and device view published) alongside the display.
The view's fresh_until is derived from the observation TTL. After it is reached, the node keeps the last accepted content but shows the stale marker. After a reset or reconnect, the retained view is delivered again; it may be shown stale until SNTP makes the clock trusted, after which fresh_until is evaluated normally.
If the node stays at WIFI or TIME, check Wi-Fi and NTP reachability. If it shows MQTT or MQTT ERR, check the broker host, port, CA, credentials, and ACL. If it is connected but no view arrives, compare the node ID and Agent ID with the rule in the Core console and confirm that Core received NodeState. I2C display missing means the OLED is not detected at address 0x3C; check wiring, power, and the address before debugging MQTT.
The most useful first distinction is whether the failure occurs before the started log:
| Symptom | Meaning and next check |
|---|---|
| open config / decode config | The path is wrong, YAML is malformed, or a key is unknown. |
| validate ... with a secret/TLS path | The referenced file is missing, empty, not regular, or unreadable. Remember that host paths are relative to configs/. |
| URL scheme or ID validation error | Match TLS mode to mqtts:// or mqtt:// and use canonical lowercase IDs. |
| connect mqtt / mqtt connection failed | Check broker reachability, CA/server name, credentials, client certificate, and ACL. |
| orbit ... stopped after startup | The service lost its runner or publish path; inspect the structured error fields and broker logs. |
| sub2api poll failed | The account/API response, HTTPS endpoint, token refresh, timeout, or upstream availability failed. The last successful observation remains until its TTL. |
| configuration generation failed | The node YAML or TLS CA failed schema/codegen validation. |
| nanopb generation unavailable | Run uv sync and ensure the nanopb_generator executable is available, or set NANOPB_GENERATOR to its path. |
Press Ctrl-C in each host terminal for a bounded, clean process shutdown. A successful signal shutdown returns zero; an orbit ... stopped error and non-zero exit require investigation.
cmd/ Agent, Core, and Web Node process assembly
internal/ config, source, transport, Agent, and Core logic
proto/orbit/v1/ versioned wire schemas
gen/go/ generated Go Protobuf bindings
nodes/display/ shared display firmware and model/variant delivery units
nodes/web/ browser display node, HTTP/SSE server, and static UI
nodes/android/ Flutter Android app and home screen widgets
web/core-console/ independent React/TypeScript console and Go embed bridge
deploy/ published-image Compose deployment
configs/ non-sensitive host configuration examples
docs/ architecture, security, MQTT, and ADR documentation
The Go service contracts and routing rules are described in the source and docs/design.md. The OLED-specific build and hardware notes are also kept in nodes/display/models/oled-128x32/variants/yd-esp32-s3/README.md.
The Flutter Android Node provides usage and session
status home screen widgets, MQTT connection settings, connection testing, and
background synchronization. Select the overview-android profile in the Core
console. See its README for Android build and setup instructions.