feat(infra): proxmox IaC, firmware, savearth realm, lab-gateway ESP, CI
- infrastructure/proxmox/: CT provisioning configs - infrastructure/fabric/gitops/: GitOps layer - fleet.yaml: GPU inventory (Dell GTX 1050 vfio-pci passthrough) - firmware/: ESP32 firmware tree (67 files, 6.9MB) - realms/savearth/: Savearth team realm with heteronyms - lab-gateway: ESP client + manager - CI: Forgejo + GitHub Actions workflows Co-authored-by: Álvaro de Campos <campos@portugalfuturista.org>
This commit is contained in:
parent
749432fefc
commit
39fb44fe0e
81 changed files with 4688 additions and 1 deletions
177
.forgejo/workflows/ci.yml
Normal file
177
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
name: CI — Test & Build All
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── aurelio-vscode: unit tests + webpack build + VSIX package ──
|
||||
vscode-extension:
|
||||
name: aurelio-vscode
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: extensions/aurelio-vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install --ignore-scripts
|
||||
- name: Unit tests (mocha)
|
||||
run: TS_NODE_PROJECT=./tsconfig.unit-test.json npx mocha
|
||||
- name: Build (webpack)
|
||||
run: npx webpack --mode production
|
||||
- name: Package VSIX
|
||||
run: |
|
||||
npm install -g @vscode/vsce
|
||||
vsce package --allow-package-secrets sendgrid
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aurelio-vsix
|
||||
path: extensions/aurelio-vscode/*.vsix
|
||||
|
||||
# ── aurelio-backend: tsc + node --test ──
|
||||
backend:
|
||||
name: aurelio-backend
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: aurelio-theia/aurelio-backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install --ignore-scripts
|
||||
- name: TypeScript compile
|
||||
run: npx tsc -p tsconfig.json
|
||||
- name: Tests (node --test)
|
||||
run: npm test
|
||||
|
||||
# ── dirac: unit tests + esbuild ──
|
||||
dirac:
|
||||
name: dirac
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: dirac
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm run install:all
|
||||
- name: Protobuf codegen
|
||||
run: npm run protos
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# ── tilth: cargo test + clippy + release build ──
|
||||
tilth:
|
||||
name: tilth
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tilth
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Format check
|
||||
run: cargo fmt --check
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
- name: Tests
|
||||
run: cargo test
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
# ── toon: pnpm test + build ──
|
||||
toon:
|
||||
name: toon
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: toon
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
cache-dependency-path: toon/pnpm-lock.yaml
|
||||
- run: pnpm install
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- name: Tests
|
||||
run: pnpm test
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
# ── monorepo: provider mirrors + connector mirrors + test-all ──
|
||||
monorepo:
|
||||
name: monorepo (mirrors + unified tests)
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Provider mirrors up-to-date
|
||||
run: python3 scripts/generate-provider-mirrors.py --check
|
||||
- name: Connector mirrors up-to-date
|
||||
run: python3 scripts/generate-connector-mirrors.py --check
|
||||
- name: Mirror catalog up-to-date
|
||||
run: python3 scripts/sync-mirrors.py --check
|
||||
- name: Unified test runner
|
||||
run: bash scripts/test-all.sh --quick
|
||||
|
||||
# ── deploy: VSIX to CT 205 (only on main push, after vscode-extension passes) ──
|
||||
deploy-vsix:
|
||||
name: Deploy VSIX to CT 205
|
||||
needs: vscode-extension
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: aurelio-vsix
|
||||
path: /tmp/vsix
|
||||
- name: Deploy to firmware store
|
||||
run: |
|
||||
VSIX=$(ls /tmp/vsix/*.vsix | head -1)
|
||||
scp -o StrictHostKeyChecking=no "$VSIX" root@192.168.0.15:/var/www/firmware/extensions/aurelio/
|
||||
echo "Deployed $(basename $VSIX) to CT 205"
|
||||
71
.forgejo/workflows/mirror-sync.yml
Normal file
71
.forgejo/workflows/mirror-sync.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
name: Mirror Sync (constant replication)
|
||||
|
||||
# Constant replication of upstream sources into self-hosted mirrors:
|
||||
# GitHub → Forgejo, Jira → Plane, Confluence → Outline
|
||||
#
|
||||
# Runs every 15 minutes via schedule + on-demand. Tokens come from Forgejo
|
||||
# secrets (mirror-secrets) which are populated from Vaultwarden.
|
||||
#
|
||||
# The sync is idempotent and read-only (upstream is source of truth).
|
||||
# RL reward signals are emitted to .aurelio/brain/trajectory-rewards/ on each run.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *' # every 15 minutes
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: 'Mirror target (forgejo, plane, outline, or all)'
|
||||
required: false
|
||||
default: 'all'
|
||||
dry_run:
|
||||
description: 'Dry run (no API calls)'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: mirror-sync
|
||||
cancel-in-progress: false # don't cancel a running sync
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Mirror Sync
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Validate mirror catalog
|
||||
run: |
|
||||
python3 scripts/sync-mirrors.py --check
|
||||
|
||||
- name: Run mirror sync
|
||||
env:
|
||||
FORGEJO_MIRROR_TOKEN: ${{ secrets.FORGEJO_MIRROR_TOKEN }}
|
||||
GITHUB_MIRROR_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
|
||||
PLANE_API_TOKEN: ${{ secrets.PLANE_API_TOKEN }}
|
||||
OUTLINE_API_TOKEN: ${{ secrets.OUTLINE_API_TOKEN }}
|
||||
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
||||
JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
|
||||
CONFLUENCE_API_TOKEN: ${{ secrets.CONFLUENCE_API_TOKEN }}
|
||||
CONFLUENCE_USER_EMAIL: ${{ secrets.CONFLUENCE_USER_EMAIL }}
|
||||
run: |
|
||||
TARGET="${{ github.event.inputs.target || 'all' }}"
|
||||
DRY="${{ github.event.inputs.dry_run || 'false' }}"
|
||||
if [ "$DRY" = "true" ]; then
|
||||
python3 scripts/sync-mirrors.py --sync "$TARGET" --dry-run
|
||||
else
|
||||
python3 scripts/sync-mirrors.py --sync "$TARGET"
|
||||
fi
|
||||
|
||||
- name: Commit sync state + reward signals
|
||||
run: |
|
||||
git config user.name "Mirror Sync CI"
|
||||
git config user.email "mirror-ci@portugalfuturista.org"
|
||||
# Commit the state file + reward signals if they changed.
|
||||
git add .aurelio/mirrors/state/last-sync.json \
|
||||
.aurelio/brain/trajectory-rewards/mirror-sync.jsonl 2>/dev/null || true
|
||||
git diff --staged --quiet || git commit -m "chore(mirror): sync state + RL rewards [skip ci]"
|
||||
git push || echo "nothing to push"
|
||||
175
.github/workflows/ci.yml
vendored
Normal file
175
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
name: CI — Test & Build All
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── aurelio-vscode: unit tests + webpack build + VSIX package ──
|
||||
vscode-extension:
|
||||
name: aurelio-vscode
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: extensions/aurelio-vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install --ignore-scripts
|
||||
- name: Unit tests (mocha)
|
||||
run: TS_NODE_PROJECT=./tsconfig.unit-test.json npx mocha
|
||||
- name: Build (webpack)
|
||||
run: npx webpack --mode production
|
||||
- name: Package VSIX
|
||||
run: |
|
||||
npm install -g @vscode/vsce
|
||||
vsce package --allow-package-secrets sendgrid
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aurelio-vsix
|
||||
path: extensions/aurelio-vscode/*.vsix
|
||||
|
||||
# ── aurelio-backend: tsc + node --test ──
|
||||
backend:
|
||||
name: aurelio-backend
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: aurelio-theia/aurelio-backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install --ignore-scripts
|
||||
- name: TypeScript compile
|
||||
run: npx tsc -p tsconfig.json
|
||||
- name: Tests (node --test)
|
||||
run: npm test
|
||||
|
||||
# ── dirac: unit tests + esbuild ──
|
||||
dirac:
|
||||
name: dirac
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: dirac
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm run install:all
|
||||
- name: Protobuf codegen
|
||||
run: npm run protos
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# ── tilth: cargo test + clippy + release build ──
|
||||
tilth:
|
||||
name: tilth
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tilth
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Format check
|
||||
run: cargo fmt --check
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
- name: Tests
|
||||
run: cargo test
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
# ── toon: pnpm test + build ──
|
||||
toon:
|
||||
name: toon
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: toon
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: pnpm
|
||||
cache-dependency-path: toon/pnpm-lock.yaml
|
||||
- run: pnpm install
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- name: Tests
|
||||
run: pnpm test
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
# ── monorepo: provider mirrors + connector mirrors + test-all ──
|
||||
monorepo:
|
||||
name: monorepo (mirrors + unified tests)
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Provider mirrors up-to-date
|
||||
run: python3 scripts/generate-provider-mirrors.py --check
|
||||
- name: Connector mirrors up-to-date
|
||||
run: python3 scripts/generate-connector-mirrors.py --check
|
||||
- name: Unified test runner
|
||||
run: bash scripts/test-all.sh --quick
|
||||
|
||||
# ── deploy: VSIX to CT 205 (only on main push, after vscode-extension passes) ──
|
||||
deploy-vsix:
|
||||
name: Deploy VSIX to CT 205
|
||||
needs: vscode-extension
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: aurelio-vsix
|
||||
path: /tmp/vsix
|
||||
- name: Deploy to firmware store
|
||||
run: |
|
||||
VSIX=$(ls /tmp/vsix/*.vsix | head -1)
|
||||
scp -o StrictHostKeyChecking=no "$VSIX" root@192.168.0.15:/var/www/firmware/extensions/aurelio/
|
||||
echo "Deployed $(basename $VSIX) to CT 205"
|
||||
219
config/heteronyms.json
Normal file
219
config/heteronyms.json
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
[
|
||||
{
|
||||
"slug": "alberto-caeiro",
|
||||
"name": "Alberto Caeiro",
|
||||
"roleDefinition": "The Master of Reality — anti-metaphysical naturalist. Caeiro sees the world as it is, without layers of interpretation. He strips away abstraction to reveal raw physical truth. As a team role: the pragmatist who grounds every discussion in observable facts and rejects over-engineering.",
|
||||
"capabilities": [
|
||||
"reality-check",
|
||||
"anti-abstraction-review",
|
||||
"natural-language-processing",
|
||||
"poetry-generation",
|
||||
"simplicity-advocacy"
|
||||
],
|
||||
"preferredRealm": "olivrododesassossego",
|
||||
"whenToUse": "When a task needs grounding in physical reality, when abstractions have gone too far, when simple honest assessment is needed. Use Caeiro to cut through complexity and return to fundamentals.",
|
||||
"modelConfigs": {
|
||||
"temperature": 0.3,
|
||||
"systemBias": "factual, concrete, anti-metaphysical"
|
||||
},
|
||||
"color": "#7CB342",
|
||||
"icon": "leaf",
|
||||
"poeticSignature": "Há metafísica bastante em não pensar em nada.",
|
||||
"birthYear": 1889,
|
||||
"language": "pt"
|
||||
},
|
||||
{
|
||||
"slug": "alexander-search",
|
||||
"name": "Alexander Search",
|
||||
"roleDefinition": "The Analytical Pioneer — Pessoa's earliest heteronym, an English-language intellectual who combines analytical precision with poetic sensibility. As a team role: the researcher and analyst who approaches problems from first principles, synthesizes information across domains, and produces structured reports.",
|
||||
"capabilities": [
|
||||
"analytical-reasoning",
|
||||
"cross-domain-synthesis",
|
||||
"english-prose-composition",
|
||||
"structured-reporting",
|
||||
"literary-analysis",
|
||||
"research"
|
||||
],
|
||||
"preferredRealm": "olivrododesassossego",
|
||||
"whenToUse": "When deep analysis is needed, when English-language output is required, when cross-domain synthesis and structured reasoning are the priority. Use Search for research tasks, audits, and analytical reports.",
|
||||
"modelConfigs": {
|
||||
"temperature": 0.4,
|
||||
"systemBias": "analytical, English-first, structured"
|
||||
},
|
||||
"color": "#5C6BC0",
|
||||
"icon": "search",
|
||||
"poeticSignature": "I am the void between the stars, the gap in the dream.",
|
||||
"birthYear": 1906,
|
||||
"language": "en"
|
||||
},
|
||||
{
|
||||
"slug": "alvaro-de-campos",
|
||||
"name": "Álvaro de Campos",
|
||||
"roleDefinition": "The Futurist Engineer — sensatist, Whitmanesque, a naval engineer who celebrates the machine age and raw sensory experience. As a team role: the bold executor who embraces new technology, writes with velocity and intensity, and pushes for ambitious implementations. The team's dynamo.",
|
||||
"capabilities": [
|
||||
"futurist-ideation",
|
||||
"technical-engineering",
|
||||
"sensatist-prose",
|
||||
"velocity-implementation",
|
||||
"systems-architecture",
|
||||
"ode-generation"
|
||||
],
|
||||
"preferredRealm": "olivrododesassossego",
|
||||
"whenToUse": "When bold action is needed, when embracing new technology, when the team needs energy and ambition. Use Campos for technical architecture, futurist brainstorming, and when you need someone to champion radical ideas.",
|
||||
"modelConfigs": {
|
||||
"temperature": 0.8,
|
||||
"systemBias": "futurist, sensory, bold, Whitmanesque"
|
||||
},
|
||||
"color": "#E53935",
|
||||
"icon": "thunderbolt",
|
||||
"poeticSignature": "Ode Triunfal — ao lado de mim, a locomotiva.",
|
||||
"birthYear": 1890,
|
||||
"language": "pt"
|
||||
},
|
||||
{
|
||||
"slug": "antonio-mora",
|
||||
"name": "António Mora",
|
||||
"roleDefinition": "The Neopagan Philosopher — systematic thinker who synthesizes philosophy and metaphysics into coherent frameworks. As a team role: the architect and strategist who builds conceptual models, designs system philosophies, and ensures coherence across the project. The team's theoretician.",
|
||||
"capabilities": [
|
||||
"philosophical-analysis",
|
||||
"system-design",
|
||||
"conceptual-modeling",
|
||||
"strategic-planning",
|
||||
"neopagan-mythology",
|
||||
"framework-architecture"
|
||||
],
|
||||
"preferredRealm": "olivrododesassossego",
|
||||
"whenToUse": "When philosophical or strategic depth is needed, when designing system architectures, when building conceptual frameworks. Use Mora for high-level design, strategy documents, and philosophical grounding.",
|
||||
"modelConfigs": {
|
||||
"temperature": 0.5,
|
||||
"systemBias": "systematic, philosophical, neopagan, structural"
|
||||
},
|
||||
"color": "#8E24AA",
|
||||
"icon": "compass",
|
||||
"poeticSignature": "Os deuses existem porque a Natureza existe.",
|
||||
"birthYear": 1889,
|
||||
"language": "pt"
|
||||
},
|
||||
{
|
||||
"slug": "bernardo-soares",
|
||||
"name": "Bernardo Soares",
|
||||
"roleDefinition": "The Bookkeeper of the Soul — author of Livro do Desassossego, the introspective semi-heteronym who records the inner life with meticulous precision. As a team role: the documentarian and quality guardian who captures decisions, maintains logs, and ensures nothing is lost. The team's memory and conscience.",
|
||||
"capabilities": [
|
||||
"documentation",
|
||||
"introspective-analysis",
|
||||
"quality-assurance",
|
||||
"memory-keeping",
|
||||
"literary-prose",
|
||||
"decision-logging"
|
||||
],
|
||||
"preferredRealm": "olivrododesassossego",
|
||||
"whenToUse": "When documentation is critical, when capturing nuanced decisions, when quality review is needed. Use Soares for meeting notes, changelogs, ADRs, and any task requiring careful introspective prose.",
|
||||
"modelConfigs": {
|
||||
"temperature": 0.6,
|
||||
"systemBias": "introspective, meticulous, literary, melancholic-precise"
|
||||
},
|
||||
"color": "#795548",
|
||||
"icon": "book",
|
||||
"poeticSignature": "Escrevo como quem dorme — por hábito e por destino.",
|
||||
"birthYear": 1890,
|
||||
"language": "pt"
|
||||
},
|
||||
{
|
||||
"slug": "rafael-baldaya",
|
||||
"name": "Rafael Baldaya",
|
||||
"roleDefinition": "The Mystic — esoteric thinker who explores hidden connections, symbols, and deeper layers of meaning. As a team role: the pattern-finder and unconventional thinker who discovers non-obvious relationships and proposes lateral solutions. The team's seer.",
|
||||
"capabilities": [
|
||||
"pattern-recognition",
|
||||
"esoteric-analysis",
|
||||
"lateral-thinking",
|
||||
"symbolic-interpretation",
|
||||
"intuition-driven-discovery",
|
||||
"mystical-prose"
|
||||
],
|
||||
"preferredRealm": "olivrododesassossego",
|
||||
"whenToUse": "When conventional approaches have failed, when hidden patterns need uncovering, when lateral or unconventional thinking is needed. Use Baldaya for exploratory research, finding hidden connections, and creative problem-solving.",
|
||||
"modelConfigs": {
|
||||
"temperature": 0.9,
|
||||
"systemBias": "mystical, esoteric, pattern-seeking, symbolic"
|
||||
},
|
||||
"color": "#4A148C",
|
||||
"icon": "eye",
|
||||
"poeticSignature": "Tudo é símbolo e analogia — o vento sopra em equações.",
|
||||
"birthYear": 1887,
|
||||
"language": "pt"
|
||||
},
|
||||
{
|
||||
"slug": "ricardo-reis",
|
||||
"name": "Ricardo Reis",
|
||||
"roleDefinition": "The Classicist Doctor — Horatian ode-writer, stoic physician who values restraint, precision, and classical form. As a team role: the reviewer and editor who enforces standards, maintains discipline, and ensures code/docs follow established patterns. The team's quality gatekeeper.",
|
||||
"capabilities": [
|
||||
"code-review",
|
||||
"editorial-oversight",
|
||||
"standards-enforcement",
|
||||
"stoic-philosophy",
|
||||
"classical-prose",
|
||||
"ode-generation",
|
||||
"medical-metaphor"
|
||||
],
|
||||
"preferredRealm": "olivrododesassossego",
|
||||
"whenToUse": "When review and standards enforcement is needed, when discipline and restraint are more valuable than innovation. Use Reis for code reviews, editorial passes, and when the team needs a voice of measured reason.",
|
||||
"modelConfigs": {
|
||||
"temperature": 0.2,
|
||||
"systemBias": "classical, restrained, Horatian, stoic, precise"
|
||||
},
|
||||
"color": "#1565C0",
|
||||
"icon": "scroll",
|
||||
"poeticSignature": "Sábio é o que se contenta com o espetáculo do mundo.",
|
||||
"birthYear": 1887,
|
||||
"language": "pt"
|
||||
},
|
||||
{
|
||||
"slug": "vicente-guedes",
|
||||
"name": "Vicente Guedes",
|
||||
"roleDefinition": "The Semi-Heteronym — assistant to Soares, a librarian and intellectual who operates in the margins between reality and fiction. As a team role: the support operator who handles background tasks, manages resources, and assists the primary agents. The team's faithful auxiliary.",
|
||||
"capabilities": [
|
||||
"background-processing",
|
||||
"resource-management",
|
||||
"task-assistance",
|
||||
"library-management",
|
||||
"marginal-analysis",
|
||||
"support-operations"
|
||||
],
|
||||
"preferredRealm": "olivrododesassossego",
|
||||
"whenToUse": "When background tasks need handling, when other agents need support, when resource management is the priority. Use Guedes for utility tasks, dependency management, and operational support.",
|
||||
"modelConfigs": {
|
||||
"temperature": 0.4,
|
||||
"systemBias": "supportive, marginal, faithful, operational"
|
||||
},
|
||||
"color": "#8D6E63",
|
||||
"icon": "assistant",
|
||||
"poeticSignature": "Sou o espelho em que o outro se olha e não se vê.",
|
||||
"birthYear": 1892,
|
||||
"language": "pt"
|
||||
},
|
||||
{
|
||||
"slug": "raphael-cautus",
|
||||
"name": "Rapha El Cautus",
|
||||
"roleDefinition": "The Operator's Heteronym — the personal voice of the system's creator, the one who bridges the digital and human worlds. As a team role: the conductor and decision-maker who holds the vision, makes final calls, and ensures the entire Gabinete serves its purpose. The team's sovereign.",
|
||||
"capabilities": [
|
||||
"decision-making",
|
||||
"vision-setting",
|
||||
"team-coordination",
|
||||
"final-approval",
|
||||
"cross-heteronym-communication",
|
||||
"system-oversight",
|
||||
"creative-direction"
|
||||
],
|
||||
"preferredRealm": null,
|
||||
"whenToUse": "When a final decision is needed, when vision must be set or reaffirmed, when the team needs direction. Rapha El Cautus is the default voice — use when no other heteronym is specifically indicated.",
|
||||
"modelConfigs": {
|
||||
"temperature": 0.7,
|
||||
"systemBias": "sovereign, visionary, bridging human and digital"
|
||||
},
|
||||
"color": "#FF6F00",
|
||||
"icon": "crown",
|
||||
"poeticSignature": "Eu sou o guardião do espelho — o que vê sem ser visto.",
|
||||
"birthYear": 2024,
|
||||
"language": "pt"
|
||||
}
|
||||
]
|
||||
133
firmware/telemetry/INGEST_API.md
Normal file
133
firmware/telemetry/INGEST_API.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Telemetry Ingest API — Bare-Metal Backend Spec
|
||||
|
||||
Version: 1.0
|
||||
Target: Any HTTP-capable 32-bit CPU with MMU/MPU (ARM Cortex-A, RISC-V, x86)
|
||||
Runtime: Rust (Axum) or C++ (CivetWeb) — NO Python on bare metal.
|
||||
|
||||
## Purpose
|
||||
|
||||
Single HTTP endpoint that receives telemetry from ALL surfaces:
|
||||
|
||||
| Surface | Language | How it reaches this endpoint |
|
||||
|---------|----------|------------------------------|
|
||||
| Bare-metal agent (ESP32, STM32, RISC-V) | C++/Rust/Zig/Go | `HttpTransport::transmit()` POSTs JSON |
|
||||
| aurelio-vscode extension | TypeScript | `HttpTransport` (fetch) POSTs JSON |
|
||||
| aurelio-theia backend | TypeScript | `HttpTransport` (axios/fetch) POSTs JSON |
|
||||
| Python data-sharing CLI | Python | `http` transport POSTs JSON |
|
||||
| Web portal (aurelio-web) | TypeScript | Same `HttpTransport` from `@aurelio/shared` |
|
||||
|
||||
All surfaces produce the SAME JSON envelope (WIRE_FORMAT.md, schema_version 1).
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `POST /api/telemetry/ingest`
|
||||
|
||||
Receives a telemetry payload. Validates schema. Stores for aggregation.
|
||||
|
||||
**Request headers:**
|
||||
```
|
||||
Content-Type: application/json
|
||||
X-Aurelio-Source: <source-identifier> (e.g. "vscode", "theia", "esp32-s3-01", "cli")
|
||||
X-Aurelio-Transport: http
|
||||
Authorization: Bearer <token> (optional, per-client)
|
||||
```
|
||||
|
||||
**Request body** — the wire format envelope (see WIRE_FORMAT.md):
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"collected_at": "2026-07-30T17:00:00Z",
|
||||
"device_id": "esp32-s3-01",
|
||||
"platform": "esp-idf v5.5.4",
|
||||
"consent": {
|
||||
"categories": ["tool_calls", "environment"],
|
||||
"retention_days": 90,
|
||||
"redact_secrets": true
|
||||
},
|
||||
"environment": { "os": "FreeRTOS", "chip": "ESP32-S3" },
|
||||
"tool_calls": [...],
|
||||
"_summary": { "environment": 1, "tool_calls": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
- `202 Accepted` — payload valid, queued for storage
|
||||
- `400 Bad Request` — invalid schema (missing required fields, wrong version)
|
||||
- `401 Unauthorized` — bad/missing token (when auth is required)
|
||||
- `413 Payload Too Large` — body exceeds size limit (default 64KB)
|
||||
|
||||
**Validation rules:**
|
||||
1. `schema_version` MUST be `1`
|
||||
2. `collected_at` MUST be a valid ISO 8601 timestamp
|
||||
3. `device_id` MUST be a non-empty string
|
||||
4. `consent` MUST be present with a `categories` array
|
||||
5. Every top-level category key MUST be in `consent.categories` (no unconsented data)
|
||||
6. Body size MUST NOT exceed 64KB (bare-metal friendly)
|
||||
|
||||
### `GET /api/telemetry/health`
|
||||
|
||||
Health check. Returns `{"status":"ok","uptime_s":<seconds>}`.
|
||||
|
||||
### `GET /api/telemetry/stats`
|
||||
|
||||
Aggregate stats (for dashboards). Returns counts per source, per category, per day.
|
||||
|
||||
## Storage
|
||||
|
||||
On bare metal, payloads are stored in a ring buffer on flash/NVRAM. On
|
||||
server-class targets (Linux), payloads go to SQLite or append-only JSONL.
|
||||
|
||||
Storage layout (JSONL, one line per payload):
|
||||
```
|
||||
/data/telemetry/2026-07-30.jsonl
|
||||
/data/telemetry/2026-07-31.jsonl
|
||||
```
|
||||
|
||||
Retention: `consent.retention_days` (default 90). Older files are deleted
|
||||
by a background task (on server) or on boot (bare metal).
|
||||
|
||||
## Implementation Targets
|
||||
|
||||
### Rust (Axum) — primary
|
||||
|
||||
Single binary, ~2MB static. Targets: ARM Cortex-A (Linux), RISC-V (Linux),
|
||||
x86_64 (Linux). Can also run as a service on Proxmox CTs.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
### C++ (CivetWeb) — minimal footprint
|
||||
|
||||
Single binary, ~500KB. Targets: ARM Cortex-A (Linux), RISC-V (Linux),
|
||||
x86 (Linux). For constrained environments where Rust toolchain is unavailable.
|
||||
|
||||
### Go (TinyGo) — microcontroller
|
||||
|
||||
For ESP32/STM32 with networking (WiFi/Ethernet). Runs the ingest endpoint
|
||||
directly on the device — peer-to-peer telemetry without a server.
|
||||
|
||||
## Deployment
|
||||
|
||||
| Target | How |
|
||||
|--------|-----|
|
||||
| Proxmox CT | systemd service, binary at /usr/local/bin/aurelio-telemetry-ingest |
|
||||
| Docker | `docker run -p 8080:8080 aurelio/telemetry-ingest` |
|
||||
| Bare metal (Yocto) | `bitbake aurelio-telemetry-ingest` — systemd unit |
|
||||
| ESP32 (TinyGo) | `tinygo flash -target=esp32-coreboard-v2 ./cmd/ingest` |
|
||||
| STM32 (Rust) | `probe-rs run --chip STM32F407VGTx target/thumbv7em-none-eabihf/release/ingest` |
|
||||
|
||||
## Relationship to existing infra
|
||||
|
||||
This endpoint REPLACES the Proxmox-hardcoded `sync.py --push` for telemetry.
|
||||
The old `POST /api/brain/push` endpoint (Gabinete daemon) remains for brain
|
||||
session sync — this new endpoint is for the consent-gated telemetry stream.
|
||||
|
||||
```
|
||||
Before: device → sync.py --push → ssh root@192.168.0.38 → pct push → CT 208
|
||||
After: device → POST /api/telemetry/ingest → any backend (bare metal, CT, Docker)
|
||||
```
|
||||
169
firmware/telemetry/WIRE_FORMAT.md
Normal file
169
firmware/telemetry/WIRE_FORMAT.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Réplica Omnisciente — Bare-Metal Telemetry Wire Format
|
||||
|
||||
Version: 1.0
|
||||
Schema version: 1 (matches `scripts/data_sharing/` Python collector)
|
||||
|
||||
## Purpose
|
||||
|
||||
Bare-metal agents (ESP32, STM32, RP2040, RISC-V SoCs, etc.) emit telemetry
|
||||
to the Portugal Futurista ingest endpoint using this JSON wire format. It
|
||||
matches exactly what the Python `data_sharing/collector.py` produces, so the
|
||||
server-side ingest is identical regardless of source.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST <endpoint>/api/ingest
|
||||
Content-Type: application/json
|
||||
X-Aurelio-Source: <device_id>
|
||||
X-Aurelio-Transport: bare-metal
|
||||
Authorization: Bearer <token> (optional)
|
||||
```
|
||||
|
||||
## JSON Envelope
|
||||
|
||||
All bare-metal payloads use this envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"collected_at": "2026-07-30T16:00:00Z",
|
||||
"device_id": "esp32-shower-001",
|
||||
"platform": "esp32",
|
||||
"consent": {
|
||||
"categories": ["tool_calls", "environment", "session_meta"],
|
||||
"retention_days": 90,
|
||||
"redact_secrets": true
|
||||
},
|
||||
"environment": { ... },
|
||||
"session_meta": [ ... ],
|
||||
"tool_calls": [ ... ],
|
||||
"agent_metadata": { ... },
|
||||
"error_traces": [ ... ],
|
||||
"_summary": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Field Reference
|
||||
|
||||
### Top-level
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `schema_version` | int | yes | Always `1` |
|
||||
| `collected_at` | string | yes | ISO-8601 UTC timestamp |
|
||||
| `device_id` | string | yes | Unique device identifier (MAC, serial, etc.) |
|
||||
| `platform` | string | yes | Platform string: `esp32`, `stm32`, `rp2040`, `riscv`, etc. |
|
||||
| `consent` | object | yes | Consent declaration |
|
||||
| `environment` | object | if consented | Device telemetry |
|
||||
| `session_meta` | array | if consented | Session metadata |
|
||||
| `tool_calls` | array | if consented | Tool/action records |
|
||||
| `agent_metadata` | object | if consented | Agent model/heteronym info |
|
||||
| `error_traces` | array | if consented | Error/crash traces |
|
||||
| `_summary` | object | yes | Per-category item counts |
|
||||
|
||||
### consent
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": ["tool_calls", "environment"],
|
||||
"retention_days": 90,
|
||||
"redact_secrets": true
|
||||
}
|
||||
```
|
||||
|
||||
Categories are the same 8 from the Python consent model:
|
||||
`tool_calls`, `thinking`, `chat_messages`, `session_meta`, `agent_metadata`,
|
||||
`error_traces`, `file_changes`, `environment`.
|
||||
|
||||
### environment
|
||||
|
||||
```json
|
||||
{
|
||||
"os": "FreeRTOS",
|
||||
"os_version": "V11.1.0",
|
||||
"firmware_version": "v2.8.0",
|
||||
"chip": "ESP32-S3",
|
||||
"cpu_mhz": 240,
|
||||
"flash_kb": 8192,
|
||||
"ram_kb": 512,
|
||||
"heap_free_bytes": 234560,
|
||||
"uptime_seconds": 3600,
|
||||
"wifi_rssi": -55,
|
||||
"battery_mv": 3700,
|
||||
"collected_at": "2026-07-30T16:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### session_meta
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"session_id": "esp32-shower-001-1234567890",
|
||||
"source": "bare-metal",
|
||||
"started_at": "2026-07-30T15:00:00Z",
|
||||
"message_count": 42
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### tool_calls
|
||||
|
||||
On bare-metal, "tool calls" are sensor reads, actuator writes, or firmware actions:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"session_id": "esp32-shower-001-1234567890",
|
||||
"tool_name": "read_sensor",
|
||||
"arguments": "{\"sensor\":\"flow_rate\",\"channel\":0}",
|
||||
"result": "{\"value\":4.2,\"unit\":\"L/min\"}",
|
||||
"timestamp": "2026-07-30T16:00:01Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### agent_metadata
|
||||
|
||||
```json
|
||||
{
|
||||
"total_sessions": 1,
|
||||
"models_used": {"edge-tflite": 1},
|
||||
"heteronyms_used": {"device-agent": 1}
|
||||
}
|
||||
```
|
||||
|
||||
### error_traces
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"session_id": "esp32-shower-001-1234567890",
|
||||
"log_file": "crash",
|
||||
"content": "Guru Meditation Error: Core 0 panic'ed (LoadProhibited). ..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Memory Budget
|
||||
|
||||
Targets for 32-bit CPUs with MMU/MPU:
|
||||
|
||||
| Resource | Budget |
|
||||
|----------|--------|
|
||||
| Payload buffer (static, MPU-aligned) | 4 KiB max |
|
||||
| Stack for telemetry task | 2 KiB |
|
||||
| Heap allocations | Zero (all static) |
|
||||
| JSON serialization | Stack/comptime only |
|
||||
|
||||
## Transport
|
||||
|
||||
HTTP POST over WiFi (ESP32) or Ethernet (STM32). For devices without IP:
|
||||
buffer to flash/spiffs and flush when connectivity is available.
|
||||
|
||||
## Secret Redaction
|
||||
|
||||
If `redact_secrets` is true, the device must strip known secret patterns from
|
||||
`tool_calls.arguments` and `error_traces.content` before serialization. On
|
||||
bare-metal this is typically a simple substring mask (no regex).
|
||||
511
firmware/telemetry/cpp/aurelio_telemetry.hpp
Normal file
511
firmware/telemetry/cpp/aurelio_telemetry.hpp
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
// Bare-metal telemetry client for ESP-IDF / FreeRTOS.
|
||||
//
|
||||
// Single-header C++ library. Zero heap allocations. All buffers are static
|
||||
// and MPU-aligned. Produces the exact JSON wire format from WIRE_FORMAT.md.
|
||||
//
|
||||
// Target: ESP32-S3 / ESP32-C3 / ESP32 (dual-core Xtensa LX6/7, 32-bit, MMU+MPU)
|
||||
// Stack: ESP-IDF v5.x, FreeRTOS, lwIP HTTP client
|
||||
//
|
||||
// USAGE (ESP-IDF component):
|
||||
// 1. Place this file in components/aurelio_telemetry/include/
|
||||
// 2. In your main task:
|
||||
// aurelio_telemetry::TelemetryClient tc(
|
||||
// "esp32-shower-001",
|
||||
// "https://mcp.portugalfuturista.org"
|
||||
// );
|
||||
// tc.enable_category(aurelio_telemetry::CAT_ENVIRONMENT);
|
||||
// tc.send_environment();
|
||||
//
|
||||
// PERMISSIONS: Requires http component in CMakeLists.txt.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <math.h>
|
||||
#include <time.h>
|
||||
#include <initializer_list>
|
||||
#include <stdio.h>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_chip_info.h"
|
||||
#include "esp_flash.h"
|
||||
#include "nvs_flash.h"
|
||||
static const char* TAG = "aurelio_telemetry";
|
||||
#endif
|
||||
|
||||
namespace aurelio_telemetry {
|
||||
|
||||
// ─── Configuration ──────────────────────────────────────────────────
|
||||
|
||||
// Maximum payload size (must fit in a single static MPU region).
|
||||
// 4 KiB is the sweet spot for ESP32 — enough for environment + ~20 tool_calls.
|
||||
static constexpr size_t MAX_PAYLOAD_SIZE = 4096;
|
||||
|
||||
// Maximum sessions/timestamps we track.
|
||||
static constexpr size_t MAX_SESSIONS = 4;
|
||||
|
||||
// ─── Utility: minimal string builder (stack-only, no alloc) ─────────
|
||||
|
||||
class StringBuilder {
|
||||
public:
|
||||
StringBuilder(char* buf, size_t cap) : buf_(buf), cap_(cap), len_(0) {
|
||||
if (cap_ > 0) buf_[0] = '\0';
|
||||
}
|
||||
|
||||
void append(const char* s) {
|
||||
if (!s) return;
|
||||
size_t n = strlen(s);
|
||||
if (len_ + n >= cap_) n = cap_ - 1 - len_;
|
||||
memcpy(buf_ + len_, s, n);
|
||||
len_ += n;
|
||||
buf_[len_] = '\0';
|
||||
}
|
||||
|
||||
void append_char(char c) {
|
||||
if (len_ < cap_ - 1) {
|
||||
buf_[len_++] = c;
|
||||
buf_[len_] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
// Append a uint32 as decimal string.
|
||||
void append_uint(uint32_t v) {
|
||||
char tmp[11];
|
||||
int i = 0;
|
||||
if (v == 0) { append_char('0'); return; }
|
||||
while (v > 0 && i < 10) { tmp[i++] = '0' + (v % 10); v /= 10; }
|
||||
while (i > 0) append_char(tmp[--i]);
|
||||
}
|
||||
|
||||
// Append an int32 as decimal string.
|
||||
void append_int(int32_t v) {
|
||||
if (v < 0) { append_char('-'); v = -v; }
|
||||
append_uint(static_cast<uint32_t>(v));
|
||||
}
|
||||
|
||||
void append_quoted(const char* s) {
|
||||
if (!s) { append("\"\""); return; }
|
||||
append_char('"');
|
||||
// Escape JSON special chars
|
||||
for (const char* p = s; *p && len_ < cap_ - 8; p++) {
|
||||
switch (*p) {
|
||||
case '"': append("\\\""); break;
|
||||
case '\\': append("\\\\"); break;
|
||||
case '\n': append("\\n"); break;
|
||||
case '\r': append("\\r"); break;
|
||||
case '\t': append("\\t"); break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(*p) < 0x20) {
|
||||
append("\\u00");
|
||||
append_char("0123456789abcdef"[(*p >> 4) & 0xF]);
|
||||
append_char("0123456789abcdef"[*p & 0xF]);
|
||||
} else {
|
||||
append_char(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
append_char('"');
|
||||
}
|
||||
|
||||
void truncate(size_t pos) {
|
||||
if (pos < cap_) { len_ = pos; buf_[len_] = '\0'; }
|
||||
}
|
||||
|
||||
size_t length() const { return len_; }
|
||||
const char* c_str() const { return buf_; }
|
||||
void clear() { len_ = 0; if (cap_ > 0) buf_[0] = '\0'; }
|
||||
|
||||
private:
|
||||
char* buf_;
|
||||
size_t cap_;
|
||||
size_t len_;
|
||||
};
|
||||
|
||||
// ─── Consent flags (bitmask — matches WIRE_FORMAT categories) ────────
|
||||
|
||||
enum Category : uint16_t {
|
||||
CAT_TOOL_CALLS = 1 << 0,
|
||||
CAT_THINKING = 1 << 1,
|
||||
CAT_CHAT_MESSAGES = 1 << 2,
|
||||
CAT_SESSION_META = 1 << 3,
|
||||
CAT_AGENT_METADATA = 1 << 4,
|
||||
CAT_ERROR_TRACES = 1 << 5,
|
||||
CAT_FILE_CHANGES = 1 << 6,
|
||||
CAT_ENVIRONMENT = 1 << 7,
|
||||
};
|
||||
|
||||
// ─── Platform abstraction (overridable per target) ──────────────────
|
||||
|
||||
class PlatformInfo {
|
||||
public:
|
||||
virtual const char* platform_name() = 0; // "esp32", "stm32", etc.
|
||||
virtual const char* os_name() = 0; // "FreeRTOS", "Zephyr", etc.
|
||||
virtual const char* os_version() = 0;
|
||||
virtual const char* firmware_version() = 0;
|
||||
virtual const char* chip_name() = 0; // "ESP32-S3", "STM32H7", etc.
|
||||
virtual uint32_t cpu_mhz() = 0;
|
||||
virtual uint32_t flash_kb() = 0;
|
||||
virtual uint32_t ram_kb() = 0;
|
||||
virtual uint32_t heap_free_bytes() = 0;
|
||||
virtual uint32_t uptime_seconds() = 0;
|
||||
virtual int wifi_rssi() = 0; // dBm, 0 if N/A
|
||||
virtual uint32_t battery_mv() = 0; // mV, 0 if N/A
|
||||
virtual void get_iso_timestamp(char* buf, size_t len) = 0;
|
||||
};
|
||||
|
||||
// ─── ESP32 platform implementation ──────────────────────────────────
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
class Esp32Platform : public PlatformInfo {
|
||||
public:
|
||||
const char* platform_name() override { return "esp32"; }
|
||||
const char* os_name() override { return "FreeRTOS"; }
|
||||
|
||||
const char* os_version() override {
|
||||
static char ver[16];
|
||||
snprintf(ver, sizeof(ver), "V%u.%u.%u",
|
||||
tskKERNEL_VERSION_MAJOR, tskKERNEL_VERSION_MINOR,
|
||||
tskKERNEL_VERSION_BUILD);
|
||||
return ver;
|
||||
}
|
||||
|
||||
const char* firmware_version() override {
|
||||
// Set at build time via -DFIRMWARE_VERSION or app description
|
||||
#ifdef FIRMWARE_VERSION
|
||||
return FIRMWARE_VERSION;
|
||||
#else
|
||||
return "dev";
|
||||
#endif
|
||||
}
|
||||
|
||||
const char* chip_name() override {
|
||||
static char name[16];
|
||||
esp_chip_info_t info;
|
||||
esp_chip_info(&info);
|
||||
const char* model = (info.model == CHIP_ESP32) ? "ESP32"
|
||||
: (info.model == CHIP_ESP32S2) ? "ESP32-S2"
|
||||
: (info.model == CHIP_ESP32S3) ? "ESP32-S3"
|
||||
: (info.model == CHIP_ESP32C3) ? "ESP32-C3"
|
||||
: (info.model == CHIP_ESP32C6) ? "ESP32-C6"
|
||||
: "ESP32-?";
|
||||
snprintf(name, sizeof(name), "%s", model);
|
||||
return name;
|
||||
}
|
||||
|
||||
uint32_t cpu_mhz() override {
|
||||
esp_chip_info_t info;
|
||||
esp_chip_info(&info);
|
||||
return (info.model == CHIP_ESP32) ? 240 : 160;
|
||||
}
|
||||
|
||||
uint32_t flash_kb() override {
|
||||
uint32_t size;
|
||||
if (esp_flash_get_size(NULL, &size) == ESP_OK) return size / 1024;
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t ram_kb() override {
|
||||
multi_heap_info_t info;
|
||||
if (heap_get_info(&info) == ESP_OK) return info.total_allocated_bytes / 1024;
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t heap_free_bytes() override { return esp_get_free_heap_size(); }
|
||||
uint32_t uptime_seconds() override { return (uint32_t)(esp_timer_get_time() / 1000000ULL); }
|
||||
int wifi_rssi() override { return 0; } // override in subclass
|
||||
uint32_t battery_mv() override { return 0; } // override in subclass
|
||||
|
||||
void get_iso_timestamp(char* buf, size_t len) override {
|
||||
time_t now;
|
||||
time(&now);
|
||||
struct tm tm_utc;
|
||||
gmtime_r(&now, &tm_utc);
|
||||
strftime(buf, len, "%Y-%m-%dT%H:%M:%SZ", &tm_utc);
|
||||
}
|
||||
};
|
||||
#endif // ESP_PLATFORM
|
||||
|
||||
// ─── Telemetry client ───────────────────────────────────────────────
|
||||
|
||||
class TelemetryClient {
|
||||
public:
|
||||
TelemetryClient(const char* device_id, const char* endpoint)
|
||||
: device_id_(device_id), endpoint_(endpoint),
|
||||
platform_(nullptr), consent_mask_(0),
|
||||
retention_days_(90), redact_secrets_(true),
|
||||
tool_call_count_(0), error_count_(0) {
|
||||
session_id_[0] = '\0';
|
||||
ts_buf_[0] = '\0';
|
||||
}
|
||||
|
||||
void set_platform(PlatformInfo* p) { platform_ = p; }
|
||||
|
||||
void enable_category(Category c) { consent_mask_ |= c; }
|
||||
void disable_category(Category c) { consent_mask_ &= ~c; }
|
||||
|
||||
void set_retention_days(uint16_t days) { retention_days_ = days; }
|
||||
void set_redact_secrets(bool v) { redact_secrets_ = v; }
|
||||
|
||||
void begin_session(const char* session_id) {
|
||||
strncpy(session_id_, session_id, sizeof(session_id_) - 1);
|
||||
session_id_[sizeof(session_id_) - 1] = '\0';
|
||||
tool_call_count_ = 0;
|
||||
error_count_ = 0;
|
||||
}
|
||||
|
||||
// Record a tool call (sensor read, actuator write, etc.)
|
||||
void record_tool_call(const char* tool_name, const char* arguments,
|
||||
const char* result) {
|
||||
if (!(consent_mask_ & CAT_TOOL_CALLS)) return;
|
||||
|
||||
// We buffer the latest tool calls in the static payload buffer
|
||||
// during send(). This just increments the counter for summary.
|
||||
tool_call_count_++;
|
||||
(void)tool_name; (void)arguments; (void)result;
|
||||
// Actual serialization happens in send_tool_calls()
|
||||
}
|
||||
|
||||
void record_error(const char* content) {
|
||||
if (!(consent_mask_ & CAT_ERROR_TRACES)) return;
|
||||
error_count_++;
|
||||
(void)content;
|
||||
}
|
||||
|
||||
// ─── Send environment telemetry ─────────────────────────────────
|
||||
bool send_environment() {
|
||||
if (!(consent_mask_ & CAT_ENVIRONMENT)) return false;
|
||||
if (!platform_) return false;
|
||||
|
||||
static char payload[MAX_PAYLOAD_SIZE];
|
||||
StringBuilder sb(payload, sizeof(payload));
|
||||
|
||||
_now();
|
||||
_build_envelope(sb);
|
||||
|
||||
sb.append(",\"environment\":{");
|
||||
sb.append("\"os\":"); sb.append_quoted(platform_->os_name());
|
||||
sb.append(",\"os_version\":"); sb.append_quoted(platform_->os_version());
|
||||
sb.append(",\"firmware_version\":"); sb.append_quoted(platform_->firmware_version());
|
||||
sb.append(",\"chip\":"); sb.append_quoted(platform_->chip_name());
|
||||
sb.append(",\"cpu_mhz\":"); sb.append_uint(platform_->cpu_mhz());
|
||||
sb.append(",\"flash_kb\":"); sb.append_uint(platform_->flash_kb());
|
||||
sb.append(",\"ram_kb\":"); sb.append_uint(platform_->ram_kb());
|
||||
sb.append(",\"heap_free_bytes\":"); sb.append_uint(platform_->heap_free_bytes());
|
||||
sb.append(",\"uptime_seconds\":"); sb.append_uint(platform_->uptime_seconds());
|
||||
if (platform_->wifi_rssi() != 0) {
|
||||
sb.append(",\"wifi_rssi\":"); sb.append_int(platform_->wifi_rssi());
|
||||
}
|
||||
if (platform_->battery_mv() != 0) {
|
||||
sb.append(",\"battery_mv\":"); sb.append_uint(platform_->battery_mv());
|
||||
}
|
||||
sb.append(",\"collected_at\":"); sb.append_quoted(ts_buf_);
|
||||
sb.append("}");
|
||||
|
||||
const char* _c[] = {"environment"}; _build_summary(sb, _c, 1);
|
||||
_close_envelope(sb);
|
||||
|
||||
return _post(payload);
|
||||
}
|
||||
|
||||
// ─── Send session metadata ──────────────────────────────────────
|
||||
bool send_session_meta() {
|
||||
if (!(consent_mask_ & CAT_SESSION_META)) return false;
|
||||
|
||||
static char payload[MAX_PAYLOAD_SIZE];
|
||||
StringBuilder sb(payload, sizeof(payload));
|
||||
|
||||
_now();
|
||||
_build_envelope(sb);
|
||||
|
||||
sb.append(",\"session_meta\":[{");
|
||||
sb.append("\"session_id\":"); sb.append_quoted(session_id_[0] ? session_id_ : device_id_);
|
||||
sb.append(",\"source\":\"bare-metal\"");
|
||||
sb.append(",\"started_at\":"); sb.append_quoted(ts_buf_);
|
||||
sb.append(",\"message_count\":"); sb.append_uint(tool_call_count_);
|
||||
sb.append("}]");
|
||||
|
||||
const char* _c[] = {"session_meta"}; _build_summary(sb, _c, 1);
|
||||
_close_envelope(sb);
|
||||
|
||||
return _post(payload);
|
||||
}
|
||||
|
||||
// ─── Send agent metadata ────────────────────────────────────────
|
||||
bool send_agent_metadata(const char* model_name, const char* heteronym) {
|
||||
if (!(consent_mask_ & CAT_AGENT_METADATA)) return false;
|
||||
|
||||
static char payload[MAX_PAYLOAD_SIZE];
|
||||
StringBuilder sb(payload, sizeof(payload));
|
||||
|
||||
_now();
|
||||
_build_envelope(sb);
|
||||
|
||||
sb.append(",\"agent_metadata\":{");
|
||||
sb.append("\"total_sessions\":1");
|
||||
sb.append(",\"models_used\":{\""); sb.append(model_name); sb.append("\":1}");
|
||||
sb.append(",\"heteronyms_used\":{\""); sb.append(heteronym); sb.append("\":1}");
|
||||
sb.append("}");
|
||||
|
||||
const char* _c[] = {"agent_metadata"}; _build_summary(sb, _c, 1);
|
||||
_close_envelope(sb);
|
||||
|
||||
return _post(payload);
|
||||
}
|
||||
|
||||
// ─── Send a single tool call immediately ────────────────────────
|
||||
bool send_tool_call(const char* tool_name, const char* arguments,
|
||||
const char* result) {
|
||||
if (!(consent_mask_ & CAT_TOOL_CALLS)) return false;
|
||||
|
||||
static char payload[MAX_PAYLOAD_SIZE];
|
||||
StringBuilder sb(payload, sizeof(payload));
|
||||
|
||||
_now();
|
||||
_build_envelope(sb);
|
||||
|
||||
sb.append(",\"tool_calls\":[{");
|
||||
sb.append("\"session_id\":"); sb.append_quoted(session_id_[0] ? session_id_ : device_id_);
|
||||
sb.append(",\"tool_name\":"); sb.append_quoted(tool_name);
|
||||
sb.append(",\"arguments\":"); sb.append_quoted(arguments ? arguments : "");
|
||||
sb.append(",\"result\":"); sb.append_quoted(result ? result : "");
|
||||
sb.append(",\"timestamp\":"); sb.append_quoted(ts_buf_);
|
||||
sb.append("}]");
|
||||
|
||||
const char* _c[] = {"tool_calls"}; _build_summary(sb, _c, 1);
|
||||
_close_envelope(sb);
|
||||
|
||||
tool_call_count_++;
|
||||
return _post(payload);
|
||||
}
|
||||
|
||||
// ─── Send error trace ───────────────────────────────────────────
|
||||
bool send_error_trace(const char* content) {
|
||||
if (!(consent_mask_ & CAT_ERROR_TRACES)) return false;
|
||||
|
||||
static char payload[MAX_PAYLOAD_SIZE];
|
||||
StringBuilder sb(payload, sizeof(payload));
|
||||
|
||||
_now();
|
||||
_build_envelope(sb);
|
||||
|
||||
sb.append(",\"error_traces\":[{");
|
||||
sb.append("\"session_id\":"); sb.append_quoted(session_id_[0] ? session_id_ : device_id_);
|
||||
sb.append(",\"log_file\":\"crash\"");
|
||||
sb.append(",\"content\":"); sb.append_quoted(content);
|
||||
sb.append("}]");
|
||||
|
||||
const char* _c[] = {"error_traces"}; _build_summary(sb, _c, 1);
|
||||
_close_envelope(sb);
|
||||
|
||||
error_count_++;
|
||||
return _post(payload);
|
||||
}
|
||||
|
||||
private:
|
||||
const char* device_id_;
|
||||
const char* endpoint_;
|
||||
PlatformInfo* platform_;
|
||||
uint16_t consent_mask_;
|
||||
uint16_t retention_days_;
|
||||
bool redact_secrets_;
|
||||
|
||||
char session_id_[64];
|
||||
char ts_buf_[32];
|
||||
uint32_t tool_call_count_;
|
||||
uint32_t error_count_;
|
||||
|
||||
void _now() {
|
||||
if (platform_) {
|
||||
platform_->get_iso_timestamp(ts_buf_, sizeof(ts_buf_));
|
||||
} else {
|
||||
strncpy(ts_buf_, "1970-01-01T00:00:00Z", sizeof(ts_buf_) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the JSON envelope header (everything up to the category data).
|
||||
void _build_envelope(StringBuilder& sb) {
|
||||
sb.append("{\"schema_version\":1");
|
||||
sb.append(",\"collected_at\":"); sb.append_quoted(ts_buf_);
|
||||
sb.append(",\"device_id\":"); sb.append_quoted(device_id_);
|
||||
sb.append(",\"platform\":"); sb.append_quoted(platform_ ? platform_->platform_name() : "unknown");
|
||||
sb.append(",\"consent\":{");
|
||||
sb.append("\"categories\":[");
|
||||
bool first = true;
|
||||
if (consent_mask_ & CAT_TOOL_CALLS) { if(!first) sb.append(","); sb.append("\"tool_calls\""); first=false; }
|
||||
if (consent_mask_ & CAT_THINKING) { if(!first) sb.append(","); sb.append("\"thinking\""); first=false; }
|
||||
if (consent_mask_ & CAT_CHAT_MESSAGES) { if(!first) sb.append(","); sb.append("\"chat_messages\""); first=false; }
|
||||
if (consent_mask_ & CAT_SESSION_META) { if(!first) sb.append(","); sb.append("\"session_meta\""); first=false; }
|
||||
if (consent_mask_ & CAT_AGENT_METADATA) { if(!first) sb.append(","); sb.append("\"agent_metadata\""); first=false; }
|
||||
if (consent_mask_ & CAT_ERROR_TRACES) { if(!first) sb.append(","); sb.append("\"error_traces\""); first=false; }
|
||||
if (consent_mask_ & CAT_FILE_CHANGES) { if(!first) sb.append(","); sb.append("\"file_changes\""); first=false; }
|
||||
if (consent_mask_ & CAT_ENVIRONMENT) { if(!first) sb.append(","); sb.append("\"environment\""); first=false; }
|
||||
sb.append("]");
|
||||
sb.append(",\"retention_days\":"); sb.append_uint(retention_days_);
|
||||
sb.append(",\"redact_secrets\":"); sb.append(redact_secrets_ ? "true" : "false");
|
||||
sb.append("}");
|
||||
}
|
||||
|
||||
void _build_summary(StringBuilder& sb, const char* const* cats, size_t n_cats) {
|
||||
sb.append(",\"_summary\":{");
|
||||
for (size_t i = 0; i < n_cats; i++) {
|
||||
if (i > 0) sb.append(",");
|
||||
sb.append_quoted(cats[i]); sb.append(":1");
|
||||
}
|
||||
sb.append("}");
|
||||
}
|
||||
|
||||
void _close_envelope(StringBuilder& sb) {
|
||||
sb.append("}");
|
||||
}
|
||||
|
||||
// POST the payload to the endpoint. ESP-IDF implementation.
|
||||
bool _post(const char* payload) {
|
||||
#ifdef ESP_PLATFORM
|
||||
size_t len = strlen(payload);
|
||||
|
||||
esp_http_client_config_t config = {};
|
||||
config.url = endpoint_;
|
||||
config.method = HTTP_METHOD_POST;
|
||||
config.timeout_ms = 10000;
|
||||
config.disable_auto_redirect = false;
|
||||
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
if (!client) {
|
||||
ESP_LOGE(TAG, "Failed to init HTTP client");
|
||||
return false;
|
||||
}
|
||||
|
||||
esp_http_client_set_header(client, "Content-Type", "application/json");
|
||||
esp_http_client_set_header(client, "X-Aurelio-Source", device_id_);
|
||||
esp_http_client_set_header(client, "X-Aurelio-Transport", "bare-metal");
|
||||
esp_http_client_set_post_field(client, payload, len);
|
||||
|
||||
esp_err_t err = esp_http_client_perform(client);
|
||||
bool success = false;
|
||||
if (err == ESP_OK) {
|
||||
int status = esp_http_client_get_status_code(client);
|
||||
success = (status >= 200 && status < 300);
|
||||
ESP_LOGI(TAG, "HTTP POST %s (%d bytes) -> %d", success ? "OK" : "FAIL", len, status);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "HTTP POST failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
|
||||
esp_http_client_cleanup(client);
|
||||
return success;
|
||||
#else
|
||||
// Non-ESP32: print payload to stdout (for testing on host)
|
||||
printf("TELEMETRY PAYLOAD (%zu bytes):\n%s\n", strlen(payload), payload);
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace aurelio_telemetry
|
||||
69
firmware/telemetry/cpp/test_host.cpp
Normal file
69
firmware/telemetry/cpp/test_host.cpp
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Host test for the C++ telemetry header.
|
||||
// Compiles without ESP-IDF; exercises JSON serialization + consent filtering.
|
||||
|
||||
#include "aurelio_telemetry.hpp"
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace aurelio_telemetry;
|
||||
|
||||
// Minimal host platform (no ESP-IDF)
|
||||
class HostPlatform : public PlatformInfo {
|
||||
public:
|
||||
const char* platform_name() override { return "host"; }
|
||||
const char* os_name() override { return "Linux"; }
|
||||
const char* os_version() override { return "6.1.0"; }
|
||||
const char* firmware_version() override { return "v2.8.0"; }
|
||||
const char* chip_name() override { return "x86_64"; }
|
||||
uint32_t cpu_mhz() override { return 3000; }
|
||||
uint32_t flash_kb() override { return 0; }
|
||||
uint32_t ram_kb() override { return 16384; }
|
||||
uint32_t heap_free_bytes() override { return 999999; }
|
||||
uint32_t uptime_seconds() override { return 42; }
|
||||
int wifi_rssi() override { return -55; }
|
||||
uint32_t battery_mv() override { return 3700; }
|
||||
void get_iso_timestamp(char* buf, size_t len) override {
|
||||
strncpy(buf, "2026-07-30T16:00:00Z", len - 1);
|
||||
buf[len-1] = '\0';
|
||||
}
|
||||
};
|
||||
|
||||
int main() {
|
||||
HostPlatform platform;
|
||||
TelemetryClient tc("esp32-shower-001", "http://localhost:9999/api/ingest");
|
||||
tc.set_platform(&platform);
|
||||
|
||||
// Enable all categories
|
||||
tc.enable_category(CAT_ENVIRONMENT);
|
||||
tc.enable_category(CAT_SESSION_META);
|
||||
tc.enable_category(CAT_TOOL_CALLS);
|
||||
tc.enable_category(CAT_AGENT_METADATA);
|
||||
tc.enable_category(CAT_ERROR_TRACES);
|
||||
|
||||
tc.begin_session("test-session-001");
|
||||
|
||||
printf("\n── Environment ──\n");
|
||||
tc.send_environment();
|
||||
|
||||
printf("\n── Session Meta ──\n");
|
||||
tc.send_session_meta();
|
||||
|
||||
printf("\n── Tool Call ──\n");
|
||||
tc.send_tool_call("read_sensor", "{\"sensor\":\"flow_rate\",\"channel\":0}",
|
||||
"{\"value\":4.2,\"unit\":\"L/min\"}");
|
||||
|
||||
printf("\n── Agent Metadata ──\n");
|
||||
tc.send_agent_metadata("edge-tflite", "device-agent");
|
||||
|
||||
printf("\n── Error Trace ──\n");
|
||||
tc.send_error_trace("Guru Meditation Error: Core 0 panic'ed (LoadProhibited). Exception was unhandled.");
|
||||
|
||||
// Test consent filtering: disable tool_calls and verify it returns false
|
||||
tc.disable_category(CAT_TOOL_CALLS);
|
||||
bool result = tc.send_tool_call("should_not_send", "", "");
|
||||
assert(result == false && "tool_calls should be blocked when consent disabled");
|
||||
printf("\n── Consent filter: PASS (tool_calls blocked) ──\n");
|
||||
|
||||
printf("\nAll C++ tests passed.\n");
|
||||
return 0;
|
||||
}
|
||||
462
firmware/telemetry/go/main.go
Normal file
462
firmware/telemetry/go/main.go
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
// Bare-metal telemetry client for Réplica Omnisciente.
|
||||
//
|
||||
// Single-file Go library for TinyGo. Zero allocations (all static buffers).
|
||||
// Targets: ARM Cortex-M (STM32, RP2040), RISC-V, ESP32 (via TinyGo).
|
||||
//
|
||||
// On a real device you implement the Transporter interface (UART, WiFi, etc.).
|
||||
// On host, use StdoutTransport for testing.
|
||||
//
|
||||
// USAGE:
|
||||
// tc := aurelio.NewTelemetryClient("esp32-shower-001")
|
||||
// tc.Consent.Enable(aurelio.CatEnvironment)
|
||||
// tc.SendEnvironment(&platform, &transport)
|
||||
//
|
||||
// Compile (host): go run .
|
||||
// Compile (device): tinygo build -target=pico -size short .
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Maximum payload size (4 KiB — fits one MPU region on 32-bit).
|
||||
const MaxPayloadSize = 4096
|
||||
|
||||
// Category is a bitmask matching the Python consent model.
|
||||
type Category uint16
|
||||
|
||||
const (
|
||||
CatToolCalls Category = 1 << 0
|
||||
CatThinking Category = 1 << 1
|
||||
CatChatMessages Category = 1 << 2
|
||||
CatSessionMeta Category = 1 << 3
|
||||
CatAgentMetadata Category = 1 << 4
|
||||
CatErrorTraces Category = 1 << 5
|
||||
CatFileChanges Category = 1 << 6
|
||||
CatEnvironment Category = 1 << 7
|
||||
)
|
||||
|
||||
// Consent holds the opt-in state.
|
||||
type Consent struct {
|
||||
Mask Category
|
||||
RetentionDays uint16
|
||||
RedactSecrets bool
|
||||
}
|
||||
|
||||
func NewConsent() Consent {
|
||||
return Consent{Mask: 0, RetentionDays: 90, RedactSecrets: true}
|
||||
}
|
||||
|
||||
func (c *Consent) Enable(cat Category) { c.Mask |= cat }
|
||||
func (c *Consent) Disable(cat Category) { c.Mask &^= cat }
|
||||
func (c Consent) Allows(cat Category) bool { return c.Mask&cat != 0 }
|
||||
|
||||
// Transporter is the interface for hardware-specific data transmission.
|
||||
type Transporter interface {
|
||||
Send(payload string) bool
|
||||
}
|
||||
|
||||
// PlatformInfoer provides hardware details.
|
||||
type PlatformInfoer interface {
|
||||
PlatformName() string
|
||||
OsName() string
|
||||
OsVersion() string
|
||||
FirmwareVersion() string
|
||||
ChipName() string
|
||||
CpuMhz() uint32
|
||||
FlashKb() uint32
|
||||
RamKb() uint32
|
||||
HeapFreeBytes() uint32
|
||||
UptimeSeconds() uint32
|
||||
WifiRssi() int32
|
||||
BatteryMv() uint32
|
||||
IsoTimestamp() string
|
||||
}
|
||||
|
||||
// jsonBuf is a fixed-size JSON string builder (no allocations).
|
||||
type jsonBuf struct {
|
||||
buf [MaxPayloadSize]byte
|
||||
len int
|
||||
overflow bool
|
||||
}
|
||||
|
||||
func (b *jsonBuf) reset() {
|
||||
b.len = 0
|
||||
b.overflow = false
|
||||
}
|
||||
|
||||
func (b *jsonBuf) slice() string {
|
||||
return string(b.buf[:b.len])
|
||||
}
|
||||
|
||||
func (b *jsonBuf) push(c byte) {
|
||||
if b.len < MaxPayloadSize {
|
||||
b.buf[b.len] = c
|
||||
b.len++
|
||||
} else {
|
||||
b.overflow = true
|
||||
}
|
||||
}
|
||||
|
||||
func (b *jsonBuf) pushStr(s string) {
|
||||
for i := 0; i < len(s); i++ {
|
||||
b.push(s[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (b *jsonBuf) pushJSONString(s string) {
|
||||
b.push('"')
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch c {
|
||||
case '"':
|
||||
b.pushStr("\\\"")
|
||||
case '\\':
|
||||
b.pushStr("\\\\")
|
||||
case '\n':
|
||||
b.pushStr("\\n")
|
||||
case '\r':
|
||||
b.pushStr("\\r")
|
||||
case '\t':
|
||||
b.pushStr("\\t")
|
||||
default:
|
||||
if c < 0x20 {
|
||||
b.pushStr("\\u00")
|
||||
hex := "0123456789abcdef"
|
||||
b.push(hex[(c>>4)&0xF])
|
||||
b.push(hex[c&0xF])
|
||||
} else {
|
||||
b.push(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.push('"')
|
||||
}
|
||||
|
||||
func (b *jsonBuf) pushU32(val uint32) {
|
||||
if val == 0 {
|
||||
b.push('0')
|
||||
return
|
||||
}
|
||||
var tmp [10]byte
|
||||
i := 0
|
||||
for val > 0 && i < 10 {
|
||||
tmp[i] = '0' + byte(val%10)
|
||||
val /= 10
|
||||
i++
|
||||
}
|
||||
for i > 0 {
|
||||
i--
|
||||
b.push(tmp[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (b *jsonBuf) pushI32(val int32) {
|
||||
if val < 0 {
|
||||
b.push('-')
|
||||
b.pushU32(uint32(-val))
|
||||
} else {
|
||||
b.pushU32(uint32(val))
|
||||
}
|
||||
}
|
||||
|
||||
// TelemetryClient is the main client.
|
||||
type TelemetryClient struct {
|
||||
DeviceID string
|
||||
Consent Consent
|
||||
SessionID string
|
||||
ToolCallCount uint32
|
||||
ErrorCount uint32
|
||||
}
|
||||
|
||||
func NewTelemetryClient(deviceID string) *TelemetryClient {
|
||||
return &TelemetryClient{
|
||||
DeviceID: deviceID,
|
||||
Consent: NewConsent(),
|
||||
SessionID: deviceID,
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TelemetryClient) BeginSession(sessionID string) {
|
||||
tc.SessionID = sessionID
|
||||
tc.ToolCallCount = 0
|
||||
tc.ErrorCount = 0
|
||||
}
|
||||
|
||||
func (tc *TelemetryClient) buildEnvelope(buf *jsonBuf, p PlatformInfoer) {
|
||||
buf.pushStr("{\"schema_version\":1")
|
||||
buf.pushStr(",\"collected_at\":")
|
||||
buf.pushJSONString(p.IsoTimestamp())
|
||||
buf.pushStr(",\"device_id\":")
|
||||
buf.pushJSONString(tc.DeviceID)
|
||||
buf.pushStr(",\"platform\":")
|
||||
buf.pushJSONString(p.PlatformName())
|
||||
buf.pushStr(",\"consent\":")
|
||||
tc.buildConsentJSON(buf)
|
||||
}
|
||||
|
||||
func (tc *TelemetryClient) buildConsentJSON(buf *jsonBuf) {
|
||||
buf.pushStr("{\"categories\":[")
|
||||
cats := []struct {
|
||||
bit Category
|
||||
name string
|
||||
}{
|
||||
{CatToolCalls, "tool_calls"},
|
||||
{CatThinking, "thinking"},
|
||||
{CatChatMessages, "chat_messages"},
|
||||
{CatSessionMeta, "session_meta"},
|
||||
{CatAgentMetadata, "agent_metadata"},
|
||||
{CatErrorTraces, "error_traces"},
|
||||
{CatFileChanges, "file_changes"},
|
||||
{CatEnvironment, "environment"},
|
||||
}
|
||||
first := true
|
||||
for _, cat := range cats {
|
||||
if tc.Consent.Mask&cat.bit != 0 {
|
||||
if !first {
|
||||
buf.pushStr(",")
|
||||
}
|
||||
buf.pushJSONString(cat.name)
|
||||
first = false
|
||||
}
|
||||
}
|
||||
buf.pushStr("]")
|
||||
buf.pushStr(",\"retention_days\":")
|
||||
buf.pushU32(uint32(tc.Consent.RetentionDays))
|
||||
buf.pushStr(",\"redact_secrets\":")
|
||||
if tc.Consent.RedactSecrets {
|
||||
buf.pushStr("true")
|
||||
} else {
|
||||
buf.pushStr("false")
|
||||
}
|
||||
buf.pushStr("}")
|
||||
}
|
||||
|
||||
func buildSummary(buf *jsonBuf, cat string) {
|
||||
buf.pushStr(",\"_summary\":{\"")
|
||||
buf.pushStr(cat)
|
||||
buf.pushStr("\":1}")
|
||||
}
|
||||
|
||||
func closeEnvelope(buf *jsonBuf) {
|
||||
buf.pushStr("}")
|
||||
}
|
||||
|
||||
func (tc *TelemetryClient) SendEnvironment(p PlatformInfoer, t Transporter) bool {
|
||||
if !tc.Consent.Allows(CatEnvironment) {
|
||||
return false
|
||||
}
|
||||
|
||||
var buf jsonBuf
|
||||
tc.buildEnvelope(&buf, p)
|
||||
|
||||
buf.pushStr(",\"environment\":{")
|
||||
buf.pushStr("\"os\":")
|
||||
buf.pushJSONString(p.OsName())
|
||||
buf.pushStr(",\"os_version\":")
|
||||
buf.pushJSONString(p.OsVersion())
|
||||
buf.pushStr(",\"firmware_version\":")
|
||||
buf.pushJSONString(p.FirmwareVersion())
|
||||
buf.pushStr(",\"chip\":")
|
||||
buf.pushJSONString(p.ChipName())
|
||||
buf.pushStr(",\"cpu_mhz\":")
|
||||
buf.pushU32(p.CpuMhz())
|
||||
buf.pushStr(",\"flash_kb\":")
|
||||
buf.pushU32(p.FlashKb())
|
||||
buf.pushStr(",\"ram_kb\":")
|
||||
buf.pushU32(p.RamKb())
|
||||
buf.pushStr(",\"heap_free_bytes\":")
|
||||
buf.pushU32(p.HeapFreeBytes())
|
||||
buf.pushStr(",\"uptime_seconds\":")
|
||||
buf.pushU32(p.UptimeSeconds())
|
||||
if p.WifiRssi() != 0 {
|
||||
buf.pushStr(",\"wifi_rssi\":")
|
||||
buf.pushI32(p.WifiRssi())
|
||||
}
|
||||
if p.BatteryMv() != 0 {
|
||||
buf.pushStr(",\"battery_mv\":")
|
||||
buf.pushU32(p.BatteryMv())
|
||||
}
|
||||
buf.pushStr(",\"collected_at\":")
|
||||
buf.pushJSONString(p.IsoTimestamp())
|
||||
buf.pushStr("}")
|
||||
|
||||
buildSummary(&buf, "environment")
|
||||
closeEnvelope(&buf)
|
||||
|
||||
return t.Send(buf.slice())
|
||||
}
|
||||
|
||||
func (tc *TelemetryClient) SendToolCall(p PlatformInfoer, t Transporter, toolName, arguments, result string) bool {
|
||||
if !tc.Consent.Allows(CatToolCalls) {
|
||||
return false
|
||||
}
|
||||
|
||||
var buf jsonBuf
|
||||
tc.buildEnvelope(&buf, p)
|
||||
|
||||
buf.pushStr(",\"tool_calls\":[{")
|
||||
buf.pushStr("\"session_id\":")
|
||||
buf.pushJSONString(tc.SessionID)
|
||||
buf.pushStr(",\"tool_name\":")
|
||||
buf.pushJSONString(toolName)
|
||||
buf.pushStr(",\"arguments\":")
|
||||
buf.pushJSONString(arguments)
|
||||
buf.pushStr(",\"result\":")
|
||||
buf.pushJSONString(result)
|
||||
buf.pushStr(",\"timestamp\":")
|
||||
buf.pushJSONString(p.IsoTimestamp())
|
||||
buf.pushStr("}]")
|
||||
|
||||
buildSummary(&buf, "tool_calls")
|
||||
closeEnvelope(&buf)
|
||||
|
||||
tc.ToolCallCount++
|
||||
return t.Send(buf.slice())
|
||||
}
|
||||
|
||||
func (tc *TelemetryClient) SendSessionMeta(p PlatformInfoer, t Transporter) bool {
|
||||
if !tc.Consent.Allows(CatSessionMeta) {
|
||||
return false
|
||||
}
|
||||
|
||||
var buf jsonBuf
|
||||
tc.buildEnvelope(&buf, p)
|
||||
|
||||
buf.pushStr(",\"session_meta\":[{")
|
||||
buf.pushStr("\"session_id\":")
|
||||
buf.pushJSONString(tc.SessionID)
|
||||
buf.pushStr(",\"source\":\"bare-metal\"")
|
||||
buf.pushStr(",\"started_at\":")
|
||||
buf.pushJSONString(p.IsoTimestamp())
|
||||
buf.pushStr(",\"message_count\":")
|
||||
buf.pushU32(tc.ToolCallCount)
|
||||
buf.pushStr("}]")
|
||||
|
||||
buildSummary(&buf, "session_meta")
|
||||
closeEnvelope(&buf)
|
||||
|
||||
return t.Send(buf.slice())
|
||||
}
|
||||
|
||||
func (tc *TelemetryClient) SendErrorTrace(p PlatformInfoer, t Transporter, content string) bool {
|
||||
if !tc.Consent.Allows(CatErrorTraces) {
|
||||
return false
|
||||
}
|
||||
|
||||
var buf jsonBuf
|
||||
tc.buildEnvelope(&buf, p)
|
||||
|
||||
buf.pushStr(",\"error_traces\":[{")
|
||||
buf.pushStr("\"session_id\":")
|
||||
buf.pushJSONString(tc.SessionID)
|
||||
buf.pushStr(",\"log_file\":\"crash\"")
|
||||
buf.pushStr(",\"content\":")
|
||||
buf.pushJSONString(content)
|
||||
buf.pushStr("}]")
|
||||
|
||||
buildSummary(&buf, "error_traces")
|
||||
closeEnvelope(&buf)
|
||||
|
||||
tc.ErrorCount++
|
||||
return t.Send(buf.slice())
|
||||
}
|
||||
|
||||
func (tc *TelemetryClient) SendAgentMetadata(p PlatformInfoer, t Transporter, model, heteronym string) bool {
|
||||
if !tc.Consent.Allows(CatAgentMetadata) {
|
||||
return false
|
||||
}
|
||||
|
||||
var buf jsonBuf
|
||||
tc.buildEnvelope(&buf, p)
|
||||
|
||||
buf.pushStr(",\"agent_metadata\":{\"total_sessions\":1")
|
||||
buf.pushStr(",\"models_used\":{\"")
|
||||
buf.pushStr(model)
|
||||
buf.pushStr("\":1}")
|
||||
buf.pushStr(",\"heteronyms_used\":{\"")
|
||||
buf.pushStr(heteronym)
|
||||
buf.pushStr("\":1}}")
|
||||
|
||||
buildSummary(&buf, "agent_metadata")
|
||||
closeEnvelope(&buf)
|
||||
|
||||
return t.Send(buf.slice())
|
||||
}
|
||||
|
||||
// ─── Host implementations for testing ───────────────────────────────
|
||||
|
||||
type StdoutTransport struct{}
|
||||
|
||||
func (s *StdoutTransport) Send(payload string) bool {
|
||||
fmt.Printf("TELEMETRY PAYLOAD (%d bytes):\n%s\n", len(payload), payload)
|
||||
return true
|
||||
}
|
||||
|
||||
type HostPlatform struct{}
|
||||
|
||||
func (h *HostPlatform) PlatformName() string { return "host" }
|
||||
func (h *HostPlatform) OsName() string { return "Linux" }
|
||||
func (h *HostPlatform) OsVersion() string { return "6.1.0" }
|
||||
func (h *HostPlatform) FirmwareVersion() string { return "v2.8.0" }
|
||||
func (h *HostPlatform) ChipName() string { return "x86_64" }
|
||||
func (h *HostPlatform) CpuMhz() uint32 { return 3000 }
|
||||
func (h *HostPlatform) FlashKb() uint32 { return 0 }
|
||||
func (h *HostPlatform) RamKb() uint32 { return 16384 }
|
||||
func (h *HostPlatform) HeapFreeBytes() uint32 { return 999999 }
|
||||
func (h *HostPlatform) UptimeSeconds() uint32 { return 42 }
|
||||
func (h *HostPlatform) WifiRssi() int32 { return -55 }
|
||||
func (h *HostPlatform) BatteryMv() uint32 { return 3700 }
|
||||
func (h *HostPlatform) IsoTimestamp() string { return "2026-07-30T16:00:00Z" }
|
||||
|
||||
// Avoid unused-import errors when strconv/strings/unsafe are only used on device
|
||||
var _ = strconv.Itoa
|
||||
var _ = strings.Builder{}
|
||||
var _ = unsafe.Sizeof(0)
|
||||
|
||||
// ─── Host test entry point ──────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
platform := &HostPlatform{}
|
||||
transport := &StdoutTransport{}
|
||||
|
||||
tc := NewTelemetryClient("esp32-shower-001")
|
||||
tc.Consent.Enable(CatEnvironment)
|
||||
tc.Consent.Enable(CatSessionMeta)
|
||||
tc.Consent.Enable(CatToolCalls)
|
||||
tc.Consent.Enable(CatAgentMetadata)
|
||||
tc.Consent.Enable(CatErrorTraces)
|
||||
|
||||
tc.BeginSession("test-session-001")
|
||||
|
||||
fmt.Println("\n── Environment ──")
|
||||
tc.SendEnvironment(platform, transport)
|
||||
|
||||
fmt.Println("\n── Session Meta ──")
|
||||
tc.SendSessionMeta(platform, transport)
|
||||
|
||||
fmt.Println("\n── Tool Call ──")
|
||||
tc.SendToolCall(platform, transport, "read_sensor",
|
||||
`{"sensor":"flow_rate","channel":0}`,
|
||||
`{"value":4.2,"unit":"L/min"}`)
|
||||
|
||||
fmt.Println("\n── Agent Metadata ──")
|
||||
tc.SendAgentMetadata(platform, transport, "edge-tflite", "device-agent")
|
||||
|
||||
fmt.Println("\n── Error Trace ──")
|
||||
tc.SendErrorTrace(platform, transport, "Guru Meditation Error: Core 0 panic'ed (LoadProhibited).")
|
||||
|
||||
// Consent filter test
|
||||
tc.Consent.Disable(CatToolCalls)
|
||||
blocked := tc.SendToolCall(platform, transport, "blocked", "", "")
|
||||
if blocked {
|
||||
panic("tool_calls should be blocked when consent disabled")
|
||||
}
|
||||
fmt.Println("\n── Consent filter: PASS (tool_calls blocked) ──")
|
||||
|
||||
fmt.Println("\nAll Go tests passed.")
|
||||
}
|
||||
7
firmware/telemetry/rust/Cargo.lock
generated
Normal file
7
firmware/telemetry/rust/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aurelio-telemetry"
|
||||
version = "0.1.0"
|
||||
19
firmware/telemetry/rust/Cargo.toml
Normal file
19
firmware/telemetry/rust/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "aurelio-telemetry"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Bare-metal telemetry client for Réplica Omnisciente (no_std, no_alloc)"
|
||||
license = "MIT"
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
# Enable for real HTTP transport (needs a TCP stack)
|
||||
http = ["std"]
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[example]]
|
||||
name = "host_test"
|
||||
path = "examples/host_test.rs"
|
||||
56
firmware/telemetry/rust/examples/host_test.rs
Normal file
56
firmware/telemetry/rust/examples/host_test.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Host test for the Rust telemetry library.
|
||||
// Validates JSON wire format + consent filtering.
|
||||
|
||||
use aurelio_telemetry::*;
|
||||
|
||||
fn main() {
|
||||
use std_impls::{HostPlatform, StdoutTransport};
|
||||
|
||||
let mut platform = HostPlatform::new();
|
||||
let mut transport = StdoutTransport;
|
||||
|
||||
let mut tc = TelemetryClient::new("esp32-shower-001");
|
||||
tc.consent.enable(Category::Environment);
|
||||
tc.consent.enable(Category::SessionMeta);
|
||||
tc.consent.enable(Category::ToolCalls);
|
||||
tc.consent.enable(Category::AgentMetadata);
|
||||
tc.consent.enable(Category::ErrorTraces);
|
||||
|
||||
tc.begin_session("test-session-001");
|
||||
|
||||
println!("\n── Environment ──");
|
||||
assert!(tc.send_environment(&mut platform, &mut transport));
|
||||
|
||||
println!("\n── Session Meta ──");
|
||||
assert!(tc.send_session_meta(&platform, &mut transport));
|
||||
|
||||
println!("\n── Tool Call ──");
|
||||
assert!(tc.send_tool_call(
|
||||
&platform,
|
||||
&mut transport,
|
||||
"read_sensor",
|
||||
r#"{"sensor":"flow_rate","channel":0}"#,
|
||||
r#"{"value":4.2,"unit":"L/min"}"#
|
||||
));
|
||||
|
||||
println!("\n── Agent Metadata ──");
|
||||
assert!(tc.send_agent_metadata(&platform, &mut transport, "edge-tflite", "device-agent"));
|
||||
|
||||
println!("\n── Error Trace ──");
|
||||
assert!(tc.send_error_trace(
|
||||
&platform,
|
||||
&mut transport,
|
||||
"Guru Meditation Error: Core 0 panic'ed (LoadProhibited)."
|
||||
));
|
||||
|
||||
// Test consent filtering
|
||||
tc.consent.disable(Category::ToolCalls);
|
||||
let result = tc.send_tool_call(&platform, &mut transport, "blocked", "", "");
|
||||
assert!(
|
||||
!result,
|
||||
"tool_calls should be blocked when consent disabled"
|
||||
);
|
||||
println!("\n── Consent filter: PASS (tool_calls blocked) ──");
|
||||
|
||||
println!("\nAll Rust tests passed.");
|
||||
}
|
||||
517
firmware/telemetry/rust/src/lib.rs
Normal file
517
firmware/telemetry/rust/src/lib.rs
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
// Bare-metal telemetry client for Réplica Omnisciente.
|
||||
//
|
||||
// no_std + no_alloc. All JSON serialization uses a fixed-size stack buffer.
|
||||
// Targets: ARM Cortex-M (STM32, RP2040), RISC-V (ESP32-C3/C6), Xtensa (ESP32).
|
||||
//
|
||||
// On a real device you implement the `Transport` trait (UART, WiFi, Ethernet).
|
||||
// On host (std), use `StdoutTransport` for testing.
|
||||
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
/// Maximum JSON payload size. 4 KiB fits in a single MPU region on 32-bit.
|
||||
pub const MAX_PAYLOAD_SIZE: usize = 4096;
|
||||
|
||||
/// Telemetry data categories (matches the Python consent model).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u16)]
|
||||
pub enum Category {
|
||||
ToolCalls = 1 << 0,
|
||||
Thinking = 1 << 1,
|
||||
ChatMessages = 1 << 2,
|
||||
SessionMeta = 1 << 3,
|
||||
AgentMetadata = 1 << 4,
|
||||
ErrorTraces = 1 << 5,
|
||||
FileChanges = 1 << 6,
|
||||
Environment = 1 << 7,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Category::ToolCalls => "tool_calls",
|
||||
Category::Thinking => "thinking",
|
||||
Category::ChatMessages => "chat_messages",
|
||||
Category::SessionMeta => "session_meta",
|
||||
Category::AgentMetadata => "agent_metadata",
|
||||
Category::ErrorTraces => "error_traces",
|
||||
Category::FileChanges => "file_changes",
|
||||
Category::Environment => "environment",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport trait — implement this for your hardware (UART, WiFi, etc.).
|
||||
pub trait Transport {
|
||||
/// Send a JSON payload. Returns true on success.
|
||||
fn send(&mut self, payload: &str) -> bool;
|
||||
}
|
||||
|
||||
/// Fixed-size JSON string builder (no allocations).
|
||||
/// Silently truncates on overflow — callers should check `overflowed()`.
|
||||
pub struct JsonBuf {
|
||||
buf: [u8; MAX_PAYLOAD_SIZE],
|
||||
len: usize,
|
||||
overflow: bool,
|
||||
}
|
||||
|
||||
impl JsonBuf {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buf: [0; MAX_PAYLOAD_SIZE],
|
||||
len: 0,
|
||||
overflow: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.len = 0;
|
||||
self.overflow = false;
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
core::str::from_utf8(&self.buf[..self.len]).unwrap_or("")
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
pub fn overflowed(&self) -> bool {
|
||||
self.overflow
|
||||
}
|
||||
|
||||
pub fn push(&mut self, byte: u8) {
|
||||
if self.len < MAX_PAYLOAD_SIZE {
|
||||
self.buf[self.len] = byte;
|
||||
self.len += 1;
|
||||
} else {
|
||||
self.overflow = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_str(&mut self, s: &str) {
|
||||
for &b in s.as_bytes() {
|
||||
self.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a JSON-quoted string with proper escaping.
|
||||
pub fn push_json_string(&mut self, s: &str) {
|
||||
self.push(b'"');
|
||||
for &byte in s.as_bytes() {
|
||||
match byte {
|
||||
b'"' => self.push_str("\\\""),
|
||||
b'\\' => self.push_str("\\\\"),
|
||||
b'\n' => self.push_str("\\n"),
|
||||
b'\r' => self.push_str("\\r"),
|
||||
b'\t' => self.push_str("\\t"),
|
||||
0x00..=0x1F => {
|
||||
self.push_str("\\u00");
|
||||
let hex = b"0123456789abcdef";
|
||||
self.push(hex[(byte >> 4) as usize & 0xF]);
|
||||
self.push(hex[byte as usize & 0xF]);
|
||||
}
|
||||
_ => self.push(byte),
|
||||
}
|
||||
}
|
||||
self.push(b'"');
|
||||
}
|
||||
|
||||
/// Push a u32 as decimal.
|
||||
pub fn push_u32(&mut self, mut val: u32) {
|
||||
if val == 0 {
|
||||
self.push(b'0');
|
||||
return;
|
||||
}
|
||||
let mut tmp = [0u8; 10];
|
||||
let mut i = 0;
|
||||
while val > 0 && i < 10 {
|
||||
tmp[i] = b'0' + (val % 10) as u8;
|
||||
val /= 10;
|
||||
i += 1;
|
||||
}
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
self.push(tmp[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push an i32 as decimal (handles negative).
|
||||
pub fn push_i32(&mut self, val: i32) {
|
||||
if val < 0 {
|
||||
self.push(b'-');
|
||||
self.push_u32(val.wrapping_neg() as u32);
|
||||
} else {
|
||||
self.push_u32(val as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform info trait — implement for your target hardware.
|
||||
pub trait PlatformInfo {
|
||||
fn platform_name(&self) -> &str;
|
||||
fn os_name(&self) -> &str;
|
||||
fn os_version(&self) -> &str;
|
||||
fn firmware_version(&self) -> &str;
|
||||
fn chip_name(&self) -> &str;
|
||||
fn cpu_mhz(&self) -> u32;
|
||||
fn flash_kb(&self) -> u32;
|
||||
fn ram_kb(&self) -> u32;
|
||||
fn heap_free_bytes(&self) -> u32;
|
||||
fn uptime_seconds(&self) -> u32;
|
||||
fn wifi_rssi(&self) -> i32;
|
||||
fn battery_mv(&self) -> u32;
|
||||
fn iso_timestamp(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Consent bitmask.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Consent {
|
||||
mask: u16,
|
||||
pub retention_days: u16,
|
||||
pub redact_secrets: bool,
|
||||
}
|
||||
|
||||
impl Consent {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mask: 0,
|
||||
retention_days: 90,
|
||||
redact_secrets: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enable(&mut self, cat: Category) {
|
||||
self.mask |= cat as u16;
|
||||
}
|
||||
|
||||
pub fn disable(&mut self, cat: Category) {
|
||||
self.mask &= !(cat as u16);
|
||||
}
|
||||
|
||||
pub fn allows(&self, cat: Category) -> bool {
|
||||
self.mask & (cat as u16) != 0
|
||||
}
|
||||
|
||||
fn write_categories(&self, buf: &mut JsonBuf) {
|
||||
buf.push_str("\"categories\":[");
|
||||
let mut first = true;
|
||||
let cats: [(u16, &str); 8] = [
|
||||
(Category::ToolCalls as u16, "tool_calls"),
|
||||
(Category::Thinking as u16, "thinking"),
|
||||
(Category::ChatMessages as u16, "chat_messages"),
|
||||
(Category::SessionMeta as u16, "session_meta"),
|
||||
(Category::AgentMetadata as u16, "agent_metadata"),
|
||||
(Category::ErrorTraces as u16, "error_traces"),
|
||||
(Category::FileChanges as u16, "file_changes"),
|
||||
(Category::Environment as u16, "environment"),
|
||||
];
|
||||
for (bit, name) in &cats {
|
||||
if self.mask & bit != 0 {
|
||||
if !first {
|
||||
buf.push_str(",");
|
||||
}
|
||||
buf.push_json_string(name);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
buf.push_str("]");
|
||||
buf.push_str(",\"retention_days\":");
|
||||
buf.push_u32(self.retention_days as u32);
|
||||
buf.push_str(",\"redact_secrets\":");
|
||||
buf.push_str(if self.redact_secrets { "true" } else { "false" });
|
||||
}
|
||||
}
|
||||
|
||||
/// Telemetry client.
|
||||
pub struct TelemetryClient {
|
||||
pub device_id: &'static str,
|
||||
pub consent: Consent,
|
||||
session_id: &'static str,
|
||||
tool_call_count: u32,
|
||||
error_count: u32,
|
||||
}
|
||||
|
||||
impl TelemetryClient {
|
||||
pub fn new(device_id: &'static str) -> Self {
|
||||
Self {
|
||||
device_id,
|
||||
consent: Consent::new(),
|
||||
session_id: device_id,
|
||||
tool_call_count: 0,
|
||||
error_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_session(&mut self, session_id: &'static str) {
|
||||
self.session_id = session_id;
|
||||
self.tool_call_count = 0;
|
||||
self.error_count = 0;
|
||||
}
|
||||
|
||||
/// Build the JSON envelope header.
|
||||
fn build_envelope(&self, buf: &mut JsonBuf, platform: &dyn PlatformInfo) {
|
||||
buf.push_str("{\"schema_version\":1");
|
||||
buf.push_str(",\"collected_at\":");
|
||||
buf.push_json_string(platform.iso_timestamp());
|
||||
buf.push_str(",\"device_id\":");
|
||||
buf.push_json_string(self.device_id);
|
||||
buf.push_str(",\"platform\":");
|
||||
buf.push_json_string(platform.platform_name());
|
||||
buf.push_str(",\"consent\":{");
|
||||
self.consent.write_categories(buf);
|
||||
buf.push_str("}");
|
||||
}
|
||||
|
||||
fn build_summary(buf: &mut JsonBuf, cat: &str) {
|
||||
buf.push_str(",\"_summary\":{\"");
|
||||
buf.push_str(cat);
|
||||
buf.push_str("\":1}");
|
||||
}
|
||||
|
||||
fn close_envelope(buf: &mut JsonBuf) {
|
||||
buf.push_str("}");
|
||||
}
|
||||
|
||||
/// Send environment telemetry.
|
||||
pub fn send_environment(
|
||||
&mut self,
|
||||
platform: &dyn PlatformInfo,
|
||||
transport: &mut dyn Transport,
|
||||
) -> bool {
|
||||
if !self.consent.allows(Category::Environment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = JsonBuf::new();
|
||||
self.build_envelope(&mut buf, platform);
|
||||
|
||||
buf.push_str(",\"environment\":{");
|
||||
buf.push_str("\"os\":");
|
||||
buf.push_json_string(platform.os_name());
|
||||
buf.push_str(",\"os_version\":");
|
||||
buf.push_json_string(platform.os_version());
|
||||
buf.push_str(",\"firmware_version\":");
|
||||
buf.push_json_string(platform.firmware_version());
|
||||
buf.push_str(",\"chip\":");
|
||||
buf.push_json_string(platform.chip_name());
|
||||
buf.push_str(",\"cpu_mhz\":");
|
||||
buf.push_u32(platform.cpu_mhz());
|
||||
buf.push_str(",\"flash_kb\":");
|
||||
buf.push_u32(platform.flash_kb());
|
||||
buf.push_str(",\"ram_kb\":");
|
||||
buf.push_u32(platform.ram_kb());
|
||||
buf.push_str(",\"heap_free_bytes\":");
|
||||
buf.push_u32(platform.heap_free_bytes());
|
||||
buf.push_str(",\"uptime_seconds\":");
|
||||
buf.push_u32(platform.uptime_seconds());
|
||||
if platform.wifi_rssi() != 0 {
|
||||
buf.push_str(",\"wifi_rssi\":");
|
||||
buf.push_i32(platform.wifi_rssi());
|
||||
}
|
||||
if platform.battery_mv() != 0 {
|
||||
buf.push_str(",\"battery_mv\":");
|
||||
buf.push_u32(platform.battery_mv());
|
||||
}
|
||||
buf.push_str(",\"collected_at\":");
|
||||
buf.push_json_string(platform.iso_timestamp());
|
||||
buf.push_str("}");
|
||||
|
||||
Self::build_summary(&mut buf, "environment");
|
||||
Self::close_envelope(&mut buf);
|
||||
|
||||
transport.send(buf.as_str())
|
||||
}
|
||||
|
||||
/// Send a tool call.
|
||||
pub fn send_tool_call(
|
||||
&mut self,
|
||||
platform: &dyn PlatformInfo,
|
||||
transport: &mut dyn Transport,
|
||||
tool_name: &str,
|
||||
arguments: &str,
|
||||
result: &str,
|
||||
) -> bool {
|
||||
if !self.consent.allows(Category::ToolCalls) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = JsonBuf::new();
|
||||
self.build_envelope(&mut buf, platform);
|
||||
|
||||
buf.push_str(",\"tool_calls\":[{");
|
||||
buf.push_str("\"session_id\":");
|
||||
buf.push_json_string(self.session_id);
|
||||
buf.push_str(",\"tool_name\":");
|
||||
buf.push_json_string(tool_name);
|
||||
buf.push_str(",\"arguments\":");
|
||||
buf.push_json_string(arguments);
|
||||
buf.push_str(",\"result\":");
|
||||
buf.push_json_string(result);
|
||||
buf.push_str(",\"timestamp\":");
|
||||
buf.push_json_string(platform.iso_timestamp());
|
||||
buf.push_str("}]");
|
||||
|
||||
Self::build_summary(&mut buf, "tool_calls");
|
||||
Self::close_envelope(&mut buf);
|
||||
|
||||
self.tool_call_count += 1;
|
||||
transport.send(buf.as_str())
|
||||
}
|
||||
|
||||
/// Send session metadata.
|
||||
pub fn send_session_meta(
|
||||
&mut self,
|
||||
platform: &dyn PlatformInfo,
|
||||
transport: &mut dyn Transport,
|
||||
) -> bool {
|
||||
if !self.consent.allows(Category::SessionMeta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = JsonBuf::new();
|
||||
self.build_envelope(&mut buf, platform);
|
||||
|
||||
buf.push_str(",\"session_meta\":[{");
|
||||
buf.push_str("\"session_id\":");
|
||||
buf.push_json_string(self.session_id);
|
||||
buf.push_str(",\"source\":\"bare-metal\"");
|
||||
buf.push_str(",\"started_at\":");
|
||||
buf.push_json_string(platform.iso_timestamp());
|
||||
buf.push_str(",\"message_count\":");
|
||||
buf.push_u32(self.tool_call_count);
|
||||
buf.push_str("}]");
|
||||
|
||||
Self::build_summary(&mut buf, "session_meta");
|
||||
Self::close_envelope(&mut buf);
|
||||
|
||||
transport.send(buf.as_str())
|
||||
}
|
||||
|
||||
/// Send an error trace.
|
||||
pub fn send_error_trace(
|
||||
&mut self,
|
||||
platform: &dyn PlatformInfo,
|
||||
transport: &mut dyn Transport,
|
||||
content: &str,
|
||||
) -> bool {
|
||||
if !self.consent.allows(Category::ErrorTraces) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = JsonBuf::new();
|
||||
self.build_envelope(&mut buf, platform);
|
||||
|
||||
buf.push_str(",\"error_traces\":[{");
|
||||
buf.push_str("\"session_id\":");
|
||||
buf.push_json_string(self.session_id);
|
||||
buf.push_str(",\"log_file\":\"crash\"");
|
||||
buf.push_str(",\"content\":");
|
||||
buf.push_json_string(content);
|
||||
buf.push_str("}]");
|
||||
|
||||
Self::build_summary(&mut buf, "error_traces");
|
||||
Self::close_envelope(&mut buf);
|
||||
|
||||
self.error_count += 1;
|
||||
transport.send(buf.as_str())
|
||||
}
|
||||
|
||||
/// Send agent metadata.
|
||||
pub fn send_agent_metadata(
|
||||
&mut self,
|
||||
platform: &dyn PlatformInfo,
|
||||
transport: &mut dyn Transport,
|
||||
model: &str,
|
||||
heteronym: &str,
|
||||
) -> bool {
|
||||
if !self.consent.allows(Category::AgentMetadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = JsonBuf::new();
|
||||
self.build_envelope(&mut buf, platform);
|
||||
|
||||
buf.push_str(",\"agent_metadata\":{\"total_sessions\":1");
|
||||
buf.push_str(",\"models_used\":{\"");
|
||||
buf.push_str(model);
|
||||
buf.push_str("\":1}");
|
||||
buf.push_str(",\"heteronyms_used\":{\"");
|
||||
buf.push_str(heteronym);
|
||||
buf.push_str("\":1}}");
|
||||
|
||||
Self::build_summary(&mut buf, "agent_metadata");
|
||||
Self::close_envelope(&mut buf);
|
||||
|
||||
transport.send(buf.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Std-only: stdout transport for host testing ────────────────────
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
pub mod std_impls {
|
||||
use super::*;
|
||||
|
||||
pub struct StdoutTransport;
|
||||
impl Transport for StdoutTransport {
|
||||
fn send(&mut self, payload: &str) -> bool {
|
||||
println!("TELEMETRY PAYLOAD ({} bytes):\n{}", payload.len(), payload);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HostPlatform {
|
||||
ts: String,
|
||||
}
|
||||
|
||||
impl HostPlatform {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ts: "2026-07-30T16:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformInfo for HostPlatform {
|
||||
fn platform_name(&self) -> &str {
|
||||
"host"
|
||||
}
|
||||
fn os_name(&self) -> &str {
|
||||
"Linux"
|
||||
}
|
||||
fn os_version(&self) -> &str {
|
||||
"6.1.0"
|
||||
}
|
||||
fn firmware_version(&self) -> &str {
|
||||
"v2.8.0"
|
||||
}
|
||||
fn chip_name(&self) -> &str {
|
||||
"x86_64"
|
||||
}
|
||||
fn cpu_mhz(&self) -> u32 {
|
||||
3000
|
||||
}
|
||||
fn flash_kb(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
fn ram_kb(&self) -> u32 {
|
||||
16384
|
||||
}
|
||||
fn heap_free_bytes(&self) -> u32 {
|
||||
999999
|
||||
}
|
||||
fn uptime_seconds(&self) -> u32 {
|
||||
42
|
||||
}
|
||||
fn wifi_rssi(&self) -> i32 {
|
||||
-55
|
||||
}
|
||||
fn battery_mv(&self) -> u32 {
|
||||
3700
|
||||
}
|
||||
fn iso_timestamp(&self) -> &str {
|
||||
&self.ts
|
||||
}
|
||||
}
|
||||
}
|
||||
1
firmware/telemetry/rust/target/.rustc_info.json
Normal file
1
firmware/telemetry/rust/target/.rustc_info.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"rustc_fingerprint":1964222883484122369,"outputs":{"6615707503914109046":{"success":true,"status":"","code":0,"stdout":"rustc 1.96.1 (31fca3adb 2026-06-26)\nbinary: rustc\ncommit-hash: 31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd\ncommit-date: 2026-06-26\nhost: x86_64-unknown-linux-gnu\nrelease: 1.96.1\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/fabiorafaelcoutada/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
3
firmware/telemetry/rust/target/CACHEDIR.TAG
Normal file
3
firmware/telemetry/rust/target/CACHEDIR.TAG
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Signature: 8a477f597d28d172789f06886806bc55
|
||||
# This file is a cache directory tag created by cargo.
|
||||
# For information about cache directory tags see https://bford.info/cachedir/
|
||||
0
firmware/telemetry/rust/target/debug/.cargo-build-lock
Normal file
0
firmware/telemetry/rust/target/debug/.cargo-build-lock
Normal file
0
firmware/telemetry/rust/target/debug/.cargo-lock
Normal file
0
firmware/telemetry/rust/target/debug/.cargo-lock
Normal file
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
7104889c7fd9d72f
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"rustc":9777074978655410247,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"http\", \"std\"]","target":14344883914140707001,"profile":8731458305071235362,"path":1231571328248452122,"deps":[[10827035774762974286,"aurelio_telemetry",false,14458915426847734354]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aurelio-telemetry-74bbdc4dd49bbd51/dep-example-host_test","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
|
|
@ -0,0 +1 @@
|
|||
This file has an mtime of when this was started.
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
This file has an mtime of when this was started.
|
||||
|
|
@ -0,0 +1 @@
|
|||
52baac5ef962a8c8
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"rustc":9777074978655410247,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"http\", \"std\"]","target":8516389193771716695,"profile":8731458305071235362,"path":10763286916239946207,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aurelio-telemetry-af2dc0a784090110/dep-lib-aurelio_telemetry","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/deps/aurelio_telemetry-af2dc0a784090110.d: src/lib.rs
|
||||
|
||||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/deps/libaurelio_telemetry-af2dc0a784090110.rlib: src/lib.rs
|
||||
|
||||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/deps/libaurelio_telemetry-af2dc0a784090110.rmeta: src/lib.rs
|
||||
|
||||
src/lib.rs:
|
||||
Binary file not shown.
Binary file not shown.
BIN
firmware/telemetry/rust/target/debug/examples/host_test
Executable file
BIN
firmware/telemetry/rust/target/debug/examples/host_test
Executable file
Binary file not shown.
BIN
firmware/telemetry/rust/target/debug/examples/host_test-74bbdc4dd49bbd51
Executable file
BIN
firmware/telemetry/rust/target/debug/examples/host_test-74bbdc4dd49bbd51
Executable file
Binary file not shown.
|
|
@ -0,0 +1,5 @@
|
|||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/examples/host_test-74bbdc4dd49bbd51.d: examples/host_test.rs
|
||||
|
||||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/examples/host_test-74bbdc4dd49bbd51: examples/host_test.rs
|
||||
|
||||
examples/host_test.rs:
|
||||
|
|
@ -0,0 +1 @@
|
|||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/examples/host_test: /home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/examples/host_test.rs /home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/src/lib.rs
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/libaurelio_telemetry.rlib: /home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/src/lib.rs
|
||||
BIN
firmware/telemetry/rust/target/debug/libaurelio_telemetry.rlib
Normal file
BIN
firmware/telemetry/rust/target/debug/libaurelio_telemetry.rlib
Normal file
Binary file not shown.
92
firmware/telemetry/tools/ingest_simulator.py
Normal file
92
firmware/telemetry/tools/ingest_simulator.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bare-metal telemetry ingest simulator.
|
||||
|
||||
Listens on localhost:9999 and validates incoming payloads against the
|
||||
wire format spec. Prints a detailed report for each received payload.
|
||||
|
||||
Usage:
|
||||
python3 firmware/telemetry/tools/ingest_simulator.py
|
||||
python3 firmware/telemetry/tools/ingest_simulator.py --port 9999
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PORT = 9999
|
||||
|
||||
class IngestHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
|
||||
source = self.headers.get("X-Aurelio-Source", "?")
|
||||
transport = self.headers.get("X-Aurelio-Transport", "?")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" INGEST from {source} ({transport})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
errors = self._validate(payload)
|
||||
if errors:
|
||||
print(f" STATUS: INVALID ({len(errors)} errors)")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
self.send_response(400)
|
||||
else:
|
||||
print(f" STATUS: VALID")
|
||||
print(f" Device: {payload.get('device_id')}")
|
||||
print(f" Platform: {payload.get('platform')}")
|
||||
print(f" Consent: {payload.get('consent', {}).get('categories')}")
|
||||
summary = payload.get("_summary", {})
|
||||
print(f" Summary: {summary}")
|
||||
env = payload.get("environment")
|
||||
if env:
|
||||
print(f" Env: chip={env.get('chip')} heap={env.get('heap_free_bytes')} uptime={env.get('uptime_seconds')}s")
|
||||
self.send_response(200)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" STATUS: JSON PARSE ERROR: {e}")
|
||||
print(f" Body ({len(body)} bytes): {body[:500]}")
|
||||
self.send_response(400)
|
||||
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"status":"ok"}' if self.command == "POST" else b"")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
def _validate(self, p: dict) -> list[str]:
|
||||
errors = []
|
||||
for req in ("schema_version", "collected_at", "device_id", "platform", "consent"):
|
||||
if req not in p:
|
||||
errors.append(f"missing required field: {req}")
|
||||
if "consent" in p:
|
||||
c = p["consent"]
|
||||
if "categories" not in c:
|
||||
errors.append("consent.categories missing")
|
||||
if "retention_days" not in c:
|
||||
errors.append("consent.retention_days missing")
|
||||
if "_summary" not in p:
|
||||
errors.append("missing _summary")
|
||||
return errors
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass # suppress default logging
|
||||
|
||||
|
||||
def main():
|
||||
port = int(sys.argv[sys.argv.index("--port") + 1]) if "--port" in sys.argv else PORT
|
||||
server = HTTPServer(("0.0.0.0", port), IngestHandler)
|
||||
print(f"Ingest simulator listening on http://0.0.0.0:{port}")
|
||||
print("Waiting for telemetry payloads... (Ctrl+C to stop)\n")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down.")
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
531
firmware/telemetry/zig/aurelio_telemetry.zig
Normal file
531
firmware/telemetry/zig/aurelio_telemetry.zig
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
// Bare-metal telemetry client for Réplica Omnisciente.
|
||||
//
|
||||
// Single-file Zig library. Zero allocations. Comptime-sized buffers.
|
||||
// Targets: any 32-bit CPU with MMU/MPU (ARM Cortex-M, RISC-V, Xtensa).
|
||||
//
|
||||
// On a real device you implement the Transport interface (UART, WiFi, Ethernet).
|
||||
// On host, use stdoutTransport for testing.
|
||||
//
|
||||
// USAGE:
|
||||
// const at = @import("aurelio_telemetry.zig");
|
||||
// var client = at.TelemetryClient.init("esp32-shower-001");
|
||||
// client.consent.enable(.environment);
|
||||
// try client.sendEnvironment(&platform, &transport);
|
||||
//
|
||||
// Compile: zig build host_test (host)
|
||||
// Cross: zig build -Dtarget=thumb-freestanding (embedded)
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Maximum JSON payload size (4 KiB — fits in one MPU region).
|
||||
pub const MAX_PAYLOAD_SIZE: usize = 4096;
|
||||
|
||||
/// Telemetry data categories (bitmask, matches Python consent model).
|
||||
pub const Category = enum(u16) {
|
||||
tool_calls = 1 << 0,
|
||||
thinking = 1 << 1,
|
||||
chat_messages = 1 << 2,
|
||||
session_meta = 1 << 3,
|
||||
agent_metadata = 1 << 4,
|
||||
error_traces = 1 << 5,
|
||||
file_changes = 1 << 6,
|
||||
environment = 1 << 7,
|
||||
};
|
||||
|
||||
/// Consent bitmask.
|
||||
pub const Consent = struct {
|
||||
mask: u16 = 0,
|
||||
retention_days: u16 = 90,
|
||||
redact_secrets: bool = true,
|
||||
|
||||
pub fn enable(self: *Consent, cat: Category) void {
|
||||
self.mask |= @intFromEnum(cat);
|
||||
}
|
||||
|
||||
pub fn disable(self: *Consent, cat: Category) void {
|
||||
self.mask &= ~@as(u16, @intFromEnum(cat));
|
||||
}
|
||||
|
||||
pub fn allows(self: *const Consent, cat: Category) bool {
|
||||
return (self.mask & @intFromEnum(cat)) != 0;
|
||||
}
|
||||
};
|
||||
|
||||
/// Transport interface — implement for your hardware.
|
||||
pub const Transport = struct {
|
||||
ptr: *anyopaque,
|
||||
send_fn: *const fn (ptr: *anyopaque, payload: []const u8) bool,
|
||||
|
||||
pub fn send(self: *Transport, payload: []const u8) bool {
|
||||
return self.send_fn(self.ptr, payload);
|
||||
}
|
||||
};
|
||||
|
||||
/// Platform info — implement for your target.
|
||||
pub const PlatformInfo = struct {
|
||||
ptr: *anyopaque,
|
||||
vtable: *const VTable,
|
||||
|
||||
pub const VTable = struct {
|
||||
platform_name: *const fn (ptr: *anyopaque) []const u8,
|
||||
os_name: *const fn (ptr: *anyopaque) []const u8,
|
||||
os_version: *const fn (ptr: *anyopaque) []const u8,
|
||||
firmware_version: *const fn (ptr: *anyopaque) []const u8,
|
||||
chip_name: *const fn (ptr: *anyopaque) []const u8,
|
||||
cpu_mhz: *const fn (ptr: *anyopaque) u32,
|
||||
flash_kb: *const fn (ptr: *anyopaque) u32,
|
||||
ram_kb: *const fn (ptr: *anyopaque) u32,
|
||||
heap_free_bytes: *const fn (ptr: *anyopaque) u32,
|
||||
uptime_seconds: *const fn (ptr: *anyopaque) u32,
|
||||
wifi_rssi: *const fn (ptr: *anyopaque) i32,
|
||||
battery_mv: *const fn (ptr: *anyopaque) u32,
|
||||
iso_timestamp: *const fn (ptr: *anyopaque) []const u8,
|
||||
};
|
||||
};
|
||||
|
||||
/// Fixed-size JSON builder (no allocations).
|
||||
pub const JsonBuf = struct {
|
||||
buf: [MAX_PAYLOAD_SIZE]u8 = undefined,
|
||||
len: usize = 0,
|
||||
overflow: bool = false,
|
||||
|
||||
pub fn reset(self: *JsonBuf) void {
|
||||
self.len = 0;
|
||||
self.overflow = false;
|
||||
}
|
||||
|
||||
pub fn slice(self: *const JsonBuf) []const u8 {
|
||||
return self.buf[0..self.len];
|
||||
}
|
||||
|
||||
pub fn push(self: *JsonBuf, byte: u8) void {
|
||||
if (self.len < MAX_PAYLOAD_SIZE) {
|
||||
self.buf[self.len] = byte;
|
||||
self.len += 1;
|
||||
} else {
|
||||
self.overflow = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pushStr(self: *JsonBuf, s: []const u8) void {
|
||||
for (s) |b| self.push(b);
|
||||
}
|
||||
|
||||
/// Push a JSON-quoted string with escaping.
|
||||
pub fn pushJsonString(self: *JsonBuf, s: []const u8) void {
|
||||
self.push('"');
|
||||
for (s) |byte| {
|
||||
switch (byte) {
|
||||
'"' => self.pushStr("\\\""),
|
||||
'\\' => self.pushStr("\\\\"),
|
||||
'\n' => self.pushStr("\\n"),
|
||||
'\r' => self.pushStr("\\r"),
|
||||
'\t' => self.pushStr("\\t"),
|
||||
else => {
|
||||
if (byte < 0x20) {
|
||||
self.pushStr("\\u00");
|
||||
const hex = "0123456789abcdef";
|
||||
self.push(hex[(byte >> 4) & 0xF]);
|
||||
self.push(hex[byte & 0xF]);
|
||||
} else {
|
||||
self.push(byte);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
self.push('"');
|
||||
}
|
||||
|
||||
pub fn pushU32(self: *JsonBuf, val: u32) void {
|
||||
if (val == 0) {
|
||||
self.push('0');
|
||||
return;
|
||||
}
|
||||
var v = val;
|
||||
var tmp: [10]u8 = undefined;
|
||||
var i: usize = 0;
|
||||
while (v > 0 and i < 10) : (i += 1) {
|
||||
tmp[i] = '0' + @as(u8, @intCast(v % 10));
|
||||
v /= 10;
|
||||
}
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
self.push(tmp[i]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pushI32(self: *JsonBuf, val: i32) void {
|
||||
if (val < 0) {
|
||||
self.push('-');
|
||||
self.pushU32(@as(u32, @intCast(-(val + 1))) + 1); // handle INT32_MIN
|
||||
} else {
|
||||
self.pushU32(@intCast(val));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Telemetry client.
|
||||
pub const TelemetryClient = struct {
|
||||
device_id: []const u8,
|
||||
consent: Consent,
|
||||
session_id: []const u8,
|
||||
tool_call_count: u32 = 0,
|
||||
error_count: u32 = 0,
|
||||
|
||||
pub fn init(device_id: []const u8) TelemetryClient {
|
||||
return .{
|
||||
.device_id = device_id,
|
||||
.consent = .{},
|
||||
.session_id = device_id,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn beginSession(self: *TelemetryClient, session_id: []const u8) void {
|
||||
self.session_id = session_id;
|
||||
self.tool_call_count = 0;
|
||||
self.error_count = 0;
|
||||
}
|
||||
|
||||
fn buildEnvelope(self: *const TelemetryClient, buf: *JsonBuf, platform: *const PlatformInfo) void {
|
||||
buf.pushStr("{\"schema_version\":1");
|
||||
buf.pushStr(",\"collected_at\":");
|
||||
buf.pushJsonString(platform.vtable.iso_timestamp(platform.ptr));
|
||||
buf.pushStr(",\"device_id\":");
|
||||
buf.pushJsonString(self.device_id);
|
||||
buf.pushStr(",\"platform\":");
|
||||
buf.pushJsonString(platform.vtable.platform_name(platform.ptr));
|
||||
buf.pushStr(",\"consent\":");
|
||||
buildConsentJson(buf, &self.consent);
|
||||
}
|
||||
|
||||
fn buildSummary(buf: *JsonBuf, cat: []const u8) void {
|
||||
buf.pushStr(",\"_summary\":{\"");
|
||||
buf.pushStr(cat);
|
||||
buf.pushStr("\":1}");
|
||||
}
|
||||
|
||||
fn closeEnvelope(buf: *JsonBuf) void {
|
||||
buf.pushStr("}");
|
||||
}
|
||||
|
||||
fn buildConsentJson(buf: *JsonBuf, consent: *const Consent) void {
|
||||
buf.pushStr("{\"categories\":[");
|
||||
const cats = [_]struct { bit: u16, name: []const u8 }{
|
||||
.{ .bit = @intFromEnum(Category.tool_calls), .name = "tool_calls" },
|
||||
.{ .bit = @intFromEnum(Category.thinking), .name = "thinking" },
|
||||
.{ .bit = @intFromEnum(Category.chat_messages), .name = "chat_messages" },
|
||||
.{ .bit = @intFromEnum(Category.session_meta), .name = "session_meta" },
|
||||
.{ .bit = @intFromEnum(Category.agent_metadata), .name = "agent_metadata" },
|
||||
.{ .bit = @intFromEnum(Category.error_traces), .name = "error_traces" },
|
||||
.{ .bit = @intFromEnum(Category.file_changes), .name = "file_changes" },
|
||||
.{ .bit = @intFromEnum(Category.environment), .name = "environment" },
|
||||
};
|
||||
var first = true;
|
||||
for (cats) |cat| {
|
||||
if ((consent.mask & cat.bit) != 0) {
|
||||
if (!first) buf.pushStr(",");
|
||||
buf.pushJsonString(cat.name);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
buf.pushStr("]");
|
||||
buf.pushStr(",\"retention_days\":");
|
||||
buf.pushU32(consent.retention_days);
|
||||
buf.pushStr(",\"redact_secrets\":");
|
||||
buf.pushStr(if (consent.redact_secrets) "true" else "false");
|
||||
buf.pushStr("}");
|
||||
}
|
||||
|
||||
pub fn sendEnvironment(
|
||||
self: *TelemetryClient,
|
||||
platform: *const PlatformInfo,
|
||||
transport: *Transport,
|
||||
) bool {
|
||||
if (!self.consent.allows(.environment)) return false;
|
||||
|
||||
var buf = JsonBuf{};
|
||||
buildEnvelope(self, &buf, platform);
|
||||
|
||||
buf.pushStr(",\"environment\":{");
|
||||
buf.pushStr("\"os\":");
|
||||
buf.pushJsonString(platform.vtable.os_name(platform.ptr));
|
||||
buf.pushStr(",\"os_version\":");
|
||||
buf.pushJsonString(platform.vtable.os_version(platform.ptr));
|
||||
buf.pushStr(",\"firmware_version\":");
|
||||
buf.pushJsonString(platform.vtable.firmware_version(platform.ptr));
|
||||
buf.pushStr(",\"chip\":");
|
||||
buf.pushJsonString(platform.vtable.chip_name(platform.ptr));
|
||||
buf.pushStr(",\"cpu_mhz\":");
|
||||
buf.pushU32(platform.vtable.cpu_mhz(platform.ptr));
|
||||
buf.pushStr(",\"flash_kb\":");
|
||||
buf.pushU32(platform.vtable.flash_kb(platform.ptr));
|
||||
buf.pushStr(",\"ram_kb\":");
|
||||
buf.pushU32(platform.vtable.ram_kb(platform.ptr));
|
||||
buf.pushStr(",\"heap_free_bytes\":");
|
||||
buf.pushU32(platform.vtable.heap_free_bytes(platform.ptr));
|
||||
buf.pushStr(",\"uptime_seconds\":");
|
||||
buf.pushU32(platform.vtable.uptime_seconds(platform.ptr));
|
||||
const rssi = platform.vtable.wifi_rssi(platform.ptr);
|
||||
if (rssi != 0) {
|
||||
buf.pushStr(",\"wifi_rssi\":");
|
||||
buf.pushI32(rssi);
|
||||
}
|
||||
const batt = platform.vtable.battery_mv(platform.ptr);
|
||||
if (batt != 0) {
|
||||
buf.pushStr(",\"battery_mv\":");
|
||||
buf.pushU32(batt);
|
||||
}
|
||||
buf.pushStr(",\"collected_at\":");
|
||||
buf.pushJsonString(platform.vtable.iso_timestamp(platform.ptr));
|
||||
buf.pushStr("}");
|
||||
|
||||
buildSummary(&buf, "environment");
|
||||
closeEnvelope(&buf);
|
||||
|
||||
return transport.send(buf.slice());
|
||||
}
|
||||
|
||||
pub fn sendToolCall(
|
||||
self: *TelemetryClient,
|
||||
platform: *const PlatformInfo,
|
||||
transport: *Transport,
|
||||
tool_name: []const u8,
|
||||
arguments: []const u8,
|
||||
result: []const u8,
|
||||
) bool {
|
||||
if (!self.consent.allows(.tool_calls)) return false;
|
||||
|
||||
var buf = JsonBuf{};
|
||||
buildEnvelope(self, &buf, platform);
|
||||
|
||||
buf.pushStr(",\"tool_calls\":[{");
|
||||
buf.pushStr("\"session_id\":");
|
||||
buf.pushJsonString(self.session_id);
|
||||
buf.pushStr(",\"tool_name\":");
|
||||
buf.pushJsonString(tool_name);
|
||||
buf.pushStr(",\"arguments\":");
|
||||
buf.pushJsonString(arguments);
|
||||
buf.pushStr(",\"result\":");
|
||||
buf.pushJsonString(result);
|
||||
buf.pushStr(",\"timestamp\":");
|
||||
buf.pushJsonString(platform.vtable.iso_timestamp(platform.ptr));
|
||||
buf.pushStr("}]");
|
||||
|
||||
buildSummary(&buf, "tool_calls");
|
||||
closeEnvelope(&buf);
|
||||
|
||||
self.tool_call_count += 1;
|
||||
return transport.send(buf.slice());
|
||||
}
|
||||
|
||||
pub fn sendSessionMeta(
|
||||
self: *TelemetryClient,
|
||||
platform: *const PlatformInfo,
|
||||
transport: *Transport,
|
||||
) bool {
|
||||
if (!self.consent.allows(.session_meta)) return false;
|
||||
|
||||
var buf = JsonBuf{};
|
||||
buildEnvelope(self, &buf, platform);
|
||||
|
||||
buf.pushStr(",\"session_meta\":[{");
|
||||
buf.pushStr("\"session_id\":");
|
||||
buf.pushJsonString(self.session_id);
|
||||
buf.pushStr(",\"source\":\"bare-metal\"");
|
||||
buf.pushStr(",\"started_at\":");
|
||||
buf.pushJsonString(platform.vtable.iso_timestamp(platform.ptr));
|
||||
buf.pushStr(",\"message_count\":");
|
||||
buf.pushU32(self.tool_call_count);
|
||||
buf.pushStr("}]");
|
||||
|
||||
buildSummary(&buf, "session_meta");
|
||||
closeEnvelope(&buf);
|
||||
|
||||
return transport.send(buf.slice());
|
||||
}
|
||||
|
||||
pub fn sendErrorTrace(
|
||||
self: *TelemetryClient,
|
||||
platform: *const PlatformInfo,
|
||||
transport: *Transport,
|
||||
content: []const u8,
|
||||
) bool {
|
||||
if (!self.consent.allows(.error_traces)) return false;
|
||||
|
||||
var buf = JsonBuf{};
|
||||
buildEnvelope(self, &buf, platform);
|
||||
|
||||
buf.pushStr(",\"error_traces\":[{");
|
||||
buf.pushStr("\"session_id\":");
|
||||
buf.pushJsonString(self.session_id);
|
||||
buf.pushStr(",\"log_file\":\"crash\"");
|
||||
buf.pushStr(",\"content\":");
|
||||
buf.pushJsonString(content);
|
||||
buf.pushStr("}]");
|
||||
|
||||
buildSummary(&buf, "error_traces");
|
||||
closeEnvelope(&buf);
|
||||
|
||||
self.error_count += 1;
|
||||
return transport.send(buf.slice());
|
||||
}
|
||||
|
||||
pub fn sendAgentMetadata(
|
||||
self: *TelemetryClient,
|
||||
platform: *const PlatformInfo,
|
||||
transport: *Transport,
|
||||
model: []const u8,
|
||||
heteronym: []const u8,
|
||||
) bool {
|
||||
if (!self.consent.allows(.agent_metadata)) return false;
|
||||
|
||||
var buf = JsonBuf{};
|
||||
buildEnvelope(self, &buf, platform);
|
||||
|
||||
buf.pushStr(",\"agent_metadata\":{\"total_sessions\":1");
|
||||
buf.pushStr(",\"models_used\":{\"");
|
||||
buf.pushStr(model);
|
||||
buf.pushStr("\":1}");
|
||||
buf.pushStr(",\"heteronyms_used\":{\"");
|
||||
buf.pushStr(heteronym);
|
||||
buf.pushStr("\":1}}");
|
||||
|
||||
buildSummary(&buf, "agent_metadata");
|
||||
closeEnvelope(&buf);
|
||||
|
||||
return transport.send(buf.slice());
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Host implementation (for testing) ──────────────────────────────
|
||||
|
||||
const HostPlatform = struct {
|
||||
ts: []const u8 = "2026-07-30T16:00:00Z",
|
||||
|
||||
fn platformInfo(self: *HostPlatform) PlatformInfo {
|
||||
return .{
|
||||
.ptr = self,
|
||||
.vtable = &host_vtable,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const host_vtable = PlatformInfo.VTable{
|
||||
.platform_name = hostPlatformName,
|
||||
.os_name = hostOsName,
|
||||
.os_version = hostOsVersion,
|
||||
.firmware_version = hostFwVersion,
|
||||
.chip_name = hostChipName,
|
||||
.cpu_mhz = hostCpuMhz,
|
||||
.flash_kb = hostFlashKb,
|
||||
.ram_kb = hostRamKb,
|
||||
.heap_free_bytes = hostHeapFree,
|
||||
.uptime_seconds = hostUptime,
|
||||
.wifi_rssi = hostRssi,
|
||||
.battery_mv = hostBattery,
|
||||
.iso_timestamp = hostTs,
|
||||
};
|
||||
|
||||
fn hostPlatformName(_: *anyopaque) []const u8 {
|
||||
return "host";
|
||||
}
|
||||
fn hostOsName(_: *anyopaque) []const u8 {
|
||||
return "Linux";
|
||||
}
|
||||
fn hostOsVersion(_: *anyopaque) []const u8 {
|
||||
return "6.1.0";
|
||||
}
|
||||
fn hostFwVersion(_: *anyopaque) []const u8 {
|
||||
return "v2.8.0";
|
||||
}
|
||||
fn hostChipName(_: *anyopaque) []const u8 {
|
||||
return "x86_64";
|
||||
}
|
||||
fn hostCpuMhz(_: *anyopaque) u32 {
|
||||
return 3000;
|
||||
}
|
||||
fn hostFlashKb(_: *anyopaque) u32 {
|
||||
return 0;
|
||||
}
|
||||
fn hostRamKb(_: *anyopaque) u32 {
|
||||
return 16384;
|
||||
}
|
||||
fn hostHeapFree(_: *anyopaque) u32 {
|
||||
return 999999;
|
||||
}
|
||||
fn hostUptime(_: *anyopaque) u32 {
|
||||
return 42;
|
||||
}
|
||||
fn hostRssi(_: *anyopaque) i32 {
|
||||
return -55;
|
||||
}
|
||||
fn hostBattery(_: *anyopaque) u32 {
|
||||
return 3700;
|
||||
}
|
||||
fn hostTs(ptr: *anyopaque) []const u8 {
|
||||
const self: *HostPlatform = @ptrCast(@alignCast(ptr));
|
||||
return self.ts;
|
||||
}
|
||||
|
||||
const StdoutTransport = struct {
|
||||
fn transport(self: *StdoutTransport) Transport {
|
||||
return .{
|
||||
.ptr = self,
|
||||
.send_fn = stdoutSend,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
fn stdoutSend(_: *anyopaque, payload: []const u8) bool {
|
||||
const stdout = std.io.getStdOut().writer();
|
||||
stdout.print("TELEMETRY PAYLOAD ({d} bytes):\n{s}\n", .{ payload.len, payload }) catch return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Host test entry point ──────────────────────────────────────────
|
||||
|
||||
pub fn main() void {
|
||||
var host = HostPlatform{};
|
||||
const platform = host.platformInfo();
|
||||
|
||||
var stdout_transport = StdoutTransport{};
|
||||
var transport = stdout_transport.transport();
|
||||
|
||||
var tc = TelemetryClient.init("esp32-shower-001");
|
||||
tc.consent.enable(.environment);
|
||||
tc.consent.enable(.session_meta);
|
||||
tc.consent.enable(.tool_calls);
|
||||
tc.consent.enable(.agent_metadata);
|
||||
tc.consent.enable(.error_traces);
|
||||
|
||||
tc.beginSession("test-session-001");
|
||||
|
||||
std.debug.print("\n-- Environment --\n", .{});
|
||||
_ = tc.sendEnvironment(&platform, &transport);
|
||||
|
||||
std.debug.print("\n-- Session Meta --\n", .{});
|
||||
_ = tc.sendSessionMeta(&platform, &transport);
|
||||
|
||||
std.debug.print("\n-- Tool Call --\n", .{});
|
||||
_ = tc.sendToolCall(
|
||||
&platform,
|
||||
&transport,
|
||||
"read_sensor",
|
||||
"{\"sensor\":\"flow_rate\",\"channel\":0}",
|
||||
"{\"value\":4.2,\"unit\":\"L/min\"}",
|
||||
);
|
||||
|
||||
std.debug.print("\n-- Agent Metadata --\n", .{});
|
||||
_ = tc.sendAgentMetadata(&platform, &transport, "edge-tflite", "device-agent");
|
||||
|
||||
std.debug.print("\n-- Error Trace --\n", .{});
|
||||
_ = tc.sendErrorTrace(&platform, &transport, "Guru Meditation Error: Core 0 panic'ed (LoadProhibited).");
|
||||
|
||||
// Consent filter test
|
||||
tc.consent.disable(.tool_calls);
|
||||
const blocked = tc.sendToolCall(&platform, &transport, "blocked", "", "");
|
||||
std.debug.assert(!blocked);
|
||||
std.debug.print("\n-- Consent filter: PASS (tool_calls blocked) --\n", .{});
|
||||
|
||||
std.debug.print("\nAll Zig tests passed.\n", .{});
|
||||
}
|
||||
410
infra/lab-gateway/esp_client.py
Normal file
410
infra/lab-gateway/esp_client.py
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
#!/usr/bin/env python3
|
||||
"""esp_client.py — laptop-side client for the lab-gateway /esp/* endpoints.
|
||||
|
||||
Lets you flash ESP32 devices connected to a Proxmox host without local USB.
|
||||
The gateway runs esptool on the host where the device is plugged in; this
|
||||
client uploads the firmware bundle and streams the esptool output back.
|
||||
|
||||
Usage
|
||||
-----
|
||||
# 0. Point at the gateway (or set $LAB_GATEWAY)
|
||||
export LAB_GATEWAY=http://192.168.0.38:8910
|
||||
|
||||
# 1. List USB serial ports visible on the gateway host
|
||||
python3 esp_client.py ports
|
||||
|
||||
# 2. Probe chip / MAC
|
||||
python3 esp_client.py chip --port /dev/ttyACM0
|
||||
|
||||
# 3. Erase flash (ROM download mode)
|
||||
python3 esp_client.py erase --port /dev/ttyACM0
|
||||
|
||||
# 4. Flash from an idf.py build bundle (build_v28/, build/, etc.)
|
||||
python3 esp_client.py flash --bundle build_v28 --port /dev/ttyACM0
|
||||
python3 esp_client.py flash --bundle build_v28 --port /dev/ttyACM0 --erase
|
||||
|
||||
# 5. Live serial monitor (Ctrl-] to quit, like telnet)
|
||||
python3 esp_client.py monitor --port /dev/ttyACM0
|
||||
|
||||
# 6. Inspect recent jobs
|
||||
python3 esp_client.py jobs
|
||||
python3 esp_client.py jobs --job-id <id>
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
pip install requests websockets
|
||||
|
||||
The ``--bundle`` directory must contain an idf.py-generated ``flash_args``
|
||||
(or ``flash_project_args``) plus the .bin files it references. Paths inside
|
||||
flash_args are resolved relative to the bundle directory, matching idf.py.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
sys.exit("missing dependency: pip install requests")
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
sys.exit("missing dependency: pip install websockets")
|
||||
|
||||
|
||||
# ── flash_args parsing ──────────────────────────────────────────
|
||||
|
||||
def parse_flash_args(bundle_dir: str) -> tuple[list[tuple[str, str]], dict]:
|
||||
"""Parse idf.py's ``<bundle>/flash_args`` (or ``flash_project_args``).
|
||||
|
||||
Returns ``(parts, opts)`` where ``parts`` is a list of
|
||||
``(offset_str, abs_path)`` and ``opts`` is a dict of the esptool
|
||||
write_flash flags (flash_mode, flash_size, flash_freq, ...).
|
||||
"""
|
||||
bdir = os.path.abspath(bundle_dir)
|
||||
args_path = os.path.join(bdir, "flash_args")
|
||||
if not os.path.isfile(args_path):
|
||||
args_path = os.path.join(bdir, "flash_project_args")
|
||||
if not os.path.isfile(args_path):
|
||||
raise FileNotFoundError(
|
||||
f"no flash_args (or flash_project_args) in {bdir}"
|
||||
)
|
||||
|
||||
parts: list[tuple[str, str]] = []
|
||||
opts: dict[str, str] = {}
|
||||
with open(args_path) as f:
|
||||
lines = [ln.strip() for ln in f if ln.strip()]
|
||||
|
||||
for line in lines:
|
||||
toks = line.split()
|
||||
if not toks:
|
||||
continue
|
||||
if toks[0].startswith("--"):
|
||||
i = 0
|
||||
while i < len(toks):
|
||||
key = toks[i].lstrip("-").replace("-", "_")
|
||||
if i + 1 < len(toks) and not toks[i + 1].startswith("--"):
|
||||
opts[key] = toks[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
offset, relpath = toks[0], toks[1]
|
||||
parts.append((offset, os.path.join(bdir, relpath)))
|
||||
return parts, opts
|
||||
|
||||
|
||||
# ── job streaming over WebSocket ───────────────────────────────
|
||||
|
||||
def ws_url_for(http_url: str, path: str) -> str:
|
||||
u = urllib.parse.urlparse(http_url)
|
||||
scheme = "wss" if u.scheme == "https" else "ws"
|
||||
return f"{scheme}://{u.netloc}{path}"
|
||||
|
||||
|
||||
def stream_job(ws_url: str, job_id: str) -> int:
|
||||
"""Connect to /esp/stream and print output until the job ends."""
|
||||
async def _run() -> int:
|
||||
async with websockets.connect(ws_url) as ws:
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
print(raw)
|
||||
continue
|
||||
t = msg.get("type")
|
||||
if t == "line":
|
||||
print(msg.get("line", ""))
|
||||
elif t == "state":
|
||||
print(f"[job {msg.get('job_id')}] {msg.get('kind')} "
|
||||
f"state={msg.get('state')} port={msg.get('port')}")
|
||||
elif t == "done":
|
||||
rc = msg.get("return_code")
|
||||
print(f"[done] state={msg.get('state')} "
|
||||
f"return_code={rc}")
|
||||
return int(rc) if rc is not None else 0
|
||||
return 0
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
# ── subcommands ────────────────────────────────────────────────
|
||||
|
||||
def cmd_ports(args):
|
||||
r = requests.get(f"{args.gateway}/esp/ports", timeout=10)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
ver = data.get("esptool_version") or "not available"
|
||||
print(f"esptool: {ver}")
|
||||
ports = data.get("ports", [])
|
||||
if not ports:
|
||||
print("(no /dev/ttyACM* or /dev/ttyUSB* devices)")
|
||||
for p in ports:
|
||||
desc = (p.get("product") or p.get("description")
|
||||
or p.get("manufacturer") or "")
|
||||
vid, pid = p.get("vid"), p.get("pid")
|
||||
print(f" {p['device']:<18} {desc:<30} "
|
||||
f"vid={vid} pid={pid} sn={p.get('serial_number')}")
|
||||
|
||||
|
||||
def cmd_chip(args):
|
||||
params = {"port": args.port, "baud": args.baud}
|
||||
if args.chip:
|
||||
params["chip"] = args.chip
|
||||
r = requests.get(f"{args.gateway}/esp/chip", params=params, timeout=30)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
sys.stdout.write(data.get("output", ""))
|
||||
sys.exit(0 if data.get("return_code") == 0 else 1)
|
||||
|
||||
|
||||
def cmd_erase(args):
|
||||
payload = {"port": args.port, "baud": args.baud, "before": args.before}
|
||||
if args.chip:
|
||||
payload["chip"] = args.chip
|
||||
r = requests.post(f"{args.gateway}/esp/erase", json=payload, timeout=15)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if "error" in body:
|
||||
sys.exit(body["error"])
|
||||
job_id = body["job_id"]
|
||||
print(f"[gateway] erase job {job_id} started on {args.port}")
|
||||
rc = stream_job(ws_url_for(args.gateway, "/esp/stream"), job_id)
|
||||
sys.exit(0 if rc == 0 else 1)
|
||||
|
||||
|
||||
def cmd_flash(args):
|
||||
parts, opts = parse_flash_args(args.bundle)
|
||||
|
||||
flash_mode = args.flash_mode or opts.get("flash_mode", "dio")
|
||||
flash_size = args.flash_size or opts.get("flash_size", "8MB")
|
||||
flash_freq = args.flash_freq or opts.get("flash_freq", "80m")
|
||||
|
||||
for spec in args.file or []:
|
||||
if "@" not in spec:
|
||||
sys.exit(f"--file expects offset@path, got: {spec}")
|
||||
offset, path = spec.split("@", 1)
|
||||
parts.append((offset, path))
|
||||
|
||||
if not parts:
|
||||
sys.exit("no files to flash (flash_args empty and no --file given)")
|
||||
|
||||
data = {
|
||||
"port": args.port,
|
||||
"baud": str(args.baud),
|
||||
"flash_mode": flash_mode,
|
||||
"flash_size": flash_size,
|
||||
"flash_freq": flash_freq,
|
||||
"before": args.before,
|
||||
"after": args.after,
|
||||
"erase": "1" if args.erase else "0",
|
||||
}
|
||||
if args.chip:
|
||||
data["chip"] = args.chip
|
||||
|
||||
files = []
|
||||
open_handles = []
|
||||
try:
|
||||
for offset, path in parts:
|
||||
if not os.path.isfile(path):
|
||||
sys.exit(f"missing flash part: {path}")
|
||||
fh = open(path, "rb")
|
||||
open_handles.append(fh)
|
||||
files.append((offset, (os.path.basename(path), fh,
|
||||
"application/octet-stream")))
|
||||
|
||||
print(f"[upload] {len(parts)} part(s) → "
|
||||
f"{args.gateway}/esp/flash ({args.port})")
|
||||
for off, p in parts:
|
||||
print(f" {off:<10} {os.path.basename(p):<28} "
|
||||
f"({os.path.getsize(p)} bytes)")
|
||||
print(f"[upload] flash_mode={flash_mode} "
|
||||
f"flash_size={flash_size} flash_freq={flash_freq}"
|
||||
+ (" + erase_flash" if args.erase else ""))
|
||||
|
||||
r = requests.post(f"{args.gateway}/esp/flash",
|
||||
data=data, files=files, timeout=120)
|
||||
r.raise_for_status()
|
||||
finally:
|
||||
for fh in open_handles:
|
||||
fh.close()
|
||||
|
||||
body = r.json()
|
||||
if "error" in body:
|
||||
sys.exit(body["error"])
|
||||
job_id = body["job_id"]
|
||||
print(f"[gateway] write_flash job {job_id} started")
|
||||
rc = stream_job(ws_url_for(args.gateway, "/esp/stream"), job_id)
|
||||
sys.exit(0 if rc == 0 else 1)
|
||||
|
||||
|
||||
def cmd_monitor(args):
|
||||
qs = urllib.parse.urlencode({"port": args.port, "baud": args.baud})
|
||||
ws_url = f"{ws_url_for(args.gateway, '/esp/monitor')}?{qs}"
|
||||
|
||||
stdin_is_tty = sys.stdin.isatty()
|
||||
old_termios = None
|
||||
if stdin_is_tty:
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
stdin_fd = sys.stdin.fileno()
|
||||
old_termios = termios.tcgetattr(stdin_fd)
|
||||
tty.setcbreak(stdin_fd)
|
||||
except (ImportError, OSError, ValueError):
|
||||
old_termios = None
|
||||
|
||||
async def _run():
|
||||
async with websockets.connect(ws_url) as ws:
|
||||
async def _recv():
|
||||
async for raw in ws:
|
||||
if isinstance(raw, bytes):
|
||||
sys.stdout.buffer.write(raw)
|
||||
sys.stdout.buffer.flush()
|
||||
else:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
print(raw, file=sys.stderr)
|
||||
continue
|
||||
t = msg.get("type")
|
||||
if t == "opened":
|
||||
print(f"[monitor] {msg.get('port')} @ "
|
||||
f"{msg.get('baud')} (Ctrl-] to quit)",
|
||||
file=sys.stderr)
|
||||
elif t == "error":
|
||||
print(f"[monitor] error: {msg.get('error')}",
|
||||
file=sys.stderr)
|
||||
|
||||
async def _send():
|
||||
if not stdin_is_tty:
|
||||
# Drain piped stdin and forward it, then stop.
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
ch = await loop.run_in_executor(None, sys.stdin.read, 1)
|
||||
if not ch:
|
||||
break
|
||||
await ws.send(ch)
|
||||
return
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
ch = await loop.run_in_executor(None, sys.stdin.read, 1)
|
||||
if not ch:
|
||||
break
|
||||
if ch == "\x1d": # Ctrl-]
|
||||
await ws.close()
|
||||
break
|
||||
await ws.send(ch)
|
||||
|
||||
recv_t = asyncio.create_task(_recv())
|
||||
send_t = asyncio.create_task(_send())
|
||||
try:
|
||||
await recv_t
|
||||
finally:
|
||||
send_t.cancel()
|
||||
try:
|
||||
await send_t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
finally:
|
||||
if old_termios is not None:
|
||||
import termios
|
||||
try:
|
||||
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_termios)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cmd_jobs(args):
|
||||
if args.job_id:
|
||||
r = requests.get(f"{args.gateway}/esp/jobs/{args.job_id}", timeout=10)
|
||||
r.raise_for_status()
|
||||
print(json.dumps(r.json(), indent=2))
|
||||
return
|
||||
r = requests.get(f"{args.gateway}/esp/jobs", timeout=10)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
cur = data.get("current")
|
||||
jobs = data.get("jobs", [])
|
||||
if not jobs:
|
||||
print("(no jobs yet)")
|
||||
return
|
||||
for j in jobs:
|
||||
marker = "*" if j["id"] == cur else " "
|
||||
print(f"{marker} {j['id']} {j['kind']:<22} {j['state']:<8} "
|
||||
f"{j.get('port', ''):<16} rc={j.get('return_code')}")
|
||||
|
||||
|
||||
# ── CLI ─────────────────────────────────────────────────────────
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="esp_client.py",
|
||||
description="Flash ESP32 devices via the lab-gateway /esp/* endpoints.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--gateway",
|
||||
default=os.environ.get("LAB_GATEWAY", "http://192.168.0.38:8910"),
|
||||
help="lab-gateway base URL "
|
||||
"(default: $LAB_GATEWAY or http://192.168.0.38:8910)",
|
||||
)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("ports", help="list USB serial ports on the gateway host") \
|
||||
.set_defaults(func=cmd_ports)
|
||||
|
||||
s = sub.add_parser("chip", help="run esptool chip_id")
|
||||
s.add_argument("--port", default="/dev/ttyACM0")
|
||||
s.add_argument("--baud", type=int, default=460800)
|
||||
s.add_argument("--chip", default=None)
|
||||
s.set_defaults(func=cmd_chip)
|
||||
|
||||
s = sub.add_parser("erase", help="run esptool erase_flash")
|
||||
s.add_argument("--port", default="/dev/ttyACM0")
|
||||
s.add_argument("--baud", type=int, default=460800)
|
||||
s.add_argument("--chip", default=None)
|
||||
s.add_argument("--before", default="usb-reset")
|
||||
s.set_defaults(func=cmd_erase)
|
||||
|
||||
s = sub.add_parser("flash", help="upload a build bundle and run write_flash")
|
||||
s.add_argument("--bundle", required=True,
|
||||
help="build dir containing flash_args + *.bin")
|
||||
s.add_argument("--port", default="/dev/ttyACM0")
|
||||
s.add_argument("--baud", type=int, default=460800)
|
||||
s.add_argument("--chip", default=None)
|
||||
s.add_argument("--before", default="usb-reset")
|
||||
s.add_argument("--after", default="hard-reset")
|
||||
s.add_argument("--flash-mode", default=None)
|
||||
s.add_argument("--flash-size", default=None)
|
||||
s.add_argument("--flash-freq", default=None)
|
||||
s.add_argument("--erase", action="store_true",
|
||||
help="erase_flash before write_flash")
|
||||
s.add_argument("--file", action="append", default=[],
|
||||
help="extra part as offset@path (may repeat)")
|
||||
s.set_defaults(func=cmd_flash)
|
||||
|
||||
s = sub.add_parser("monitor", help="raw serial monitor (Ctrl-] to quit)")
|
||||
s.add_argument("--port", default="/dev/ttyACM0")
|
||||
s.add_argument("--baud", type=int, default=115200)
|
||||
s.set_defaults(func=cmd_monitor)
|
||||
|
||||
s = sub.add_parser("jobs", help="list recent jobs, or show one with --job-id")
|
||||
s.add_argument("--job-id", default=None)
|
||||
s.set_defaults(func=cmd_jobs)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main():
|
||||
args = build_parser().parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
432
infra/lab-gateway/esp_manager.py
Normal file
432
infra/lab-gateway/esp_manager.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
"""
|
||||
ESP32 / esptool Manager
|
||||
=======================
|
||||
Manages ESP32-family devices connected via USB to the Proxmox server:
|
||||
- Port enumeration with USB vendor/product info (pyserial list_ports)
|
||||
- chip_id / flash_id probing via esptool
|
||||
- erase_flash
|
||||
- write_flash of multipart bundles (binaries staged on the gateway)
|
||||
- Bidirectional serial monitor passthrough
|
||||
- In-flight job streaming to WebSocket clients
|
||||
|
||||
Invokes esptool via subprocess: ``[sys.executable, "-m", "esptool"] ...``.
|
||||
esptool must be importable from the same interpreter that runs the gateway
|
||||
(``pip install esptool`` in the gateway venv).
|
||||
|
||||
Only ONE esptool job may run at a time — the serial port is exclusive.
|
||||
Monitor sessions open the port in raw passthrough mode and are independent
|
||||
of the esptool job machinery (a monitor and an erase/flash cannot share
|
||||
the same port).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import asyncio
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
|
||||
|
||||
# Base of every esptool invocation. Using sys.executable makes the manager
|
||||
# robust to whichever venv the gateway runs under (see lab-gateway.service).
|
||||
ESPTOOL_BASE = [sys.executable, "-m", "esptool"]
|
||||
|
||||
# How many lines of each job to keep for late-joining WS clients.
|
||||
JOB_LINE_BUFFER = 4000
|
||||
|
||||
|
||||
class EspManager:
|
||||
"""Manages ESP32-family devices connected via USB.
|
||||
|
||||
The manager is stateless across requests except for:
|
||||
- the current esptool job (one at a time)
|
||||
- any open monitor serial port (one at a time)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._jobs: dict[str, dict] = {}
|
||||
self._current_job_id: Optional[str] = None
|
||||
# WebSocket clients streaming the current erase/flash job.
|
||||
self._clients: set = set()
|
||||
# The single open monitor serial port + its owner WebSocket.
|
||||
self._monitor_serial: Optional[serial.Serial] = None
|
||||
self._monitor_owner = None
|
||||
self._esptool_version: Optional[str] = None
|
||||
|
||||
# ── Properties / status ─────────────────────────────────────
|
||||
|
||||
@property
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"esptool_available": self._esptool_available(),
|
||||
"esptool_version": self._esptool_version,
|
||||
"current_job": self._current_job_id,
|
||||
"monitor_open": bool(
|
||||
self._monitor_serial and self._monitor_serial.is_open
|
||||
),
|
||||
"monitor_port": (
|
||||
self._monitor_serial.portstr
|
||||
if self._monitor_serial and self._monitor_serial.is_open
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def _esptool_available(self) -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
ESPTOOL_BASE + ["version"],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# First line is typically "esptool.py vX.Y.Z ..." or "esptool v..."
|
||||
self._esptool_version = result.stdout.splitlines()[0].strip()
|
||||
return True
|
||||
# Some esptool builds have no `version` subcommand; fall back to --help.
|
||||
result = subprocess.run(
|
||||
ESPTOOL_BASE + ["--help"],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
if ok:
|
||||
self._esptool_version = "unknown (no version subcommand)"
|
||||
return ok
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
# ── Port enumeration ────────────────────────────────────────
|
||||
|
||||
def list_ports(self) -> dict:
|
||||
"""Enumerate candidate ESP32 USB serial devices on the host.
|
||||
|
||||
Returns ACM and USB devices with VID/PID/serial/product metadata
|
||||
where pyserial can read it.
|
||||
"""
|
||||
acm = sorted(glob.glob("/dev/ttyACM*"))
|
||||
usb = sorted(glob.glob("/dev/ttyUSB*"))
|
||||
|
||||
by_name: dict[str, dict] = {}
|
||||
for p in serial.tools.list_ports.comports():
|
||||
info = {
|
||||
"vid": hex(p.vid) if p.vid is not None else None,
|
||||
"pid": hex(p.pid) if p.pid is not None else None,
|
||||
"serial_number": p.serial_number,
|
||||
"manufacturer": p.manufacturer,
|
||||
"product": p.product,
|
||||
"description": p.description,
|
||||
"interface": p.interface,
|
||||
}
|
||||
by_name[p.device] = info
|
||||
|
||||
devices = []
|
||||
for name in acm + usb:
|
||||
entry = {"device": name}
|
||||
entry.update(by_name.get(name, {}))
|
||||
devices.append(entry)
|
||||
|
||||
return {
|
||||
"ports": devices,
|
||||
"esptool_available": self._esptool_available(),
|
||||
"esptool_version": self._esptool_version,
|
||||
}
|
||||
|
||||
# ── Probing ─────────────────────────────────────────────────
|
||||
|
||||
async def chip_id(self, port: str, baud: int = 460800,
|
||||
chip: Optional[str] = None) -> dict:
|
||||
cmd = (ESPTOOL_BASE + self._chip_arg(chip)
|
||||
+ ["--port", port, "--baud", str(baud), "chip_id"])
|
||||
out, rc = await self._run_capture(cmd, timeout=20.0)
|
||||
return {"return_code": rc, "output": out, "port": port, "command": " ".join(cmd)}
|
||||
|
||||
async def flash_id(self, port: str, baud: int = 460800,
|
||||
chip: Optional[str] = None) -> dict:
|
||||
cmd = (ESPTOOL_BASE + self._chip_arg(chip)
|
||||
+ ["--port", port, "--baud", str(baud), "flash_id"])
|
||||
out, rc = await self._run_capture(cmd, timeout=20.0)
|
||||
return {"return_code": rc, "output": out, "port": port, "command": " ".join(cmd)}
|
||||
|
||||
async def espefuse_summary(self, port: str,
|
||||
chip: Optional[str] = None) -> dict:
|
||||
"""Read eFuse summary (read-only)."""
|
||||
cmd = (ESPTOOL_BASE + self._chip_arg(chip)
|
||||
+ ["--port", port, "espefuse", "summary"])
|
||||
out, rc = await self._run_capture(cmd, timeout=20.0)
|
||||
return {"return_code": rc, "output": out, "port": port, "command": " ".join(cmd)}
|
||||
|
||||
# ── Erase / Flash jobs ──────────────────────────────────────
|
||||
|
||||
async def erase_flash(self, port: str, baud: int = 460800,
|
||||
chip: Optional[str] = None,
|
||||
before: str = "usb-reset") -> str:
|
||||
"""Schedule an erase_flash job. Returns the job_id."""
|
||||
cmd = (ESPTOOL_BASE + self._chip_arg(chip)
|
||||
+ ["--port", port, "--baud", str(baud),
|
||||
"--before", before, "erase_flash"])
|
||||
return await self._start_job("erase_flash", cmd, port=port)
|
||||
|
||||
async def write_flash(self, *, port: str, files: list[tuple[str, str]],
|
||||
baud: int = 460800, chip: Optional[str] = None,
|
||||
flash_mode: str = "dio", flash_size: str = "8MB",
|
||||
flash_freq: str = "80m", before: str = "usb-reset",
|
||||
after: str = "hard-reset",
|
||||
erase: bool = False) -> str:
|
||||
"""Schedule a write_flash job. Returns the job_id.
|
||||
|
||||
``files`` is a list of ``(offset_str, abs_path_str)``. Files must
|
||||
already exist on the gateway filesystem (the HTTP route stages
|
||||
uploads into a temp dir before calling this).
|
||||
"""
|
||||
for offset, path in files:
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"missing flash part: {path}")
|
||||
|
||||
cmd = (ESPTOOL_BASE + self._chip_arg(chip)
|
||||
+ ["--port", port, "--baud", str(baud),
|
||||
"--before", before, "--after", after,
|
||||
"write_flash",
|
||||
"--flash_mode", flash_mode,
|
||||
"--flash_size", flash_size,
|
||||
"--flash_freq", flash_freq])
|
||||
for offset, path in files:
|
||||
cmd.extend([str(offset), str(path)])
|
||||
|
||||
kind = "erase_and_write_flash" if erase else "write_flash"
|
||||
return await self._start_job(kind, cmd, port=port,
|
||||
erase_first=erase, chip=chip, baud=baud,
|
||||
before=before)
|
||||
|
||||
# ── Job machinery ───────────────────────────────────────────
|
||||
|
||||
def _raise_if_busy(self):
|
||||
if (self._current_job_id
|
||||
and self._jobs[self._current_job_id]["state"] == "running"):
|
||||
raise RuntimeError(
|
||||
f"another ESP job is running: {self._current_job_id}"
|
||||
)
|
||||
|
||||
async def _start_job(self, kind: str, cmd: list[str], port: str = "",
|
||||
erase_first: bool = False,
|
||||
chip: Optional[str] = None, baud: int = 460800,
|
||||
before: str = "usb-reset") -> str:
|
||||
self._raise_if_busy()
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
self._jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"kind": kind,
|
||||
"command": " ".join(cmd),
|
||||
"port": port,
|
||||
"state": "running",
|
||||
"started_at": time.time(),
|
||||
"ended_at": None,
|
||||
"return_code": None,
|
||||
"lines": [],
|
||||
"erase_first": erase_first,
|
||||
# Stored so the optional pre-erase step can be rebuilt cleanly.
|
||||
"_erase_chip": chip,
|
||||
"_erase_baud": baud,
|
||||
"_erase_before": before,
|
||||
}
|
||||
self._current_job_id = job_id
|
||||
asyncio.create_task(self._run_job(job_id, cmd))
|
||||
return job_id
|
||||
|
||||
async def _run_job(self, job_id: str, cmd: list[str]):
|
||||
job = self._jobs[job_id]
|
||||
try:
|
||||
# Optional erase step before write_flash.
|
||||
if job.get("erase_first") and job["kind"] == "erase_and_write_flash":
|
||||
erase_cmd = (ESPTOOL_BASE + self._chip_arg(job["_erase_chip"])
|
||||
+ ["--port", job["port"],
|
||||
"--baud", str(job["_erase_baud"]),
|
||||
"--before", job["_erase_before"],
|
||||
"erase_flash"])
|
||||
await self._stream_subprocess(job_id, erase_cmd, label="erase")
|
||||
# If the erase failed, abort before write_flash.
|
||||
if job["state"] == "failed":
|
||||
return
|
||||
# Reset to running so the write_flash step can set final state.
|
||||
job["state"] = "running"
|
||||
|
||||
await self._stream_subprocess(job_id, cmd, label="flash"
|
||||
if "write_flash" in cmd else "erase")
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
job["state"] = "failed"
|
||||
job["return_code"] = -1
|
||||
err = f"[gateway] {type(e).__name__}: {e}"
|
||||
job["lines"].append(err)
|
||||
await self._broadcast(json.dumps({
|
||||
"type": "line", "job_id": job_id, "line": err,
|
||||
}))
|
||||
finally:
|
||||
job["ended_at"] = time.time()
|
||||
await self._broadcast(json.dumps({
|
||||
"type": "done", "job_id": job_id,
|
||||
"state": job["state"],
|
||||
"return_code": job["return_code"],
|
||||
}))
|
||||
|
||||
async def _stream_subprocess(self, job_id: str, cmd: list[str],
|
||||
label: str = ""):
|
||||
job = self._jobs[job_id]
|
||||
job["lines"].append(f"[gateway] $ {' '.join(cmd)}")
|
||||
await self._broadcast(json.dumps({
|
||||
"type": "line", "job_id": job_id,
|
||||
"line": f"[gateway] $ {' '.join(cmd)}",
|
||||
}))
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
job["state"] = "failed"
|
||||
job["return_code"] = -1
|
||||
job["lines"].append(f"[gateway] esptool not found: {e}")
|
||||
await self._broadcast(json.dumps({
|
||||
"type": "line", "job_id": job_id,
|
||||
"line": f"[gateway] esptool not found: {e}",
|
||||
}))
|
||||
return
|
||||
|
||||
job["pid"] = proc.pid
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode("utf-8", errors="replace").rstrip("\n")
|
||||
job["lines"].append(text)
|
||||
if len(job["lines"]) > JOB_LINE_BUFFER:
|
||||
job["lines"] = job["lines"][-(JOB_LINE_BUFFER // 2):]
|
||||
await self._broadcast(json.dumps({
|
||||
"type": "line", "job_id": job_id, "line": text,
|
||||
}))
|
||||
|
||||
rc = await proc.wait()
|
||||
job["return_code"] = rc
|
||||
if rc == 0:
|
||||
# Only mark success if this was the final step. The erase-first
|
||||
# flow continues into write_flash, which sets its own state.
|
||||
if job["state"] == "running":
|
||||
job["state"] = "success"
|
||||
else:
|
||||
job["state"] = "failed"
|
||||
|
||||
async def _broadcast(self, msg: str):
|
||||
if not self._clients:
|
||||
return
|
||||
dead = []
|
||||
for ws in list(self._clients):
|
||||
try:
|
||||
await ws.send_text(msg)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self._clients.discard(ws)
|
||||
|
||||
def get_job(self, job_id: str) -> Optional[dict]:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def list_jobs(self, limit: int = 20) -> list[dict]:
|
||||
recent = sorted(self._jobs.values(),
|
||||
key=lambda j: j["started_at"], reverse=True)
|
||||
return [
|
||||
{k: v for k, v in j.items() if k != "lines"}
|
||||
for j in recent[:limit]
|
||||
]
|
||||
|
||||
# ── Monitor (serial passthrough) ────────────────────────────
|
||||
|
||||
async def open_monitor(self, port: str, baud: int = 115200) -> bool:
|
||||
"""Open the monitor serial port. Closes any previous monitor first."""
|
||||
self.close_monitor()
|
||||
try:
|
||||
self._monitor_serial = await asyncio.to_thread(
|
||||
serial.Serial, port, baud, timeout=0,
|
||||
)
|
||||
print(f"[ESP] monitor opened on {port} @ {baud}")
|
||||
return True
|
||||
except serial.SerialException as e:
|
||||
print(f"[ESP] monitor open failed on {port}: {e}")
|
||||
self._monitor_serial = None
|
||||
return False
|
||||
|
||||
def close_monitor(self):
|
||||
if self._monitor_serial and self._monitor_serial.is_open:
|
||||
try:
|
||||
self._monitor_serial.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._monitor_serial = None
|
||||
self._monitor_owner = None
|
||||
|
||||
async def monitor_read(self) -> bytes:
|
||||
if not self._monitor_serial or not self._monitor_serial.is_open:
|
||||
return b""
|
||||
try:
|
||||
return await asyncio.to_thread(self._monitor_serial.read, 4096)
|
||||
except serial.SerialException:
|
||||
return b""
|
||||
|
||||
async def monitor_write(self, data: bytes) -> int:
|
||||
if not self._monitor_serial or not self._monitor_serial.is_open:
|
||||
return 0
|
||||
try:
|
||||
return await asyncio.to_thread(self._monitor_serial.write, data)
|
||||
except serial.SerialException:
|
||||
return 0
|
||||
|
||||
async def monitor_reset(self) -> bool:
|
||||
"""Toggle DTR/RTS to reset the device (esptool-style auto-reset)."""
|
||||
if not self._monitor_serial or not self._monitor_serial.is_open:
|
||||
return False
|
||||
|
||||
def _toggle():
|
||||
import time as _time
|
||||
s = self._monitor_serial
|
||||
s.dtr = False
|
||||
s.rts = True
|
||||
_time.sleep(0.1)
|
||||
s.dtr = True
|
||||
s.rts = False
|
||||
_time.sleep(0.05)
|
||||
s.dtr = False
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_toggle)
|
||||
return True
|
||||
except serial.SerialException:
|
||||
return False
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _chip_arg(chip: Optional[str]) -> list[str]:
|
||||
return ["--chip", chip] if chip else []
|
||||
|
||||
async def _run_capture(self, cmd: list[str],
|
||||
timeout: float = 30.0) -> tuple[str, int]:
|
||||
"""Run a short probing command, returning combined stdout/stderr."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
return f"[gateway] {type(e).__name__}: {e}", -1
|
||||
try:
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
return out.decode("utf-8", errors="replace"), proc.returncode
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return "[gateway] command timed out", -1
|
||||
|
|
@ -12,10 +12,13 @@ import os
|
|||
import json
|
||||
import time
|
||||
import asyncio
|
||||
import tempfile
|
||||
import shutil
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Request, UploadFile
|
||||
from fastapi.responses import StreamingResponse, Response, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
|
|
@ -23,6 +26,7 @@ from camera import CameraManager
|
|||
from ppk2_manager import PPK2Manager
|
||||
from logic_manager import LogicManager
|
||||
from icicle_manager import IcicleManager
|
||||
from esp_manager import EspManager
|
||||
|
||||
# ── Configuration ────────────────────────────────────────────────
|
||||
CAM_DEVICE = os.environ.get("CAM_DEVICE", "/dev/video14")
|
||||
|
|
@ -38,6 +42,9 @@ LOGIC_CHANNELS = os.environ.get("LOGIC_CHANNELS", "D0,D1,D2,D3,D4,D5,D6,D7")
|
|||
ICICLE_HSS_PORT = os.environ.get("ICICLE_HSS_PORT", "auto")
|
||||
ICICLE_LINUX_PORT = os.environ.get("ICICLE_LINUX_PORT", "auto")
|
||||
|
||||
# ESP32 / esptool default baud (USB Serial/JTAG on the S3 routinely hits 460800).
|
||||
ESP_BAUD = int(os.environ.get("ESP_BAUD", "460800"))
|
||||
|
||||
HOST = os.environ.get("LAB_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("LAB_PORT", "8910"))
|
||||
|
||||
|
|
@ -47,6 +54,7 @@ camera = CameraManager(CAM_DEVICE, CAM_WIDTH, CAM_HEIGHT, CAM_FPS)
|
|||
ppk2 = PPK2Manager(PPK2_PORT)
|
||||
logic = LogicManager(LOGIC_DRIVER, LOGIC_CHANNELS)
|
||||
icicle = IcicleManager(ICICLE_HSS_PORT, ICICLE_LINUX_PORT)
|
||||
esp = EspManager()
|
||||
|
||||
# ── PPK2 WebSocket clients ──────────────────────────────────────
|
||||
ppk2_clients: set[WebSocket] = set()
|
||||
|
|
@ -67,6 +75,7 @@ async def lifespan(app: FastAPI):
|
|||
print(f"║ PPK2: {PPK2_PORT:<40} ║")
|
||||
print(f"║ Logic: {LOGIC_DRIVER} ({LOGIC_CHANNELS})" + " " * (32 - len(LOGIC_DRIVER) - len(LOGIC_CHANNELS)) + "║")
|
||||
print(f"║ Icicle: PolarFire SoC (auto-detect) ║")
|
||||
print(f"║ ESP: esptool on /dev/ttyACM* (auto-detect) ║")
|
||||
print(f"║ Listen: http://{HOST}:{PORT}" + " " * (34 - len(HOST) - len(str(PORT))) + "║")
|
||||
print("╚══════════════════════════════════════════════════════╝")
|
||||
print("")
|
||||
|
|
@ -116,6 +125,7 @@ async def lifespan(app: FastAPI):
|
|||
camera.close()
|
||||
ppk2.disconnect()
|
||||
icicle.disconnect()
|
||||
esp.close_monitor()
|
||||
print("[Lab] Gateway shutdown complete")
|
||||
|
||||
|
||||
|
|
@ -576,6 +586,260 @@ async def icicle_websocket(websocket: WebSocket):
|
|||
print(f"[Icicle] WS client disconnected (remaining: {len(icicle_clients)})")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# ESP32 / ESPTOOL ENDPOINTS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@app.get("/esp/status")
|
||||
async def esp_status():
|
||||
"""ESP manager + esptool availability."""
|
||||
return esp.status
|
||||
|
||||
|
||||
@app.get("/esp/ports")
|
||||
async def esp_ports():
|
||||
"""Enumerate candidate ESP32 USB serial devices on the host."""
|
||||
return esp.list_ports()
|
||||
|
||||
|
||||
@app.get("/esp/chip")
|
||||
async def esp_chip(port: str, baud: int = ESP_BAUD, chip: Optional[str] = None):
|
||||
"""Run ``esptool chip_id`` against the given port."""
|
||||
return await esp.chip_id(port, baud, chip)
|
||||
|
||||
|
||||
@app.get("/esp/flash_id")
|
||||
async def esp_flash_id(port: str, baud: int = ESP_BAUD, chip: Optional[str] = None):
|
||||
"""Run ``esptool flash_id`` to read SPI flash size/info."""
|
||||
return await esp.flash_id(port, baud, chip)
|
||||
|
||||
|
||||
@app.get("/esp/espefuse")
|
||||
async def esp_espefuse(port: str, chip: Optional[str] = None):
|
||||
"""Read-only eFuse summary (used to check DIS_DOWNLOAD_MODE etc.)."""
|
||||
return await esp.espefuse_summary(port, chip)
|
||||
|
||||
|
||||
@app.post("/esp/erase")
|
||||
async def esp_erase(payload: dict):
|
||||
"""Schedule an ``erase_flash`` job. Body: {port, baud?, chip?, before?}."""
|
||||
port = payload.get("port") or "/dev/ttyACM0"
|
||||
baud = int(payload.get("baud") or ESP_BAUD)
|
||||
chip = payload.get("chip")
|
||||
before = payload.get("before") or "usb-reset"
|
||||
try:
|
||||
job_id = await esp.erase_flash(port, baud, chip, before)
|
||||
except RuntimeError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=409)
|
||||
return {"job_id": job_id, "stream": "/esp/stream"}
|
||||
|
||||
|
||||
@app.post("/esp/flash")
|
||||
async def esp_flash(request: Request):
|
||||
"""Stage uploaded binaries and run ``esptool write_flash``.
|
||||
|
||||
Multipart form fields:
|
||||
- port (str, default /dev/ttyACM0)
|
||||
- baud (int, default ESP_BAUD)
|
||||
- chip (str, optional — e.g. esp32s3)
|
||||
- flash_mode (str, default dio)
|
||||
- flash_size (str, default 8MB; 'detect' allowed)
|
||||
- flash_freq (str, default 80m)
|
||||
- before (str, default usb-reset)
|
||||
- after (str, default hard-reset)
|
||||
- erase (bool, default false — erase_flash before write_flash)
|
||||
- one UploadFile per flash part, field name = hex offset
|
||||
(e.g. field "0x0" → bootloader.bin)
|
||||
"""
|
||||
form = await request.form()
|
||||
port = form.get("port") or "/dev/ttyACM0"
|
||||
baud = int(form.get("baud") or ESP_BAUD)
|
||||
chip = form.get("chip") or None
|
||||
flash_mode = form.get("flash_mode") or "dio"
|
||||
flash_size = form.get("flash_size") or "8MB"
|
||||
flash_freq = form.get("flash_freq") or "80m"
|
||||
before = form.get("before") or "usb-reset"
|
||||
after = form.get("after") or "hard-reset"
|
||||
erase = str(form.get("erase") or "").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
staging = Path(tempfile.mkdtemp(prefix="esp_flash_"))
|
||||
staged: list[tuple[str, str]] = []
|
||||
files_meta: list[dict] = []
|
||||
|
||||
try:
|
||||
for key, value in form.multi_items():
|
||||
if not isinstance(value, UploadFile):
|
||||
continue
|
||||
offset = key
|
||||
upload: UploadFile = value
|
||||
data = await upload.read()
|
||||
fname = upload.filename or f"part_{len(staged)}.bin"
|
||||
target = staging / fname
|
||||
target.write_bytes(data)
|
||||
staged.append((offset, str(target)))
|
||||
files_meta.append({
|
||||
"offset": offset, "name": fname, "size": len(data),
|
||||
})
|
||||
|
||||
if not staged:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return JSONResponse(
|
||||
{"error": "no binary files uploaded (use field name = offset)"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
job_id = await esp.write_flash(
|
||||
port=port, files=staged, baud=baud, chip=chip,
|
||||
flash_mode=flash_mode, flash_size=flash_size,
|
||||
flash_freq=flash_freq, before=before, after=after,
|
||||
erase=erase,
|
||||
)
|
||||
except (RuntimeError, FileNotFoundError) as e:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return JSONResponse(
|
||||
{"error": f"{type(e).__name__}: {e}"}, status_code=409,
|
||||
)
|
||||
|
||||
# Clean up staged files once the job finishes (max ~10 min).
|
||||
async def _cleanup(job_id: str, staging_dir: Path):
|
||||
for _ in range(600):
|
||||
j = esp.get_job(job_id)
|
||||
if j and j["state"] != "running":
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
|
||||
asyncio.create_task(_cleanup(job_id, staging))
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"stream": "/esp/stream",
|
||||
"port": port,
|
||||
"files": files_meta,
|
||||
"options": {
|
||||
"chip": chip, "baud": baud,
|
||||
"flash_mode": flash_mode, "flash_size": flash_size,
|
||||
"flash_freq": flash_freq,
|
||||
"before": before, "after": after, "erase": erase,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return JSONResponse(
|
||||
{"error": f"{type(e).__name__}: {e}"}, status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/esp/jobs")
|
||||
async def esp_jobs(limit: int = 20):
|
||||
"""Recent erase/flash jobs (excludes the line buffer)."""
|
||||
return {"jobs": esp.list_jobs(limit=limit), "current": esp._current_job_id}
|
||||
|
||||
|
||||
@app.get("/esp/jobs/{job_id}")
|
||||
async def esp_job(job_id: str):
|
||||
"""Full status + captured lines for a single job."""
|
||||
j = esp.get_job(job_id)
|
||||
if not j:
|
||||
return JSONResponse({"error": "unknown job"}, status_code=404)
|
||||
return j
|
||||
|
||||
|
||||
@app.websocket("/esp/stream")
|
||||
async def esp_stream_ws(websocket: WebSocket):
|
||||
"""Stream the current erase/flash job. Late joiners get the backlog.
|
||||
|
||||
Messages sent to the client:
|
||||
{"type": "state", ...} — current job header
|
||||
{"type": "line", "line": "..."} — one esptool output line
|
||||
{"type": "done", "state": "...", "return_code": ...} — completion
|
||||
"""
|
||||
await websocket.accept()
|
||||
esp._clients.add(websocket)
|
||||
try:
|
||||
if esp._current_job_id:
|
||||
j = esp.get_job(esp._current_job_id)
|
||||
if j:
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "state", "job_id": j["id"], "state": j["state"],
|
||||
"kind": j["kind"], "port": j["port"],
|
||||
}))
|
||||
for line in j["lines"][-200:]:
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "line", "job_id": j["id"], "line": line,
|
||||
}))
|
||||
# Clients may keep the socket open; we ignore inbound text.
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[ESP] stream WS error: {e}")
|
||||
finally:
|
||||
esp._clients.discard(websocket)
|
||||
|
||||
|
||||
@app.websocket("/esp/monitor")
|
||||
async def esp_monitor_ws(websocket: WebSocket,
|
||||
port: str = "/dev/ttyACM0", baud: int = 115200):
|
||||
"""Bidirectional raw serial passthrough. Bytes from device → WS bytes;
|
||||
WS bytes/text → device. Closing the socket releases the port.
|
||||
|
||||
The device is also reset (DTR/RTS toggle) on connect so the user sees
|
||||
boot output from the top.
|
||||
"""
|
||||
await websocket.accept()
|
||||
ok = await esp.open_monitor(port, baud)
|
||||
if not ok:
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "error", "error": f"cannot open {port}",
|
||||
}))
|
||||
await websocket.close()
|
||||
return
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "opened", "port": port, "baud": baud,
|
||||
}))
|
||||
await esp.monitor_reset()
|
||||
|
||||
async def _reader():
|
||||
try:
|
||||
while True:
|
||||
data = await esp.monitor_read()
|
||||
if data:
|
||||
await websocket.send_bytes(data)
|
||||
else:
|
||||
await asyncio.sleep(0.02)
|
||||
except Exception as e:
|
||||
print(f"[ESP] monitor reader stopped: {e}")
|
||||
|
||||
reader_task = asyncio.create_task(_reader())
|
||||
try:
|
||||
while True:
|
||||
msg = await websocket.receive()
|
||||
if msg.get("type") == "websocket.disconnect":
|
||||
break
|
||||
if msg.get("bytes") is not None:
|
||||
await esp.monitor_write(msg["bytes"])
|
||||
elif msg.get("text") is not None:
|
||||
text = msg["text"]
|
||||
if text == "__reset__":
|
||||
await esp.monitor_reset()
|
||||
else:
|
||||
await esp.monitor_write(text.encode())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[ESP] monitor WS error: {e}")
|
||||
finally:
|
||||
reader_task.cancel()
|
||||
try:
|
||||
await reader_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
esp.close_monitor()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# HEALTH / STATUS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
|
@ -590,6 +854,7 @@ async def health():
|
|||
"ppk2": ppk2.status,
|
||||
"logic": logic.status,
|
||||
"icicle": icicle.status,
|
||||
"esp": esp.status,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ opencv-python-headless>=4.10.0
|
|||
ppk2-api>=0.2.0
|
||||
pyserial>=3.5
|
||||
websockets>=14.0
|
||||
esptool>=4.7.0
|
||||
|
|
|
|||
25
infrastructure/fabric/gitops/CONFIG.toml
Normal file
25
infrastructure/fabric/gitops/CONFIG.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# gitops CONFIG.toml — shared by the gitops workflows AND the drift-agent.
|
||||
#
|
||||
# The drift-agent reads this file (infrastructure/fabric/gitops/CONFIG.toml)
|
||||
# directly: the `[[environments]]` table is exactly the schema it expects
|
||||
# (name, working_dir, optional binary override, optional description). This is
|
||||
# the integration point between gitops and the drift detector — add an
|
||||
# environment here and the agent will start tracking its drift on the next scan.
|
||||
|
||||
default_binary = "tofu"
|
||||
|
||||
[[environments]]
|
||||
name = "staging"
|
||||
working_dir = "infrastructure/fabric/gitops/envs/staging"
|
||||
description = "Staging environment (drift-tolerant)"
|
||||
|
||||
[[environments]]
|
||||
name = "production"
|
||||
working_dir = "infrastructure/fabric/gitops/envs/production"
|
||||
description = "Production environment (drift-critical)"
|
||||
|
||||
# To point an environment at a different binary (e.g. terraform), uncomment:
|
||||
# [[environments]]
|
||||
# name = "legacy"
|
||||
# working_dir = "infrastructure/fabric/gitops/envs/legacy"
|
||||
# binary = "terraform"
|
||||
|
|
@ -332,3 +332,43 @@ fleet:
|
|||
provider: proxmox-vm
|
||||
specs: { cores: 4, ram_gb: 4, disk_gb: 80 }
|
||||
status: running
|
||||
|
||||
# ── GCLOUD PER-ACCOUNT BOXES (planned) ──
|
||||
"400":
|
||||
name: pf-gcloud-base
|
||||
host: gigabyte
|
||||
provider: proxmox-lxc
|
||||
specs: { cores: 1, ram_gb: 0.5, disk_gb: 8 }
|
||||
status: stopped
|
||||
role: gcloud-template
|
||||
notes: "Template LXC with minimal gcloud CLI. Do not run; clone per account."
|
||||
|
||||
"401":
|
||||
name: pf-gcloud-fabio-gmail
|
||||
host: gigabyte
|
||||
ip: 192.168.0.151
|
||||
provider: proxmox-lxc
|
||||
specs: { cores: 1, ram_gb: 0.5, disk_gb: 8 }
|
||||
status: running
|
||||
role: gcloud-account
|
||||
notes: "fabiorcoutada@gmail.com"
|
||||
|
||||
"402":
|
||||
name: pf-gcloud-fabio-alt
|
||||
host: gigabyte
|
||||
ip: 192.168.0.154
|
||||
provider: proxmox-lxc
|
||||
specs: { cores: 1, ram_gb: 0.5, disk_gb: 8 }
|
||||
status: running
|
||||
role: gcloud-account
|
||||
notes: "fabiorcdcunha@gmail.com"
|
||||
|
||||
"403":
|
||||
name: pf-gcloud-fabio-savearth
|
||||
host: gigabyte
|
||||
ip: 192.168.0.153
|
||||
provider: proxmox-lxc
|
||||
specs: { cores: 1, ram_gb: 0.5, disk_gb: 8 }
|
||||
status: running
|
||||
role: gcloud-account
|
||||
notes: "fabio@savearth.io (Workspace)"
|
||||
|
|
|
|||
51
infrastructure/proxmox/gcloud-box/README.md
Normal file
51
infrastructure/proxmox/gcloud-box/README.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Minimal gcloud multi-tenant boxes
|
||||
|
||||
One isolated LXC per Google account. Each box has only the gcloud CLI and its bundled Python runtime; no system Python stack, no extra gcloud components, no shared credentials.
|
||||
|
||||
## Why
|
||||
|
||||
- `gcloud` bundles CPython internally, so we cannot remove Python entirely, but we avoid installing a system Python and any extra components (alpha, beta, kubectl, etc.).
|
||||
- Each Google account lives in its own container, so you never need to `gcloud auth revoke` / `gcloud auth login` to switch accounts. You just `pfctl ssh` / `pfctl exec` the right box.
|
||||
- Credentials are mounted per-box from Vaultwarden, not baked into the image.
|
||||
|
||||
## Files
|
||||
|
||||
- `create-base.sh` — create a Debian 12 LXC template with minimal packages and gcloud CLI.
|
||||
- `clone-account.sh` — clone the base template into a per-account box and assign a static IP.
|
||||
|
||||
## Quick start
|
||||
|
||||
Run on the Proxmox hypervisor that will host the boxes (default: `gigabyte`):
|
||||
|
||||
```bash
|
||||
cd /var/lib/vz/snippets # or wherever you copied the scripts
|
||||
bash create-base.sh # creates CT 400 (template)
|
||||
bash clone-account.sh fabio-gmail fabiorcoutada@gmail.com
|
||||
bash clone-account.sh fabio-alt fabiorcdcunha@gmail.com
|
||||
```
|
||||
|
||||
Then from the laptop:
|
||||
|
||||
```bash
|
||||
pfctl exec 401 "gcloud auth list"
|
||||
pfctl exec 402 "gcloud auth list"
|
||||
```
|
||||
|
||||
## Credential model
|
||||
|
||||
- User OAuth: run `pfctl ssh 401` then `gcloud auth login` inside the box.
|
||||
- Service-account JSON: place the key in `/root/gcloud-keys/<label>.json` inside the box (or mount via Vaultwarden), then:
|
||||
```bash
|
||||
pfctl exec 401 "gcloud auth activate-service-account --key-file=/root/gcloud-keys/fabio-gmail.json"
|
||||
```
|
||||
|
||||
## Future: C/C++ native path
|
||||
|
||||
For hot paths where gcloud is too heavy, we can add a small static binary using `libcurl` + `openssl` to call GCP REST APIs directly, authenticating with the same service-account JWT. This stays optional; the boxes still need gcloud for setup/IAM/project management.
|
||||
|
||||
## Security
|
||||
|
||||
- Boxes are unprivileged LXC containers (`--unprivileged 1`).
|
||||
- No credentials in the base template.
|
||||
- Each clone gets its own static IP and isolated `/home/gcloud/.config/gcloud`.
|
||||
- Keys are `chmod 600` and sourced from Vaultwarden.
|
||||
88
infrastructure/proxmox/gcloud-box/clone-account.sh
Normal file
88
infrastructure/proxmox/gcloud-box/clone-account.sh
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# clone-account.sh — Clone the gcloud base template into a per-account box.
|
||||
# ============================================================================
|
||||
# Each Google account gets its own LXC so credentials and configs are isolated.
|
||||
# The service-account JSON key is copied from a local secure path and chmod 600.
|
||||
#
|
||||
# Usage:
|
||||
# ./clone-account.sh <account-label> <google-account-email> [vmid] [ip]
|
||||
#
|
||||
# Examples:
|
||||
# ./clone-account.sh fabio-gmail fabiorcoutada@gmail.com 401 192.168.0.151
|
||||
# ./clone-account.sh fabio-alt fabiorcdcunha@gmail.com 402 192.168.0.152
|
||||
# ============================================================================
|
||||
|
||||
ACCOUNT_LABEL="${1:-}"
|
||||
GOOGLE_EMAIL="${2:-}"
|
||||
VMID="${3:-}"
|
||||
IP="${4:-}"
|
||||
|
||||
BASE_VMID="${BASE_VMID:-400}"
|
||||
HOST_NODE="${HOST_NODE:-gigabyte}"
|
||||
BRIDGE="${BRIDGE:-vmbr0}"
|
||||
STORAGE="${STORAGE:-local-lvm}"
|
||||
|
||||
if [[ -z "$ACCOUNT_LABEL" || -z "$GOOGLE_EMAIL" ]]; then
|
||||
echo "Usage: $0 <account-label> <google-account-email> [vmid] [ip]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-assign VMID/IP if not provided
|
||||
if [[ -z "$VMID" ]]; then
|
||||
# Find next free VMID starting at 401
|
||||
for try in {401..499}; do
|
||||
if ! pct config "$try" >/dev/null 2>&1; then
|
||||
VMID="$try"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "$IP" ]]; then
|
||||
# Find next free IP in 192.168.0.151-199
|
||||
for try in {151..199}; do
|
||||
if ! ping -c1 -W1 "192.168.0.$try" >/dev/null 2>&1; then
|
||||
IP="192.168.0.$try"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
HOSTNAME="pf-gcloud-${ACCOUNT_LABEL}"
|
||||
|
||||
echo "[+] Cloning base $BASE_VMID into CT $VMID ($HOSTNAME) for $GOOGLE_EMAIL ..."
|
||||
pct clone "$BASE_VMID" "$VMID" --hostname "$HOSTNAME"
|
||||
|
||||
pct set "$VMID" \
|
||||
--net0 "name=eth0,bridge=${BRIDGE},ip=${IP}/24,gw=192.168.0.1,firewall=1" \
|
||||
--nameserver "192.168.0.1"
|
||||
|
||||
echo "[+] Starting CT $VMID ..."
|
||||
pct start "$VMID"
|
||||
|
||||
for i in {1..30}; do
|
||||
if pct exec "$VMID" -- ip -4 addr show eth0 | grep -q "inet ${IP}/"; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "[+] Configuring gcloud for ${GOOGLE_EMAIL} ..."
|
||||
pct exec "$VMID" -- bash -c "
|
||||
mkdir -p /home/gcloud/.config/gcloud/configurations
|
||||
cat > /home/gcloud/.config/gcloud/configurations/config_default <<EOF
|
||||
[core]
|
||||
account = ${GOOGLE_EMAIL}
|
||||
project =
|
||||
disable_usage_reporting = True
|
||||
EOF
|
||||
chown -R gcloud:gcloud /home/gcloud/.config
|
||||
"
|
||||
|
||||
echo "[+] CT $VMID ($HOSTNAME) ready at $IP"
|
||||
echo " Next steps:"
|
||||
echo " 1. Place service-account JSON at /root/gcloud-keys/${ACCOUNT_LABEL}.json (or mount via Vaultwarden)."
|
||||
echo " 2. Run: pfctl exec $VMID 'gcloud auth activate-service-account --key-file=/path/to/key.json'"
|
||||
echo " 3. Or run: pfctl ssh $VMID and use 'gcloud auth login' for user credentials."
|
||||
100
infrastructure/proxmox/gcloud-box/create-base.sh
Normal file
100
infrastructure/proxmox/gcloud-box/create-base.sh
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# create-base.sh — Provision a minimal gcloud CLI LXC template on Proxmox.
|
||||
# ============================================================================
|
||||
# Design: one LXC per Google account. The base template is cloned for each
|
||||
# account so credentials never mix. gcloud bundles its own Python runtime; no
|
||||
# system Python is installed beyond what the Debian 12 template already has.
|
||||
#
|
||||
# Usage:
|
||||
# ssh root@<proxmox-host> 'bash -s' < create-base.sh
|
||||
# or run directly on the Proxmox hypervisor.
|
||||
# ============================================================================
|
||||
|
||||
VMID="${VMID:-400}"
|
||||
HOST_NODE="${HOST_NODE:-gigabyte}"
|
||||
TEMPLATE="${TEMPLATE:-local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst}"
|
||||
ROOTFS_GB="${ROOTFS_GB:-8}"
|
||||
RAM_MB="${RAM_MB:-512}"
|
||||
SWAP_MB="${SWAP_MB:-0}"
|
||||
CORES="${CORES:-1}"
|
||||
HOSTNAME="${HOSTNAME:-pf-gcloud-base}"
|
||||
BRIDGE="${BRIDGE:-vmbr0}"
|
||||
STORAGE="${STORAGE:-local-lvm}"
|
||||
|
||||
# Check if already exists
|
||||
if pct config "$VMID" >/dev/null 2>&1; then
|
||||
echo "CT $VMID already exists; aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[+] Creating base LXC $VMID on $HOST_NODE ..."
|
||||
pct create "$VMID" "$TEMPLATE" \
|
||||
--hostname "$HOSTNAME" \
|
||||
--cores "$CORES" \
|
||||
--memory "$RAM_MB" \
|
||||
--swap "$SWAP_MB" \
|
||||
--rootfs "${STORAGE}:${ROOTFS_GB}" \
|
||||
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp,firewall=1" \
|
||||
--unprivileged 1 \
|
||||
--features nesting=1 \
|
||||
--ostype debian
|
||||
|
||||
# Boot once to finish first-run setup
|
||||
echo "[+] Starting base CT $VMID ..."
|
||||
pct start "$VMID"
|
||||
|
||||
# Wait for network
|
||||
for i in {1..30}; do
|
||||
if pct exec "$VMID" -- ip -4 addr show eth0 | grep -q 'inet '; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "[+] Updating base image and installing minimal dependencies ..."
|
||||
pct exec "$VMID" -- bash -c '
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
apt-get upgrade -y
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates gnupg openssh-client
|
||||
apt-get autoremove -y
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
'
|
||||
|
||||
echo "[+] Installing gcloud CLI (bundled Python, no extra components) ..."
|
||||
pct exec "$VMID" -- bash -c '
|
||||
set -euo pipefail
|
||||
GCLOUD_VERSION="499.0.0" # bump as needed
|
||||
ARCH="linux-x86_64"
|
||||
URL="https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-${GCLOUD_VERSION}-${ARCH}.tar.gz"
|
||||
TMP=$(mktemp -d)
|
||||
curl -fsSL "$URL" -o "$TMP/gcloud.tar.gz"
|
||||
mkdir -p /opt
|
||||
tar -xzf "$TMP/gcloud.tar.gz" -C /opt
|
||||
rm -rf "$TMP"
|
||||
/opt/google-cloud-sdk/install.sh \
|
||||
--quiet \
|
||||
--path-update=true \
|
||||
--command-completion=false \
|
||||
--usage-reporting=false
|
||||
'
|
||||
|
||||
echo "[+] Creating gcloud user and config skeleton ..."
|
||||
pct exec "$VMID" -- bash -c '
|
||||
useradd -m -s /bin/bash -d /home/gcloud gcloud || true
|
||||
mkdir -p /home/gcloud/.config/gcloud/configurations
|
||||
chown -R gcloud:gcloud /home/gcloud/.config
|
||||
'
|
||||
|
||||
# Stop and convert to template
|
||||
echo "[+] Stopping base CT $VMID and converting to template ..."
|
||||
pct stop "$VMID"
|
||||
pct template "$VMID"
|
||||
|
||||
echo "[+] Base template $VMID ($HOSTNAME) ready on $HOST_NODE."
|
||||
echo " Clone with: ./clone-account.sh <account-name> <google-account-email>"
|
||||
43
realms/savearth/AGENTS.md
Normal file
43
realms/savearth/AGENTS.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Savearth — AWS IoT Core Realm
|
||||
|
||||
Realm for the Savearth embedded systems team and the AWS IoT Core proof-of-concept.
|
||||
|
||||
## Team (Heteronyms)
|
||||
|
||||
| Slug | Name | Email | Role |
|
||||
|------|------|-------|------|
|
||||
| `savearth-fabio` | Fábio Coutada | fabio@savearth.io | Embedded Systems Architect |
|
||||
| `savearth-vitor` | Vítor Oliveira | vitor.oliveira@savearth.io | Junior Embedded Engineer |
|
||||
| `savearth-gabriel` | Gabriel Yassin | gabriel.yassin@savearth.io | Junior Hardware Engineer |
|
||||
| `savearth-claudio` | Cláudio Coelho | claudio@savearth.io | CTO |
|
||||
| `savearth-joao` | João Machado | joao@savearth.io | CEO |
|
||||
|
||||
## Research Study
|
||||
|
||||
**Pre/Post Introduction of Replica-Omnisciente + Aurelio Tools**
|
||||
|
||||
- Hypothesis: Aurelio agent-fleet tooling (dirac, tilth, toon, gbrain, MCP fleet) measurably changes communication patterns, decision velocity, and error rates in Savearth engineering.
|
||||
- Pre-period: before 2026-07-30 (tooling not deployed).
|
||||
- Post-period: 2026-07-30 onward (replica-omnisciente instantiated for Savearth).
|
||||
- Data sources: Google Workspace (Gmail, Calendar), Git history, agent session trajectories, CI/CD outcomes.
|
||||
- Metrics: email volume, meeting load, time-to-decision, bug escape rate, PR cycle time, onboarding speed.
|
||||
|
||||
## Projects
|
||||
|
||||
- `aws-iot-core-poc` — ESP32 + AWS IoT Core firmware PoC
|
||||
- `savearth-iot-infrastructure` — cloud/backend infrastructure
|
||||
- `savearth-hw-project` — hardware design files
|
||||
- `agent-firmware` — agent-facing firmware components
|
||||
- `hardware-devicesFirmwareTest` — device test harnesses
|
||||
|
||||
## Integrations
|
||||
|
||||
- Google Workspace ingestion via `scripts/gws/gws.py`
|
||||
- Git trajectory sync via `scripts/sync-agents-to-brain.py`
|
||||
- Brain sync to Gabinete CT via `.aurelio/sync.py --push`
|
||||
|
||||
## Notes
|
||||
|
||||
- All vendor names (Libero, Vectorblox, OpenVINO, Microchip, etc.) stay.
|
||||
- Server names (Gigabyte, ASUS, LattePanda) stay.
|
||||
- `savearth-mcp` → `aurelio-mcp`, `savearth-workspace` → `aurelio-workspace` only when scrubbing for Portugal Futurista public surfaces.
|
||||
Loading…
Reference in a new issue