From 532dc4d17a35bac4950160e843a6bd879fd5b4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:29:51 +0100 Subject: [PATCH] chore(cockpit): stage local working changes Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 8 ++ .github/workflows/ci_cd_audit.yml | 0 .github/workflows/pages_publish.yml | 47 +++++++++ README.md | 48 +++++++++ docs/plans/TODO_2026-03-10.md | 62 +++++++++++ firmware/include/voice_controller.h | 129 +++++++++++++++++++++++ firmware/src/voice_controller.cpp | 157 ++++++++++++++++++++++++++++ specs/constraints.yaml | 2 + test/test_mcp_runtime_status.py | 4 + 9 files changed, 457 insertions(+) create mode 100644 .github/workflows/ci_cd_audit.yml create mode 100644 .github/workflows/pages_publish.yml create mode 100644 docs/plans/TODO_2026-03-10.md create mode 100644 firmware/include/voice_controller.h create mode 100644 firmware/src/voice_controller.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cf99aa..e7f800e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,17 @@ on: permissions: contents: read +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: python-stable: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/ci_cd_audit.yml b/.github/workflows/ci_cd_audit.yml new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/pages_publish.yml b/.github/workflows/pages_publish.yml new file mode 100644 index 0000000..a9e037a --- /dev/null +++ b/.github/workflows/pages_publish.yml @@ -0,0 +1,47 @@ +# Publication automatique des artefacts et endpoints sur GitHub Pages + +## Objectif +Rendre accessibles publiquement les endpoints JSON des badges dynamiques et les artefacts CI/CD via GitHub Pages. + +## Workflow recommandé +- Utiliser GitHub Actions pour publier docs/badges/*.json, endpoints.md, evidence pack et documentation sur la branche gh-pages. +- Déclencher le workflow à chaque push sur main ou release majeure. + +## Exemple de workflow (pages_publish.yml) + +```yaml +name: Publish Badges & Evidence to GitHub Pages +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + publish-pages: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Préparer les artefacts + run: | + mkdir -p public + cp -r docs/badges/*.json public/ + cp endpoints.md public/ + cp -r docs/evidence public/ + - name: Déployer sur gh-pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./public + force_orphan: true +``` + +## Vérification +- Les endpoints et artefacts sont accessibles sur https://electron-rare.github.io/Kill_LIFE/ +- Les badges shields.io peuvent pointer vers les endpoints publiés. + +--- + +> Ce workflow doit être adapté si d’autres artefacts ou documentation doivent être publiés. diff --git a/README.md b/README.md index c283b3d..83be13b 100644 --- a/README.md +++ b/README.md @@ -465,3 +465,51 @@ R : [docs/index.md](docs/index.md), [RUNBOOK.md](RUNBOOK.md), [INSTALL.md](INSTA ## 📜 Licence MIT. Voir [`licenses/MIT.txt`](licenses/MIT.txt). + + + + + + + + + + + + + + + + + + + + + + + + +## Audit & Execution Plan (2026-03-10) + +### Snapshot +- Priority: `P2` +- Tech profile: `other` +- Workflows: `yes` +- Tests: `yes` +- Debt markers: `6` +- Source files: `156` + +### Corrections Prioritaires +- [ ] Optimisation ciblée perf/maintenabilité +- [ ] Ajouter/fiabiliser les commandes de vérification automatiques. +- [ ] Clore les points bloquants avant optimisation avancée. + +### Optimisation +- [ ] Identifier le hotspot principal et mesurer avant/après. +- [ ] Réduire la complexité des modules les plus touchés. + +### Mémoire chantier +- Control plane: `/Users/electron/.codex/memories/electron_rare_chantier` +- Repo card: `/Users/electron/.codex/memories/electron_rare_chantier/REPOS/Kill_LIFE.md` + + diff --git a/docs/plans/TODO_2026-03-10.md b/docs/plans/TODO_2026-03-10.md new file mode 100644 index 0000000..f3e0868 --- /dev/null +++ b/docs/plans/TODO_2026-03-10.md @@ -0,0 +1,62 @@ +# TODO Kill_LIFE — 10 mars 2026 (v2) + +Mis à jour après résolution des bloqueurs. + +--- + +## FAIT + +- [x] constraints.yaml enrichi (kicad v10 pref + compliance path) +- [x] PyYAML + pytest installés dans .venv +- [x] test_mcp_runtime_status.py fixé → 20/20 tests +- [x] git pull origin main (+988 lignes, ZeroClaw/n8n) +- [x] Compliance validation OK (profile prototype, 5 standards) + +--- + +## IMMÉDIAT — Toolchain + +- [ ] Installer PlatformIO: `.venv/bin/pip install platformio` +- [ ] Valider: `.venv/bin/pio run -e native` +- [ ] Valider: `.venv/bin/pio test -e native` +- [ ] Gate S1 checklist: pio run ok, pio test ok + +--- + +## SEMAINE 1-2 — Premier firmware + +- [ ] Spec WiFi scanner ESP32: + - [ ] `specs/00_intake.md` → ajouter section WiFi scan + - [ ] `specs/01_spec.md` → AC: scan networks, output JSON, < 5s + - [ ] `specs/02_arch.md` → ESP32 WiFi API, serial output + - [ ] `specs/03_plan.md` → 1 lot, 3 tâches +- [ ] Implémenter `firmware/src/main.cpp` (WiFi.scanNetworks) +- [ ] Tests Unity `firmware/test/test_wifi_scan.cpp` +- [ ] `pio run -e esp32s3_arduino` → build OK +- [ ] Passer Gate S0 → S1 + +--- + +## SEMAINE 2-3 — Premier hardware + +- [ ] Créer `hardware/esp32_minimal/esp32_minimal.kicad_sch` +- [ ] Composants: ESP32-S3-WROOM-1, USB-C (CC resistors), LDO 3.3V (AMS1117), LEDs, decoupling caps +- [ ] `schops.py snapshot` → before.json +- [ ] `schops.py apply-fields --rules hardware/rules/fields.yaml` +- [ ] `schops.py erc` → green +- [ ] `schops.py bom` → CSV +- [ ] `schops.py netlist` → XML +- [ ] `schops.py snapshot` → after.json + +--- + +## SEMAINE 3+ — Avancé + +- [ ] Installer ZeroClaw (cargo install) +- [ ] Tester n8n smoke workflow (`tools/ai/integrations/n8n/kill_life_smoke_workflow.json`) +- [ ] Exécuter lot autonome: `tools/cockpit/run_next_lots_autonomously.sh` +- [ ] Compliance profile `iot_wifi_eu` end-to-end +- [ ] CI firmware dans GitHub Actions (ajouter pio à ci.yml) +- [ ] Evidence packs automatisés post-merge +- [ ] Design blocks réutilisables (power, UART, SPI) +- [ ] Mettre à jour datasets HuggingFace diff --git a/firmware/include/voice_controller.h b/firmware/include/voice_controller.h new file mode 100644 index 0000000..32f10ee --- /dev/null +++ b/firmware/include/voice_controller.h @@ -0,0 +1,129 @@ +#pragma once + +#include +#include +#include + +enum class MediaMode { + kIdle, + kMp3, + kRadio, +}; + +enum class VoicePhase { + kIdle, + kRecording, + kThinking, + kSpeaking, + kError, +}; + +enum class PlayerAction { + kNone, + kDuck, + kStopResume, +}; + +struct MediaSnapshot { + MediaMode mode = MediaMode::kIdle; + bool playing = false; + std::string station; + std::string track; + int volume = 40; + int battery_pct = -1; + std::string wifi_ssid; + int wifi_rssi = 0; + std::vector available_stations; +}; + +struct VoiceIntent { + std::string type = "none"; + std::string target; + std::string value; + std::string spoken_confirmation; + bool resume_media_after_tts = false; +}; + +struct VoiceSessionResponse { + bool ok = false; + std::string session_id; + std::string transcript; + VoiceIntent intent; + std::string reply_text; + std::string reply_audio_url; + PlayerAction player_action = PlayerAction::kNone; + std::string provider = "none"; + std::string error; +}; + +class BackendClient { + public: + virtual ~BackendClient() = default; + + virtual bool SendPlayerEvent(const std::string& device_id, + const std::string& event_name, + const MediaSnapshot& media, + const std::string& detail) = 0; + + virtual VoiceSessionResponse SubmitVoiceSession( + const std::string& device_id, + const MediaSnapshot& media, + const std::vector& wav_data) = 0; + + virtual bool DownloadReplyAudio(const std::string& audio_url, + std::vector* wav_data) = 0; +}; + +class MediaController { + public: + virtual ~MediaController() = default; + + virtual MediaSnapshot Snapshot() const = 0; + virtual void ApplyIntent(const VoiceIntent& intent) = 0; + virtual void PrepareForReply(PlayerAction action) = 0; + virtual void RestoreAfterReply(bool resume_media_after_tts) = 0; + virtual bool PlayReplyAudio(const std::vector& wav_data) = 0; +}; + +class UiRenderer { + public: + virtual ~UiRenderer() = default; + + virtual void Render(const MediaSnapshot& media, + VoicePhase phase, + const std::string& headline, + const std::string& summary, + bool show_ring) = 0; +}; + +class VoiceController { + public: + VoiceController(std::string device_id, + BackendClient& backend, + MediaController& media, + UiRenderer& ui); + + void Boot(); + bool BeginPushToTalk(); + bool CompletePushToTalk(const std::vector& wav_data); + + VoicePhase phase() const { return phase_; } + const VoiceSessionResponse& last_response() const { return last_response_; } + + private: + bool Fail(const std::string& error_text); + void RenderState(const std::string& headline, + const std::string& summary, + bool show_ring); + static std::string IdleSummary(const MediaSnapshot& media); + static bool ShouldPublishPlaybackStarted(const MediaSnapshot& before, + const MediaSnapshot& after, + const VoiceIntent& intent); + + std::string device_id_; + BackendClient& backend_; + MediaController& media_; + UiRenderer& ui_; + VoicePhase phase_ = VoicePhase::kIdle; + VoiceSessionResponse last_response_; +}; diff --git a/firmware/src/voice_controller.cpp b/firmware/src/voice_controller.cpp new file mode 100644 index 0000000..e8da720 --- /dev/null +++ b/firmware/src/voice_controller.cpp @@ -0,0 +1,157 @@ +#include "voice_controller.h" + +#include +#include + +namespace { + +std::string ModeLabel(MediaMode mode) { + switch (mode) { + case MediaMode::kMp3: + return "MP3"; + case MediaMode::kRadio: + return "Radio"; + case MediaMode::kIdle: + default: + return "Idle"; + } +} + +} // namespace + +VoiceController::VoiceController(std::string device_id, + BackendClient& backend, + MediaController& media, + UiRenderer& ui) + : device_id_(std::move(device_id)), + backend_(backend), + media_(media), + ui_(ui) {} + +void VoiceController::Boot() { + phase_ = VoicePhase::kIdle; + RenderState("pret", IdleSummary(media_.Snapshot()), false); + backend_.SendPlayerEvent(device_id_, "boot", media_.Snapshot(), + "voice-controller-ready"); +} + +bool VoiceController::BeginPushToTalk() { + if (phase_ != VoicePhase::kIdle) { + return false; + } + + phase_ = VoicePhase::kRecording; + RenderState("j'ecoute", "Maintiens le bouton pour parler.", true); + return true; +} + +bool VoiceController::CompletePushToTalk(const std::vector& wav_data) { + if (phase_ != VoicePhase::kRecording) { + return false; + } + + if (wav_data.empty()) { + return Fail("capture audio vide"); + } + + phase_ = VoicePhase::kThinking; + RenderState("je reflechis", "Envoi de la requete a Mascarade.", true); + + last_response_ = backend_.SubmitVoiceSession(device_id_, media_.Snapshot(), + wav_data); + if (!last_response_.ok) { + const std::string error_text = + last_response_.error.empty() ? "session vocale en echec" + : last_response_.error; + return Fail(error_text); + } + + const MediaSnapshot before = media_.Snapshot(); + media_.ApplyIntent(last_response_.intent); + const MediaSnapshot after = media_.Snapshot(); + + if (ShouldPublishPlaybackStarted(before, after, last_response_.intent)) { + const std::string detail = + !after.station.empty() ? after.station : after.track; + backend_.SendPlayerEvent(device_id_, "playback_started", after, detail); + } + + media_.PrepareForReply(last_response_.player_action); + + phase_ = VoicePhase::kSpeaking; + RenderState("je reponds", + last_response_.reply_text.empty() + ? "Reponse audio en preparation." + : last_response_.reply_text, + true); + + if (!last_response_.reply_audio_url.empty()) { + std::vector reply_audio; + if (backend_.DownloadReplyAudio(last_response_.reply_audio_url, + &reply_audio)) { + if (!media_.PlayReplyAudio(reply_audio)) { + backend_.SendPlayerEvent(device_id_, "playback_failed", + media_.Snapshot(), + "tts reply playback failed"); + } + } else { + backend_.SendPlayerEvent(device_id_, "playback_failed", media_.Snapshot(), + "tts reply download failed"); + } + } + + media_.RestoreAfterReply(last_response_.intent.resume_media_after_tts); + + phase_ = VoicePhase::kIdle; + RenderState("pret", IdleSummary(media_.Snapshot()), false); + return true; +} + +bool VoiceController::Fail(const std::string& error_text) { + phase_ = VoicePhase::kError; + last_response_.ok = false; + last_response_.error = error_text; + RenderState("erreur reseau", error_text, false); + backend_.SendPlayerEvent(device_id_, "voice_error", media_.Snapshot(), + error_text); + media_.RestoreAfterReply(true); + phase_ = VoicePhase::kIdle; + RenderState("pret", IdleSummary(media_.Snapshot()), false); + return false; +} + +void VoiceController::RenderState(const std::string& headline, + const std::string& summary, + bool show_ring) { + ui_.Render(media_.Snapshot(), phase_, headline, summary, show_ring); +} + +std::string VoiceController::IdleSummary(const MediaSnapshot& media) { + std::ostringstream summary; + summary << ModeLabel(media.mode) << " | volume " << media.volume; + if (media.mode == MediaMode::kRadio && !media.station.empty()) { + summary << " | " << media.station; + } else if (media.mode == MediaMode::kMp3 && !media.track.empty()) { + summary << " | " << media.track; + } else if (!media.playing) { + summary << " | pause"; + } + return summary.str(); +} + +bool VoiceController::ShouldPublishPlaybackStarted( + const MediaSnapshot& before, const MediaSnapshot& after, + const VoiceIntent& intent) { + if (!after.playing) { + return false; + } + + if (intent.type == "play" || intent.type == "switch_mode" || + intent.type == "select_station" || intent.type == "next" || + intent.type == "previous") { + return !before.playing || before.station != after.station || + before.track != after.track || before.mode != after.mode; + } + + return false; +} diff --git a/specs/constraints.yaml b/specs/constraints.yaml index 64018b4..19aabd3 100644 --- a/specs/constraints.yaml +++ b/specs/constraints.yaml @@ -24,6 +24,7 @@ firmware: hardware: kicad: version_min: 9 + version_preferred: 10 schematic_ops: allow_bulk_edits: true require_erc_green: true @@ -34,3 +35,4 @@ repo_rules: compliance: profile: prototype + active_profile_path: "compliance/active_profile.yaml" diff --git a/test/test_mcp_runtime_status.py b/test/test_mcp_runtime_status.py index 530f727..ad21498 100644 --- a/test/test_mcp_runtime_status.py +++ b/test/test_mcp_runtime_status.py @@ -3,9 +3,13 @@ from __future__ import annotations import json import subprocess +import sys import unittest +from pathlib import Path from unittest import mock +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from tools.mcp_runtime_status import classify_overall, derive_blockers, run_check