17 KiB
Repository Guidelines
Project Structure & Layering
components/platform/{include,internal,src}: hardware/system abstraction only.components/domain/{include,internal,src}: business-domain services.components/control_plane/{include,internal,src}: lifecycle, policy, health, retry/backoff orchestration.components/app_composition/{include,internal,src}: composition boundary;app_mainonly wires modules.dependency_whitelist.md: approved exceptions for unavoidable lateral dependencies.components/domain/third_party/,components/domain/assets/fonts/,tools/: vendored code, embedded assets, utility scripts.build/: generated artifacts only (ignored).
Mandatory Architecture Constraints
- Dependencies are one-way only:
app_composition -> control_plane -> domain -> platform. - Lateral direct dependencies are forbidden unless listed in
dependency_whitelist.md. - Cross-domain communication must use events or Port interfaces; never include another domain's
internal/*.h. - Each component keeps exactly one public facade header in
include/; private headers stay ininternal/viaPRIV_INCLUDE_DIRS.- Long-term canonical facade headers in this repo:
platform/include/platform.hdomain/include/domain.hcontrol_plane/include/control_plane.happ_composition/include/app_composition.h
- Upper layers may include only immediate downstream public facade headers; leapfrog includes across multiple layers are forbidden.
- Cross-cutting contracts must have a single declared owner layer and one public declaration point; duplicated declarations across layers are forbidden.
- Runtime state must have a single source of truth (SSOT); orchestration layers should query/subscribe rather than duplicate lower-layer lifecycle or readiness state.
- Blocking APIs must define timeout and idempotent semantics (no destructive background continuation after caller timeout).
- Facade contracts for commands/events must use strongly typed enums/structs; primitive
int/uintmagic values across facade boundaries are forbidden. - Non-owner facades must not expose downstream-layer public types directly (for example,
typedef platform_*indomain/include); bridge/mapping belongs in implementation adapters.
Contract Ownership and Declaration
- Each cross-cutting contract family (shared structs/enums/callback signatures/events) must have exactly one owner layer and one public declaration point.
- Non-owner layers must not redefine semantically equivalent contracts under new names.
- Cross-layer reuse should prefer owner types at owner boundaries, but non-owner facades must not leak downstream types; use explicit adapter mapping at implementation boundaries.
- Owner changes require an explicit architecture decision record and a staged migration plan.
Canonical Contract Ownership Matrix (Normative)
The following matrix is authoritative for all new and refactored cross-layer contracts in this repository.
| Contract family | Owner layer | Single declaration point | Non-owner usage rule |
|---|---|---|---|
Direction catalog, angle math, direction lookup (domain_direction_*) |
domain |
components/domain/include/domain.h |
control_plane consumes via domain facade only; do not duplicate equivalent structs/enums in upper facades. |
| Direction calibration policy/state model (calibration angle, sweep, search policy) | domain |
components/domain/include/domain.h |
Hardware wiring details must be bridged in implementation adapters, not copied into upper-layer public facades. |
| Connectivity semantic prompt IDs/events (connecting/success/fail/config) | domain |
components/domain/include/domain.h |
platform must not expose duplicated semantic enums in public facade; map semantics in adapters. |
| Hardware/runtime primitive descriptors (GPIO digital input config, low-level actuator primitives, storage identity/blob KV APIs, wall-clock sync API) | platform |
components/platform/include/platform.h |
Upper layers call through owner facade or owner-backed adapters; no semantic redefinition in non-owner public headers. |
| LAN HTTP transport contract (bounded request/response structs, handler delegate, server lifecycle/state) | platform |
components/platform/include/platform.h |
domain owns route semantics and maps them in implementation; upper layers must not expose platform HTTP types. |
| Multimodal session transport contract exposed to orchestration (connected/disconnected/error/text/binary event family, audio uplink callback family) | domain |
components/domain/include/domain.h |
platform internal transport types stay internal; cross-layer public contract is declared once at domain facade. |
| Device-auth persistence port (opaque auth key/state record slots and legacy migration targets) | domain |
components/domain/include/domain.h |
control_plane owns serialized auth record schemas and consumes typed auth slots through domain; platform remains an opaque blob KV owner only. |
User birth profile and daily fortune result (domain_birth_profile_*, domain_fortune_result_*) |
domain |
components/domain/include/domain.h |
control_plane consumes and persists these contracts through the domain facade; platform storage remains opaque and semantics-free. |
LAN user configuration web application contract (domain_user_config_server_*) |
domain |
components/domain/include/domain.h |
control_plane owns connectivity-driven start/stop policy; page routes and user/fortune JSON remain domain-owned. |
LCD expression semantic catalog and trigger request contract (domain_expression_*) |
domain |
components/domain/include/domain.h |
control_plane requests semantic expressions through domain; platform exposes playback primitives only and must not duplicate semantic expression IDs publicly. |
| LCD presentation playback primitives (asset sequence/slot reels/idle playback request) | platform |
components/platform/include/platform.h |
Upper layers must use domain expression mapping; playback asset directories and procedural presentation details are bridged in implementation adapters. |
| Connectivity orchestration policy contract (bootstrap flow, retry/backoff/fallback policy APIs) | control_plane |
components/control_plane/include/control_plane.h |
app_composition triggers these policies but must not implement policy logic. |
Daily fortune fetch/apply orchestration policy (control_plane_fortune_*) |
control_plane |
components/control_plane/include/control_plane.h |
app_composition starts control-plane workers only; birth-profile storage, direction semantics, and transport primitives remain in their owner layers. |
| Model-command and multimodal runtime orchestration contract (model command type/callback, runtime state snapshot) | control_plane |
components/control_plane/include/control_plane.h |
Non-owner layers subscribe/query through control_plane APIs; avoid duplicated runtime state mirrors. |
| App startup wiring contract (boot profile, startup sequence entrypoints) | app_composition |
components/app_composition/include/app_composition.h |
main/app_main calls composition entrypoints only; policy and workers must remain below composition. |
Additional enforcement rules:
- Any new cross-layer contract must map to one matrix row above before code is merged.
- If a new contract family does not fit existing rows, add an ADR first, then update this matrix in the same PR.
- Public facade headers must not
typedefdownstream-layer public types; use implementation-layer mapping/adapters instead.
LCD Expression Management
- Use
docs/lcd-expressions.mdas the operational guide for managing, requesting, and adding LCD expressions. - New expression semantics must be declared as
domain_expression_*contracts incomponents/domain/include/domain.hand mapped in the domain runtime adapter catalog. - Upper layers must request expressions through
domain_runtime_request_expression(); they must not callplatform_lcd_*playback APIs directly. - Platform LCD code must expose playback primitives only and must not reference
DOMAIN_EXPRESSION_*semantic IDs. - Asset-backed expressions should place
.binframes undercomponents/platform/assets/lvgl_animations/<asset_dir>/; custom playback order belongs in the domain catalog, not in duplicated asset files or control-plane logic. - Do not add one-off expression APIs for each emotion/state unless an ADR explicitly justifies a new contract family.
Concurrency and Callback Safety
- Any mutable runtime state accessed by multiple tasks/ISRs/callback contexts must be protected by explicit synchronization (mutex/spinlock/task-serialized model).
- Callback registrations (
cb + user_data) must be read/written under synchronization and invoked outside locks using snapshotted copies. - Public state-query APIs should return immutable snapshot structs, not pointers/references to mutable internal state.
- When lock acquisition can block, code must use bounded waits and return explicit failure on lock timeout.
Managed Worker Ownership
- Every long-running worker/task/timer must have one explicit owner component and a documented lifecycle (
create/start/stop/join). - Stop paths must request cooperative cancellation first, then perform bounded wait/join, and finally apply deterministic fallback (for example force-delete) if timeout expires.
- Owner modules must clear task handles/state deterministically on worker exit to avoid stale lifecycle signals.
- Composition layer may create bootstrap wiring tasks, but policy workers must be owned by
control_plane(or lower owner layer), not byapp_composition.
Runtime Composition Boundary
- Composition boundary owns application wiring: startup sequence, task graph assembly, and inter-module boot orchestration.
- Policy/orchestration layers own policy logic and coordination rules, but not top-level application boot wiring.
- Entry code (
app_main) should only invoke composition-level entrypoints.
Blocking and Lifecycle Contracts
- Any potentially blocking API must define timeout semantics, return behavior, and side effects after timeout.
- Default timeout behavior must be explicit and documented (including non-positive timeout handling).
- Start/Stop APIs must be idempotent and safe to call repeatedly.
- Stateful modules should expose lifecycle query APIs (for example, running/started status) instead of duplicating state in higher layers.
- Stop semantics must include real worker/resource teardown with bounded wait and deterministic fallback behavior.
- Unbounded waits in lifecycle/orchestration paths should be avoided unless explicitly justified and safe.
- Long-running loops in managed components must support cooperative termination.
Build, Flash, and Validation
-
Use the ESP-IDF/toolchain version declared by the project documentation or lockfiles.
-
Activate ESP-IDF v5.5.3 before building.
-
Before running any
idf.pycommand, first determine which activation style exists in the current environment:- Prefer path expressions that are portable across developer machines. Avoid hardcoded user-specific absolute paths such as
/home/<user>/...or/Users/<user>/...in docs, scripts, and shell snippets when a variable-based form is sufficient. - For POSIX shells, prefer parameterized paths such as:
export ESPRESSIF_ROOT="${ESPRESSIF_ROOT:-$HOME/.espressif}" export IDF_VERSION_TAG="v5.5.3" - If
eimexists on the current machine, prefer running ESP-IDF commands througheim run, for example:eim run <COMMAND> [IDF_VERSION] eim run "idf.py build" v5.5.3 eim run "idf.py --version" - In sandboxed or other write-restricted environments,
eimlogging initialization may fail if the default user state/log directories are not writable. In that case, pass--log-filewith a writable path, for example:eim --log-file ./eim-build.log run "idf.py build" v5.5.3 eim --log-file /tmp/eim.log run "idf.py --version" v5.5.3 - If
eimdoes not exist, prefer the newer EIM-managed activation script when available:source "$ESPRESSIF_ROOT/tools/activate_idf_${IDF_VERSION_TAG}.sh" - If the EIM activation script does not exist, fall back to the legacy ESP-IDF export flow:
export IDF_PATH="$ESPRESSIF_ROOT/${IDF_VERSION_TAG}/esp-idf" source "$IDF_PATH/export.sh" - If both
eimand activation scripts exist, prefereim runfirst. - Do not assume one activation path universally applies; verify the current machine/setup first.
- On non-POSIX environments, use the equivalent shell-native path syntax, but keep the same decision order:
eim runfirst, activation script second, legacyexport.shflow last.
- Prefer path expressions that are portable across developer machines. Avoid hardcoded user-specific absolute paths such as
-
idf.py set-target esp32s3: one-time target setup. -
idf.py build: compile and validate. -
idf.py -p <PORT> flash monitor: flash and open serial monitor (Ctrl+]to exit). -
idf.py menuconfig: adjust project options. -
idf.py fullclean && idf.py build: clear stale artifacts.
Coding Style & Naming Conventions
- Use 4-space indentation and keep braces/function style consistent with existing
components/*/*.c. - Prefer
lower_snake_casefor functions and variables; use module-prefixed public APIs. - Use
UPPER_SNAKE_CASEfor macros/constants (CMD_SEND_DATA,WIFI_CONNECTED_BIT). - Prefix file-local statics with
s_(for examples_server,s_jobs) and keep per-fileTAGlogging constants.
Language Policy
All developer-facing artifacts must be written in English. This includes:
- Source code: identifiers, comments, log tags, and string literals used for diagnostics.
- Version control: branch names, commit messages, PR titles and descriptions, and code-review comments.
- Documentation: READMEs, architecture docs, inline API docs, and this file (
AGENTS.md).
Exception — user-facing content: UI strings, end-user help text, localization resource files, and any material rendered directly to end users may use the target locale language.
Testing Guidelines
There is no dedicated unit-test directory currently. Minimum validation:
- Build check:
idf.py build. - For architecture-impacting PRs, also run focused guard checks:
- Verify no duplicated cross-cutting contract families across facades.
- Verify no new unbounded wait is introduced in lifecycle/orchestration paths.
- Verify application boot wiring remains in the composition boundary.
- Verify facade APIs do not use primitive magic command/event IDs in public callback signatures.
- Verify non-owner facade headers do not expose downstream-layer types directly.
- Verify new managed workers have explicit stop control and bounded teardown path.
- Add lightweight static checks (
rg, linters, or scripts) as needed to enforce these constraints.
Branching Strategy (GitFlow)
This project follows GitFlow. All work happens on topic branches; direct commits to main or develop are forbidden.
Long-lived branches
| Branch | Purpose | Receives merges from |
|---|---|---|
main |
Production-ready releases; every commit is a tagged release. | release/*, hotfix/* |
develop |
Integration branch; latest delivered features for the next release. | feature/*, release/*, hotfix/* |
Short-lived branches
| Branch pattern | Base | Merges back into | Naming example |
|---|---|---|---|
feature/<topic> |
develop |
develop |
feature/ble-pairing |
release/<version> |
develop |
main and develop |
release/1.2.0 |
hotfix/<topic> |
main |
main and develop |
hotfix/crash-on-boot |
Rules
- Create feature branches from
develop; merge back via PR with squash or rebase (no merge commits ondevelop). - When
developis release-ready, cut arelease/<version>branch for stabilization; only bug-fixes go there. - After release QA, merge
release/*intomainwith a version tag, then back-merge intodevelop. - Hotfixes branch from
main, merge into bothmain(tagged) anddevelop. - Delete short-lived branches after merge.
Commit & Pull Request Guidelines
Use a clear conventional format:
- Commit style: Strictly follow the Conventional Commits specification.
- Keep commits focused and atomic by layer/module (
platform,domain,control_plane,app_composition). - Source code is hosted in Gitea; submit changes through a Gitea Pull Request targeting the appropriate GitFlow branch.
- PRs include purpose, affected layer(s), whitelist changes (if any), config changes, and validation evidence.
- If work is executed from a staged remediation/audit checklist, remove completed checklist items in the same PR so the document stays a live source of truth.
Security & Configuration Tips
- Never hardcode credentials or API keys in source files; configure through
menuconfig. - Do not commit local
sdkconfigvariants, serial-port-specific commands, or generatedbuild/artifacts.