feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s

Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
@@ -0,0 +1,84 @@
"""Create a per-device NVS image and setup label; never print device secrets.
Use with the *existing* physical_confirm inventory credential, not a newly
invented server key. Output is private production material, not source code.
"""
import argparse
import csv
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
from urllib.parse import urlsplit
N = int(''.join([
'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E',
'3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE',
'9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED5290770',
'96966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF69558171839',
'95497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6',
'E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200CBBE117577A615D6C77',
'0988C0BAD946E208E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF']), 16)
def security_material(username, password, salt):
"""Match esp_srp.c: hash raw salt and SHA512 bytes (preserve leading zeros)."""
inner = hashlib.sha512(f'{username}:{password}'.encode()).digest()
x = int.from_bytes(hashlib.sha512(salt + inner).digest(), 'big')
return pow(5, x, N).to_bytes(384, 'big')
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--device-id', required=True)
p.add_argument('--model', default='ECHOEAR-S3')
p.add_argument('--origin', required=True)
p.add_argument('--key-version', type=int, required=True)
p.add_argument('--device-key-file', type=Path, required=True, help='64 hex chars or controlled migration credential JSON')
p.add_argument('--output', type=Path, required=True, help='new private directory; refuses overwrite')
a = p.parse_args()
origin = urlsplit(a.origin)
if (not re.fullmatch(r'KP01-[0-9A-F]{12}', a.device_id) or
not re.fullmatch(r'[A-Za-z0-9_.-]{1,32}', a.model) or
not 1 <= a.key_version <= 0xffffffff or len(a.origin) > 150 or
origin.scheme != 'https' or not origin.hostname or origin.username or
origin.path or origin.query or origin.fragment):
p.error('invalid identity, model, key version or HTTPS origin')
key_hex = a.device_key_file.read_text(encoding='ascii').strip()
if key_hex.startswith('{'):
identity = json.loads(key_hex)
if identity.get('device_id') != a.device_id or identity.get('key_version') != a.key_version:
p.error('credential identity does not match the requested factory image')
key_hex = identity.get('device_key_hex', '')
if not re.fullmatch(r'[0-9a-fA-F]{64}', key_hex):
p.error('device key must be exactly 32 bytes in hexadecimal')
a.output.mkdir(parents=True, exist_ok=False, mode=0o700)
password = secrets.token_urlsafe(16)
salt = secrets.token_bytes(16)
verifier = security_material(a.device_id, password, salt)
values = [('identity', 'namespace', '', ''),
('factory_id', 'data', 'string', a.device_id),
('model', 'data', 'string', a.model),
('origin', 'data', 'string', a.origin),
('key_version', 'data', 'u32', a.key_version),
('device_key', 'data', 'hex2bin', key_hex),
('salt', 'data', 'hex2bin', salt.hex()),
('verifier', 'data', 'hex2bin', verifier.hex())]
csv_path = a.output / 'factory.csv'
with csv_path.open('x', encoding='utf-8', newline='') as f:
writer = csv.writer(f); writer.writerow(('key', 'type', 'encoding', 'value')); writer.writerows(values)
os.chmod(csv_path, 0o600)
subprocess.run([sys.executable, '-m', 'esp_idf_nvs_partition_gen', 'generate',
str(csv_path.resolve()), str((a.output / 'ble_factory.bin').resolve()), '0x6000'], check=True)
label = a.output / 'setup-label.json'
label.write_text(json.dumps({'device_id': a.device_id, 'model': a.model, 'setup_password': password}, ensure_ascii=False, indent=2), encoding='utf-8')
os.chmod(label, 0o600); os.chmod(a.output / 'ble_factory.bin', 0o600)
print(f'Created private NVS image and label in {a.output.resolve()}. No device key is present in the label.')
if __name__ == '__main__':
main()
@@ -0,0 +1,86 @@
"""One bounded COM6 reboot; observe real MQTT lifecycle and durable command ACK.
Requires the existing, COM6-restricted operator helper in the API container.
Records operational metadata only. Does not open serial or change ownership.
"""
import datetime
import json
from pathlib import Path
import subprocess
REMOTE = r'''
import datetime,json,subprocess,time,uuid
p=json.loads(subprocess.check_output(['docker','inspect','data-terminal-cloud-postgres-1']))[0]
env=dict(x.split('=',1) for x in p['Config']['Env'] if '=' in x)
psql=['docker','exec',p['Id'],'psql','-U',env['POSTGRES_USER'],'-d',env['POSTGRES_DB'],'-At','-v','ON_ERROR_STOP=1','-c']
query="""SELECT json_build_object(
'utc',now(),'connected',p.connected,'online',COALESCE(p.connected AND p.last_seen_at>now()-interval '90 seconds',false),
'session',p.connection_id,'heartbeat',p.last_seen_at,'firmware',t.firmware_version,
'desired_revision',t.desired_revision,'reported_revision',t.reported_revision,
'speaker',t.reported_config->'volume'->'speaker','power',t.reported_config->'power'->>'mode',
'bound',(SELECT count(*) FROM device_binding WHERE device_id=d.id AND unbound_at IS NULL),
'pending',(SELECT count(*) FROM device_command WHERE device_id=d.id AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()),
'busy',EXISTS(SELECT 1 FROM call_session WHERE device_id=d.id AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING')) OR
EXISTS(SELECT 1 FROM conversation WHERE device_id=d.id AND status NOT IN ('COMPLETED','FAILED','EXPIRED')) OR
EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=d.id AND status IN ('pending','accepted')))
FROM device d JOIN fleet_presence p ON p.device_id=d.id JOIN device_twin t ON t.device_id=d.id
WHERE d.public_id='623a4e54-bdc4-4c98-b03e-27267d3ff360' AND d.factory_device_id='KP01-020026091601' AND d.status='registered'"""
def state():
return json.loads(subprocess.check_output(psql+[query],text=True,timeout=10))
def emit(key,value):
print(json.dumps({key:value}),flush=True)
initial=state()
assert initial['bound']==1 and not initial['busy'] and initial['pending']==0 and initial['online'], 'COM6 not idle/online or binding changed'
assert initial['desired_revision']==initial['reported_revision'] and initial['power']=='active', 'COM6 config unsettled or standby'
session=initial.pop('session');emit('initial',initial)
start=time.monotonic()
command=subprocess.Popen(['docker','exec','data-terminal-cloud-api-1','/tmp/com6-power-verify','reboot'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
first=command.stdout.readline().strip()
assert first, 'reboot helper did not return a command ID'
receipt=json.loads(first);uuid.UUID(receipt['command_id']);emit('command',receipt)
observations=[];offline=False;reconnected=False
while time.monotonic()-start<100:
current=state();changed=current.pop('session')!=session
current['session_changed']=changed;current['elapsed_sec']=round(time.monotonic()-start,3)
observations.append(current)
offline=offline or not current['online']
reconnected=reconnected or (changed and current['online'])
if len(observations)==1 or (len(observations)>1 and (current['online'],changed)!=(observations[-2]['online'],observations[-2]['session_changed'])):
emit('transition',current)
if command.poll() is not None and reconnected and current['pending']==0 and current['desired_revision']==current['reported_revision']:
break
time.sleep(.25)
output,error=command.communicate(timeout=15)
receipts=[json.loads(line) for line in output.splitlines() if line.strip()]
terminal=receipts[-1] if receipts else {}
emit('observations',observations)
emit('result',{'exit':command.returncode,'command_id':receipt['command_id'],'status':terminal.get('status'),
'delivery_attempts':terminal.get('delivery_attempts'),'offline_observed':offline,'reconnected':reconnected,
'settings_preserved':current['speaker']==initial['speaker'] and current['power']==initial['power'] and current['bound']==initial['bound'] and current['desired_revision']==initial['desired_revision'] and current['reported_revision']==current['desired_revision'],
'error':error.strip()})
assert command.returncode==0 and terminal.get('status')=='SUCCEEDED' and reconnected, 'reboot/reconnect failed'
assert current['speaker']==initial['speaker'] and current['power']==initial['power'] and current['bound']==1 and current['desired_revision']==initial['desired_revision'] and current['reported_revision']==current['desired_revision']
# A very short reboot may reconnect before TCP detects the old peer is gone.
# Report the observation faithfully; do not call it an offline pass.
'''
if __name__ == '__main__':
stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
evidence = Path('E:/Codex/story/.local-evidence/com6-controls-20260922') / ('reconnect-' + stamp + '.jsonl')
ssh = ['ssh', '-i', 'E:/Codex/story/.local-evidence/account-reset-18642041700-20260916/migration-key.pem',
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', 'root@82.157.201.114', 'python3 -u -']
process = subprocess.Popen(ssh, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8')
process.stdin.write(REMOTE)
process.stdin.close()
with evidence.open('w', encoding='utf-8') as out:
for line in process.stdout:
out.write(line)
out.flush()
if '"observations"' not in line:
print(line.strip(), flush=True)
error = process.stderr.read()
result = process.wait()
if error:
print(error, flush=True)
print(json.dumps({'evidence': str(evidence), 'exit': result}), flush=True)
raise SystemExit(result)
@@ -0,0 +1,51 @@
"""COM6-only fixed-sentence smoke test; retain numeric/audio-state evidence only."""
import datetime,json,pathlib,re,serial,subprocess,time
probe = """
import json,subprocess
p=json.loads(subprocess.check_output(['docker','inspect','data-terminal-cloud-postgres-1']))[0]
e=dict(x.split('=',1) for x in p['Config']['Env'] if '=' in x)
q=\"SELECT json_build_object('bound',(SELECT count(*) FROM device_binding WHERE device_id=d.id AND unbound_at IS NULL),'busy',EXISTS(SELECT 1 FROM call_session WHERE device_id=d.id AND call_state IN ('PREPARING','STARTING','TALKING','STOPPING')) OR EXISTS(SELECT 1 FROM conversation WHERE device_id=d.id AND status NOT IN ('COMPLETED','FAILED','EXPIRED')) OR EXISTS(SELECT 1 FROM fleet_ota WHERE device_id=d.id AND status IN ('pending','accepted'))) FROM device d WHERE public_id='623a4e54-bdc4-4c98-b03e-27267d3ff360' AND factory_device_id='KP01-020026091601' AND status='registered'\"
print(subprocess.check_output(['docker','exec',p['Id'],'psql','-U',e['POSTGRES_USER'],'-d',e['POSTGRES_DB'],'-At','-v','ON_ERROR_STOP=1','-c',q],text=True).strip())
"""
result=subprocess.run(['ssh','-i','E:/Codex/story/.local-evidence/account-reset-18642041700-20260916/migration-key.pem','-o','BatchMode=yes','-o','ConnectTimeout=10','root@82.157.201.114','python3 -'],input=probe,text=True,capture_output=True,check=True)
state=json.loads(result.stdout)
if state['busy'] or state['bound']!=1:raise SystemExit('COM6 is busy or binding changed; test not started')
port=serial.Serial();port.port='COM6';port.baudrate=115200;port.timeout=.2;port.dtr=False;port.rts=False
port.open()
patterns=[r'RTC diagnostic: .*',r'USB diagnostic: .*',r'chat end acknowledged=\d+',
r'Network diagnostic: sockets=\d+ limit=\d+',
r'mbedtls_net_connect error, return -0x[0-9a-f]+ errno=\d+',
r'platform_conversation_begin failed, ret: -?\d+',r'volc_start failed, ret: -?\d+',
r'PLATFORM_HTTP method=\w+ status=\d+ elapsed_ms=\d+ new_connection=\d+',
r'Failed to allocate \d+ bytes',r'Cannot allocate memory',
r'get rtc config failed',r'engine start failed: -?\d+',r'engine is not in CREATED state',r'bot id is invalid',
r'engine handle\([a-zA-Z0-9()]+\) or bot id\([a-zA-Z0-9()]+\) is NULL',r'rtc instance is NULL',
r'connect failed, https client (?:context|connect) return: -0x[0-9a-f]+',
r'RTC configuration request prepared',
r'Failed to join room: -?\d+',r'Failed to get response from server',
r'webclient POST request failed, response\(-?\d+\) error',
r'RTC config rejected code=[A-Za-z_0-9.]+ fields room=\d user=\d token=\d task=\d',
r'PLAYBACK (?:stats .*|start .*|idle .*|diagnostic sentence requested ret=-?\d+|feeder write failed|queue overflow.*)',
r'DOWNLINK span_ms=.*',r'AUDIO conversation status=-?\d+',r'device_control: state .*',
r'Platform error code: [A-Z_0-9]+',r'Guru Meditation.*',r'Stack canary.*',r'CORRUPT HEAP.*']
allow=re.compile('|'.join('('+p+')' for p in patterns))
lines=[];stopped=False
try:
port.write(b'\ndiag-say\n');start=time.monotonic()
print('COM6 fixed-sentence test started; no transcript captured',flush=True)
diagnosed=False
while time.monotonic()-start<55:
if not diagnosed and time.monotonic()-start>=15:port.write(b'diag-state\n');diagnosed=True
if not stopped and time.monotonic()-start>=40:port.write(b'diag-stop\n');stopped=True
raw=port.readline().decode('utf-8','replace').strip()
found=allow.search(raw)
if found:
line=found.group(0)[:700];lines.append(line);print(line,flush=True)
finally:
if not stopped:port.write(b'diag-stop\n')
port.close()
stamp=datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')
path=pathlib.Path('E:/Codex/story/.local-evidence/com6-controls-20260922')/('voice-'+stamp+'.log')
path.write_text('\n'.join(lines)+'\n',encoding='utf-8')
print(json.dumps({'evidence':str(path),'lines':len(lines),'downlink':any(x.startswith('DOWNLINK ') for x in lines),'playback':any(x.startswith('PLAYBACK stats ') for x in lines),'hangup_ack':any('chat end acknowledged=1' in x for x in lines)}),flush=True)
@@ -0,0 +1,448 @@
"""Plan or safely flash the EchoEar ESP32-S3 16 MiB A/B firmware.
No serial port is opened unless the command is ``flash --execute``. All writes
require a matching physical MAC, a fresh full-flash backup, and verified input
images. The device remains in its bootloader after completion: reset it manually.
Full USB firmware writes require --reset-runtime and subsequent Wi-Fi provisioning
to clear old OTA version/journal metadata. Identity-only writes may preserve NVS.
Requires Python 3.10+; hardware commands additionally need esptool 4.x or 5.x.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path, PureWindowsPath
import re
import struct
import subprocess
import sys
from device_prepare import private_directory
PROJECT = Path(__file__).resolve().parents[1]
FLASH_SIZE = 0x1000000
FACTORY_OFFSET = 0xE4E000
FACTORY_SIZE = 0x6000
NVS_OFFSET = 0x9000
NVS_SIZE = 0x6000
TABLE_OFFSET = 0x8000
SECTOR = 0x1000
# name: type, subtype, offset, size, flags. Exact supported 16 MiB A/B layout.
LAYOUT = {
"nvs": (1, 2, 0x9000, 0x6000, 0),
"phy_init": (1, 1, 0xF000, 0x1000, 0),
"ota_0": (0, 0x10, 0x10000, 0x400000, 0),
"ota_1": (0, 0x11, 0x410000, 0x400000, 0),
"otadata": (1, 0, 0x810000, 0x2000, 0),
"model": (1, 0x82, 0x812000, 0x80000, 0),
"coredump": (1, 3, 0x91E000, 0x10000, 0),
"storage": (1, 0x83, 0x92E000, 0x200000, 0),
"eaf": (1, 0x82, 0xB2E000, 0x320000, 0),
"ble_factory": (1, 2, FACTORY_OFFSET, FACTORY_SIZE, 0),
}
REGIONS = {
"bootloader": (0, 0x8000),
"partition-table": (TABLE_OFFSET, SECTOR),
"app": (0x10000, 0x400000),
"otadata": (0x810000, 0x2000),
"model": (0x812000, 0x80000),
"eaf": (0xB2E000, 0x320000),
}
class FlashError(ValueError):
"""An input or hardware safety check failed."""
def require(condition, message):
if not condition:
raise FlashError(message)
def number(value):
require(type(value) in (str, int), "Expected an integer or hexadecimal string")
try:
return int(value, 0) if isinstance(value, str) else value
except ValueError as exc:
raise FlashError("Invalid integer in manifest") from exc
def normalize_mac(value):
require(isinstance(value, str), "MAC must be a string")
compact = re.sub("[:-]", "", value or "")
require(bool(re.fullmatch(r"[0-9a-fA-F]{12}", compact)), "MAC must contain 12 hex digits")
require(compact.lower() not in ("000000000000", "ffffffffffff"), "Invalid MAC address")
return ":".join(compact[i:i + 2].upper() for i in range(0, 12, 2))
def sha256(data):
return hashlib.sha256(data).hexdigest()
def load_json(path):
try:
value = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as exc:
raise FlashError(f"Cannot read JSON: {path}") from exc
require(isinstance(value, dict), f"Expected a JSON object: {path}")
return value
def object_field(parent, name):
value = parent.get(name)
require(isinstance(value, dict), f"Missing or invalid object: {name}")
return value
def contained_file(directory, relative):
require(isinstance(relative, str) and relative, "Missing image filename")
win = PureWindowsPath(relative)
require(not win.drive and not win.is_absolute() and not Path(relative).is_absolute(),
"Absolute image paths are not allowed in manifests")
require(".." not in win.parts and ".." not in Path(relative).parts,
"Image path must not escape its build/factory directory")
path = (directory / relative).resolve()
require(path.is_relative_to(directory.resolve()), "Image symlink escapes its directory")
require(path.is_file(), f"Image is missing: {path}")
return path
def partition_table(data):
"""Validate IDF's 32-byte partition records and the mandatory MD5 record."""
require(len(data) in (0xC00, 0x1000), "Partition table must be 3072 or 4096 bytes")
result = {}
for pos in range(0, 0xC00, 32):
record = data[pos:pos + 32]
if record[:16] == b"\xeb\xeb" + b"\xff" * 14:
require(record[16:] == hashlib.md5(data[:pos]).digest(), "Partition-table MD5 mismatch")
require(data[pos + 32:] == b"\xff" * (len(data) - pos - 32),
"Unsupported signed or nonempty partition-table padding")
require(result == LAYOUT, "Partition layout is not the supported 16 MiB / 4 MiB A/B layout")
return result
require(record[:2] == b"\xaa\x50", "Missing partition-table entry or MD5 record")
_, kind, subtype, offset, size, label, flags = struct.unpack("<HBBII16sI", record)
try:
name = label.split(b"\0", 1)[0].decode("ascii")
except UnicodeError as exc:
raise FlashError("Non-ASCII partition name") from exc
require(name and name not in result, "Duplicate or empty partition name")
require(offset % SECTOR == 0 and size > 0 and size % SECTOR == 0,
"Unaligned partition")
require(offset >= 0x9000 and offset + size <= FLASH_SIZE, "Partition out of flash bounds")
for old in result.values():
require(offset + size <= old[2] or offset >= old[2] + old[3], "Overlapping partitions")
result[name] = (kind, subtype, offset, size, flags)
raise FlashError("Missing partition-table MD5")
def esp_image(data, application=False):
"""Validate ESP image segments, XOR checksum, appended SHA256 and chip ID."""
require(len(data) >= 64 and data[0] == 0xE9, "Not an ESP executable image")
require(1 <= data[1] <= 16, "Invalid ESP image segment count")
require(struct.unpack_from("<H", data, 12)[0] == 9, "Image is not for ESP32-S3")
require(data[2] == 3 and data[3] == 0x4F, "Image must target DOUT / 16MB / 80MHz")
require(data[23] == 1, "ESP image must include its validation SHA256")
position, checksum = 24, 0xEF
for _ in range(data[1]):
require(position + 8 <= len(data), "Truncated ESP segment header")
size = struct.unpack_from("<I", data, position + 4)[0]
position += 8
require(size > 0 and position + size <= len(data), "Truncated or empty ESP segment")
for byte in data[position:position + size]:
checksum ^= byte
position += size
checksum_pos = (position // 16 + 1) * 16 - 1
require(checksum_pos + 33 == len(data), "Unexpected ESP image tail or signature")
require(data[checksum_pos] == checksum, "ESP image checksum mismatch")
require(data[-32:] == hashlib.sha256(data[:-32]).digest(), "ESP image SHA256 mismatch")
if not application:
return {}
require(struct.unpack_from("<I", data, 32)[0] == 0xABCD5432, "App descriptor is missing")
def cstring(offset, length):
return data[offset:offset + length].split(b"\0", 1)[0].decode("utf-8", errors="replace")
project = cstring(80, 32)
require(project == "VolcRTCDemo", "Image is not the EchoEar VolcRTCDemo application")
versions = set(re.findall(rb"\b\d+\.\d+\.\d+-controls\.\d+\b", data))
require(len(versions) == 1, "Application must contain one identifiable controls firmware version")
return {"project": project, "firmware_version": next(iter(versions)).decode("ascii"),
"idf_app_version": cstring(48, 32), "idf_version": cstring(144, 32),
"hardware_acceptance": "not_verified_by_this_tool"}
@dataclass
class Image:
name: str
offset: int
data: bytes
source: str
def summary(self):
return {"name": self.name, "offset": hex(self.offset), "size": len(self.data),
"sha256": sha256(self.data), "source": self.source}
@dataclass
class Plan:
images: list[Image]
application: dict
factory: dict | None
identity_only: bool
reset_runtime: bool
def summary(self):
return {"mode": "identity-only" if self.identity_only else "full-firmware",
"chip": "esp32s3", "flash_size": FLASH_SIZE,
"application": {"action": "retained_on_device", "firmware_version": None}
if self.identity_only else self.application,
"reference_build": self.application if self.identity_only else None,
"factory": self.factory,
"reset_runtime": self.reset_runtime,
"execute_requires_reset_runtime": not self.identity_only,
"runtime_effect": "Erase Wi-Fi, binding, session and OTA state; BLE provisioning required"
if self.reset_runtime else "Preserve Wi-Fi, binding and session state",
"images": [image.summary() for image in self.images],
"completion": "Verify written bytes, stay in bootloader; manual reset required"}
def build_plan(build_dir, factory_dir=None, identity_only=False, reset_runtime=False, expect_mac=None):
build_dir = Path(build_dir).resolve()
manifest = load_json(build_dir / "flasher_args.json")
require(object_field(manifest, "extra_esptool_args").get("chip") == "esp32s3", "Build target must be esp32s3")
settings = object_field(manifest, "flash_settings")
require(settings.get("flash_mode") == "dout" and settings.get("flash_freq") == "80m"
and settings.get("flash_size") in ("detect", "16MB"), "Unsupported build flash settings")
flash_files = manifest.get("flash_files")
require(isinstance(flash_files, dict) and len(flash_files) == len(REGIONS),
"Build must contain exactly bootloader, app, partition-table, otadata, model and eaf")
mapped = {number(key): value for key, value in flash_files.items()}
require(len(mapped) == len(REGIONS), "Duplicate flash offsets")
images, app_info = [], {}
for name, (offset, maximum) in REGIONS.items():
entry = object_field(manifest, name)
require(number(entry.get("offset")) == offset and mapped.get(offset) == entry.get("file"),
f"Build metadata for {name} disagrees with supported address")
require(entry.get("encrypted") in (False, "false"), "Encrypted build images are unsupported")
path = contained_file(build_dir, entry.get("file"))
data = path.read_bytes()
require(0 < len(data) <= maximum, f"{name} image does not fit its allocated region")
if name == "partition-table":
partition_table(data)
elif name == "bootloader":
esp_image(data)
elif name == "app":
app_info = esp_image(data, application=True)
elif name == "otadata":
require(len(data) == maximum and data == b"\xff" * maximum,
"Expected the IDF initial empty OTA selection image")
if not identity_only:
images.append(Image(name, offset, data, str(path)))
factory_summary = None
if factory_dir:
factory_dir = Path(factory_dir).resolve()
factory = load_json(factory_dir / "manifest.json")
require(factory.get("schema_version") == 1 and factory.get("chip") == "esp32s3"
and number(factory.get("flash_size")) == FLASH_SIZE, "Unsupported factory manifest")
require(isinstance(factory.get("device_id"), str)
and bool(re.fullmatch(r"KP01-[0-9A-F]{12}", factory["device_id"])),
"Invalid factory device ID")
mac = normalize_mac(factory.get("expected_mac"))
if expect_mac:
require(mac == normalize_mac(expect_mac), "Factory manifest MAC does not match --expect-mac")
entry = object_field(factory, "image")
require(entry.get("file") == "ble_factory.bin" and number(entry.get("offset")) == FACTORY_OFFSET
and number(entry.get("size")) == FACTORY_SIZE, "Incorrect factory image location/size")
path = contained_file(factory_dir, entry.get("file"))
data = path.read_bytes()
require(len(data) == FACTORY_SIZE and sha256(data) == entry.get("sha256"),
"Factory image size or SHA256 mismatch")
require(data != b"\xff" * FACTORY_SIZE and data != b"\0" * FACTORY_SIZE,
"Factory image is empty")
images.append(Image("ble_factory", FACTORY_OFFSET, data, str(path)))
factory_summary = {"device_id": factory["device_id"], "expected_mac": mac}
require(not identity_only or factory_summary is not None, "--identity-only requires --factory-dir")
if reset_runtime:
images.append(Image("reset-runtime", NVS_OFFSET, b"\xff" * NVS_SIZE, "explicit --reset-runtime"))
images.sort(key=lambda image: image.offset)
previous_end = 0
for image in images:
end = (image.offset + len(image.data) + SECTOR - 1) // SECTOR * SECTOR
require(image.offset % SECTOR == 0 and image.offset >= previous_end and end <= FLASH_SIZE,
"Flash regions or their erase sectors overlap")
previous_end = end
return Plan(images, app_info, factory_summary, identity_only, reset_runtime)
class Esptool:
def __init__(self, python, port, baud, runner=subprocess.run):
self.python, self.port, self.baud, self.runner = python, port, baud, runner
self.major = 4
self.first = True
def invoke(self, args):
result = self.runner([self.python, "-X", "utf8", "-m", "esptool"] + args,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace", check=False)
if result.returncode:
raise FlashError(f"esptool failed ({result.returncode}); no later commands were run:\n{result.stdout}")
return result.stdout
def check_version(self):
output = self.invoke(["version"])
match = re.search(r"(?<!\d)([45])\.\d+(?:\.\d+)?", output)
require(bool(match), "Install esptool 4.x or 5.x in the selected Python environment")
self.major = int(match.group(1))
def command(self, command, *args):
before = "default_reset" if self.first else "no_reset"
self.first = False
prefix = ["--chip", "esp32s3", "--port", self.port, "--baud", str(self.baud),
"--before", before, "--after", "no_reset"]
if self.major == 5:
prefix = [x.replace("_", "-") if x in ("default_reset", "no_reset") else x for x in prefix]
command = command.replace("_", "-")
args = tuple(x.replace("_", "-") if x.startswith("--flash_") else x for x in args)
return self.invoke(prefix + [command] + list(args))
def save_json(path, value):
with path.open("x", encoding="utf-8") as output:
json.dump(value, output, ensure_ascii=True, indent=2)
output.write("\n")
os.chmod(path, 0o600)
def execute_plan(plan, *, python, port, expect_mac, backup_dir, baud=460800, runner=subprocess.run):
require(plan.identity_only or plan.reset_runtime,
"Full USB firmware flashing requires --reset-runtime to remove stale OTA version/journal metadata; "
"Wi-Fi must be provisioned again. For an ordinary update preserving settings, use cloud OTA.")
require(bool(port) and not port.startswith("-") and "\0" not in port, "An explicit serial port is required")
require(baud in (115200, 230400, 460800, 921600), "Unsupported baud rate")
expected = normalize_mac(expect_mac)
if plan.factory:
require(plan.factory["expected_mac"] == expected, "Factory manifest MAC mismatch")
backup_dir = Path(backup_dir).resolve()
require(not backup_dir.exists(), "Backup directory already exists; select a new private directory")
tool = Esptool(python, port, baud, runner)
tool.check_version()
private_directory(backup_dir)
save_json(backup_dir / "plan.json", plan.summary())
# Capture exact validated bytes, avoiding input mutation between planning and writing.
staged = []
for image in plan.images:
target = backup_dir / (image.name + ".bin")
with target.open("xb") as output:
output.write(image.data)
os.chmod(target, 0o600)
staged.extend([hex(image.offset), str(target)])
log = []
def command(name, *args):
try:
output = tool.command(name, *args)
except FlashError as exc:
log.append({"command": name, "error": str(exc)})
raise
log.append({"command": name, "output": output})
print(f"Completed {name}", flush=True)
return output
try:
probe = command("read_mac")
macs = re.findall(r"(?im)^\s*MAC:\s*([0-9a-f]{2}(?::[0-9a-f]{2}){5})\b", probe)
require({normalize_mac(mac) for mac in macs} == {expected},
"Connected device MAC does not match --expect-mac")
require(bool(re.search(r"ESP32[- ]?S3", probe, re.I)), "Connected device is not ESP32-S3")
flash = command("flash_id")
require(bool(re.search(r"Detected flash size:\s*16\s*MB\b", flash, re.I)), "Connected device flash is not 16MB")
security = command("get_security_info")
require(bool(re.search(r"Secure Boot:\s*Disabled", security, re.I))
and bool(re.search(r"Flash Encryption:\s*Disabled", security, re.I)),
"Secure Boot / flash encryption is enabled or could not be confirmed disabled")
backup = backup_dir / "before-16mb.bin"
command("read_flash", "0x0", hex(FLASH_SIZE), str(backup))
require(backup.is_file() and backup.stat().st_size == FLASH_SIZE, "Incomplete full-flash backup; refusing to write")
original = backup.read_bytes()
os.chmod(backup, 0o600)
save_json(backup_dir / "backup-manifest.json", {
"schema_version": 1, "created_at": datetime.now(timezone.utc).isoformat(),
"expected_mac": expected, "chip": "esp32s3", "flash_size": FLASH_SIZE,
"file": backup.name, "size": len(original), "sha256": sha256(original),
})
table = original[TABLE_OFFSET:TABLE_OFFSET + 0xC00]
if table == b"\xff" * 0xC00:
require(not plan.identity_only and plan.factory is not None,
"Blank partition table needs full firmware and --factory-dir")
else:
partition_table(table)
preserved = []
for name, offset, size, enabled in (
("preserved-runtime", NVS_OFFSET, NVS_SIZE, not plan.reset_runtime),
("preserved-factory", FACTORY_OFFSET, FACTORY_SIZE, plan.factory is None)):
if enabled:
target = backup_dir / (name + ".bin")
with target.open("xb") as output:
output.write(original[offset:offset + size])
os.chmod(target, 0o600)
preserved.extend([hex(offset), str(target)])
# Recheck staged bytes immediately before the first destructive command.
for index, image in enumerate(plan.images):
require(Path(staged[2 * index + 1]).read_bytes() == image.data, "Staged image changed; refusing to write")
command("write_flash", "--flash_mode", "keep", "--flash_size", "keep", "--flash_freq", "keep", *staged)
command("verify_flash", *(staged + preserved))
result = {"status": "written_and_verified", "expected_mac": expected,
"backup_dir": str(backup_dir),
"firmware_version": None if plan.identity_only else plan.application["firmware_version"],
"firmware_action": "preserved" if plan.identity_only else "written",
"hardware_acceptance": "not_tested", "next_step": "Manually reset device to boot firmware"}
save_json(backup_dir / "result.json", result)
return result
finally:
save_json(backup_dir / "esptool-log.json", log)
def parser():
result = argparse.ArgumentParser(description=__doc__)
result.add_argument("command", nargs="?", default="plan", choices=("plan", "ports", "flash"))
result.add_argument("--build-dir", type=Path, default=PROJECT / "build")
result.add_argument("--factory-dir", type=Path)
result.add_argument("--identity-only", action="store_true")
result.add_argument("--reset-runtime", action="store_true", help="Required for full USB firmware writes; erase ordinary NVS including Wi-Fi, binding, session and OTA metadata")
result.add_argument("--execute", action="store_true")
result.add_argument("--port")
result.add_argument("--expect-mac")
result.add_argument("--backup-dir", type=Path)
result.add_argument("--python", default=sys.executable, help="Python with esptool 4.x or 5.x and pyserial installed")
result.add_argument("--baud", type=int, choices=(115200, 230400, 460800, 921600), default=460800)
return result
def main(argv=None):
cli = parser()
args = cli.parse_args(argv)
try:
require(not args.execute or args.command == "flash", "--execute is only valid with the flash command")
if args.command == "ports":
code = ("import json; from serial.tools import list_ports; "
"print(json.dumps([{'port':p.device,'description':p.description,'hwid':p.hwid} "
"for p in list_ports.comports()],ensure_ascii=True,indent=2))")
subprocess.run([args.python, "-X", "utf8", "-c", code], check=True)
return 0
plan = build_plan(args.build_dir, args.factory_dir, args.identity_only, args.reset_runtime, args.expect_mac)
if args.command == "flash":
require(args.execute and args.port and args.expect_mac and args.backup_dir,
"flash requires --execute --port --expect-mac --backup-dir; use plan for an offline preview")
result = execute_plan(plan, python=args.python, port=args.port, expect_mac=args.expect_mac,
backup_dir=args.backup_dir, baud=args.baud)
else:
result = {"status": "plan_only_no_hardware_access", **plan.summary()}
print(json.dumps(result, ensure_ascii=True, indent=2))
return 0
except (ValueError, OSError, subprocess.SubprocessError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,162 @@
"""Convert the online burn-in JSON to a private EchoEar factory NVS image.
No network or serial access. No cloud credentials or Wi-Fi belong in this image.
"""
import argparse
import csv
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
from urllib.parse import urlsplit
from ble_factory import security_material
FACTORY_OFFSET = 0xE4E000
FACTORY_SIZE = 0x6000
def normalize_mac(value):
compact = value.replace(":", "").replace("-", "")
if not re.fullmatch(r"[0-9a-fA-F]{12}", compact) or compact.lower() in ("000000000000", "ffffffffffff"):
raise ValueError("Expected MAC must contain exactly 12 hex digits")
return ":".join(compact[i:i + 2] for i in range(0, 12, 2)).lower()
def read_credential(path):
if path.stat().st_size > 16384:
raise ValueError("Credential JSON exceeds 16 KiB")
try:
item = json.loads(path.read_text(encoding="utf-8-sig"))
except (ValueError, UnicodeError):
raise ValueError("Invalid credential JSON; download the burn-in file again") from None
if not isinstance(item, dict):
raise ValueError("Credential must be a JSON object")
factory_id = item.get("factory_id", item.get("device_id"))
if item.get("factory_id") and item.get("device_id") and item["factory_id"] != item["device_id"]:
raise ValueError("Conflicting factory_id and device_id")
if not isinstance(factory_id, str) or not re.fullmatch(r"KP01-[0-9A-F]{12}", factory_id):
raise ValueError("Invalid factory_id; use the online burn-in JSON")
# The cloud ID is random: it is deliberately NOT compared to the eFuse MAC.
model = item.get("model")
if model != "ECHOEAR-S3":
raise ValueError("This tool supports model ECHOEAR-S3 only")
key = item.get("device_key_hex")
if not isinstance(key, str) or not re.fullmatch(r"[0-9a-fA-F]{64}", key):
raise ValueError("device_key_hex must be 32 bytes in hexadecimal")
version = item.get("key_version")
if type(version) is not int or not 1 <= version <= 0xFFFFFFFF:
raise ValueError("Invalid key_version")
origin = item.get("origin")
if not isinstance(origin, str) or not origin.isascii() or len(origin) > 150 or any(c.isspace() for c in origin):
raise ValueError("Invalid HTTPS origin")
try:
url = urlsplit(origin)
port = url.port
except ValueError:
raise ValueError("Invalid HTTPS origin") from None
if (url.scheme != "https" or not url.hostname or url.username or url.password or
url.path or url.query or url.fragment or "\\" in origin or
not re.fullmatch(r"[A-Za-z0-9.:-]+", url.netloc) or (port is not None and not 1 <= port <= 65535)):
raise ValueError("origin must be HTTPS scheme + host + optional port, without a path")
return {"device_id": factory_id, "model": model, "origin": origin,
"key_version": version, "device_key_hex": key.lower()}
def private_directory(path):
"""Create a new directory and restrict access before writing secrets."""
path.mkdir(parents=True, exist_ok=False, mode=0o700)
if os.name == "nt":
who = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"],
capture_output=True, check=True)
sid = re.search(rb"S-1-[0-9-]+", who.stdout)
if not sid:
raise ValueError("Cannot determine the current Windows user SID")
subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r",
"*" + sid.group().decode("ascii") + ":(OI)(CI)F"],
capture_output=True, check=True)
else:
os.chmod(path, 0o700)
def private_json(path, value):
with path.open("x", encoding="utf-8", newline="\n") as stream:
json.dump(value, stream, ensure_ascii=False, indent=2)
stream.write("\n")
os.chmod(path, 0o600)
def prepare(credential, output, expected_mac):
item = read_credential(credential)
expected_mac = normalize_mac(expected_mac)
output = output.resolve()
# Fail on a missing generator before creating a partially populated directory.
import importlib.util
if importlib.util.find_spec("esp_idf_nvs_partition_gen") is None:
raise ValueError("Missing esp-idf-nvs-partition-gen; use the ESP-IDF Python environment")
private_directory(output)
password, salt = secrets.token_urlsafe(16), secrets.token_bytes(16)
verifier = security_material(item["device_id"], password, salt)
csv_path = output / "factory.csv"
rows = [("identity", "namespace", "", ""),
("factory_id", "data", "string", item["device_id"]),
("model", "data", "string", item["model"]),
("origin", "data", "string", item["origin"]),
("key_version", "data", "u32", item["key_version"]),
("device_key", "data", "hex2bin", item["device_key_hex"]),
("salt", "data", "hex2bin", salt.hex()),
("verifier", "data", "hex2bin", verifier.hex())]
with csv_path.open("x", encoding="utf-8", newline="") as stream:
writer = csv.writer(stream)
writer.writerow(("key", "type", "encoding", "value"))
writer.writerows(rows)
os.chmod(csv_path, 0o600)
image_path = output / "ble_factory.bin"
# Never print generator output: failures could contain a CSV value/secret.
generated = subprocess.run([sys.executable, "-m", "esp_idf_nvs_partition_gen", "generate",
str(csv_path), str(image_path), hex(FACTORY_SIZE)], capture_output=True)
if generated.returncode:
raise ValueError("NVS generation failed; private partial output was kept, choose a new output directory on retry")
image = image_path.read_bytes()
os.chmod(image_path, 0o600)
if len(image) != FACTORY_SIZE:
raise ValueError("Generator returned an unexpected factory partition size")
private_json(output / "setup-label.json", {"device_id": item["device_id"],
"model": item["model"], "setup_password": password})
manifest = {"schema_version": 1, "chip": "esp32s3", "flash_size": 0x1000000,
"device_id": item["device_id"], "model": item["model"],
"origin": item["origin"], "key_version": item["key_version"],
"expected_mac": expected_mac,
"image": {"file": "ble_factory.bin", "offset": FACTORY_OFFSET,
"size": FACTORY_SIZE, "sha256": hashlib.sha256(image).hexdigest()}}
private_json(output / "manifest.json", manifest)
return manifest
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--credential", required=True, type=Path, help="Online *-burn-in-vN.json")
parser.add_argument("--output", required=True, type=Path, help="NEW private directory (refuses overwrite)")
parser.add_argument("--expect-mac", required=True, help="Physical board eFuse MAC, not the cloud factory ID")
args = parser.parse_args()
try:
manifest = prepare(args.credential, args.output, args.expect_mac)
except ValueError as exc:
# Validation messages are constant strings; they never embed supplied values.
print("Preparation failed: " + str(exc), file=sys.stderr)
return 1
except (OSError, subprocess.SubprocessError):
print("Preparation failed. Use ESP-IDF Python and a NEW writable output folder; check file permissions. Existing private files were kept.", file=sys.stderr)
return 1
print(json.dumps({"result": "prepared_not_flashed", "device_id": manifest["device_id"],
"origin": manifest["origin"], "expected_mac": manifest["expected_mac"],
"output": str(args.output.resolve())}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,807 @@
// Build with the Windows .NET Framework C# compiler; no third-party UI packages.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web.Script.Serialization;
using System.Windows.Forms;
namespace EchoEarFlasher
{
internal static class Program
{
[STAThread]
private static int Main(string[] args)
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
bool offline = args.Length == 2 && (args[0] == "--render-preview" || args[0] == "--ui-smoke-test");
using (FlashForm form = new FlashForm(offline))
{
if (offline)
{
if (args[0] == "--render-preview") form.RenderPreview(args[1]);
else return form.RunSmokeTest(args[1]);
return 0;
}
if (args.Length != 0) return 2;
Application.Run(form);
}
return 0;
}
}
internal sealed class PortItem
{
internal string Port;
internal string Description;
internal bool Known;
public override string ToString() { return Port + " · " + Description; }
}
internal sealed class BridgeResult
{
internal int ExitCode;
internal Dictionary<string, object> Payload;
internal string Error;
}
internal sealed class FlashForm : Form
{
private static readonly Color Navy = Color.FromArgb(21, 40, 65);
private static readonly Color Teal = Color.FromArgb(0, 126, 128);
private static readonly Color TextColor = Color.FromArgb(35, 51, 69);
private static readonly Color Muted = Color.FromArgb(106, 122, 137);
private static readonly Color Line = Color.FromArgb(224, 233, 238);
private const string KnownMac = "B4:3A:45:16:34:A8";
private const string AdminUrl = "https://82.157.201.114/maintenance?area=devices";
private readonly bool offline;
private readonly JavaScriptSerializer json = new JavaScriptSerializer();
private readonly List<Control> inputs = new List<Control>();
private readonly List<Panel> cards = new List<Panel>();
private string bridgePath;
private string sessionFile;
private string lastBackup;
private Dictionary<string, object> previewSummary;
private bool busy;
private bool loading;
private Panel workspace;
private FlowLayoutPanel stack;
private Panel advancedCard;
private TextBox credential;
private TextBox mac;
private TextBox build;
private TextBox python;
private TextBox output;
private ComboBox ports;
private RadioButton identity;
private RadioButton full;
private CheckBox acknowledged;
private Button refresh;
private Button previewButton;
private Button flashButton;
private Button advancedToggle;
private Button backupButton;
private Label sidebarPort;
private Label sidebarStage;
private Label stageDetail;
private Label status;
private Label summaryLine;
private Label summaryPlatform;
private Label summaryFirmware;
private Label previewBadge;
private RichTextBox log;
private DataGridView regions;
private ProgressBar progress;
internal FlashForm(bool offlineMode)
{
offline = offlineMode;
loading = true;
Text = "EchoEar 设备烧录助手";
Font = new Font("Microsoft YaHei UI", 9F);
BackColor = Color.FromArgb(242, 246, 249);
ForeColor = TextColor;
ClientSize = new Size(1120, 838);
MinimumSize = new Size(920, 650);
StartPosition = FormStartPosition.CenterScreen;
AutoScaleMode = AutoScaleMode.Dpi;
BuildSidebar();
workspace = new Panel { Dock = DockStyle.Fill, Padding = new Padding(24, 20, 18, 14) };
Controls.Add(workspace);
workspace.BringToFront();
Panel heading = new Panel { Dock = DockStyle.Top, Height = 74 };
heading.Controls.Add(LabelAt("设备初始化与固件烧录", 0, 0, 730, 34, 21, Navy, true));
heading.Controls.Add(LabelAt("导入后台烧录信息,确认设备身份,再备份和写入。", 1, 41, 730, 25, 10, Muted, false));
stack = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, AutoScroll = true, Padding = new Padding(0, 3, 2, 8) };
workspace.Controls.Add(stack);
workspace.Controls.Add(heading);
BuildConfiguration();
BuildAdvanced();
BuildPreview();
BuildExecution();
FindDefaults();
WireChanges();
loading = false;
Resize += delegate { ResizeCards(); };
Shown += async delegate { ResizeCards(); if (!offline) await RefreshPorts(); };
FormClosing += delegate(object sender, FormClosingEventArgs e) {
if (busy) { e.Cancel = true; SetStatus("操作正在进行,请等待结束后关闭窗口。", false); }
};
ResizeCards();
UpdateActions();
}
private Label LabelAt(string text, int x, int y, int width, int height, float size, Color color, bool bold)
{
return new Label { Text = text, Location = new Point(x, y), Size = new Size(width, height), Font = new Font("Microsoft YaHei UI", size, bold ? FontStyle.Bold : FontStyle.Regular), ForeColor = color, AutoEllipsis = true, TextAlign = ContentAlignment.MiddleLeft };
}
private Button ButtonAt(string text, int x, int y, int width, bool primary)
{
Button button = new Button { Text = text, Location = new Point(x, y), Size = new Size(width, 34), FlatStyle = FlatStyle.Flat, BackColor = primary ? Teal : Color.White, ForeColor = primary ? Color.White : TextColor, Cursor = Cursors.Hand, UseVisualStyleBackColor = false };
button.FlatAppearance.BorderColor = primary ? Teal : Line;
button.FlatAppearance.BorderSize = 1;
return button;
}
private TextBox InputAt(Panel parent, int x, int y, int width)
{
TextBox input = new TextBox { Location = new Point(x, y), Width = width, BorderStyle = BorderStyle.FixedSingle, Font = new Font("Microsoft YaHei UI", 9.5F), BackColor = Color.White, Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right };
parent.Controls.Add(input);
inputs.Add(input);
return input;
}
private Panel Card(int height)
{
Panel panel = new Panel { BackColor = Color.White, Height = height, Width = 816, Margin = new Padding(0, 0, 0, 12) };
panel.Paint += delegate(object sender, PaintEventArgs e) { using (Pen p = new Pen(Line)) e.Graphics.DrawRectangle(p, 0, 0, panel.Width - 1, panel.Height - 1); };
cards.Add(panel);
stack.Controls.Add(panel);
return panel;
}
private void BuildSidebar()
{
Panel side = new Panel { Dock = DockStyle.Left, Width = 222, Height = ClientSize.Height, BackColor = Navy };
side.Controls.Add(LabelAt("ECHOEAR", 24, 27, 176, 32, 22, Color.White, true));
side.Controls.Add(LabelAt("设备烧录助手", 25, 69, 175, 24, 11, Color.FromArgb(177, 204, 221), false));
Panel accent = new Panel { BackColor = Color.FromArgb(54, 196, 182), Location = new Point(25, 113), Size = new Size(37, 4) };
side.Controls.Add(accent);
side.Controls.Add(LabelAt("当前设备", 25, 149, 172, 24, 9, Color.FromArgb(161, 181, 201), false));
side.Controls.Add(LabelAt("EchoEar S3", 25, 176, 174, 30, 17, Color.White, true));
side.Controls.Add(LabelAt("ESP32-S3 / 16 MB", 25, 210, 176, 28, 10, Color.FromArgb(205, 220, 232), false));
sidebarPort = LabelAt("串口 · 尚未选择", 25, 247, 176, 28, 10, Color.FromArgb(87, 216, 198), true);
side.Controls.Add(sidebarPort);
side.Controls.Add(LabelAt("操作状态", 25, 314, 172, 24, 9, Color.FromArgb(161, 181, 201), false));
sidebarStage = LabelAt("等待导入", 25, 345, 174, 32, 17, Color.White, true);
side.Controls.Add(sidebarStage);
stageDetail = LabelAt("选择后台下载的 JSON\n然后检查并生成预览。", 25, 387, 172, 76, 10, Color.FromArgb(205, 220, 232), false);
stageDetail.AutoEllipsis = false;
side.Controls.Add(stageDetail);
Label flow = LabelAt("01 导入与检查\n\n02 备份与写入\n\n03 重启与配网", 25, 503, 173, 136, 10, Color.FromArgb(178, 201, 218), false);
side.Controls.Add(flow);
Button admin = ButtonAt("打开设备管理后台 ↗", 24, 700, 174, false);
admin.Anchor = AnchorStyles.Left | AnchorStyles.Bottom;
admin.BackColor = Color.FromArgb(31, 57, 82);
admin.ForeColor = Color.White;
admin.FlatAppearance.BorderColor = Color.FromArgb(57, 80, 101);
admin.Location = new Point(24, ClientSize.Height - 97);
admin.Click += delegate { OpenTarget(AdminUrl); };
side.Controls.Add(admin);
Label foot = LabelAt("本地操作 · 密钥不显示", 25, ClientSize.Height - 50, 175, 24, 9, Color.FromArgb(161, 181, 201), false);
foot.Anchor = AnchorStyles.Left | AnchorStyles.Bottom;
side.Controls.Add(foot);
Controls.Add(side);
}
private void BuildConfiguration()
{
Panel card = Card(213);
card.Controls.Add(LabelAt("1 选择烧录内容", 18, 11, 350, 27, 12, Navy, true));
identity = new RadioButton { Text = "重新初始化(保留固件)", Checked = true, Location = new Point(19, 47), Size = new Size(235, 28) };
full = new RadioButton { Text = "完整固件 + 初始化", Location = new Point(272, 47), Size = new Size(224, 28) };
inputs.Add(identity); inputs.Add(full);
card.Controls.Add(identity); card.Controls.Add(full);
card.Controls.Add(LabelAt("烧录信息", 19, 91, 80, 26, 9, Muted, false));
credential = InputAt(card, 104, 93, 586);
Button browse = ButtonAt("选择 JSON", 704, 88, 94, false);
browse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
browse.Click += delegate { ChooseFile(credential, "后台烧录信息 (*.json)|*.json", "选择后台下载的烧录信息 JSON"); };
inputs.Add(browse); card.Controls.Add(browse);
card.Controls.Add(LabelAt("设备串口", 19, 135, 80, 26, 9, Muted, false));
ports = new ComboBox { Location = new Point(104, 136), Size = new Size(315, 28), DropDownStyle = ComboBoxStyle.DropDownList, Anchor = AnchorStyles.Top | AnchorStyles.Left };
inputs.Add(ports); card.Controls.Add(ports);
refresh = ButtonAt("刷新", 432, 131, 70, false);
refresh.Click += async delegate { await RefreshPorts(); };
inputs.Add(refresh); card.Controls.Add(refresh);
card.Controls.Add(LabelAt("设备 MAC", 517, 135, 79, 26, 9, Muted, false));
mac = InputAt(card, 600, 136, 197);
mac.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
mac.Text = KnownMac;
card.Controls.Add(LabelAt("连接设备 USB;自动识别已知设备,其余串口请手动选择。", 104, 171, 682, 27, 9, Muted, false));
}
private void BuildAdvanced()
{
advancedCard = Card(39);
advancedToggle = ButtonAt("▸ 高级设置:固件目录、Python、备份位置", 10, 2, 510, false);
advancedToggle.FlatAppearance.BorderSize = 0;
advancedToggle.TextAlign = ContentAlignment.MiddleLeft;
advancedToggle.Click += delegate {
bool expanding = advancedCard.Height < 100;
advancedCard.Height = expanding ? 188 : 39;
advancedToggle.Text = (expanding ? "▾" : "▸") + " 高级设置:固件目录、Python、备份位置";
ResizeCards();
};
inputs.Add(advancedToggle); advancedCard.Controls.Add(advancedToggle);
advancedCard.Controls.Add(LabelAt("固件目录", 19, 48, 80, 26, 9, Muted, false));
build = InputAt(advancedCard, 104, 50, 586);
Button buildBrowse = ButtonAt("选择目录", 704, 45, 94, false);
buildBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
buildBrowse.Click += delegate { ChooseFolder(build, "选择包含 flasher_args.json 的固件 build 目录"); };
inputs.Add(buildBrowse); advancedCard.Controls.Add(buildBrowse);
advancedCard.Controls.Add(LabelAt("Python", 19, 91, 80, 26, 9, Muted, false));
python = InputAt(advancedCard, 104, 93, 586);
Button pythonBrowse = ButtonAt("选择程序", 704, 88, 94, false);
pythonBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
pythonBrowse.Click += delegate { ChooseFile(python, "Python 程序 (python.exe)|python.exe|应用程序 (*.exe)|*.exe", "选择已安装 esptool、pyserial 的 Python"); };
inputs.Add(pythonBrowse); advancedCard.Controls.Add(pythonBrowse);
advancedCard.Controls.Add(LabelAt("备份位置", 19, 134, 80, 26, 9, Muted, false));
output = InputAt(advancedCard, 104, 136, 586);
Button outputBrowse = ButtonAt("选择目录", 704, 131, 94, false);
outputBrowse.Anchor = AnchorStyles.Top | AnchorStyles.Right;
outputBrowse.Click += delegate { ChooseFolder(output, "选择备份与初始化文件的保存位置"); };
inputs.Add(outputBrowse); advancedCard.Controls.Add(outputBrowse);
}
private void BuildPreview()
{
Panel card = Card(231);
card.Controls.Add(LabelAt("2 检查写入预览", 18, 10, 350, 28, 12, Navy, true));
previewBadge = LabelAt("尚未生成", 669, 12, 128, 24, 9, Muted, true);
previewBadge.TextAlign = ContentAlignment.MiddleRight;
previewBadge.Anchor = AnchorStyles.Top | AnchorStyles.Right;
card.Controls.Add(previewBadge);
summaryLine = LabelAt("设备编号 — 型号 —", 19, 44, 779, 24, 9, TextColor, false);
summaryPlatform = LabelAt("平台地址 —", 19, 70, 779, 24, 9, Muted, false);
summaryFirmware = LabelAt("固件 保留设备当前固件", 19, 96, 779, 24, 9, Teal, true);
foreach (Label label in new[] { summaryLine, summaryPlatform, summaryFirmware }) { label.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; card.Controls.Add(label); }
regions = new DataGridView { Location = new Point(19, 128), Size = new Size(778, 83), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, BackgroundColor = Color.White, BorderStyle = BorderStyle.None, ReadOnly = true, AllowUserToAddRows = false, AllowUserToDeleteRows = false, AllowUserToResizeRows = false, RowHeadersVisible = false, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, SelectionMode = DataGridViewSelectionMode.FullRowSelect, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, GridColor = Line, EnableHeadersVisualStyles = false, ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing, ColumnHeadersHeight = 27, MultiSelect = false };
regions.ColumnHeadersDefaultCellStyle = new DataGridViewCellStyle { BackColor = Color.FromArgb(242, 246, 249), ForeColor = Muted, Font = new Font("Microsoft YaHei UI", 8.5F) };
regions.DefaultCellStyle = new DataGridViewCellStyle { BackColor = Color.White, ForeColor = TextColor, SelectionBackColor = Color.FromArgb(225, 244, 241), SelectionForeColor = TextColor, Font = new Font("Microsoft YaHei UI", 8.5F), Padding = new Padding(2) };
regions.RowTemplate.Height = 26;
regions.Columns.Add("name", "写入区域"); regions.Columns.Add("offset", "起始地址"); regions.Columns.Add("size", "大小"); regions.Columns.Add("hash", "SHA-256 校验摘要");
regions.Columns[0].FillWeight = 26; regions.Columns[1].FillWeight = 19; regions.Columns[2].FillWeight = 19; regions.Columns[3].FillWeight = 36;
card.Controls.Add(regions);
}
private void BuildExecution()
{
Panel card = Card(239);
acknowledged = new CheckBox { Text = "我已了解:会清除旧 Wi-Fi 和语音运行配置,完成后需重新配网。", Location = new Point(19, 12), Size = new Size(775, 29), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right };
acknowledged.CheckedChanged += delegate { UpdateActions(); };
inputs.Add(acknowledged); card.Controls.Add(acknowledged);
previewButton = ButtonAt("1 检查并生成预览", 19, 50, 178, false);
previewButton.Click += async delegate { await PreparePreview(); };
card.Controls.Add(previewButton);
flashButton = ButtonAt("2 开始烧录", 210, 50, 178, true);
flashButton.Enabled = false;
flashButton.Click += async delegate { await Flash(); };
card.Controls.Add(flashButton);
backupButton = ButtonAt("打开备份文件夹", 639, 50, 158, false);
backupButton.Anchor = AnchorStyles.Top | AnchorStyles.Right;
backupButton.Click += delegate {
string path = String.IsNullOrEmpty(lastBackup) ? output.Text.Trim() : lastBackup;
if (Directory.Exists(path)) OpenTarget(path);
else
{
string parent = Path.GetDirectoryName(path);
string available = Directory.Exists(parent) ? parent : output.Text.Trim();
if (Directory.Exists(available)) OpenTarget(available);
SetStatus("当前操作尚未生成备份;已打开可用的会话或保存目录。", false);
}
};
card.Controls.Add(backupButton);
progress = new ProgressBar { Location = new Point(19, 97), Size = new Size(778, 4), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, Style = ProgressBarStyle.Blocks, Value = 0, MarqueeAnimationSpeed = 0 };
card.Controls.Add(progress);
status = LabelAt("准备就绪。检查只生成预览,不会写入设备。", 19, 109, 778, 27, 9, Muted, false);
status.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
card.Controls.Add(status);
log = new RichTextBox { Location = new Point(19, 146), Size = new Size(778, 75), Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, ReadOnly = true, BorderStyle = BorderStyle.None, BackColor = Color.FromArgb(246, 248, 250), ForeColor = Muted, Font = new Font("Microsoft YaHei UI", 8.5F), DetectUrls = false, ScrollBars = RichTextBoxScrollBars.Vertical };
card.Controls.Add(log);
}
private void ResizeCards()
{
if (stack == null) return;
// Configuration remains readable; horizontal scrolling is available at small sizes/high DPI.
int width = Math.Max(816, stack.ClientSize.Width - 23);
stack.SuspendLayout();
foreach (Panel panel in cards) panel.Width = width;
stack.ResumeLayout();
}
private void FindDefaults()
{
string executableRoot = AppDomain.CurrentDomain.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
string distributedBridge = Path.Combine(executableRoot, "engine", "flash_app_bridge.py");
bool distributed = File.Exists(distributedBridge);
if (distributed) bridgePath = distributedBridge;
else
{
DirectoryInfo cursor = new DirectoryInfo(executableRoot);
for (int depth = 0; cursor != null && depth < 8; depth++, cursor = cursor.Parent)
{
string candidate = Path.Combine(cursor.FullName, "flash_app_bridge.py");
if (File.Exists(candidate)) { bridgePath = candidate; break; }
candidate = Path.Combine(cursor.FullName, "tools", "flash_app_bridge.py");
if (File.Exists(candidate)) { bridgePath = candidate; break; }
}
}
string projectRoot = bridgePath == null ? executableRoot : Directory.GetParent(Path.GetDirectoryName(bridgePath)).FullName;
build.Text = Path.Combine(distributed ? executableRoot : projectRoot, distributed ? "firmware-build" : "build");
output.Text = distributed ? Path.Combine(executableRoot, "backups") : Path.Combine(projectRoot, "backups", "gui");
python.Text = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".espressif", "python_env", "idf5.5_py3.12_env", "Scripts", "python.exe");
string portablePython = Path.Combine(executableRoot, "python", "python.exe");
if (File.Exists(portablePython)) python.Text = portablePython;
}
private void WireChanges()
{
foreach (TextBox input in new[] { credential, mac, build, python, output }) input.TextChanged += delegate { InvalidatePreview(); };
identity.CheckedChanged += delegate { InvalidatePreview(); };
full.CheckedChanged += delegate { InvalidatePreview(); };
ports.SelectedIndexChanged += delegate {
PortItem port = ports.SelectedItem as PortItem;
sidebarPort.Text = port == null ? "串口 · 尚未选择" : "串口 · " + port.Port;
InvalidatePreview();
};
}
private void InvalidatePreview()
{
if (loading) return;
bool wasValid = !String.IsNullOrEmpty(sessionFile);
sessionFile = null;
previewSummary = null;
acknowledged.Checked = false;
previewBadge.Text = "需要重新检查";
previewBadge.ForeColor = Muted;
summaryLine.Text = "设备编号 — 型号 —";
summaryPlatform.Text = "平台地址 —";
summaryFirmware.Text = identity.Checked ? "固件 保留设备当前固件" : "固件 检查后显示待写入版本;文件校验通过不等于真机验收";
regions.Rows.Clear();
if (wasValid) SetStatus("配置已更改,请重新检查并生成预览。", false);
UpdateActions();
}
private void UpdateActions()
{
if (flashButton == null) return;
flashButton.Enabled = !busy && !String.IsNullOrEmpty(sessionFile) && acknowledged.Checked;
flashButton.BackColor = flashButton.Enabled ? Teal : Color.FromArgb(223, 230, 235);
flashButton.ForeColor = flashButton.Enabled ? Color.White : Muted;
flashButton.FlatAppearance.BorderColor = flashButton.Enabled ? Teal : Color.FromArgb(210, 220, 227);
previewButton.Enabled = !busy;
backupButton.Enabled = !busy;
}
private void SetBusy(bool value, string stage)
{
busy = value;
foreach (Control input in inputs) input.Enabled = !value;
UseWaitCursor = value;
progress.Style = value ? ProgressBarStyle.Marquee : ProgressBarStyle.Blocks;
progress.MarqueeAnimationSpeed = value ? 35 : 0;
if (!value) progress.Value = 0;
if (!String.IsNullOrEmpty(stage)) sidebarStage.Text = stage;
UpdateActions();
}
private void SetStatus(string message, bool error)
{
status.Text = message;
status.ForeColor = error ? Color.FromArgb(175, 64, 47) : Muted;
}
private void AppendLog(string message)
{
if (log.TextLength > 28000) log.Clear();
log.AppendText(DateTime.Now.ToString("HH:mm:ss") + " " + message.Replace("\r", " ").Replace("\n", " ") + Environment.NewLine);
log.SelectionStart = log.TextLength;
log.ScrollToCaret();
}
private void ChooseFile(TextBox destination, string filter, string title)
{
using (OpenFileDialog dialog = new OpenFileDialog { Filter = filter, Title = title, CheckFileExists = true })
if (dialog.ShowDialog(this) == DialogResult.OK) destination.Text = dialog.FileName;
}
private void ChooseFolder(TextBox destination, string title)
{
using (FolderBrowserDialog dialog = new FolderBrowserDialog { Description = title, SelectedPath = Directory.Exists(destination.Text) ? destination.Text : "" })
if (dialog.ShowDialog(this) == DialogResult.OK) destination.Text = dialog.SelectedPath;
}
private void OpenTarget(string target)
{
try { Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); }
catch { SetStatus("无法打开,请在资源管理器或浏览器中手动打开。", true); }
}
private bool CheckEngine()
{
if (offline) { SetStatus("当前为离线界面验证模式,已禁用设备操作。", true); return false; }
if (String.IsNullOrEmpty(bridgePath) || !File.Exists(bridgePath)) { SetStatus("未找到烧录引擎 flash_app_bridge.py,请使用完整程序包。", true); return false; }
if (!File.Exists(python.Text.Trim()))
{
advancedCard.Height = 188;
advancedToggle.Text = "▾ 高级设置:固件目录、Python、备份位置";
SetStatus("请选择 Python 程序:高级设置中需使用已安装 esptool、pyserial 的环境。", true);
return false;
}
return true;
}
private async Task RefreshPorts()
{
if (busy || !CheckEngine()) return;
InvalidatePreview();
SetBusy(true, "识别串口");
SetStatus("正在枚举 USB 串口…", false);
BridgeResult result = await RunBridge("ports", new Dictionary<string, object>(), "ports");
if (result.ExitCode == 0 && result.Payload != null)
{
ports.Items.Clear();
PortItem known = null;
foreach (object entry in Items(result.Payload, "ports"))
{
Dictionary<string, object> item = entry as Dictionary<string, object>;
if (item == null) continue;
string serial = Regex.Replace(Get(item, "serial_number"), "[^a-fA-F0-9]", "").ToUpperInvariant();
int vid = 0; Int32.TryParse(Get(item, "vid"), out vid);
PortItem port = new PortItem { Port = Get(item, "port"), Description = Get(item, "description"), Known = vid == 0x303A && serial == "B43A451634A8" };
if (String.IsNullOrEmpty(port.Port)) continue;
if (port.Known) { known = port; port.Description += " · 已知设备"; }
ports.Items.Add(port);
}
if (known != null) ports.SelectedItem = known;
SetStatus(known != null ? "已识别已知设备。选择后台 JSON 后生成预览。" : (ports.Items.Count > 0 ? "找到串口,请手动选择目标设备并核对 MAC。" : "未发现串口,请检查 USB 线与设备连接后刷新。"), false);
AppendLog(known != null ? "已识别已知设备串口 " + known.Port + "。" : "串口列表已刷新;未自动选择未知设备。");
}
else ReportFailure(result, "串口识别失败");
SetBusy(false, "等待导入");
}
private async Task PreparePreview()
{
if (busy || !CheckEngine()) return;
PortItem selected = ports.SelectedItem as PortItem;
if (!File.Exists(credential.Text.Trim())) { SetStatus("请先选择后台下载的烧录信息 JSON。", true); return; }
if (selected == null) { SetStatus("请选择目标设备串口。", true); return; }
if (!Regex.IsMatch(mac.Text.Trim(), "^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$")) { SetStatus("设备 MAC 格式应为 B4:3A:45:16:34:A8。", true); return; }
if (!Directory.Exists(build.Text.Trim())) { SetStatus("固件参考目录不存在,请在高级设置中选择 build 目录。", true); return; }
if (String.IsNullOrWhiteSpace(output.Text)) { SetStatus("请选择备份保存位置。", true); return; }
Dictionary<string, object> request = new Dictionary<string, object> {
{ "credential_path", credential.Text.Trim() }, { "build_dir", build.Text.Trim() }, { "mode", identity.Checked ? "identity" : "full" },
{ "expected_mac", mac.Text.Trim().ToUpperInvariant() }, { "output_root", output.Text.Trim() }, { "port", selected.Port }
};
InvalidatePreview();
SetBusy(true, "检查信息");
SetStatus("正在检查烧录信息和文件,生成写入预览…", false);
AppendLog("开始检查。本步骤不会写入设备。");
BridgeResult result = await RunBridge("preview", request, "preview");
if (result.ExitCode == 0 && result.Payload != null)
{
Dictionary<string, object> summary = Dict(result.Payload, "summary");
string session = Get(result.Payload, "session_file");
if (summary == null || String.IsNullOrEmpty(session)) { result.Error = "烧录引擎未返回完整预览,请重新检查。"; ReportFailure(result, "检查失败"); }
else
{
ApplyPreview(session, summary);
SetStatus("预览已生成。请核对设备和区域,再勾选配网提示。", false);
AppendLog("预览检查完成;尚未开始备份或写入。");
}
}
else ReportFailure(result, "检查失败");
SetBusy(false, String.IsNullOrEmpty(sessionFile) ? "检查失败" : "预览就绪");
stageDetail.Text = String.IsNullOrEmpty(sessionFile) ? "根据下方提示修正后\n重新检查。" : "核对设备身份与写入内容。\n开始烧录前还会再次确认。";
}
private void ApplyPreview(string session, Dictionary<string, object> summary)
{
previewSummary = summary;
sessionFile = session;
// A new attempt must never send the user to an earlier device's backup.
lastBackup = Get(summary, "backup_dir");
acknowledged.Checked = false;
previewBadge.Text = "检查通过 · 待确认";
previewBadge.ForeColor = Teal;
summaryLine.Text = "设备编号 " + Get(summary, "device_id") + " 型号 " + Get(summary, "model");
summaryPlatform.Text = "平台 " + Get(summary, "origin") + " 密钥版本 " + Get(summary, "key_version");
summaryFirmware.Text = Get(summary, "mode") == "identity" ? "固件 保留设备当前固件 · 只更新身份并清除运行配置" : "固件 将写入 " + Get(summary, "firmware_version") + "(尚未实机验收)· 文件校验不代表真机验收";
regions.Rows.Clear();
foreach (object entry in Items(summary, "regions"))
{
Dictionary<string, object> region = entry as Dictionary<string, object>;
if (region == null) continue;
string hash = Get(region, "sha256");
string offset = Get(region, "offset");
long numeric; if (Int64.TryParse(offset, out numeric)) offset = "0x" + numeric.ToString("X");
string size = Get(region, "size");
if (Int64.TryParse(size, out numeric)) size = numeric.ToString("N0") + " B";
int index = regions.Rows.Add(Get(region, "name"), offset, size, hash.Length > 20 ? hash.Substring(0, 20) + "…" : hash);
regions.Rows[index].Cells[3].ToolTipText = hash;
}
regions.ClearSelection();
UpdateActions();
}
private async Task Flash()
{
if (offline || busy || String.IsNullOrEmpty(sessionFile) || previewSummary == null || !acknowledged.Checked) return;
string confirmation = "请确认即将操作的设备:\n\n串口:" + Get(previewSummary, "port") + "\n目标 MAC:" + Get(previewSummary, "expected_mac") + "\n设备编号:" + Get(previewSummary, "device_id") + "\n模式:" + (Get(previewSummary, "mode") == "identity" ? "重新初始化,保留现有固件" : "完整固件与初始化") + "\n\n会先读取实际 MAC 并与目标核对,完整备份后才写入。\n会清除旧 Wi-Fi 和语音运行配置,完成后需重新配网。\n\n操作期间请勿拔掉 USB 或断电。是否开始?";
if (MessageBox.Show(this, confirmation, "确认设备并开始烧录", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) != DialogResult.Yes) return;
string approvedSession = sessionFile;
SetBusy(true, "准备烧录");
SetStatus("正在核对设备并备份,请保持 USB 连接…", false);
stageDetail.Text = "按实际步骤显示状态。\n操作期间请保持连接。";
AppendLog("用户已确认。开始核对硬件身份、备份和写入。");
BridgeResult result = await RunBridge("flash", new Dictionary<string, object> { { "session_file", approvedSession }, { "execute", true } }, "complete");
InvalidatePreview();
if (IsVerifiedCompletion(result))
{
Dictionary<string, object> completed = Dict(result.Payload, "result");
lastBackup = Get(completed, "backup_dir");
SetBusy(false, "烧录完成");
stageDetail.Text = "请手动重启设备。\n随后在家庭端重新蓝牙配网。";
previewBadge.Text = "烧录完成"; previewBadge.ForeColor = Teal;
SetStatus("完成并校验通过。请手动重启设备,再通过家庭端蓝牙重新配网。", false);
AppendLog("备份、写入和校验完成。请手动重启设备并重新配网。");
MessageBox.Show(this, "烧录与校验已完成。\n\n1. 手动重启设备。\n2. 在家庭端选择老人,重新进行蓝牙配网。\n3. 使用 2.4 GHz Wi-Fi,按设备提示完成确认。\n\n备份可通过“打开备份文件夹”查看。", "设备已准备好", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
else
{
if (result.ExitCode == 0 && String.IsNullOrEmpty(result.Error)) result.Error = "引擎未确认写入与校验成功完成,必须重新检查设备状态。";
ReportFailure(result, "烧录未完成");
SetBusy(false, "烧录失败");
stageDetail.Text = "保留设备连接并查看提示。\n重试前需要重新生成预览。";
}
}
private static bool IsVerifiedCompletion(BridgeResult result)
{
return result != null && result.ExitCode == 0 && String.IsNullOrEmpty(result.Error) &&
Get(result.Payload, "event") == "complete" && Get(Dict(result.Payload, "result"), "status") == "written_and_verified";
}
private void ReportFailure(BridgeResult result, string heading)
{
string message = String.IsNullOrEmpty(result.Error) ? "引擎未成功完成(退出码 " + result.ExitCode + ")。请检查环境、USB 连接和文件后重试。" : result.Error;
SetStatus(heading + ":" + message, true);
AppendLog(heading + ":" + message);
}
private async Task<BridgeResult> RunBridge(string command, Dictionary<string, object> request, string expectedEvent)
{
string pythonPath = python.Text.Trim();
string engine = bridgePath;
return await Task.Run(delegate {
BridgeResult result = new BridgeResult { ExitCode = -1 };
string requestFile = Path.Combine(Path.GetTempPath(), "echoear-request-" + Guid.NewGuid().ToString("N") + ".json");
try
{
// Request data contains paths and the public MAC only; never copy credential JSON here.
File.WriteAllText(requestFile, json.Serialize(request), new UTF8Encoding(false));
ProcessStartInfo start = new ProcessStartInfo {
FileName = pythonPath, Arguments = QuoteWindows("-u") + " " + QuoteWindows(engine) + " " + QuoteWindows(command) + " --request " + QuoteWindows(requestFile),
WorkingDirectory = Path.GetDirectoryName(engine), UseShellExecute = false, CreateNoWindow = true,
RedirectStandardOutput = true, RedirectStandardError = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8
};
start.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
using (Process process = new Process { StartInfo = start })
{
process.Start();
// Drain stderr without exposing raw dependency output that could contain credentials.
Task stderr = Task.Run(delegate { while (process.StandardError.ReadLine() != null) { } });
string line;
while ((line = process.StandardOutput.ReadLine()) != null)
{
if (line.Length > 1024 * 1024) { result.Error = "烧录引擎返回了过大的响应。"; continue; }
Dictionary<string, object> data;
try { data = new JavaScriptSerializer().Deserialize<Dictionary<string, object>>(line); }
catch { result.Error = "烧录引擎响应格式错误;原始输出已隐藏。"; continue; }
if (data == null) continue;
string kind = Get(data, "event");
if (kind == "phase")
{
string message = Get(data, "message");
string stage = Get(data, "stage");
Ui(delegate { SetStatus(message, false); AppendLog(message); sidebarStage.Text = StageName(stage); stageDetail.Text = message; });
}
else if (kind == expectedEvent) result.Payload = data;
else if (kind == "error") result.Error = Get(data, "message");
}
process.WaitForExit();
stderr.Wait();
result.ExitCode = process.ExitCode;
if (!String.IsNullOrEmpty(result.Error) || result.Payload == null) { result.Payload = null; if (result.ExitCode == 0) result.ExitCode = -1; }
}
}
catch { result.Error = "无法运行烧录引擎。请检查 Python 环境、程序文件以及目录读写权限。"; result.ExitCode = -1; result.Payload = null; }
finally { try { if (File.Exists(requestFile)) File.Delete(requestFile); } catch { } }
return result;
});
}
private void Ui(Action action)
{
if (IsDisposed || !IsHandleCreated) return;
try { BeginInvoke(action); } catch (InvalidOperationException) { }
}
private static string StageName(string stage)
{
switch (stage.ToLowerInvariant())
{
case "validating": return "检查信息";
case "preparing": return "生成预览";
case "ready": return "预览就绪";
case "checking": return "核对设备";
case "backup": return "完整备份";
case "writing": return "写入设备";
case "verifying": return "校验写入";
default: return "正在处理";
}
}
private static string QuoteWindows(string value)
{
if (value == null) value = "";
StringBuilder result = new StringBuilder("\"");
int backslashes = 0;
foreach (char character in value)
{
if (character == '\\') { backslashes++; continue; }
if (character == '"') { result.Append('\\', backslashes * 2 + 1); result.Append('"'); }
else { result.Append('\\', backslashes); result.Append(character); }
backslashes = 0;
}
result.Append('\\', backslashes * 2);
result.Append('"');
return result.ToString();
}
private static string Get(Dictionary<string, object> data, string key)
{
object value; return data != null && data.TryGetValue(key, out value) && value != null ? Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) : "";
}
private static Dictionary<string, object> Dict(Dictionary<string, object> data, string key)
{
object value; return data != null && data.TryGetValue(key, out value) ? value as Dictionary<string, object> : null;
}
private static IEnumerable Items(Dictionary<string, object> data, string key)
{
object value; return data != null && data.TryGetValue(key, out value) && value is IEnumerable ? (IEnumerable)value : new object[0];
}
private Dictionary<string, object> DemoSummary()
{
return new Dictionary<string, object> {
{ "mode", "identity" }, { "port", "COM6" }, { "expected_mac", KnownMac }, { "device_id", "DEMO-ECHOEAR-001(演示)" }, { "model", "ECHOEAR-S3" },
{ "origin", "https://82.157.201.114" }, { "key_version", 1 }, { "reset_runtime", true }, { "firmware_version", null }, { "backup_dir", @"C:\offline-demo\current-session\backup" },
{ "regions", new object[] {
new Dictionary<string, object> { { "name", "ble_factory" }, { "offset", "0xe4e000" }, { "size", 24576 }, { "sha256", "c38bf520abf240ae9e0b0000000000000000000000000000000000000000000000" } },
new Dictionary<string, object> { { "name", "reset-runtime" }, { "offset", "0x9000" }, { "size", 24576 }, { "sha256", "d18942e3789d23fdf64c0000000000000000000000000000000000000000000000" } }
} }
};
}
internal void RenderPreview(string path)
{
if (!offline) throw new InvalidOperationException("Offline only");
loading = true;
ports.Items.Add(new PortItem { Port = "COM6", Description = "EchoEar S3 · 演示", Known = true });
ports.SelectedIndex = 0;
credential.Text = @"C:\Users\demo\Downloads\echoear-device.json";
loading = false;
ApplyPreview("offline-demo-only", DemoSummary());
sidebarPort.Text = "串口 · COM6";
sidebarStage.Text = "预览就绪";
stageDetail.Text = "演示界面 · 未连接设备\n核对信息后再开始。";
SetStatus("演示界面:仅展示操作流程,未读取或写入任何设备。", false);
AppendLog("演示预览已生成。实际烧录前会核对 MAC 并完整备份。");
ClientSize = new Size(1120, 900);
// Native combo/grid rendering needs a visible handle. The offline-only window stays
// off screen, never activates, and does not appear in the taskbar or run the bridge.
ShowInTaskbar = false;
StartPosition = FormStartPosition.Manual;
Location = new Point(-30000, -30000);
IntPtr ignored = Handle;
CreateChildHandles(this);
Show();
PerformLayout();
ResizeCards();
Application.DoEvents();
string absolute = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(absolute));
using (Bitmap bitmap = new Bitmap(Width, Height)) { DrawToBitmap(bitmap, new Rectangle(0, 0, Width, Height)); bitmap.Save(absolute, ImageFormat.Png); }
Hide();
}
protected override bool ShowWithoutActivation { get { return offline; } }
private static void CreateChildHandles(Control parent)
{
foreach (Control child in parent.Controls) { child.CreateControl(); IntPtr handle = child.Handle; CreateChildHandles(child); }
}
internal int RunSmokeTest(string path)
{
List<object> checks = new List<object>();
bool passed = true;
Action<string, bool> check = delegate(string name, bool ok) { checks.Add(new Dictionary<string, object> { { "name", name }, { "passed", ok } }); passed &= ok; };
check("initial_flash_disabled", !flashButton.Enabled);
ApplyPreview("offline-session", DemoSummary());
check("preview_requires_acknowledgement", !flashButton.Enabled);
acknowledged.Checked = true;
check("acknowledged_preview_enables_flash", flashButton.Enabled);
credential.Text = "changed.json";
check("credential_change_invalidates_preview", sessionFile == null && !flashButton.Enabled && !acknowledged.Checked);
ApplyPreview("offline-session", DemoSummary()); acknowledged.Checked = true;
mac.Text = "B4:3A:45:16:34:A9";
check("mac_change_invalidates_preview", sessionFile == null && !flashButton.Enabled);
ApplyPreview("offline-session", DemoSummary()); acknowledged.Checked = true;
full.Checked = true;
check("mode_change_invalidates_preview", sessionFile == null && !flashButton.Enabled);
ApplyPreview("offline-session", DemoSummary()); acknowledged.Checked = true;
SetBusy(true, "测试");
check("busy_disables_actions_and_inputs", !flashButton.Enabled && !previewButton.Enabled && inputs.All(delegate(Control input) { return !input.Enabled; }));
FormClosingEventArgs close = new FormClosingEventArgs(CloseReason.UserClosing, false);
OnFormClosing(close);
check("busy_blocks_window_close", close.Cancel);
SetBusy(false, "测试完成");
check("idle_restores_inputs", inputs.All(delegate(Control input) { return input.Enabled; }));
InvalidatePreview();
check("retry_requires_new_preview", !flashButton.Enabled && sessionFile == null);
check("quotes_trailing_backslash", QuoteWindows("C:\\path with spaces\\") == "\"C:\\path with spaces\\\\\"");
check("quotes_embedded_quote", QuoteWindows("a\"b") == "\"a\\\"b\"");
Dictionary<string, object> verifiedResult = new Dictionary<string, object> {
{ "status", "written_and_verified" }, { "expected_mac", KnownMac }, { "backup_dir", @"C:\offline-demo\backup" },
{ "firmware_version", null }, { "firmware_action", "preserved" }, { "next_step", "reboot_and_provision" }
};
BridgeResult completion = new BridgeResult { ExitCode = 0, Payload = new Dictionary<string, object> { { "event", "complete" }, { "result", verifiedResult } } };
check("real_backend_completion_status_accepted", IsVerifiedCompletion(completion));
verifiedResult["status"] = "ok";
check("generic_success_status_rejected", !IsVerifiedCompletion(completion));
verifiedResult["status"] = "written_and_verified";
completion.ExitCode = 1;
check("nonzero_exit_rejects_complete_payload", !IsVerifiedCompletion(completion));
completion.ExitCode = 0;
completion.Error = "protocol_error";
check("error_rejects_complete_payload", !IsVerifiedCompletion(completion));
lastBackup = @"C:\offline-demo\previous-device\backup";
ApplyPreview("new-session", DemoSummary());
check("new_preview_replaces_previous_backup_target", lastBackup == @"C:\offline-demo\current-session\backup");
InvalidatePreview();
check("failed_attempt_retains_current_backup_target", lastBackup == @"C:\offline-demo\current-session\backup");
check("bridge_stages_map_to_real_phases", StageName("writing") == "写入设备" && StageName("preparing") == "生成预览" && StageName("checking") == "核对设备" && StageName("ready") == "预览就绪" && StageName("verifying") == "校验写入");
string absolute = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(absolute));
File.WriteAllText(absolute, json.Serialize(new Dictionary<string, object> { { "passed", passed }, { "checks", checks }, { "hardware_accessed", false }, { "background_processes_started", 0 } }), new UTF8Encoding(false));
return passed ? 0 : 1;
}
}
}
@@ -0,0 +1,47 @@
param(
[string]$OutputDirectory = (Join-Path $PSScriptRoot 'dist'),
[string]$BuildDirectory = (Join-Path $PSScriptRoot '..\..\build')
)
$ErrorActionPreference = 'Stop'
$firmwareRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$sourcePath = Join-Path $PSScriptRoot 'EchoEarFlasher.cs'
$compiler = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
if (-not (Test-Path -LiteralPath $compiler)) { throw 'Windows .NET Framework C# compiler is required.' }
$outputPath = [IO.Path]::GetFullPath($OutputDirectory)
$buildPath = (Resolve-Path -LiteralPath $BuildDirectory).Path
$manifestPath = Join-Path $buildPath 'flasher_args.json'
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
if ($manifest.extra_esptool_args.chip -ne 'esp32s3') { throw 'The build must target ESP32-S3.' }
$null = New-Item -ItemType Directory -Path $outputPath -Force
$enginePath = Join-Path $outputPath 'engine'
$packageBuild = Join-Path $outputPath 'firmware-build'
$null = New-Item -ItemType Directory -Path $enginePath -Force
$null = New-Item -ItemType Directory -Path $packageBuild -Force
# Allowlist only application code; never copy private credentials or backups.
foreach ($name in @('ble_factory.py', 'device_prepare.py', 'device_flash.py', 'flash_app_bridge.py')) {
Copy-Item -LiteralPath (Join-Path $firmwareRoot "tools\$name") -Destination (Join-Path $enginePath $name) -Force
}
Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $packageBuild 'flasher_args.json') -Force
foreach ($entry in $manifest.flash_files.PSObject.Properties) {
$relative = [string]$entry.Value
if ([IO.Path]::IsPathRooted($relative) -or ($relative -split '[/\\]') -contains '..') { throw 'Unsafe image path.' }
$imagePath = [IO.Path]::GetFullPath((Join-Path $buildPath $relative))
if (-not $imagePath.StartsWith($buildPath.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Image escapes build directory.' }
$target = Join-Path $packageBuild $relative
$null = New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force
Copy-Item -LiteralPath $imagePath -Destination $target -Force
}
$executable = Join-Path $outputPath 'EchoEarFlasher.exe'
& $compiler /nologo /target:winexe /platform:x64 /optimize+ /codepage:65001 `
/r:System.dll /r:System.Core.dll /r:System.Drawing.dll /r:System.Windows.Forms.dll /r:System.Web.Extensions.dll `
"/out:$executable" $sourcePath
if ($LASTEXITCODE -ne 0) { throw 'Application compilation failed.' }
$guide = Join-Path $PSScriptRoot '使用说明.txt'
if (Test-Path -LiteralPath $guide) { Copy-Item -LiteralPath $guide -Destination $outputPath -Force }
$records = Get-ChildItem -LiteralPath $outputPath -File -Recurse | Where-Object { $_.FullName -ne (Join-Path $outputPath 'package-files.json') } | ForEach-Object {
[ordered]@{ file = $_.FullName.Substring($outputPath.Length + 1); size = $_.Length; sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() }
}
[ordered]@{ application = 'EchoEar 烧录助手'; hardware = 'ESP32-S3 / 16MB'; built_at = (Get-Date).ToString('o'); files = @($records) } |
ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $outputPath 'package-files.json') -Encoding UTF8
Write-Output "Built: $executable"
@@ -0,0 +1,45 @@
EchoEar 烧录助手 — Windows 图形界面
启动
双击 EchoEarFlasher.exe。engine 和 firmware-build 文件夹必须与程序放在一起。
这台电脑已具备所需 Python 环境,无需打开终端。
适用设备:原 COM6 的 EchoEar ESP32-S3,16MB Flash,MAC B4:3A:45:16:34:A8。
恢复迁移后连接失败的设备
1. 打开“线上设备管理”,用运维账号登录。
2. 创建设备,型号 ECHOEAR-S3,下载 KP01-…-burn-in-v1.json。
3. 在助手里选择该 JSON,确认串口 COM6 和硬件 MAC。
4. 模式保持“重新初始化(保留固件)”。
5. 点击检查/预览,核对出厂编号、平台地址、写入区域。
6. 确认将清除旧 Wi-Fi 和语音配置,点击开始烧录并在确认窗口核对目标。
7. 等待检查设备 → 完整16MB备份 → 写入 → 校验完成。
8. 成功后手动复位或断电重启,再到家庭端/小程序重新蓝牙配网与绑定老人。
预览只处理电脑上的文件,不打开设备串口。只有点击开始烧录并确认才会写设备。
烧录过程中不要拔线或关闭程序;界面显示实际阶段,不显示虚构的进度百分比。
失败时查看提示,备份保留;重新检查并预览后再重试。
完整固件模式
需要重新刷整机固件时,选择完整固件模式。它会写配套Bootloader、分区表、
应用、OTA启动信息、语音模型和屏幕资源,同时清理旧运行状态,之后必须重新配网。
本包包含项目现有1.7.0-controls.28构建:离线文件校验通过,尚未完成实机验收。
本次云端清库后的身份恢复推荐保留现有固件,无需升级到.28。
完整模式必须使用一整套构建目录,不能随意选择单个应用.bin作为整机镜像。
初始化信息
设备编号、型号、平台地址和设备密钥来自后台JSON,无需手填密钥。
家庭Wi-Fi、账号归属和老人通过家庭端/小程序配置;不要写入火山主账号AK/SK。
本次云端已经清空记录,因此可按新设备重新建立身份。对于仍在使用的其他设备,
本地清配置不会替代云端解绑,需先处理归属。
文件与依赖
生成的工厂身份、完整Flash备份含设备秘密,保存在界面指定的本地私有目录。
程序不会把密钥显示在界面日志,不会自动上传备份,不会执行整片擦除。
本版本使用电脑上已安装的Python运行烧录引擎,不是自带Python的独立运行包。
默认查找:%USERPROFILE%\.espressif\python_env\idf5.5_py3.12_env\Scripts\python.exe。
若换电脑,可在高级设置选择Python 3.10+解释器;该解释器需有
esptool 4.x/5.x、esp-idf-nvs-partition-gen、pyserial。
本机已验证依赖版本:esptool 4.12.0、esp-idf-nvs-partition-gen 0.1.9、pyserial 3.5。
备份目录及初始化材料请妥善保存,勿提交Git或公开分享。
程序和模拟测试通过不等于真机烧录验收;本次交付没有替用户执行烧录。
@@ -0,0 +1,26 @@
"""Migrate this backed-up COM6 board to A/B without erasing identity or Wi-Fi."""
import hashlib,json,pathlib,subprocess,sys
project=pathlib.Path(__file__).resolve().parents[1]
evidence=pathlib.Path('E:/Codex/story/.local-evidence/com6-controls-20260922')
backup=evidence/'before-controls-16mb.bin'
assert backup.stat().st_size==0x1000000
assert hashlib.sha256(backup.read_bytes()).hexdigest()=='df426d931d3208ea3714f3393e65c06de75da31606dd6668d9afa027e4d0a187'
tool='E:/Codex/toolchains/esp-idf-v5.5.1/components/esptool_py/esptool/esptool.py'
base=[sys.executable,tool,'--chip','esp32s3','--port','COM6','--baud','921600']
probe=subprocess.check_output(base+['read_mac'],text=True,stderr=subprocess.STDOUT)
assert 'b4:3a:45:16:34:a8' in probe.lower(),'wrong physical board'
files=[(0x0,'bootloader/bootloader.bin',0x8000),(0x8000,'partition_table/partition-table.bin',0x1000),
(0x10000,'VolcRTCDemo.bin',0x400000),(0x810000,'ota_data_initial.bin',0x2000),
(0x812000,'srmodels/srmodels.bin',0x80000)]
args=[];manifest=[]
for offset,relative,maximum in files:
path=project/'build'/relative;data=path.read_bytes()
assert 0<len(data)<=maximum
assert not (offset<0x10000 and offset+len(data)>0x9000),'NVS overlap'
assert offset+len(data)<=0x892000,'protected resource overlap'
manifest.append({'offset':hex(offset),'file':relative,'size':len(data),'sha256':hashlib.sha256(data).hexdigest()})
args += [hex(offset),str(path)]
(evidence/'flash-manifest.json').write_text(json.dumps(manifest,indent=2))
with (evidence/'flash-controls.log').open('w',encoding='utf-8') as log:
subprocess.run(base+['write_flash','--flash_mode','dout','--flash_size','16MB','--flash_freq','80m']+args,stdout=log,stderr=subprocess.STDOUT,check=True)
print(json.dumps({'result':'flashed_and_verified','board':'B4:3A:45:16:34:A8','regions':manifest,'protected':'NVS, storage, display resources, factory identity'}))
@@ -0,0 +1,316 @@
"""JSONL-only bridge for the Windows EchoEar flashing app.
``ports`` and ``preview`` never open a serial port. ``flash`` accepts only a
short-lived, one-use preview session and an explicit execute=true acknowledgement.
No credentials, setup passwords or raw subprocess output are sent to the GUI.
"""
from __future__ import annotations
import argparse
import contextlib
from datetime import datetime, timezone
import hashlib
import hmac
import io
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import sys
import time
import device_flash
import device_prepare
PROJECT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = PROJECT / "backups" / "gui"
SESSION_TTL = 30 * 60
SESSION_FIELDS = {"schema_version", "created_at", "created_epoch", "expires_epoch",
"credential_path", "build_dir", "mode", "expected_mac", "port",
"factory_dir", "backup_dir", "input_hashes", "plan_hash", "signature"}
PREVIEW_FIELDS = {"credential_path", "build_dir", "mode", "expected_mac", "port", "output_root"}
FLASH_FIELDS = {"session_file", "execute", "port", "expected_mac"}
MESSAGES = {
"invalid_request": "输入无效。请检查烧录信息 JSON、固件目录、COM 端口和设备 MAC。",
"invalid_credential": "烧录信息 JSON 无效,请从线上设备管理后台重新下载。",
"invalid_build": "固件文件校验失败。请选择完整的 EchoEar ESP32-S3 16MB 构建目录。",
"preparation_failed": "初始化信息生成失败。请检查 ESP-IDF Python 环境和输出目录权限,再重新预览。",
"stale_preview": "预览已过期、已使用或输入文件发生变化。请重新预览后再烧录。",
"confirmation_required": "尚未确认执行。请在界面中确认清除本地 Wi-Fi、语音、会话和 OTA 状态后再开始。",
"port_mismatch": "设备端口或 MAC 与预览不一致。请重新预览。",
"ports_failed": "串口列表读取失败。请检查 Python 环境是否安装 pyserial。",
"flash_failed": "烧录未完成。请检查 USB 连接、设备 MAC 和串口占用;已生成的备份及诊断记录已保留。重新尝试前请重新预览。",
"internal_error": "操作失败,未向界面输出敏感诊断内容。请检查运行环境和文件权限。",
}
STAGES = {
"version": ("checking", "检查 esptool 版本"),
"read_mac": ("checking", "连接设备并核对芯片与 MAC"),
"flash_id": ("checking", "核对 16MB Flash 容量"),
"get_security_info": ("checking", "核对设备安全配置"),
"read_flash": ("backup", "正在完整备份 16MB Flash,请保持 USB 连接"),
"write_flash": ("writing", "备份完成,正在写入,请保持 USB 连接"),
"verify_flash": ("verifying", "正在校验设备中写入的数据"),
}
class BridgeError(ValueError):
def __init__(self, code):
self.code = code
super().__init__(MESSAGES[code])
def require(condition, code="invalid_request"):
if not condition:
raise BridgeError(code)
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
def digest(value):
return hashlib.sha256(canonical(value)).hexdigest()
def safe_path(value, code="invalid_request"):
require(isinstance(value, str) and value and len(value) < 4096 and "\0" not in value, code)
return Path(value).expanduser().resolve()
def read_object(path, code="invalid_request"):
try:
require(path.is_file() and path.stat().st_size <= 65536, code)
value = json.loads(path.read_text(encoding="utf-8-sig"))
require(isinstance(value, dict), code)
return value
except (OSError, ValueError, UnicodeError):
raise BridgeError(code) from None
def normalize_port(value):
require(isinstance(value, str) and re.fullmatch(r"COM[1-9][0-9]{0,4}", value, re.I))
return value.upper()
def hidden_run(args, **kwargs):
if os.name == "nt":
kwargs["creationflags"] = kwargs.get("creationflags", 0) | subprocess.CREATE_NO_WINDOW
return subprocess.run(args, **kwargs)
class _HiddenSubprocess:
"""Keep existing preparation/ACL helpers quiet without changing their source."""
def run(self, args, **kwargs):
return hidden_run(args, **kwargs)
def __getattr__(self, name):
return getattr(subprocess, name)
@contextlib.contextmanager
def hidden_preparation():
original = device_prepare.subprocess
device_prepare.subprocess = _HiddenSubprocess()
try:
yield
finally:
device_prepare.subprocess = original
def input_snapshot(credential, build, factory=None):
"""Include every reference build image even when retaining existing firmware."""
manifest = device_flash.load_json(build / "flasher_args.json")
files = [credential, build / "flasher_args.json"]
for name in device_flash.REGIONS:
entry = device_flash.object_field(manifest, name)
files.append(device_flash.contained_file(build, entry.get("file")))
if factory is not None:
files.extend(factory / name for name in ("manifest.json", "ble_factory.bin", "factory.csv", "setup-label.json"))
return {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(set(files))}
def phase(emit, stage, message):
emit({"event": "phase", "stage": stage, "message": message})
def enumerate_ports(request, emit):
require(not request)
try:
from serial.tools import list_ports
ports = []
for item in list_ports.comports():
if not re.fullmatch(r"COM[1-9][0-9]{0,4}", item.device, re.I):
continue
ports.append({"port": item.device.upper(), **{
name: getattr(item, name, None) for name in
("description", "hwid", "serial_number", "manufacturer", "product", "vid", "pid")}})
ports.sort(key=lambda item: int(item["port"][3:]))
except Exception:
raise BridgeError("ports_failed") from None
emit({"event": "ports", "ports": ports})
def preview(request, emit):
require(set(request) <= PREVIEW_FIELDS)
phase(emit, "validating", "离线检查初始化信息和固件文件,不连接设备")
credential = safe_path(request.get("credential_path"))
build = safe_path(request.get("build_dir", str(PROJECT / "build")))
mode = request.get("mode", "identity")
require(mode in ("identity", "full"))
port = normalize_port(request.get("port"))
try:
mac = device_flash.normalize_mac(request.get("expected_mac"))
except ValueError:
raise BridgeError("invalid_request") from None
output = safe_path(request.get("output_root", str(DEFAULT_OUTPUT)))
try:
require(credential.is_file() and credential.stat().st_size <= 16384, "invalid_credential")
credential_hash = hashlib.sha256(credential.read_bytes()).hexdigest()
info = device_prepare.read_credential(credential)
except (ValueError, OSError):
raise BridgeError("invalid_credential") from None
try:
original_inputs = input_snapshot(credential, build)
require(original_inputs[str(credential)] == credential_hash, "stale_preview")
device_flash.build_plan(build, reset_runtime=True)
except BridgeError:
raise
except (ValueError, OSError):
raise BridgeError("invalid_build") from None
phase(emit, "preparing", "生成本机私有初始化文件和烧录预览")
now = time.time()
created_at = datetime.fromtimestamp(now, timezone.utc).isoformat()
directory = output / (datetime.fromtimestamp(now, timezone.utc).strftime("%Y%m%dT%H%M%SZ-") + secrets.token_hex(6))
factory, backup = directory / "factory", directory / "backup"
try:
with hidden_preparation():
device_prepare.private_directory(directory)
device_prepare.prepare(credential, factory, mac)
plan = device_flash.build_plan(build, factory, mode == "identity", True, mac)
require(original_inputs == input_snapshot(credential, build), "stale_preview")
frozen_inputs = input_snapshot(credential, build, factory)
session = {"schema_version": 1, "created_at": created_at, "created_epoch": now,
"expires_epoch": now + SESSION_TTL, "credential_path": str(credential),
"build_dir": str(build), "mode": mode, "expected_mac": mac, "port": port,
"factory_dir": str(factory), "backup_dir": str(backup),
"input_hashes": frozen_inputs, "plan_hash": digest(plan.summary())}
integrity_key = secrets.token_bytes(32)
key_path = directory / "integrity.key"
with key_path.open("xb") as stream:
stream.write(integrity_key)
os.chmod(key_path, 0o600)
session["signature"] = hmac.new(integrity_key, canonical(session), hashlib.sha256).hexdigest()
session_file = directory / "session.json"
device_prepare.private_json(session_file, session)
except BridgeError:
raise
except Exception:
raise BridgeError("preparation_failed") from None
summary = {"mode": mode, "port": port, "expected_mac": mac, "device_id": info["device_id"],
"model": info["model"], "origin": info["origin"], "key_version": info["key_version"],
"firmware_version": None if mode == "identity" else plan.application["firmware_version"],
"reference_firmware_version": plan.application["firmware_version"], "reset_runtime": True,
"runtime_effect": "清除本地 Wi-Fi、语音、会话和 OTA 状态;完成后须重新蓝牙配网。",
"regions": [{key: value for key, value in image.summary().items() if key != "source"}
for image in plan.images],
"factory_dir": str(factory), "backup_dir": str(backup), "created_at": created_at,
"expires_in_seconds": SESSION_TTL}
phase(emit, "ready", "预览完成。确认清除配网信息后方可开始烧录")
emit({"event": "preview", "session_file": str(session_file), "summary": summary})
def load_session(path):
require(path.name == "session.json", "stale_preview")
session = read_object(path, "stale_preview")
require(set(session) == SESSION_FIELDS and session.get("schema_version") == 1, "stale_preview")
signature = session.pop("signature")
try:
key = (path.parent / "integrity.key").read_bytes()
require(len(key) == 32 and isinstance(signature, str), "stale_preview")
require(hmac.compare_digest(signature, hmac.new(key, canonical(session), hashlib.sha256).hexdigest()), "stale_preview")
require(type(session["created_epoch"]) in (int, float) and type(session["expires_epoch"]) in (int, float), "stale_preview")
now = time.time()
require(session["created_epoch"] - 60 <= now < session["expires_epoch"], "stale_preview")
require(session["expires_epoch"] - session["created_epoch"] == SESSION_TTL, "stale_preview")
require(session["mode"] in ("identity", "full"), "stale_preview")
require(normalize_port(session["port"]) == session["port"], "stale_preview")
require(device_flash.normalize_mac(session["expected_mac"]) == session["expected_mac"], "stale_preview")
require(safe_path(session["factory_dir"]) == path.parent / "factory", "stale_preview")
require(safe_path(session["backup_dir"]) == path.parent / "backup", "stale_preview")
require(not (path.parent / "execute.json").exists(), "stale_preview")
except (OSError, ValueError, TypeError, KeyError):
raise BridgeError("stale_preview") from None
return session
def flash(request, emit):
require(set(request) <= FLASH_FIELDS)
require(request.get("execute") is True, "confirmation_required")
path = safe_path(request.get("session_file"), "stale_preview")
session = load_session(path)
if "port" in request:
require(normalize_port(request["port"]) == session["port"], "port_mismatch")
if "expected_mac" in request:
try:
require(device_flash.normalize_mac(request["expected_mac"]) == session["expected_mac"], "port_mismatch")
except device_flash.FlashError:
raise BridgeError("port_mismatch") from None
phase(emit, "validating", "重新核对已确认的文件、端口、MAC 和烧录范围")
credential = safe_path(session["credential_path"], "stale_preview")
build = safe_path(session["build_dir"], "stale_preview")
factory = safe_path(session["factory_dir"], "stale_preview")
try:
require(input_snapshot(credential, build, factory) == session["input_hashes"], "stale_preview")
device_prepare.read_credential(credential)
plan = device_flash.build_plan(build, factory, session["mode"] == "identity", True, session["expected_mac"])
require(digest(plan.summary()) == session["plan_hash"], "stale_preview")
require(input_snapshot(credential, build, factory) == session["input_hashes"], "stale_preview")
# Exclusive creation consumes the session before even the first probe.
device_prepare.private_json(path.parent / "execute.json", {"started_at": datetime.now(timezone.utc).isoformat()})
except (ValueError, OSError):
raise BridgeError("stale_preview") from None
def runner(args, **kwargs):
for argument in args:
stage = STAGES.get(argument.replace("-", "_"))
if stage:
phase(emit, *stage)
break
return hidden_run(args, **kwargs)
try:
# Existing CLI progress and raw tool output are intentionally not GUI logs.
with hidden_preparation(), contextlib.redirect_stdout(io.StringIO()):
result = device_flash.execute_plan(plan, python=sys.executable, port=session["port"],
expect_mac=session["expected_mac"], backup_dir=Path(session["backup_dir"]), runner=runner)
except Exception:
raise BridgeError("flash_failed") from None
result["next_step"] = "烧录和校验完成。请手动重启设备,然后在家庭端重新蓝牙配网和绑定。"
emit({"event": "complete", "result": result})
def main(argv=None, output=None):
output = output or sys.stdout
def emit(value):
output.write(json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n")
output.flush()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("ports", "preview", "flash"))
parser.add_argument("--request", required=True, type=Path)
try:
args = parser.parse_args(argv)
request = read_object(args.request)
{"ports": enumerate_ports, "preview": preview, "flash": flash}[args.command](request, emit)
return 0
except BridgeError as exc:
emit({"event": "error", "code": exc.code, "message": MESSAGES[exc.code]})
return 2
except Exception:
emit({"event": "error", "code": "internal_error", "message": MESSAGES["internal_error"]})
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Extract the fixed call-status glyphs into the GFX compatibility font ABI.
No font converter, network, LVGL headers, or rasterizer is used. The source is
the checked-in uncompressed 1bpp font; Pillow is needed only with --preview.
Run --check to verify the committed generated files without modifying them.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_font.c"
SOURCE_SHA256 = "abfffdf0cd809cf2e3cd9425988b8945786fb5166bf4c5e91d2b6e1fb3839f74"
FONT_C = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_call_status_font.c"
FONT_H = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_call_status_font.h"
HOST_TEST = ROOT / "tools/test_call_status_font.c"
CHARACTERS = "?待机正在连接通话中结束失败断"
STATUSES = ["待机", "正在连接", "通话中", "正在结束", "通话结束", "连接失败", "连接中断"]
SCALE = 2
def array_body(source: str, name: str) -> str:
match = re.search(r"\b" + re.escape(name) + r"\[\]\s*=\s*\{(.*?)\};", source, re.S)
if not match:
raise ValueError(f"missing source array {name}")
return match.group(1)
def numbers(body: str) -> list[int]:
body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
return [int(value, 0) for value in re.findall(r"0[xX][0-9a-fA-F]+|\b\d+\b", body)]
def fields(body: str) -> dict[str, str]:
return dict(re.findall(r"\.(\w+)\s*=\s*([^,}\s]+)", body))
def bitmap_pixels(data: bytes, width: int, height: int) -> list[int]:
return [(data[index // 8] >> (7 - index % 8)) & 1 for index in range(width * height)]
def pack_pixels(pixels: list[int]) -> bytes:
packed = bytearray((len(pixels) + 7) // 8)
for index, bit in enumerate(pixels):
packed[index // 8] |= bit << (7 - index % 8)
return bytes(packed)
def pixel_hash(pixels: list[int]) -> int:
value = 2166136261
for bit in pixels:
value = ((value ^ (255 if bit else 0)) * 16777619) & 0xFFFFFFFF
return value
def extract() -> tuple[str, list[dict], int, int]:
raw = SOURCE.read_bytes()
digest = hashlib.sha256(raw).hexdigest()
if digest != SOURCE_SHA256:
raise ValueError(f"source font changed: {digest}; review before updating the pinned hash")
source = raw.decode("utf-8").replace("\r\n", "\n")
original_header = re.match(r"/\*.*?\*/", source, re.S).group(0)
if not re.search(r"\.bpp\s*=\s*1\s*,", source) or not re.search(r"\.bitmap_format\s*=\s*0\s*,", source):
raise ValueError("expected uncompressed 1bpp source font")
bitmap_body = array_body(source, "glyph_bitmap")
bitmap = bytes(numbers(bitmap_body))
descriptors = [{key: int(value, 0) for key, value in fields(body).items()}
for body in re.findall(r"\{([^{}]*)\}", array_body(source, "glyph_dsc"))]
cmaps = [fields(body) for body in re.findall(r"\{([^{}]*)\}", array_body(source, "cmaps"))]
tables: dict[str, list[int]] = {}
def table(name: str) -> list[int]:
if name not in tables:
tables[name] = numbers(array_body(source, name))
return tables[name]
def glyph_id(codepoint: int) -> int:
for cmap in cmaps:
offset = codepoint - int(cmap["range_start"])
if not 0 <= offset < int(cmap["range_length"]):
continue
start = int(cmap["glyph_id_start"])
kind = cmap["type"].removeprefix("LV_FONT_FMT_TXT_CMAP_")
if kind == "FORMAT0_TINY":
return start + offset
if kind == "FORMAT0_FULL":
return start + table(cmap["glyph_id_ofs_list"])[offset]
offsets = table(cmap["unicode_list"])
if offset not in offsets:
continue
index = offsets.index(offset)
if kind == "SPARSE_TINY":
return start + index
if kind == "SPARSE_FULL":
return start + table(cmap["glyph_id_ofs_list"])[index]
raise ValueError(f"unsupported source cmap {kind}")
raise ValueError(f"missing source glyph U+{codepoint:04X}")
# Independently cross-check the cmap result against converter bitmap comments.
labelled_offsets = {}
cursor = 0
for match in re.finditer(r"/\* U\+([0-9A-Fa-f]+).*?\*/(.*?)(?=/\* U\+|\Z)", bitmap_body, re.S):
labelled_offsets[int(match.group(1), 16)] = cursor
cursor += len(numbers(match.group(2)))
assert cursor == len(bitmap)
glyphs = []
for char in sorted(set(CHARACTERS)):
codepoint = ord(char)
gid = glyph_id(codepoint)
original = descriptors[gid]
assert original["bitmap_index"] == labelled_offsets[codepoint], f"cmap/comment mismatch for {char}"
width, height = original["box_w"], original["box_h"]
size = (width * height + 7) // 8
assert 0 < size and original["bitmap_index"] + size <= len(bitmap)
original_pixels = bitmap_pixels(bitmap[original["bitmap_index"]:][:size], width, height)
pixels = [original_pixels[(y // SCALE) * width + x // SCALE]
for y in range(height * SCALE) for x in range(width * SCALE)]
packed = pack_pixels(pixels)
assert bitmap_pixels(packed, width * SCALE, height * SCALE) == pixels
assert sum(pixels) == sum(original_pixels) * SCALE * SCALE > 0
glyphs.append({"char": char, "codepoint": codepoint, "source_glyph_id": gid,
"width": width * SCALE, "height": height * SCALE,
"ofs_x": original["ofs_x"] * SCALE, "ofs_y": original["ofs_y"] * SCALE,
# GFX reads adv_w as 1/256 px, unlike source LVGL's 1/16 px.
"advance": original["adv_w"] * SCALE * 16,
"bitmap": packed, "pixels": pixels, "pixel_hash": pixel_hash(pixels)})
line_height = int(re.search(r"\.line_height\s*=\s*(\d+)", source).group(1)) * SCALE
baseline = int(re.search(r"\.base_line\s*=\s*(\d+)", source).group(1)) * SCALE
assert line_height <= 42
for glyph in glyphs:
top = line_height - baseline - glyph["height"] - glyph["ofs_y"]
assert 0 <= top and top + glyph["height"] <= line_height
by_char = {glyph["char"]: glyph for glyph in glyphs}
for status in STATUSES:
assert len(status) <= 4
assert sum(max(by_char[c]["advance"] >> 8, by_char[c]["width"] + by_char[c]["ofs_x"]) for c in status) <= 240
return original_header, glyphs, line_height, baseline
def generated_files(original_header: str, glyphs: list[dict], line_height: int, baseline: int) -> dict[Path, str]:
comment = ("/* Generated by tools/generate_call_status_font.py; do not hand edit.\n"
f" * Source SHA256: {SOURCE_SHA256}\n"
" * 2x nearest-neighbor glyphs; packed 1bpp, GFX 1/256-pixel advance.\n"
" * The original source font's leading notice is preserved below.\n */\n")
c = [comment, original_header, '\n#ifdef LVGL_VERSION_MAJOR\n#error "Build this font in its own GFX-only translation unit"\n#endif\n#include "widget/gfx_font_lvgl.h"\n',
'#if LVGL_VERSION_MAJOR != 8 || LV_FONT_FMT_TXT_LARGE != 1\n#error "Unexpected GFX font compatibility ABI"\n#endif\n_Static_assert(sizeof(lv_font_fmt_txt_glyph_dsc_t) == 16, "GFX LARGE glyph ABI required");\n',
'static const uint8_t status_bitmap[] = {']
cursor = 0
for glyph in glyphs:
glyph["bitmap_index"] = cursor
c.append(f' /* U+{glyph["codepoint"]:04X} {glyph["char"]} */')
values = list(glyph["bitmap"])
for start in range(0, len(values), 24):
c.append(' ' + ','.join(str(byte) for byte in values[start:start + 24]) + ',')
cursor += len(values)
c += ['};\n', 'static const lv_font_fmt_txt_glyph_dsc_t status_glyphs[] = {', ' {0}, /* glyph zero is reserved */']
for g in glyphs:
c.append(f' {{{g["bitmap_index"]},{g["advance"]},{g["width"]},{g["height"]},{g["ofs_x"]},{g["ofs_y"]}}}, /* {g["char"]} */')
c += ['};\n', '#define STATUS_CMAP(cp, id) { .range_start=(cp), .range_length=1, .glyph_id_start=(id), .unicode_list=NULL, .glyph_id_ofs_list=NULL, .list_length=0, .type=LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY }',
'static const lv_font_fmt_txt_cmap_t status_cmaps[] = {']
c += [f' STATUS_CMAP(0x{g["codepoint"]:04X}, {index}),' for index, g in enumerate(glyphs, 1)]
c += ['};', '#undef STATUS_CMAP\n',
'static const lv_font_fmt_txt_dsc_t status_font_dsc = {',
' .glyph_bitmap=status_bitmap, .glyph_dsc=status_glyphs, .cmaps=status_cmaps,',
f' .kern_dsc=NULL, .kern_scale=0, .cmap_num={len(glyphs)}, .bpp=1,',
' .kern_classes=0, .bitmap_format=0, .cache=NULL', '};\n',
'static const lv_font_t status_font = {',
' .get_glyph_dsc=lv_font_get_glyph_dsc_fmt_txt, .get_glyph_bitmap=lv_font_get_bitmap_fmt_txt,',
f' .line_height={line_height}, .base_line={baseline}, .subpx=LV_FONT_SUBPX_NONE, .static_bitmap=1,',
' .underline_position=-6, .underline_thickness=2, .dsc=&status_font_dsc, .fallback=NULL, .user_data=NULL',
'};\n', 'const void *echoear_call_status_font(void) { return &status_font; }\n']
header = comment + '#pragma once\n#ifdef __cplusplus\nextern "C" {\n#endif\n/* GFX compatibility font; deliberately exposes no LVGL types. */\nconst void *echoear_call_status_font(void);\n#ifdef __cplusplus\n}\n#endif\n'
host = [comment, '#include <assert.h>\n#include <stdint.h>\n#include <stdio.h>\n#include "widget/gfx_font_internal.h"\n#include "echoear_call_status_font.h"\n',
'static const struct { uint32_t cp; int w, h, x, y, advance; uint32_t hash; } expected[] = {']
for g in glyphs:
host.append(f' {{0x{g["codepoint"]:04X},{g["width"]},{g["height"]},{g["ofs_x"]},{g["ofs_y"]},{g["advance"]},0x{g["pixel_hash"]:08X}}},')
host += ['};\n', 'int main(void) {', ' const void *font=echoear_call_status_font(); assert(gfx_is_lvgl_font(font));',
' gfx_font_ctx_t ctx={0}; gfx_font_lv_init_context(&ctx,font);',
f' assert(ctx.get_line_height(&ctx)=={line_height} && ctx.get_base_line(&ctx)=={baseline});',
' for (unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) {',
' gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,expected[i].cp,0));',
' assert(g.box_w==expected[i].w && g.box_h==expected[i].h);',
' assert(g.ofs_x==expected[i].x && g.ofs_y==expected[i].y && g.adv_w==(uint32_t)expected[i].advance);',
f' assert(g.bitmap_index+(g.box_w*g.box_h+7)/8 <= {cursor});',
' int top=ctx.adjust_baseline_offset(&ctx,&g); assert(top>=0 && top+g.box_h<=ctx.get_line_height(&ctx));',
' assert(ctx.get_advance_width(&ctx,&g)==32 || expected[i].cp==0x3F);',
' const uint8_t *bits=ctx.get_glyph_bitmap(&ctx,expected[i].cp,&g); assert(bits); uint32_t hash=2166136261u;',
' for(int y=0;y<g.box_h;y++) for(int x=0;x<g.box_w;x++) hash=(hash^ctx.get_pixel_value(&ctx,bits,x,y,g.box_w))*16777619u;',
' assert(hash==expected[i].hash);', ' }',
' /* Exhaustive Unicode lookup also exercises every cmap range-end boundary. */',
' for(uint32_t cp=0;cp<=0x10FFFF;cp++) {',
' int present=0; for(unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) present |= expected[i].cp==cp;',
' gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,cp,0)==(present!=0));', ' }',
' puts("GFX call-status font: ABI, coverage, exact pixels, metrics and Unicode boundaries PASS");', ' return 0;', '}\n']
return {FONT_C: '\n'.join(c), FONT_H: header, HOST_TEST: '\n'.join(host)}
def preview(path: Path, glyphs: list[dict], line_height: int, baseline: int) -> None:
from PIL import Image, ImageDraw
by_char = {g["char"]: g for g in glyphs}
panel = 360 # CONFIG_EXAMPLE_LCD_INTERFACE_QSPI: HW_LCD_H_RES / V_RES.
image = Image.new('RGB', (panel * 3, panel * 3), '#24272b')
draw = ImageDraw.Draw(image)
labels = STATUSES + ['?', '正在连接']
for index, label in enumerate(labels):
left, top = index % 3 * panel, index // 3 * panel
draw.ellipse((left, top, left + panel - 1, top + panel - 1), fill='#080b10', outline='#64707b')
draw.rectangle((left + 60, top + 58, left + 299, top + 99), outline='#28313b')
width = sum(max(by_char[c]['advance'] >> 8, by_char[c]['width'] + by_char[c]['ofs_x']) for c in label)
x, line_top = left + (panel - width) // 2, top + 58 + (42 - line_height) // 2
for char in label:
glyph = by_char[char]
y = line_top + line_height - baseline - glyph['height'] - glyph['ofs_y']
for py in range(glyph['height']):
for px in range(glyph['width']):
if glyph['pixels'][py * glyph['width'] + px]:
image.putpixel((x + glyph['ofs_x'] + px, y + py), (255, 255, 255))
x += max(glyph['advance'] >> 8, glyph['width'] + glyph['ofs_x'])
draw.text((left + 70, top + 286), f'status {index + 1} / 360x360 / 1:1 px', fill='#9faab6')
draw.text((left + 61, top + 306), 'GLYPH PREVIEW - NOT A SCREEN PHOTO', fill='#9faab6')
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
parser.add_argument('--preview', type=Path)
parser.add_argument('--report', type=Path)
args = parser.parse_args()
notice, glyphs, line_height, baseline = extract()
outputs = generated_files(notice, glyphs, line_height, baseline)
for path, text in outputs.items():
if args.check:
assert path.read_text(encoding='utf-8') == text, f'generated file differs: {path}'
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding='utf-8', newline='\n')
if args.preview:
preview(args.preview, glyphs, line_height, baseline)
report = {'source': str(SOURCE.relative_to(ROOT)), 'source_sha256': SOURCE_SHA256,
'glyph_count': len(glyphs), 'characters': ''.join(g['char'] for g in glyphs),
'statuses': STATUSES, 'bitmap_bytes': sum(len(g['bitmap']) for g in glyphs),
'line_height': line_height, 'base_line': baseline, 'scale': SCALE,
'files': {str(path.relative_to(ROOT)): {'bytes': path.stat().st_size, 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in outputs},
'glyphs': [{key: value for key, value in g.items() if key not in ('pixels', 'bitmap')} for g in glyphs]}
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
@@ -0,0 +1,40 @@
"""Read provisioning diagnostics without resetting the device by default."""
import argparse
import time
import serial
parser = argparse.ArgumentParser()
parser.add_argument("--port", default="COM6")
parser.add_argument("--seconds", type=int, default=45)
parser.add_argument("--reset", action="store_true")
args = parser.parse_args()
port = serial.Serial()
port.port = args.port
port.baudrate = 115200
port.timeout = 1
port.dtr = False
port.rts = False
port.open()
try:
if args.reset:
from esptool.reset import HardReset
HardReset(port, uses_usb=True)()
deadline = time.monotonic() + args.seconds
resets = 0
print(f"Monitoring {args.port} for {args.seconds}s; reset={args.reset}", flush=True)
markers = ("ble_setup:", "ble_wifi:", "Guru Meditation", "assert failed:",
"panic", "rst:", "Rebooting", "Touch released", "Startup task finished",
"ELF file SHA256:", "wifi:Expected", "wifi_init: Failed", "esp_netif_lwip:",
"esp-tls:", "HTTP_CLIENT:", "Stack protection fault",
"PLATFORM_HTTP", "Platform error code:", "call_client:", "Startup stack headroom",
"WiFi init internal_free", "WiFi initialization unavailable", "RTC", "volc_rtc_create:")
while time.monotonic() < deadline:
line = port.readline().decode(errors="replace").strip()
if "rst:" in line:
resets += 1
if line and any(marker in line for marker in markers):
print(line, flush=True)
print(f"Reset markers observed: {resets}", flush=True)
finally:
port.close()
@@ -0,0 +1,19 @@
"""Capture operational metadata only; no voice text, credentials, or network names."""
import pathlib,re,serial,sys,time,json
s=serial.Serial();s.port='COM6';s.baudrate=115200;s.timeout=.2;s.dtr=False;s.rts=False;s.open()
s.write(b'\ndiag-state\n')
duration=int(sys.argv[1]) if len(sys.argv)>1 else 40
end=time.monotonic()+duration
output=[];tags={}
allow=re.compile(r'(RTC diagnostic:|device_control:|device_ota:|Platform error code:|POST /api/v1/device/(control|mqtt|ota)|Direct chat ready;|Startup task finished|Guru Meditation|Backtrace:|assert failed:|CORRUPT HEAP|Stack canary|Rebooting|esp-tls:|esp_mqtt:|mqtt_client:|Transport failed|Failed to allocate|Failed to create|Cannot allocate|system_api:)')
while time.monotonic()<end:
line=s.readline().decode('utf-8','replace').strip()
tag=re.search(r'\) ([A-Za-z_0-9-]+):',line)
if tag:tags[tag[1]]=tags.get(tag[1],0)+1
if allow.search(line):
safe=line[:700];output.append(safe);print(safe,flush=True)
s.close()
summary={'seconds':duration,'log_tags':tags,'selected_lines':len(output)}
print(json.dumps(summary),flush=True)
path=pathlib.Path('E:/Codex/story/.local-evidence/com6-controls-20260922/serial-controls.log')
with path.open('a',encoding='utf-8') as f:f.write('\n'.join(output)+'\n'+json.dumps(summary)+'\n')
@@ -0,0 +1,31 @@
"""Package the application image for the platform's existing firmware upload flow."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import shutil
import struct
project = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--model', required=True, help='Exact inventory model, e.g. ECHOEAR-S3')
parser.add_argument('--app', type=Path, default=project / 'build/VolcRTCDemo.bin')
parser.add_argument('--out', type=Path, required=True)
args = parser.parse_args()
version = re.search(r'^#define DEVICE_FIRMWARE_VERSION "([^"]+)"', (project / 'main/firmware_version.h').read_text(), re.M).group(1)
image = args.app.read_bytes()
if not args.model or len(args.model.encode()) > 120:
parser.error('Model must match the device inventory and fit 120 bytes')
if not 1024 <= len(image) <= 4 * 1024 * 1024:
parser.error('The application must fit the 4 MiB OTA slot')
if image[0] != 0xE9 or struct.unpack_from('<H', image, 12)[0] != 9:
parser.error('Expected an ESP32-S3 application image')
if version.encode() + b'\0' not in image:
parser.error('Build is stale: compile the current firmware version first')
args.out.mkdir(parents=True, exist_ok=True)
output = args.out / f'echoear-{version}.bin'
shutil.copyfile(args.app, output)
manifest = {'model': args.model, 'version': version, 'size': len(image), 'sha256': hashlib.sha256(image).hexdigest(), 'file': output.name, 'max_ota_size': 4 * 1024 * 1024, 'upload_kind': 'application_only'}
(args.out / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(manifest, ensure_ascii=False))
@@ -0,0 +1,53 @@
/* Generated by tools/generate_call_status_font.py; do not hand edit.
* Source SHA256: abfffdf0cd809cf2e3cd9425988b8945786fb5166bf4c5e91d2b6e1fb3839f74
* 2x nearest-neighbor glyphs; packed 1bpp, GFX 1/256-pixel advance.
* The original source font's leading notice is preserved below.
*/
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include "widget/gfx_font_internal.h"
#include "echoear_call_status_font.h"
static const struct { uint32_t cp; int w, h, x, y, advance; uint32_t hash; } expected[] = {
{0x003F,16,26,2,-2,4960,0xE71CEB21},
{0x4E2D,26,30,2,-4,8192,0x3888DC51},
{0x5728,30,30,0,-4,8192,0x3089182D},
{0x5931,30,30,2,-4,8192,0xDA574F79},
{0x5F85,30,32,2,-4,8192,0xCE76BC5D},
{0x63A5,28,30,2,-4,8192,0x7D6D9261},
{0x65AD,28,30,2,-4,8192,0x77C36EB9},
{0x673A,30,30,2,-4,8192,0xDAC1CC21},
{0x675F,30,30,0,-4,8192,0xB9914901},
{0x6B63,30,26,0,-2,8192,0xB05EAE01},
{0x7ED3,30,30,0,-4,8192,0x652017C5},
{0x8BDD,30,30,0,-4,8192,0x35175309},
{0x8D25,32,32,0,-6,8192,0x99373BE1},
{0x8FDE,30,30,0,-4,8192,0x353F4839},
{0x901A,30,26,0,-2,8192,0x66E5D84D},
};
int main(void) {
const void *font=echoear_call_status_font(); assert(gfx_is_lvgl_font(font));
gfx_font_ctx_t ctx={0}; gfx_font_lv_init_context(&ctx,font);
assert(ctx.get_line_height(&ctx)==38 && ctx.get_base_line(&ctx)==10);
for (unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) {
gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,expected[i].cp,0));
assert(g.box_w==expected[i].w && g.box_h==expected[i].h);
assert(g.ofs_x==expected[i].x && g.ofs_y==expected[i].y && g.adv_w==(uint32_t)expected[i].advance);
assert(g.bitmap_index+(g.box_w*g.box_h+7)/8 <= 1595);
int top=ctx.adjust_baseline_offset(&ctx,&g); assert(top>=0 && top+g.box_h<=ctx.get_line_height(&ctx));
assert(ctx.get_advance_width(&ctx,&g)==32 || expected[i].cp==0x3F);
const uint8_t *bits=ctx.get_glyph_bitmap(&ctx,expected[i].cp,&g); assert(bits); uint32_t hash=2166136261u;
for(int y=0;y<g.box_h;y++) for(int x=0;x<g.box_w;x++) hash=(hash^ctx.get_pixel_value(&ctx,bits,x,y,g.box_w))*16777619u;
assert(hash==expected[i].hash);
}
/* Exhaustive Unicode lookup also exercises every cmap range-end boundary. */
for(uint32_t cp=0;cp<=0x10FFFF;cp++) {
int present=0; for(unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) present |= expected[i].cp==cp;
gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,cp,0)==(present!=0));
}
puts("GFX call-status font: ABI, coverage, exact pixels, metrics and Unicode boundaries PASS");
return 0;
}
@@ -0,0 +1,392 @@
"""Safety regression tests; all device I/O is simulated, no serial port opens."""
import contextlib
import hashlib
import io
import json
from pathlib import Path
import struct
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import device_flash as flash
MAC = "B4:3A:45:16:34:A8"
def table_bytes(layout=None):
records = b"".join(struct.pack("<HBBII16sI", 0x50AA, *entry[:4], name.encode(), entry[4])
for name, entry in (layout or flash.LAYOUT).items())
records += b"\xeb\xeb" + b"\xff" * 14 + hashlib.md5(records).digest()
return records.ljust(0xC00, b"\xff")
def executable(application=False, chip=9):
header = bytearray(24)
header[:4] = bytes((0xE9, 1, 3, 0x4F))
struct.pack_into("<H", header, 12, chip)
header[23] = 1
payload = bytearray(288 if application else 64)
if application:
struct.pack_into("<I", payload, 0, 0xABCD5432)
payload[16:16 + 4] = b"test"
payload[48:48 + 11] = b"VolcRTCDemo\0"
payload[112:112 + 6] = b"v5.5.1"
version = b"1.7.0-controls.28\0"
payload[256:256 + len(version)] = version
result = bytes(header) + struct.pack("<II", 0x3C000020, len(payload)) + bytes(payload)
checksum = 0xEF
for value in payload:
checksum ^= value
result = result.ljust((len(result) // 16 + 1) * 16 - 1, b"\0") + bytes((checksum,))
return result + hashlib.sha256(result).digest()
def make_build(directory):
directory.mkdir()
manifest = {"extra_esptool_args": {"chip": "esp32s3"},
"flash_settings": {"flash_mode": "dout", "flash_size": "detect", "flash_freq": "80m"},
"flash_files": {}}
for name, (offset, _) in flash.REGIONS.items():
if name == "bootloader":
data = executable()
elif name == "app":
data = executable(True)
elif name == "partition-table":
data = table_bytes()
elif name == "otadata":
data = b"\xff" * 0x2000
else:
data = b"asset contents\0"
filename = name + ".bin"
(directory / filename).write_bytes(data)
manifest[name] = {"offset": hex(offset), "file": filename, "encrypted": "false"}
manifest["flash_files"][hex(offset)] = filename
(directory / "flasher_args.json").write_text(json.dumps(manifest))
return manifest
def make_factory(directory):
directory.mkdir()
data = b"\xaa" * flash.FACTORY_SIZE
(directory / "ble_factory.bin").write_bytes(data)
manifest = {"schema_version": 1, "chip": "esp32s3", "flash_size": flash.FLASH_SIZE,
"device_id": "KP01-123456789ABC", "expected_mac": MAC,
"image": {"file": "ble_factory.bin", "offset": "0xe4e000",
"size": "0x6000", "sha256": flash.sha256(data)}}
(directory / "manifest.json").write_text(json.dumps(manifest))
return manifest
class SimulatedEsptool:
def __init__(self, *, mac=MAC, size="16MB", chip="ESP32-S3", version="4.12.0",
security="Disabled", short_backup=False, blank=False, incompatible=False,
failure=None, duplicate_mac=None):
self.mac, self.size, self.chip, self.version = mac, size, chip, version
self.security, self.short_backup, self.blank = security, short_backup, blank
self.incompatible, self.failure = incompatible, failure
self.duplicate_mac = duplicate_mac
self.calls = []
self.commands = []
def __call__(self, args, **kwargs):
self.calls.append(args)
commands = {"version", "read_mac", "flash_id", "get_security_info", "read_flash", "write_flash", "verify_flash"}
command = next(arg.replace("-", "_") for arg in args if arg.replace("-", "_") in commands)
self.commands.append(command)
output = "OK"
if command == self.failure:
return subprocess.CompletedProcess(args, 2, "Simulated failure")
if command == "version":
output = f"esptool v{self.version}\n{self.version}\n"
elif command == "read_mac":
output = f"Chip is {self.chip}\nMAC: {self.mac.lower()}\n"
if self.duplicate_mac:
output += f"Uploading stub...\nRunning stub...\nStub running...\nMAC: {self.duplicate_mac.lower()}\n"
elif command == "flash_id":
output = f"Detected flash size: {self.size}\n"
elif command == "get_security_info":
output = f"Secure Boot: {self.security}\nFlash Encryption: Disabled\n"
elif command == "read_flash":
data = bytearray(b"\xff" * flash.FLASH_SIZE)
if not self.blank:
data[flash.TABLE_OFFSET:flash.TABLE_OFFSET + 0xC00] = table_bytes()
if self.incompatible:
data[flash.TABLE_OFFSET] = 0
if self.short_backup:
data = data[:64]
Path(args[-1]).write_bytes(data)
return subprocess.CompletedProcess(args, 0, output)
class FlashSafetyTests(unittest.TestCase):
def setUp(self):
# Keep test writes in the workspace on Windows sandboxed runners.
test_parent = Path(__file__).resolve().parent
self.temporary = tempfile.TemporaryDirectory(prefix=".flash-test-", dir=test_parent)
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.assertTrue(self.root.resolve().is_relative_to(test_parent))
# ACL creation is independently tested by device_prepare; do not run
# Windows account/ACL commands for each mocked hardware case.
private = patch.object(flash, "private_directory", side_effect=lambda p: p.mkdir(mode=0o700))
self.private = private.start()
self.addCleanup(private.stop)
self.build = self.root / "build"
self.manifest = make_build(self.build)
self.factory = self.root / "factory"
self.factory_manifest = make_factory(self.factory)
def update_build_manifest(self):
(self.build / "flasher_args.json").write_text(json.dumps(self.manifest))
def plan(self, **kwargs):
return flash.build_plan(self.build, **kwargs)
def execute(self, plan=None, **kwargs):
runner = SimulatedEsptool(**kwargs)
with contextlib.redirect_stdout(io.StringIO()):
result = flash.execute_plan(plan or self.plan(reset_runtime=True), python="python", port="COM6", expect_mac=MAC,
backup_dir=self.root / "backup", runner=runner)
return result, runner
def blocked(self, *, plan=None, **kwargs):
runner = SimulatedEsptool(**kwargs)
with self.assertRaises(flash.FlashError), contextlib.redirect_stdout(io.StringIO()):
flash.execute_plan(plan or self.plan(reset_runtime=True), python="python", port="COM6", expect_mac=MAC,
backup_dir=self.root / "backup", runner=runner)
self.assertNotIn("write_flash", runner.commands)
return runner
def test_default_plan_never_opens_hardware(self):
with patch.object(flash.subprocess, "run") as run, contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(flash.main(["--build-dir", str(self.build)]), 0)
run.assert_not_called()
def test_plan_has_required_display_and_model_assets(self):
plan = self.plan()
self.assertEqual({image.name for image in plan.images}, set(flash.REGIONS))
self.assertEqual(plan.application["firmware_version"], "1.7.0-controls.28")
self.assertFalse(plan.reset_runtime)
self.assertTrue(plan.summary()["execute_requires_reset_runtime"])
def test_full_flash_requires_runtime_reset_before_any_side_effect(self):
runner = self.blocked(plan=self.plan())
self.assertEqual(runner.calls, [])
self.private.assert_not_called()
self.assertFalse((self.root / "backup").exists())
def test_factory_id_is_independent_of_hardware_mac(self):
plan = self.plan(factory_dir=self.factory, expect_mac=MAC)
self.assertEqual(plan.factory["device_id"], "KP01-123456789ABC")
def test_identity_only_and_reset_are_exact_regions(self):
plan = self.plan(factory_dir=self.factory, identity_only=True, reset_runtime=True)
self.assertEqual([(x.offset, len(x.data)) for x in plan.images], [(0x9000, 0x6000), (0xE4E000, 0x6000)])
self.assertEqual(plan.images[0].data, b"\xff" * 0x6000)
self.assertIsNone(plan.summary()["application"]["firmware_version"])
self.assertEqual(plan.summary()["reference_build"]["firmware_version"], "1.7.0-controls.28")
self.assertFalse(plan.summary()["execute_requires_reset_runtime"])
def test_identity_only_does_not_claim_reference_build_was_installed(self):
result, runner = self.execute(plan=self.plan(factory_dir=self.factory, identity_only=True))
self.assertIsNone(result["firmware_version"])
self.assertEqual(result["firmware_action"], "preserved")
self.assertIn(str(self.root / "backup" / "preserved-runtime.bin"), runner.calls[-1])
def test_acl_failure_prevents_staging_or_device_access(self):
self.private.side_effect = PermissionError("ACL failed")
runner = SimulatedEsptool()
with self.assertRaises(PermissionError):
flash.execute_plan(self.plan(reset_runtime=True), python="python", port="COM6", expect_mac=MAC,
backup_dir=self.root / "backup", runner=runner)
self.assertEqual(runner.commands, ["version"])
self.assertFalse((self.root / "backup" / "ble_factory.bin").exists())
def test_identity_only_requires_factory(self):
with self.assertRaises(flash.FlashError):
self.plan(identity_only=True)
def test_factory_mac_must_match(self):
with self.assertRaises(flash.FlashError):
self.plan(factory_dir=self.factory, expect_mac="10:20:30:40:50:60")
def test_factory_hash_tampering_is_rejected(self):
(self.factory / "ble_factory.bin").write_bytes(b"\xbb" * flash.FACTORY_SIZE)
with self.assertRaises(flash.FlashError):
self.plan(factory_dir=self.factory)
def test_factory_wrong_offset_rejected(self):
self.factory_manifest["image"]["offset"] = "0x9000"
(self.factory / "manifest.json").write_text(json.dumps(self.factory_manifest))
with self.assertRaises(flash.FlashError):
self.plan(factory_dir=self.factory)
def test_missing_eaf_rejected(self):
(self.build / "eaf.bin").unlink()
with self.assertRaises(flash.FlashError):
self.plan()
def test_build_cannot_include_nvs(self):
self.manifest["flash_files"]["0x9000"] = "runtime.bin"
self.update_build_manifest()
with self.assertRaises(flash.FlashError):
self.plan()
def test_escape_paths_rejected(self):
for path in ("../outside.bin", "C:\\outside.bin", "/outside.bin", "..\\outside.bin"):
with self.subTest(path=path), self.assertRaises(flash.FlashError):
flash.contained_file(self.build, path)
def test_image_address_mismatch_rejected(self):
self.manifest["app"]["offset"] = "0x9000"
self.update_build_manifest()
with self.assertRaises(flash.FlashError):
self.plan()
def test_oversize_app_rejected(self):
(self.build / "app.bin").write_bytes(b"\xff" * (0x400000 + 1))
with self.assertRaises(flash.FlashError):
self.plan()
def test_wrong_chip_image_rejected(self):
(self.build / "app.bin").write_bytes(executable(True, chip=2))
with self.assertRaises(flash.FlashError):
self.plan()
def test_image_checksum_rejected(self):
image = bytearray(executable(True))
image[300] ^= 1
with self.assertRaisesRegex(flash.FlashError, "checksum"):
flash.esp_image(bytes(image), True)
def test_image_sha_rejected(self):
image = bytearray(executable(True))
image[-1] ^= 1
with self.assertRaisesRegex(flash.FlashError, "SHA256"):
flash.esp_image(bytes(image), True)
def test_bad_partition_md5_rejected(self):
table = bytearray(table_bytes())
table[16] ^= 1
with self.assertRaises(flash.FlashError):
flash.partition_table(bytes(table))
def test_valid_md5_wrong_layout_rejected(self):
layout = dict(flash.LAYOUT)
layout["ota_0"] = (0, 0x10, 0x10000, 0x300000, 0)
with self.assertRaises(flash.FlashError):
flash.partition_table(table_bytes(layout))
def test_mutated_input_types_fail_closed(self):
for name in ("extra_esptool_args", "app", "flash_settings"):
with self.subTest(name=name):
old = self.manifest[name]
self.manifest[name] = None
self.update_build_manifest()
with self.assertRaises(flash.FlashError):
self.plan()
self.manifest[name] = old
def test_missing_execute_gate_prevents_io(self):
with patch.object(flash.subprocess, "run") as run, contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(flash.main(["flash", "--build-dir", str(self.build)]), 2)
run.assert_not_called()
def test_plan_execute_is_rejected(self):
with contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(flash.main(["plan", "--execute"]), 2)
def test_wrong_physical_mac_stops_before_backup(self):
runner = self.blocked(mac="10:20:30:40:50:60")
self.assertNotIn("read_flash", runner.commands)
def test_duplicate_matching_mac_from_esptool_connection_is_accepted(self):
result, _ = self.execute(duplicate_mac=MAC)
self.assertEqual(result["status"], "written_and_verified")
def test_differing_mac_lines_are_rejected(self):
runner = self.blocked(duplicate_mac="10:20:30:40:50:60")
self.assertNotIn("read_flash", runner.commands)
def test_wrong_physical_chip_stops_before_backup(self):
runner = self.blocked(chip="ESP32-C3")
self.assertNotIn("read_flash", runner.commands)
def test_wrong_flash_size_stops_before_backup(self):
runner = self.blocked(size="8MB")
self.assertNotIn("read_flash", runner.commands)
def test_secure_boot_device_is_rejected(self):
self.blocked(security="Enabled")
def test_incomplete_backup_prevents_write(self):
self.blocked(short_backup=True)
def test_incompatible_live_partition_prevents_write(self):
self.blocked(incompatible=True)
def test_blank_board_without_factory_prevents_write(self):
self.blocked(blank=True)
def test_blank_board_rejects_identity_only(self):
self.blocked(plan=self.plan(factory_dir=self.factory, identity_only=True), blank=True)
def test_blank_board_full_factory_flash_succeeds(self):
result, runner = self.execute(plan=self.plan(factory_dir=self.factory, reset_runtime=True), blank=True)
self.assertEqual(result["status"], "written_and_verified")
self.assertIn("write_flash", runner.commands)
def test_existing_backup_directory_is_never_overwritten(self):
directory = self.root / "backup"
directory.mkdir()
sentinel = directory / "sentinel.txt"
sentinel.write_text("keep")
with self.assertRaises(flash.FlashError):
self.execute()
self.assertEqual(sentinel.read_text(), "keep")
def test_full_backup_before_write_and_readback_after(self):
result, runner = self.execute()
self.assertEqual(runner.commands, ["version", "read_mac", "flash_id", "get_security_info", "read_flash", "write_flash", "verify_flash"])
directory = self.root / "backup"
manifest = json.loads((directory / "backup-manifest.json").read_text())
self.assertEqual(manifest["size"], flash.FLASH_SIZE)
self.assertEqual(manifest["sha256"], flash.sha256((directory / "before-16mb.bin").read_bytes()))
self.assertEqual(result["hardware_acceptance"], "not_tested")
for index, args in enumerate(runner.calls[1:]):
self.assertEqual(args[args.index("--after") + 1], "no_reset")
self.assertEqual(args[args.index("--before") + 1], "default_reset" if index == 0 else "no_reset")
verify = runner.calls[-1]
self.assertIn(str(directory / "reset-runtime.bin"), verify)
self.assertNotIn(str(directory / "preserved-runtime.bin"), verify)
self.assertIn(str(directory / "preserved-factory.bin"), verify)
def test_esptool_v5_uses_hyphenated_commands(self):
_, runner = self.execute(version="5.0.2")
self.assertIn("write-flash", runner.calls[-2])
self.assertIn("verify-flash", runner.calls[-1])
self.assertIn("--flash-mode", runner.calls[-2])
self.assertIn("no-reset", runner.calls[-1])
def test_write_failure_never_claims_success(self):
runner = SimulatedEsptool(failure="write_flash")
with self.assertRaises(flash.FlashError), contextlib.redirect_stdout(io.StringIO()):
flash.execute_plan(self.plan(reset_runtime=True), python="python", port="COM6", expect_mac=MAC,
backup_dir=self.root / "backup", runner=runner)
self.assertNotIn("verify_flash", runner.commands)
self.assertFalse((self.root / "backup" / "result.json").exists())
self.assertTrue((self.root / "backup" / "before-16mb.bin").is_file())
def test_verify_failure_never_claims_success(self):
runner = SimulatedEsptool(failure="verify_flash")
with self.assertRaises(flash.FlashError), contextlib.redirect_stdout(io.StringIO()):
flash.execute_plan(self.plan(reset_runtime=True), python="python", port="COM6", expect_mac=MAC,
backup_dir=self.root / "backup", runner=runner)
self.assertFalse((self.root / "backup" / "result.json").exists())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,38 @@
"""Explicit USB-only smoke test: request a fixed sentence, then hang up."""
import argparse
import time
import serial
p = argparse.ArgumentParser()
p.add_argument("--port", default="COM6")
p.add_argument("--seconds", type=int, default=60)
args = p.parse_args()
port = serial.Serial()
port.port, port.baudrate, port.timeout = args.port, 115200, 0.2
port.dtr = port.rts = False
port.open()
markers = ("PLATFORM_HTTP", "Platform error code:", "call_client:", "USB diagnostic:",
"RTC", "rtc", "volc_start", "PLAYBACK", "DOWNLINK", "AUDIO conversation", "on_volc", "rst:",
"panic", "Rebooting", "assert failed", "Guru Meditation", "conv_ai_service", "ERR|", "volc_conv.c",
"Backtrace", "PC :", "EXCVADDR", "A0 :", "Core 1 register")
try:
print("Starting explicit USB test conversation", flush=True)
port.write(b"diag-say\n")
started = time.monotonic()
stopped = False
diagnosed = False
while time.monotonic() - started < args.seconds:
if not diagnosed and time.monotonic() - started >= 10:
port.write(b"diag-state\n")
diagnosed = True
if not stopped and time.monotonic() - started >= args.seconds - 15:
port.write(b"diag-stop\n")
stopped = True
print("Requested hangup", flush=True)
line = port.readline().decode(errors="replace").strip()
if line and any(m in line for m in markers):
print(line, flush=True)
finally:
if not stopped:
port.write(b"diag-stop\n")
port.close()
@@ -0,0 +1,337 @@
/* Host-only RTOS/codec boundary stubs. Production functions are inserted below.
* Tests use synthetic byte markers, no hardware, network, or captured audio. */
#include <assert.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <errno.h>
#include <unistd.h>
#define ESP_PLATFORM 1
#define pdTRUE 1
#define pdPASS 1
#define pdMS_TO_TICKS(n) (n)
#define portMAX_DELAY UINT32_MAX
#define tskNO_AFFINITY (-1)
#define MALLOC_CAP_SPIRAM 1
#define MALLOC_CAP_8BIT 2
#define ESP_AUDIO_ERR_OK 0
#define ESP_OPUS_DEC_FRAME_DURATION_120_MS 120
#define ESP_OPUS_DEC_CONFIG_DEFAULT() {0}
#define VOLC_DATA_TYPE_AUDIO 1
#define VOLC_AUDIO_DATA_TYPE_PCM 1
#define AUDIO_DATA_TYPE_OPUS 2
#define VOLC_DATA_TYPE_MESSAGE 3
#define VOLC_MSG_CONV_STATUS 4
#define VOLC_CONV_STATUS_INTERRUPTED 5
#define MAGIC_CONV "conv"
#define MAGIC_OFFSET 8
#define LOGI(...) ((void)0)
#define LOGE(...) ((void)0)
#define HAL_SAFE_FREE(p) do {free(p); (p)=NULL;} while(0)
typedef int audio_data_type_e;
typedef int BaseType_t;
static void __rtc_diagnostic_priority_apply(void) {} /* separately tested in voice-diagnostic-host.c */
typedef int volc_audio_data_type_e;
typedef void *byte_rtc_engine_t;
typedef void *volc_opt_t;
typedef struct {int unused;} volc_room_info_t;
typedef struct {int unused;} byte_rtc_event_handler_t;
typedef void (*volc_msg_cb)(void *, void *);
typedef struct {int code;struct {int conv_status;} data;} volc_msg_t;
typedef struct {int type; struct {struct {int data_type;} audio;struct {bool is_binary;} message;} info;} volc_data_info_t;
typedef void (*volc_data_cb)(void *, const void *, int, volc_data_info_t *);
typedef struct {char *p_channel_name,*p_uid,*p_token;} volc_rtc_option_t;
typedef struct {bool auto_publish_audio,auto_publish_video,auto_subscribe_audio,auto_subscribe_video;} byte_rtc_room_options_t;
static uint64_t esp_timer_get_time(void) {
struct timespec now; clock_gettime(CLOCK_MONOTONIC,&now);
return (uint64_t)now.tv_sec*1000000+now.tv_nsec/1000;
}
static uint64_t volc_osal_get_time_ms(void) {return esp_timer_get_time()/1000;}
static void vTaskDelay(unsigned ms) {
struct timespec t={.tv_sec=ms/1000,.tv_nsec=(long)(ms%1000)*1000000};
nanosleep(&t,NULL);
}
static struct timespec deadline(unsigned ms) {
struct timespec t; clock_gettime(CLOCK_REALTIME,&t);
t.tv_sec+=ms/1000; t.tv_nsec+=(long)(ms%1000)*1000000;
if(t.tv_nsec>=1000000000){t.tv_sec++;t.tv_nsec-=1000000000;}
return t;
}
static atomic_int fail_alloc_after=-1, live_allocations, live_tasks;
static void *host_alloc(size_t n) {
int value=atomic_load(&fail_alloc_after);
if(value>=0 && atomic_fetch_sub(&fail_alloc_after,1)==0) return NULL;
void *p=calloc(1,n); assert(p); atomic_fetch_add(&live_allocations,1); return p;
}
static void host_free(void *p) {if(p){free(p);atomic_fetch_sub(&live_allocations,1);}}
static void *heap_caps_calloc(size_t n,size_t size,int caps) {
assert(caps==(MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT));return host_alloc(n*size);
}
static void heap_caps_free(void *p) {host_free(p);}
typedef struct {
pthread_mutex_t mutex; pthread_cond_t readable;
unsigned *items, capacity, head, count;
} *QueueHandle_t;
static QueueHandle_t xQueueCreate(unsigned n,unsigned item_size) {
assert(item_size==sizeof(unsigned));
QueueHandle_t q=host_alloc(sizeof(*q)+n*sizeof(unsigned)); if(!q)return NULL;
q->items=(unsigned *)(q+1);q->capacity=n;
pthread_mutex_init(&q->mutex,NULL);pthread_cond_init(&q->readable,NULL);return q;
}
static int xQueueSend(QueueHandle_t q,const unsigned *item,unsigned wait) {
assert(wait==0);assert(q);pthread_mutex_lock(&q->mutex);
int ok=q->count<q->capacity;
if(ok){q->items[(q->head+q->count)%q->capacity]=*item;q->count++;pthread_cond_signal(&q->readable);}
pthread_mutex_unlock(&q->mutex);return ok;
}
static int xQueueReceive(QueueHandle_t q,unsigned *item,unsigned wait) {
assert(q);pthread_mutex_lock(&q->mutex);
struct timespec end=deadline(wait==portMAX_DELAY?5000:wait);
while(!q->count && wait){int result=pthread_cond_timedwait(&q->readable,&q->mutex,&end);if(result==ETIMEDOUT)break;assert(result==0);}
int ok=q->count!=0;
if(ok){*item=q->items[q->head];q->head=(q->head+1)%q->capacity;q->count--;}
pthread_mutex_unlock(&q->mutex);return ok;
}
static unsigned uxQueueMessagesWaiting(QueueHandle_t q) {
pthread_mutex_lock(&q->mutex);unsigned count=q->count;pthread_mutex_unlock(&q->mutex);return count;
}
static void vQueueDelete(QueueHandle_t q) {
pthread_mutex_destroy(&q->mutex);pthread_cond_destroy(&q->readable);host_free(q);
}
typedef struct {pthread_mutex_t mutex;pthread_cond_t ready;unsigned count;} *SemaphoreHandle_t;
static SemaphoreHandle_t make_sem(unsigned count) {
SemaphoreHandle_t s=host_alloc(sizeof(*s));if(!s)return NULL;
pthread_mutex_init(&s->mutex,NULL);pthread_cond_init(&s->ready,NULL);s->count=count;return s;
}
static SemaphoreHandle_t xSemaphoreCreateMutex(void){return make_sem(1);}
static SemaphoreHandle_t xSemaphoreCreateBinary(void){return make_sem(0);}
static int xSemaphoreTake(SemaphoreHandle_t s,unsigned wait) {
assert(s);pthread_mutex_lock(&s->mutex);struct timespec end=deadline(wait==portMAX_DELAY?5000:wait);
while(!s->count && wait){int rc=pthread_cond_timedwait(&s->ready,&s->mutex,&end);if(rc==ETIMEDOUT){assert(wait!=portMAX_DELAY);break;}assert(rc==0);}
int ok=s->count!=0;if(ok)s->count--;pthread_mutex_unlock(&s->mutex);return ok;
}
static int xSemaphoreGive(SemaphoreHandle_t s) {
pthread_mutex_lock(&s->mutex);assert(s->count==0);s->count=1;
pthread_cond_signal(&s->ready);pthread_mutex_unlock(&s->mutex);return pdTRUE;
}
static void vSemaphoreDelete(SemaphoreHandle_t s) {
pthread_mutex_destroy(&s->mutex);pthread_cond_destroy(&s->ready);host_free(s);
}
typedef struct {void (*fn)(void *);void *arg;pthread_t thread;} *TaskHandle_t;
static atomic_bool worker_allowed=true;
static void *task_entry(void *arg) {
TaskHandle_t t=arg;void (*fn)(void *)=t->fn;void *context=t->arg;
while(!atomic_load(&worker_allowed))vTaskDelay(1);
fn(context);assert(!"task should delete itself");return NULL;
}
static int xTaskCreatePinnedToCoreWithCaps(void (*fn)(void *),const char *name,unsigned stack,void *arg,int priority,TaskHandle_t *out,int core,int caps) {
(void)name;assert(stack==16384 && priority==5 && core==tskNO_AFFINITY && caps==(MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT));
TaskHandle_t t=host_alloc(sizeof(*t));if(!t)return 0;t->fn=fn;t->arg=arg;*out=t;
atomic_fetch_add(&live_tasks,1);assert(!pthread_create(&t->thread,NULL,task_entry,t));return pdPASS;
}
static void vTaskSuspend(TaskHandle_t task) {assert(!task);for(;;)vTaskDelay(1000);}
static void vTaskDeleteWithCaps(TaskHandle_t task) {
assert(task);pthread_cancel(task->thread);pthread_join(task->thread,NULL);host_free(task);atomic_fetch_sub(&live_tasks,1);
}
typedef struct {int sample_rate,channel,frame_duration;} esp_opus_dec_cfg_t;
typedef struct {uint8_t *buffer;size_t len,consumed;} esp_audio_dec_in_raw_t;
typedef struct {uint8_t *buffer;size_t len,decoded_size;} esp_audio_dec_out_frame_t;
typedef struct {int unused;} esp_audio_dec_info_t;
static atomic_int decode_calls, decoder_opens, decoder_mode, decoder_open_fail;
static atomic_bool decoder_blocked;
static pthread_t test_thread;
static int esp_opus_dec_open(const esp_opus_dec_cfg_t *cfg,size_t size,void **handle) {
(void)size;assert(cfg->sample_rate==16000 && cfg->channel==1 && cfg->frame_duration==120);
if(atomic_load(&decoder_open_fail))return -1;
*handle=host_alloc(1);atomic_fetch_add(&decoder_opens,1);return *handle?0:-1;
}
static void esp_opus_dec_close(void *handle){host_free(handle);}
static int esp_opus_dec_decode(void *handle,esp_audio_dec_in_raw_t *raw,esp_audio_dec_out_frame_t *frame,esp_audio_dec_info_t *info) {
(void)info;assert(handle && raw->len && frame->len>=2);assert(!pthread_equal(pthread_self(),test_thread));
atomic_fetch_add(&decode_calls,1);while(atomic_load(&decoder_blocked))vTaskDelay(1);
int mode=atomic_load(&decoder_mode);raw->consumed=1;frame->decoded_size=2;
frame->buffer[0]=raw->buffer[0];frame->buffer[1]=0;
if(mode==1)return -1;
if(mode==2)raw->consumed=0;
if(mode==3)raw->consumed=raw->len+1;
if(mode==4)frame->decoded_size=frame->len+1;
return 0;
}
static int join_result,leave_result;
static void *byte_rtc_get_user_data(byte_rtc_engine_t engine){return engine;}
static int byte_rtc_join_room(byte_rtc_engine_t engine,const char *room,const char *uid,const char *token,byte_rtc_room_options_t *options) {
(void)engine;(void)room;(void)uid;(void)token;(void)options;return join_result;
}
static int byte_rtc_leave_room(byte_rtc_engine_t engine,const char *room){(void)engine;(void)room;return leave_result;}
static bool _is_target_message(const uint8_t *message,const char *magic){return !memcmp(message,magic,4);}
static int _on_conversion_status_message_parsed(uint8_t *message,void *rtc){(void)rtc;return *message;}
/* @PRODUCTION@ */
static atomic_uint emitted;
static atomic_uint interruptions;
static atomic_bool emit_blocked,emit_entered;
static uint8_t output[256];
static void on_pcm(void *context,const void *data,int size,volc_data_info_t *info) {
(void)context;assert(size>0 && info->type==VOLC_DATA_TYPE_AUDIO);
atomic_store(&emit_entered,true);while(atomic_load(&emit_blocked))vTaskDelay(1);
unsigned count=atomic_load(&emitted);assert(count<sizeof(output));output[count]=*(const uint8_t *)data;
atomic_store(&emitted,count+1);
}
static void on_message(void *context,void *value) {
(void)context;volc_msg_t *msg=value;
assert(msg->code==VOLC_MSG_CONV_STATUS && msg->data.conv_status==VOLC_CONV_STATUS_INTERRUPTED);
/* This models the service clearing queued PCM, not a raw-audio capture. */
atomic_store(&emitted,0);atomic_fetch_add(&interruptions,1);
}
static void wait_value(atomic_int *value,int expected) {
uint64_t end=esp_timer_get_time()+3000000;
while(atomic_load(value)<expected && esp_timer_get_time()<end)vTaskDelay(1);
assert(atomic_load(value)>=expected);
}
static void wait_emitted(unsigned count) {
uint64_t end=esp_timer_get_time()+3000000;
while(atomic_load(&emitted)<count && esp_timer_get_time()<end)vTaskDelay(1);
assert(atomic_load(&emitted)==count);
}
static void wait_free(rtc_impl_t *rtc) {
uint64_t end=esp_timer_get_time()+3000000;
while(uxQueueMessagesWaiting(rtc->rx_free)<RTC_RX_PACKET_COUNT && esp_timer_get_time()<end)vTaskDelay(1);
assert(uxQueueMessagesWaiting(rtc->rx_free)==RTC_RX_PACKET_COUNT);
}
static void reset_stubs(void) {
atomic_store(&worker_allowed,true);atomic_store(&decoder_blocked,false);
atomic_store(&decode_calls,0);atomic_store(&decoder_opens,0);atomic_store(&emitted,0);
atomic_store(&decoder_mode,0);atomic_store(&decoder_open_fail,0);atomic_store(&fail_alloc_after,-1);
atomic_store(&interruptions,0);atomic_store(&emit_blocked,false);atomic_store(&emit_entered,false);
join_result=leave_result=0;test_thread=pthread_self();memset(output,0,sizeof(output));
}
static void fixture_init(rtc_impl_t *rtc,bool allow_worker) {
reset_stubs();atomic_store(&worker_allowed,allow_worker);memset(rtc,0,sizeof(*rtc));
rtc->rtc=rtc;rtc->opus_encoder=(void *)1;rtc->data_callback=on_pcm;rtc->message_callback=on_message;
assert(__rtc_rx_init(rtc)==0);
volc_rtc_option_t opt={.p_channel_name="room-A",.p_uid="device",.p_token="fixture"};
assert(__rtc_start(rtc,&opt)==0);
rtc->b_channel_joined=rtc->b_user_joined=true;
}
static void fixture_close(rtc_impl_t *rtc) {
atomic_store(&worker_allowed,true);atomic_store(&decoder_blocked,false);leave_result=0;
atomic_store(&emit_blocked,false);
__rtc_stop(rtc);__rtc_rx_destroy(rtc);
if(rtc->opus_decoder){esp_opus_dec_close(rtc->opus_decoder);rtc->opus_decoder=NULL;}
uint64_t end=esp_timer_get_time()+3000000;
while(atomic_load(&live_tasks) && esp_timer_get_time()<end)vTaskDelay(1);
assert(atomic_load(&live_tasks)==0);assert(atomic_load(&live_allocations)==0);
}
static void packet(rtc_impl_t *rtc,const char *room,const uint8_t *bytes,size_t size) {
_on_audio_data(rtc,room,"assistant",0,AUDIO_DATA_TYPE_OPUS,bytes,size,NULL,0);
}
static void interrupt_reply(rtc_impl_t *rtc,const char *room) {
uint8_t message[9]={'c','o','n','v',0,0,0,1,VOLC_CONV_STATUS_INTERRUPTED};
_on_message_received(rtc,room,"assistant",message,sizeof(message),true);
}
static void test_owned_multiframe(void) {
rtc_impl_t rtc;fixture_init(&rtc,false);uint8_t bytes[]={11,12,13};
packet(&rtc,"room-A",bytes,sizeof(bytes));memset(bytes,99,sizeof(bytes));
assert(!atomic_load(&decode_calls));atomic_store(&worker_allowed,true);
wait_emitted(3);assert(output[0]==11 && output[1]==12 && output[2]==13);fixture_close(&rtc);
}
static void test_slow_decode_bounded_queue(void) {
rtc_impl_t rtc;fixture_init(&rtc,true);atomic_store(&decoder_blocked,true);uint8_t byte=21;
packet(&rtc,"room-A",&byte,1);wait_value(&decode_calls,1);
for(unsigned i=1;i<RTC_RX_PACKET_COUNT;i++)packet(&rtc,"room-A",&byte,1);
assert(atomic_load(&decoder_blocked));assert(uxQueueMessagesWaiting(rtc.rx_pending)==RTC_RX_PACKET_COUNT-1);
packet(&rtc,"room-A",&byte,1);assert(atomic_load(&rtc.rx_overflow)==1);
packet(&rtc,"room-A",&byte,RTC_RX_PACKET_MAX+1);packet(&rtc,"room-A",NULL,1);packet(&rtc,"room-A",&byte,0);
assert(atomic_load(&rtc.rx_oversized)==3);assert(atomic_load(&rtc.rx_producers)==0);
atomic_store(&decoder_blocked,false);wait_emitted(RTC_RX_PACKET_COUNT);fixture_close(&rtc);
}
static void test_interrupt_inflight(void) {
rtc_impl_t rtc;fixture_init(&rtc,true);atomic_store(&decoder_blocked,true);uint8_t old[]={31,32},queued=33;
packet(&rtc,"room-A",old,2);wait_value(&decode_calls,1);packet(&rtc,"room-A",&queued,1);
interrupt_reply(&rtc,"room-A");atomic_store(&decoder_blocked,false);wait_free(&rtc);assert(!atomic_load(&emitted));
uint8_t fresh=34;packet(&rtc,"room-A",&fresh,1);wait_emitted(1);assert(output[0]==34);assert(atomic_load(&decoder_opens)==2);
fixture_close(&rtc);
}
static void test_interrupt_after_final_check(void) {
rtc_impl_t rtc;fixture_init(&rtc,true);atomic_store(&emit_blocked,true);uint8_t old=35;
packet(&rtc,"room-A",&old,1);
uint64_t end=esp_timer_get_time()+3000000;
while(!atomic_load(&emit_entered) && esp_timer_get_time()<end)vTaskDelay(1);
assert(atomic_load(&emit_entered));
/* The decoder's final epoch check has passed and its PCM delivery callback
* is paused immediately before the simulated player's enqueue operation. */
interrupt_reply(&rtc,"room-A");assert(atomic_load(&interruptions)==0);
uint8_t fresh=36;packet(&rtc,"room-A",&fresh,1);atomic_store(&emit_blocked,false);
wait_free(&rtc);assert(atomic_load(&interruptions)==1);
assert(atomic_load(&emitted)==1 && output[0]==36);fixture_close(&rtc);
}
typedef struct {rtc_impl_t *rtc;atomic_bool done;int result;} stop_arg;
static void *stop_call(void *arg) {stop_arg *a=arg;a->result=__rtc_stop(a->rtc);atomic_store(&a->done,true);return NULL;}
static void test_stop_race_and_room(void) {
rtc_impl_t rtc;fixture_init(&rtc,true);atomic_store(&decoder_blocked,true);uint8_t old=41;
packet(&rtc,"room-A",&old,1);wait_value(&decode_calls,1);packet(&rtc,"room-A",&old,1);
stop_arg arg={.rtc=&rtc};pthread_t stopper;assert(!pthread_create(&stopper,NULL,stop_call,&arg));
uint64_t end=esp_timer_get_time()+3000000;while(atomic_load(&rtc.rx_accepting) && esp_timer_get_time()<end)vTaskDelay(1);
assert(!atomic_load(&rtc.rx_accepting));assert(!atomic_load(&arg.done));
packet(&rtc,"room-A",&old,1);atomic_store(&decoder_blocked,false);pthread_join(stopper,NULL);
assert(arg.result==0 && atomic_load(&arg.done));wait_free(&rtc);assert(!atomic_load(&emitted));
volc_rtc_option_t opt={.p_channel_name="room-B",.p_uid="device",.p_token="fixture"};assert(__rtc_start(&rtc,&opt)==0);
packet(&rtc,"room-A",&old,1);uint8_t fresh=42;packet(&rtc,"room-B",&fresh,1);
wait_emitted(1);assert(output[0]==42);fixture_close(&rtc);
}
static void test_join_leave_failures(void) {
rtc_impl_t rtc;fixture_init(&rtc,true);assert(__rtc_stop(&rtc)==0);
volc_rtc_option_t opt={.p_channel_name="room-retry",.p_uid="device",.p_token="fixture"};join_result=-9;
assert(__rtc_start(&rtc,&opt)==-9);assert(!atomic_load(&rtc.rx_accepting));assert(!rtc.b_pipeline_started);
uint8_t byte=51;packet(&rtc,"room-retry",&byte,1);join_result=0;assert(__rtc_start(&rtc,&opt)==0);
packet(&rtc,"room-retry",&byte,1);wait_emitted(1);leave_result=-8;
assert(__rtc_stop(&rtc)==-8);assert(!atomic_load(&rtc.rx_accepting));assert(rtc.b_pipeline_started);
packet(&rtc,"room-retry",&byte,1);wait_free(&rtc);assert(atomic_load(&emitted)==1);fixture_close(&rtc);
}
static void test_decoder_rejects_invalid_progress(void) {
rtc_impl_t rtc;fixture_init(&rtc,true);uint8_t byte=61;
for(int mode=1;mode<=4;mode++){
atomic_store(&decoder_mode,mode);packet(&rtc,"room-A",&byte,1);wait_free(&rtc);assert(!atomic_load(&emitted));
}
atomic_store(&decoder_mode,0);packet(&rtc,"room-A",&byte,1);wait_emitted(1);fixture_close(&rtc);
}
static void test_decoder_open_failure_retry(void) {
rtc_impl_t rtc;fixture_init(&rtc,true);uint8_t byte=71;atomic_store(&decoder_open_fail,1);
packet(&rtc,"room-A",&byte,1);wait_free(&rtc);assert(!atomic_load(&emitted));
atomic_store(&decoder_open_fail,0);packet(&rtc,"room-A",&byte,1);wait_emitted(1);fixture_close(&rtc);
}
static void test_allocation_failures(void) {
for(int failure=0;failure<6;failure++){
reset_stubs();rtc_impl_t rtc={0};atomic_store(&fail_alloc_after,failure);
assert(__rtc_rx_init(&rtc)==-1);atomic_store(&fail_alloc_after,-1);__rtc_rx_destroy(&rtc);
assert(atomic_load(&live_allocations)==0 && atomic_load(&live_tasks)==0);
}
}
static void test_non_opus_passthrough(void) {
reset_stubs();rtc_impl_t rtc={.b_pipeline_started=true,.b_channel_joined=true,.b_user_joined=true,.data_callback=on_pcm};
uint8_t byte=81;_on_audio_data(&rtc,"room", "assistant",0,VOLC_AUDIO_DATA_TYPE_PCM,&byte,1,NULL,0);
assert(atomic_load(&emitted)==1 && output[0]==81 && atomic_load(&decode_calls)==0);
}
int main(void) {
test_owned_multiframe();puts("PASS owned packet + multi-frame input");
test_slow_decode_bounded_queue();puts("PASS callback returns during blocked decoder + bounds + full queue");
test_interrupt_inflight();puts("PASS interrupt invalidates in-flight and queued reply");
test_interrupt_after_final_check();puts("PASS interrupt serializes final-check/delivery race and fresh epoch");
test_stop_race_and_room();puts("PASS stop waits + old room cannot emit into new call");
test_join_leave_failures();puts("PASS join retry + failed leave still closes delivery");
test_decoder_rejects_invalid_progress();puts("PASS decoder error/progress/output bounds");
test_decoder_open_failure_retry();puts("PASS decoder open failure retries safely");
test_allocation_failures();puts("PASS all allocation failure cleanup");
test_non_opus_passthrough();puts("PASS non-Opus passthrough");
return 0;
}
@@ -0,0 +1,36 @@
// Extract production queue/lifecycle/decoder functions; no model implementation.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import {fileURLToPath} from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const source = path.resolve(here, '../components/volc_conv_ai/src/transports/high_quality/src/volc_rtc.c');
const raw = fs.readFileSync(source);
const text = raw.toString('utf8').replaceAll('\r\n', '\n');
function between(start, end) {
const a=text.indexOf(start), b=text.indexOf(end,a);
if(a<0 || b<0) throw new Error(`Missing production marker ${start}`);
return text.slice(a,b+end.length);
}
function func(signature, terminator='\n}') { return between(signature,terminator); }
const production = [
between('/* Own complete transport packets:', '} rtc_rx_packet_t;'),
between('typedef struct {\n bool b_pipeline_started;', '} rtc_impl_t;'),
func('static void __send_data_2_user('),
func('static void _send_message_2_user('),
between('/* RTC_RX_HOST_BEGIN:', '/* RTC_RX_HOST_END */'),
func('static int __rtc_start('),
func('static int __rtc_stop('),
func('static void _on_audio_data(', '\n};'),
func('static void _on_message_received(', '\n};'),
].join('\n\n');
const template = fs.readFileSync(path.join(here,'test_rtc_rx_host.c.in'),'utf8');
if(template.split('/* @PRODUCTION@ */').length!==2) throw new Error('Invalid host template');
const output = path.resolve(process.argv[2] ?? path.join(here,'rtc-rx-host.c'));
fs.mkdirSync(path.dirname(output),{recursive:true});
fs.writeFileSync(output, template.replace('/* @PRODUCTION@ */',production));
const manifest={source,source_sha256:crypto.createHash('sha256').update(raw).digest('hex'),output,
host_sha256:crypto.createHash('sha256').update(fs.readFileSync(output)).digest('hex'),
build:'gcc -std=c11 -D_POSIX_C_SOURCE=200809L -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer -pthread rtc-rx-host.c -o rtc-rx-host'};
fs.writeFileSync(output+'.json',JSON.stringify(manifest,null,2)+'\n');
console.log(JSON.stringify(manifest));
@@ -0,0 +1,166 @@
/* Synthetic-only SDK/RTOS boundaries. No device, network or captured speech. */
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdatomic.h>
#include <string.h>
#include <stdarg.h>
static void host_log(const char *fmt, ...) {(void)fmt;}
#define LOGI(...) host_log(__VA_ARGS__)
#define ESP_LOGI(tag,...) host_log(__VA_ARGS__)
#define pdFALSE 0
#define eInvalid 0
#define eDeleted 1
#define ESP_PLATFORM 1
typedef unsigned UBaseType_t;
typedef struct {unsigned number, priority, base; int core;} host_task_t;
typedef host_task_t *TaskHandle_t;
typedef struct {unsigned xTaskNumber,uxBasePriority;int eCurrentState;} TaskStatus_t;
static host_task_t rtc_task={42,4,4,1}, conv_task={43,5,5,1};
static bool rtc_present=true, conv_present;
static bool engine_ready=true, publish_fail;
static const char *current_name="VolcRTCMain";
static unsigned priority_writes, start_events, tts_sends, audio_sends, creates;
static bool authorized=true, rtc_mode=true, create_fail;
static int tts_result;
static TaskHandle_t xTaskGetHandle(const char *name) {
if(!strcmp(name,"VolcRTCMain")) return rtc_present?&rtc_task:NULL;
if(!strcmp(name,"conv_ai")) return conv_present?&conv_task:NULL;
assert(0);return NULL;
}
static void vTaskGetInfo(TaskHandle_t task,TaskStatus_t *s,int a,int b) {assert(!task);(void)a;(void)b;s->xTaskNumber=rtc_task.number;s->uxBasePriority=rtc_task.base;s->eCurrentState=eInvalid;}
static unsigned uxTaskPriorityGet(TaskHandle_t task) {assert(!task);return rtc_task.priority;}
static void vTaskPrioritySet(TaskHandle_t task,unsigned priority) {
assert(!task);
bool inherited=rtc_task.priority>rtc_task.base;
rtc_task.base=priority;
if(!inherited || rtc_task.priority<priority) rtc_task.priority=priority;
priority_writes++;
}
static int xTaskGetCoreID(TaskHandle_t task) {assert(!task);return rtc_task.core;}
static TaskHandle_t xTaskGetCurrentTaskHandle(void) {return &rtc_task;}
static const char *pcTaskGetName(TaskHandle_t task) {assert(!task);return current_name;}
static uint64_t esp_timer_get_time(void) {return 987654321;}
enum {VOLC_SERVICE_AI_CONVERSATION=1,VOLC_SERVICE_AI_CONVERSATION_START,VOLC_SERVICE_AI_CONVERSATION_INTERRUPT,
VOLC_SERVICE_AI_CONVERSATION_QUIT,VOLC_SERVICE_AI_CONVERSATION_OVER,VOLC_SERVICE_AI_CONVERSATION_TOGGLE};
static int aios_event_pub(int event,void *a,void *b) {assert(event==VOLC_SERVICE_AI_CONVERSATION);assert(!a&&!b);start_events++;return publish_fail?-1:0;}
static bool call_client_is_engine_ready(void) {return engine_ready;}
bool conv_ai_service_diagnostic_active(void);
typedef void *volc_hal_capture_t;
typedef struct {int data_type;} volc_hal_frame_info_t;
typedef struct {bool commit;int data_type;} volc_audio_frame_info_t;
enum {VOLC_AUDIO_DATA_TYPE_PCM=11,VOLC_AGENT_TYPE_TTS=12};
static struct {void *engine;int wait_time;} conv_service={(void*)1,0};
static atomic_bool is_stop,stop_requested;
static bool platform_conversation_audio_authorized(void) {return authorized;}
bool platform_runtime_rtc(void) {return rtc_mode;}
static uint64_t host_now_ms=1234;
static uint64_t volc_osal_get_time_ms(void) {return host_now_ms;}
typedef struct {int type;} esp_gmf_afe_evt_t;
enum {ESP_GMF_AFE_EVT_VAD_START=1,ESP_GMF_AFE_EVT_VAD_END};
static int get_rssi(void) {return -10;}
static int volc_send_audio_data(void *engine,const void *data,int len,volc_audio_frame_info_t *info) {
assert(engine==conv_service.engine&&data&&len>0&&!info->commit);audio_sends++;return 0;
}
static int volc_send_text_to_agent(void *engine,const char *text,int type,int interrupt) {
assert(engine==conv_service.engine&&type==VOLC_AGENT_TYPE_TTS&&interrupt==1);
assert(strstr(text,"这是连续语音播放测试。")&&strstr(text,"平稳地读完。"));tts_sends++;return tts_result;
}
typedef int aios_ret_t;
typedef struct {int id;} aios_event_t;
typedef struct {void *conv_thread_id;} volc_conv_service_manager_t;
typedef struct {void *display_handle;} volc_hal_context_t;
typedef void *volc_hal_display_t;
static volc_hal_context_t hal_context={NULL};
static volc_hal_context_t *volc_get_global_hal_context(void) {return &hal_context;}
enum {AIOS_Ret_NotHandled,AIOS_Ret_Handled,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT};
typedef struct {char name[32];int stack_size,priority,stack_in_ext;} volc_osal_thread_param_t;
static void volc_hal_display_set_content(void *h,int obj,int kind,const char *text) {(void)h;(void)obj;(void)kind;(void)text;}
static void diagnostic_prepare(void);
static void conv_ai_service_prepare_task(void) {diagnostic_prepare();}
static void conv_ai_service_task(void *arg) {(void)arg;}
void conv_ai_service_diagnostic_reset(void);
static void volc_osal_thread_destroy(void *handle) {(void)handle;}
static void vTaskDeleteWithCaps(TaskHandle_t handle) {assert(handle==&conv_task);conv_present=false;}
static void conv_ai_service_task_stop(void) {conv_ai_service_diagnostic_reset();}
static int volc_osal_thread_create(void **handle,volc_osal_thread_param_t *p,void (*f)(void*),void *arg) {
(void)p;(void)f;(void)arg;creates++;if(create_fail)return -1;*handle=&conv_task;conv_present=true;return 0;
}
/* @PRODUCTION@ */
static void capture(void) {
int16_t pcm[8]={1,2,3,4,5,6,7,8};volc_hal_frame_info_t info={VOLC_AUDIO_DATA_TYPE_PCM};
__audio_capture_cb(NULL,pcm,sizeof(pcm),&info);
}
int main(void) {
// Execute the real VAD + PCM callback: silence does not postpone idle,
// continuous detected speech does, and its end starts a fresh minute.
is_stop=false;stop_requested=false;call_idle_activity();
host_now_ms+=59000;capture();assert(!call_idle_expired());
host_now_ms+=1000;capture();assert(call_idle_expired());
esp_gmf_afe_evt_t vad={ESP_GMF_AFE_EVT_VAD_START};
call_idle_capture_event(&vad,NULL);
for(int tick=0;tick<180;tick++){host_now_ms+=1000;capture();assert(!call_idle_expired());}
vad.type=ESP_GMF_AFE_EVT_VAD_END;call_idle_capture_event(&vad,NULL);
host_now_ms+=60000;capture();assert(call_idle_expired());
audio_sends=0;
rtc_task.priority=rtc_task.base=3;
current_name="worker";__rtc_diagnostic_priority_apply();assert(priority_writes==0&&rtc_task.base==3);
diagnostic_rtc_priority(false);diagnostic_rtc_priority(true);assert(priority_writes==0);
current_name="VolcRTCMain";__rtc_diagnostic_priority_apply();
assert(rtc_task.priority==6&&rtc_task.base==6&&priority_writes==1&&!conv_ai_service_diagnostic_active());
unsigned writes=priority_writes;__rtc_diagnostic_priority_apply();assert(priority_writes==writes);
rtc_task.priority=rtc_task.base=7;__rtc_diagnostic_priority_apply();assert(rtc_task.base==7&&priority_writes==writes);
rtc_task.priority=8;rtc_task.base=3; // inherited effective priority is not the configured base
__rtc_diagnostic_priority_apply();assert(rtc_task.base==6&&rtc_task.priority==8);
rtc_task.priority=rtc_task.base;__rtc_diagnostic_priority_apply();assert(rtc_task.priority==6);
rtc_task.number++;rtc_task.priority=rtc_task.base=3; // a new same-name task must receive the floor too
__rtc_diagnostic_priority_apply();assert(rtc_task.base==6);
rtc_task.priority=rtc_task.base=4;__rtc_diagnostic_priority_apply();assert(rtc_task.base==6);
writes=priority_writes;diagnostic_rtc_priority(false);diagnostic_rtc_priority(true);
conv_ai_service_diagnostic_reset();diagnostic_close();diagnostic_prepare();
__rtc_diagnostic_priority_apply();assert(rtc_task.base==6&&priority_writes==writes);
volc_rtc_diagnostic_snapshot();assert(atomic_load(&diagnostic_rtc_seen));
assert(atomic_load(&diagnostic_rtc_base)==6&&priority_writes==writes);
puts("PASS normal-call self-only floor, higher base, inherited effective priority, new task, no diagnostic override");
capture();assert(audio_sends==1);
conv_ai_service_diagnostic_tts();assert(start_events==1&&diagnostic_uplink_suppressed());
capture();assert(audio_sends==1);
assert(diagnostic_take_pending()==DIAGNOSTIC_TTS&&diagnostic_uplink_suppressed());
diagnostic_send_tts();assert(tts_sends==1&&diagnostic_uplink_suppressed());
conv_present=true;conv_ai_service_diagnostic_tts();assert(start_events==1);
assert(diagnostic_take_pending()==DIAGNOSTIC_TTS);diagnostic_send_tts();assert(tts_sends==2);
assert(diagnostic_take_pending()==DIAGNOSTIC_NONE);capture();assert(audio_sends==1);
conv_ai_service_diagnostic_say();assert(start_events==1&&!diagnostic_uplink_suppressed());
assert(diagnostic_take_pending()==DIAGNOSTIC_SAY);capture();assert(audio_sends==2);
conv_ai_service_diagnostic_tts();diagnostic_rtc_priority(true);conv_ai_service_diagnostic_reset();__rtc_diagnostic_priority_apply();
assert(diagnostic_take_pending()==DIAGNOSTIC_NONE&&!diagnostic_uplink_suppressed()&&rtc_task.priority==6);
capture();assert(audio_sends==3);
puts("PASS fixed TTS bypass, same-room repetition, mic-only gate, say/reset restores uplink");
conv_ai_service_diagnostic_tts();tts_result=-1;diagnostic_send_tts();assert(!diagnostic_uplink_suppressed());
assert(diagnostic_take_pending()==DIAGNOSTIC_NONE);
rtc_mode=false;conv_ai_service_diagnostic_tts();unsigned sends=tts_sends;diagnostic_send_tts();
assert(tts_sends==sends&&!diagnostic_uplink_suppressed());rtc_mode=true;tts_result=0;
conv_present=false;create_fail=true;volc_conv_service_manager_t manager={0};
aios_event_t event={VOLC_SERVICE_AI_CONVERSATION_START};
conv_ai_service_diagnostic_tts();diagnostic_rtc_priority(true);
assert(__state_conversation(&manager,&event)==AIOS_Ret_Handled&&creates==1&&!manager.conv_thread_id);
assert(!diagnostic_uplink_suppressed()&&diagnostic_take_pending()==DIAGNOSTIC_NONE&&rtc_task.priority==6);
authorized=false;capture();assert(audio_sends==3);
puts("PASS send failure, unsupported transport and actual task-create failure restore diagnostics");
conv_present=true;conv_ai_service_diagnostic_tts();diagnostic_close();
conv_ai_service_diagnostic_tts();assert(!diagnostic_uplink_suppressed()&&!conv_ai_service_diagnostic_active());
assert(diagnostic_take_pending()==DIAGNOSTIC_NONE);
diagnostic_rtc_priority(true);assert(rtc_task.priority==6);
conv_ai_service_diagnostic_reset();conv_ai_service_diagnostic_tts(); // reset must preserve closing gate
assert(!diagnostic_uplink_suppressed());
conv_present=false;publish_fail=true;conv_ai_service_diagnostic_tts();
assert(!diagnostic_uplink_suppressed()&&!conv_ai_service_diagnostic_active());publish_fail=false;
conv_ai_service_diagnostic_tts();assert(diagnostic_uplink_suppressed());diagnostic_prepare();
assert(diagnostic_take_pending()==DIAGNOSTIC_TTS);diagnostic_close();diagnostic_prepare();
assert(!diagnostic_uplink_suppressed()&&!conv_ai_service_diagnostic_active());
puts("PASS closing request rejection, no priority pollution, event failure and next-call reset");
puts("voice diagnostic host PASS");return 0;
}
@@ -0,0 +1,45 @@
// Extract actual USB diagnostic, microphone callback and manager failure path.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import {fileURLToPath} from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(here, '..');
const names = ['main/conv_ai_embedded_kit.c',
'components/application/service/conv_service/src/volc_conv.c',
'components/application/service/conv_service/src/volc_conv_service.c',
'components/volc_conv_ai/src/transports/high_quality/src/volc_rtc.c'];
const sha = b => crypto.createHash('sha256').update(b).digest('hex');
const raw = names.map(p => fs.readFileSync(path.join(root,p)));
const texts = raw.map(b => b.toString('utf8').replaceAll('\r\n','\n'));
function between(text,start,end) {
const a=text.indexOf(start), b=text.indexOf(end,a);
if(a<0 || b<0) throw new Error('Missing source marker '+start);
return text.slice(a,b+end.length);
}
const production = [
'#define CONV_SERVICE_TIMEOUT 60\n' + between(texts[1],'/* CALL_IDLE_BEGIN:', '/* CALL_IDLE_END */'),
between(texts[0],'/* RTC_PRIORITY_DIAGNOSTIC_BEGIN:', '/* RTC_PRIORITY_DIAGNOSTIC_END */'),
between(texts[1],'/* VOICE_DIAGNOSTIC_BEGIN:', '/* VOICE_DIAGNOSTIC_END */'),
between(texts[3],'/* RTC_SELF_PRIORITY_BEGIN:', '/* RTC_SELF_PRIORITY_END */'),
// Existing capture callback accepts a positive int length; the cast retains
// its loop bound and makes that conversion explicit for host -Wsign-compare.
between(texts[1],'static void __audio_capture_cb(', '\n}').replace('i < len / sizeof(int16_t)', 'i < (int)(len / sizeof(int16_t))'),
between(texts[2],'static aios_ret_t __state_conversation(', '\n}'),
].join('\n\n');
// Verify real end/error paths still route through the tested reset function.
for (const start of ['CONV_AI_QUIT:', 'void conv_ai_service_task_stop(']) {
const tail=texts[1].slice(texts[1].indexOf(start));
if(!tail.slice(0,1500).includes('diagnostic_close();')) throw new Error('Missing reset '+start);
}
if(!between(texts[1],'void conv_ai_service_prepare_task(', '\n}').includes('diagnostic_prepare();')) throw new Error('Missing prepare gate');
const template=fs.readFileSync(path.join(here,'test_voice_diagnostic_host.c.in'),'utf8');
if(template.split('/* @PRODUCTION@ */').length!==2) throw new Error('Invalid template');
const output=path.resolve(process.argv[2] ?? path.join(here,'voice-diagnostic-host.c'));
fs.mkdirSync(path.dirname(output),{recursive:true});
fs.writeFileSync(output,template.replace('/* @PRODUCTION@ */',production));
const manifest={source_rows:names.map((p,i)=>({path:path.join(root,p),sha256:sha(raw[i])})),
output,host_sha256:sha(fs.readFileSync(output)),
build:'clang -std=c11 -g -O1 -fsanitize=undefined -fno-omit-frame-pointer voice-diagnostic-host.c -o voice-diagnostic-host'};
fs.writeFileSync(output+'.json',JSON.stringify(manifest,null,2)+'\n');
console.log(JSON.stringify(manifest));