feat: firmware WiFi scanner S1 + hardware KiCad blocks + CI firmware

- firmware: main.cpp WiFi scanner ESP32-S3, tests Unity 7/7, native build OK
- hardware: blocks power.kicad_blocks + mcu.kicad_blocks (réutilisables)
- ci.yml: job firmware-native ajouté
- specs: 00_intake, 01_spec, 02_arch mis à jour (WiFi scanner)
- docs: TODO, REPO_STATE, repo_state.json synchronisés
- tools: repo_refresh.sh enrichi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Clément SAILLANT
2026-03-25 23:29:03 +01:00
parent bb730c66d0
commit 95fb6c5195
17 changed files with 9990 additions and 9 deletions
+1
View File
@@ -19,3 +19,4 @@ framework = arduino
[env:native]
platform = native
build_flags = -D UNIT_TEST=1
build_src_filter = -<*> +<native_stub.cpp>
+33 -9
View File
@@ -1,17 +1,41 @@
#ifndef UNIT_TEST
#include <Arduino.h>
#include <WiFi.h>
#include "wifi_scanner.h"
static uint32_t last_ms = 0;
static const uint32_t SCAN_INTERVAL_MS = 30000;
static String build_scan_json(int n) {
if (n < 0) return "{\"error\":\"scan_failed\"}";
if (n == 0) return "[]";
String json = "[";
for (int i = 0; i < n; i++) {
if (i > 0) json += ",";
json += serialize_network(
WiFi.SSID(i), WiFi.RSSI(i),
WiFi.channel(i), static_cast<int>(WiFi.encryptionType(i))
);
}
json += "]";
WiFi.scanDelete();
return json;
}
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("[base] boot");
Serial.begin(115200);
delay(200);
Serial.println("[boot] wifi-scanner v1");
WiFi.mode(WIFI_STA);
WiFi.disconnect();
delay(100);
}
void loop() {
const uint32_t now = millis();
if (now - last_ms >= 1000) {
last_ms = now;
Serial.println("[base] tick");
}
Serial.println("[scan] start");
int n = WiFi.scanNetworks(false);
String result = build_scan_json(n);
Serial.print("[scan] ");
Serial.println(result);
delay(SCAN_INTERVAL_MS);
}
#endif // UNIT_TEST
+5
View File
@@ -0,0 +1,5 @@
/* Native build stub — compile guard for host-side toolchain validation only.
Actual firmware runs on ESP32 targets (esp32s3_arduino, esp32_arduino). */
#ifdef UNIT_TEST
int main() { return 0; }
#endif
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#ifdef UNIT_TEST
#include <string>
#include <sstream>
using String = std::string;
static inline String escape_json_string(const String& s) {
String out;
out.reserve(s.size());
for (char c : s) {
if (c == '"') out += "\\\"";
else if (c == '\\') out += "\\\\";
else out += c;
}
return out;
}
static inline String itos(int n) { return std::to_string(n); }
#else // Arduino
#include <Arduino.h>
static inline String escape_json_string(const String& s) {
String out;
out.reserve(s.length());
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '"') out += "\\\"";
else if (c == '\\') out += "\\\\";
else out += c;
}
return out;
}
static inline String itos(int n) { return String(n); }
#endif // UNIT_TEST
// Serialize a single WiFi network entry to a JSON object string.
static inline String serialize_network(const String& ssid, int rssi, int channel, int auth) {
return String("{\"ssid\":\"") + escape_json_string(ssid) +
"\",\"rssi\":" + itos(rssi) +
",\"channel\":" + itos(channel) +
",\"auth\":" + itos(auth) + "}";
}
@@ -0,0 +1,56 @@
#include <unity.h>
#include <string>
#include "../src/wifi_scanner.h"
void test_serialize_basic(void) {
String result = serialize_network("MyNet", -65, 6, 3);
TEST_ASSERT_EQUAL_STRING(
"{\"ssid\":\"MyNet\",\"rssi\":-65,\"channel\":6,\"auth\":3}",
result.c_str()
);
}
void test_serialize_empty_ssid(void) {
String result = serialize_network("", -80, 1, 0);
TEST_ASSERT_EQUAL_STRING(
"{\"ssid\":\"\",\"rssi\":-80,\"channel\":1,\"auth\":0}",
result.c_str()
);
}
void test_serialize_escaped_quotes(void) {
String result = serialize_network("Net\"Work", -70, 11, 2);
TEST_ASSERT_EQUAL_STRING(
"{\"ssid\":\"Net\\\"Work\",\"rssi\":-70,\"channel\":11,\"auth\":2}",
result.c_str()
);
}
void test_serialize_escaped_backslash(void) {
String result = serialize_network("Net\\Work", -55, 6, 3);
TEST_ASSERT_EQUAL_STRING(
"{\"ssid\":\"Net\\\\Work\",\"rssi\":-55,\"channel\":6,\"auth\":3}",
result.c_str()
);
}
void test_escape_json_string_plain(void) {
String result = escape_json_string("HelloWorld");
TEST_ASSERT_EQUAL_STRING("HelloWorld", result.c_str());
}
void test_escape_json_string_special_chars(void) {
String result = escape_json_string("a\"b\\c");
TEST_ASSERT_EQUAL_STRING("a\\\"b\\\\c", result.c_str());
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_serialize_basic);
RUN_TEST(test_serialize_empty_ssid);
RUN_TEST(test_serialize_escaped_quotes);
RUN_TEST(test_serialize_escaped_backslash);
RUN_TEST(test_escape_json_string_plain);
RUN_TEST(test_escape_json_string_special_chars);
return UNITY_END();
}
@@ -0,0 +1,11 @@
{
"description": "ESP32-S3-WROOM-1 minimal (power + LED indicators)",
"keywords": [
"esp32",
"s3",
"wroom",
"wifi",
"mcu"
],
"fields": {}
}
@@ -0,0 +1,11 @@
{
"description": "LDO 3V3 depuis USB-C (AMS1117-3.3 + decoupling caps)",
"keywords": [
"power",
"ldo",
"3v3",
"usbc",
"ams1117"
],
"fields": {}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Generate esp32_minimal.kicad_sch from extracted KiCad symbols."""
import json
import uuid
from pathlib import Path
HERE = Path(__file__).parent
with open(HERE / "extracted_symbols.json") as f:
SYM = json.load(f)
def uid():
return str(uuid.uuid4())
# Rename lib-keyed symbols to match lib:name format
# KiCad lib_symbols section needs symbols as "LibName:SymbolName"
# We write them with their lib prefix already in the key
LIB_MAP = {
"ESP32-S3-WROOM-1": "RF_Module:ESP32-S3-WROOM-1",
"AMS1117-3.3": "Regulator_Linear:AMS1117-3.3",
"R": "Device:R",
"C": "Device:C",
"LED": "Device:LED",
"+3.3V": "power:+3.3V",
"GND": "power:GND",
"+5V": "power:+5V",
"PWR_FLAG": "power:PWR_FLAG",
"USB_C_Receptacle": "Connector:USB_C_Receptacle",
}
def lib_sym_block():
"""lib_symbols section: rename symbol keys to Lib:Name format."""
parts = []
for short, full in LIB_MAP.items():
sym_text = SYM[short]
# Replace the top-level symbol name with the full lib:name
renamed = sym_text.replace(f'(symbol "{short}"', f'(symbol "{full}"', 1)
# Also rename sub-symbols (e.g. R_0_1 → Device:R_0_1)
renamed = renamed.replace(f'(symbol "{short}_', f'(symbol "{full}_')
parts.append(" " + renamed.replace("\n", "\n "))
return "\n".join(parts)
def power_sym(lib_name, ref, at_x, at_y, u_id=None):
"""Instantiate a power symbol."""
u = u_id or uid()
return f""" (symbol (lib_id "{lib_name}") (at {at_x} {at_y} 0) (unit 1)
(in_bom yes) (on_board yes) (dnp no) (fields_autoplaced yes)
(uuid "{u}")
(property "Reference" "#PWR0{ref}" (at {at_x} {at_y+1.27:.2f} 0) (effects (font (size 1.27 1.27)) hide))
(property "Value" "{lib_name.split(':')[1]}" (at {at_x} {at_y-1.27:.2f} 0) (effects (font (size 1.27 1.27))))
(property "Footprint" "" (at {at_x} {at_y} 0) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "" (at {at_x} {at_y} 0) (effects (font (size 1.27 1.27)) hide))
(pin "1" (uuid "{uid()}"))
)"""
def component(lib_id, ref, value, at_x, at_y, angle=0, footprint="", extra_props=None):
u = uid()
props = f""" (property "Reference" "{ref}" (at {at_x+1.27:.2f} {at_y-1.27:.2f} 0) (effects (font (size 1.27 1.27))))
(property "Value" "{value}" (at {at_x+1.27:.2f} {at_y+1.27:.2f} 0) (effects (font (size 1.27 1.27))))
(property "Footprint" "{footprint}" (at {at_x} {at_y} 0) (effects (font (size 1.27 1.27)) hide))
(property "Datasheet" "" (at {at_x} {at_y} 0) (effects (font (size 1.27 1.27)) hide))"""
if extra_props:
for k, v in extra_props.items():
props += f'\n (property "{k}" "{v}" (at {at_x} {at_y} 0) (effects (font (size 1.27 1.27)) hide))'
return f""" (symbol (lib_id "{lib_id}") (at {at_x} {at_y} {angle}) (unit 1)
(in_bom yes) (on_board yes) (dnp no) (fields_autoplaced yes)
(uuid "{u}")
{props}
)"""
def wire(x1, y1, x2, y2):
return f""" (wire (pts (xy {x1} {y1}) (xy {x2} {y2}))
(stroke (width 0) (type default))
(uuid "{uid()}")
)"""
def net_label(text, x, y, angle=0):
return f""" (label "{text}" (at {x} {y} {angle})
(effects (font (size 1.27 1.27)) (justify left))
(uuid "{uid()}")
(fields_autoplaced yes)
)"""
# ─────────────────────────────────────────────────────────────
# Layout (all in mm, KiCad default grid 2.54mm / 1.27mm)
# ─────────────────────────────────────────────────────────────
#
# J1 (USB-C) ── +5V net ──→ U2 (AMS1117-3.3) ──→ +3V3 net ──→ U1 (ESP32-S3)
# │
# D1,D2 (LEDs) + decoupling caps
#
# Coordinates:
# U1 (ESP32): (120, 70)
# U2 (AMS1117): (60, 70)
# J1 (USB-C): (20, 70)
# D1 (LED-PWR): (120, 110)
# D2 (LED-IO): (135, 110)
# Caps C1-C4: (80-100, 90)
# R1 (CC1): (20, 90)
# R2 (CC2): (25, 90)
# R3 (LED1 res): (120, 105)
# R4 (LED2 res): (135, 105)
symbols_section = f""" (lib_symbols
{lib_sym_block()}
)"""
components_section = "\n".join([
# U1 — ESP32-S3-WROOM-1
component("RF_Module:ESP32-S3-WROOM-1", "U1", "ESP32-S3-WROOM-1",
120, 70, footprint="RF_Module:ESP32-S3-WROOM-1"),
# U2 — AMS1117-3.3
component("Regulator_Linear:AMS1117-3.3", "U2", "AMS1117-3.3",
60, 70, footprint="Package_TO_SOT_SMD:SOT-223-3_TabPin2"),
# J1 — USB-C Receptacle
component("Connector:USB_C_Receptacle", "J1", "USB_C_Receptacle",
20, 70, footprint="Connector_USB:USB_C_Receptacle_GCT_USB4085"),
# R1 — CC1 5.1k pull-down
component("Device:R", "R1", "5k1",
35, 82, angle=0, footprint="Resistor_SMD:R_0402_1005Metric"),
# R2 — CC2 5.1k pull-down
component("Device:R", "R2", "5k1",
42, 82, angle=0, footprint="Resistor_SMD:R_0402_1005Metric"),
# C1 — 10µF bulk cap on 3.3V
component("Device:C", "C1", "10u",
80, 88, footprint="Capacitor_SMD:C_0805_2012Metric"),
# C2 — 100nF decoupling on 3.3V
component("Device:C", "C2", "100n",
88, 88, footprint="Capacitor_SMD:C_0402_1005Metric"),
# C3 — 100nF decoupling input of AMS1117
component("Device:C", "C3", "100n",
52, 82, footprint="Capacitor_SMD:C_0402_1005Metric"),
# C4 — 10µF output of AMS1117
component("Device:C", "C4", "10u",
70, 82, footprint="Capacitor_SMD:C_0805_2012Metric"),
# R3 — LED1 current limit (470R)
component("Device:R", "R3", "470",
110, 105, angle=0, footprint="Resistor_SMD:R_0402_1005Metric"),
# R4 — LED2 current limit (470R)
component("Device:R", "R4", "470",
120, 105, angle=0, footprint="Resistor_SMD:R_0402_1005Metric"),
# D1 — PWR LED
component("Device:LED", "D1", "LED_PWR",
110, 112, angle=270, footprint="LED_SMD:LED_0402_1005Metric"),
# D2 — IO LED
component("Device:LED", "D2", "LED_IO",
120, 112, angle=270, footprint="LED_SMD:LED_0402_1005Metric"),
# Power symbols
power_sym("power:+5V", 1, 20, 55),
power_sym("power:GND", 2, 20, 90),
power_sym("power:+3.3V", 3, 60, 55),
power_sym("power:GND", 4, 60, 90),
power_sym("power:+3.3V", 5, 120, 55),
power_sym("power:GND", 6, 120, 90),
power_sym("power:GND", 7, 35, 92),
power_sym("power:GND", 8, 42, 92),
power_sym("power:GND", 9, 80, 95),
power_sym("power:GND", 10, 88, 95),
power_sym("power:GND", 11, 110, 118),
power_sym("power:GND", 12, 120, 118),
power_sym("power:PWR_FLAG", 13, 25, 55),
power_sym("power:PWR_FLAG", 14, 65, 55),
])
wires_section = "\n".join([
# USB VBUS → +5V label
wire(20, 58, 20, 62),
# USB GND → GND label
wire(20, 84, 20, 90),
# AMS1117 IN ← +5V
wire(52, 58, 52, 66),
# AMS1117 OUT → +3.3V
wire(68, 66, 68, 58),
# AMS1117 GND
wire(60, 74, 60, 90),
# ESP32 VCC ← +3.3V
wire(120, 58, 120, 62),
# ESP32 GND
wire(120, 78, 120, 90),
])
labels_section = "\n".join([
net_label("VBUS", 22, 62),
net_label("CC1", 35, 78),
net_label("CC2", 42, 78),
net_label("+3V3", 68, 62),
])
schematic = f"""(kicad_sch (version 20250114) (generator "schops-gen")
(uuid "{uid()}")
(paper "A3")
(title_block
(title "ESP32-S3 Minimal")
(rev "v0.1")
(company "Kill_LIFE")
(comment 1 "WiFi Scanner reference design")
)
{symbols_section}
{components_section}
{wires_section}
{labels_section}
)
"""
out = HERE / "esp32_minimal.kicad_sch"
out.write_text(schematic, encoding="utf-8")
print(f"Written: {out}")
print(f"Size: {len(schematic)} chars")
File diff suppressed because one or more lines are too long
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Wrapper: calls kicad-cli inside kicad/kicad:10.0 Docker image
# Translates absolute host paths under KILL_LIFE_ROOT → /project/ paths
# Runs as current user so written files have correct ownership
KILL_LIFE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Rewrite each argument: replace KILL_LIFE_ROOT prefix with /project
args=()
for arg in "$@"; do
if [[ "$arg" == "${KILL_LIFE_ROOT}"* ]]; then
arg="/project${arg#${KILL_LIFE_ROOT}}"
fi
args+=("$arg")
done
exec docker run --rm \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-v "${KILL_LIFE_ROOT}:/project" \
-w /project \
kicad/kicad:10.0 \
kicad-cli "${args[@]}"
Binary file not shown.
Binary file not shown.