feat: 链路控制、延迟校准与长测工具,补全文档
- 固件:新增 link_control / stream_control,覆盖运行中 Wi-Fi 断线恢复、取帧节拍、 温控与质量/帧率减载;UDP 发送增加有界重试预算与帧期限,PSRAM JPEG 直发减少拷贝。 - 协议:重组器严格校验分片布局,统计未收齐、超时与完全缺失的帧,处理帧号回绕与设备重启。 - 桌面端:视频改 latest-value 通道并前置帧时间标记,拒绝积压和慢解码的过期画面;新增 延迟标记页与 --latency-report 校准,完善录制、服务端与退出流程。 - 文档与脚本:新增硬件、开发、延迟、功耗、实时性、稳定性文档和改动记录,重写根 README; 新增构建、性能、延迟与串口长测脚本,以及长测客户端脚本。 - 构建:固定 esp-1.97.0.0 工具链,分区表随构建复制,更新 sdkconfig 与忽略规则。
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize opt-in client marker reports using explicit, half-open row ranges."""
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
|
||||
|
||||
def describe(values):
|
||||
ordered = sorted(values)
|
||||
if not ordered:
|
||||
raise ValueError('No valid marker samples in the selected range')
|
||||
return {
|
||||
'count': len(ordered),
|
||||
'mean': statistics.mean(ordered),
|
||||
'p50': statistics.median(ordered),
|
||||
'p95': ordered[int((len(ordered)-1)*.95)],
|
||||
'p99': ordered[int((len(ordered)-1)*.99)],
|
||||
'minimum': ordered[0],
|
||||
'maximum': ordered[-1],
|
||||
}
|
||||
|
||||
|
||||
def summarize(rows, selection):
|
||||
start, end = selection['start_row'], selection['end_row']
|
||||
if not 0 <= start < end <= len(rows):
|
||||
raise ValueError('Range must select complete rows from the report')
|
||||
samples = rows[start:end]
|
||||
seen = set()
|
||||
for sample in samples:
|
||||
if not all(isinstance(v, (int, float)) and math.isfinite(v) for v in sample.values()):
|
||||
raise ValueError('Sample contains a nonfinite value')
|
||||
if not 0 <= sample['lowerMs'] <= sample['upperMs'] <= 5000:
|
||||
raise ValueError('Invalid refresh bracket')
|
||||
key = (sample['counter'], sample['sourceDrawMs'])
|
||||
if key in seen:
|
||||
raise ValueError('A source marker was counted more than once')
|
||||
seen.add(key)
|
||||
return {
|
||||
**selection,
|
||||
'samples': len(samples),
|
||||
'sample_span_seconds': (samples[-1]['drawMs']-samples[0]['drawMs'])/1000,
|
||||
'marker_event_to_paint_ms': describe([(s['lowerMs']+s['upperMs'])/2 for s in samples]),
|
||||
'lower_bound_ms': describe([s['lowerMs'] for s in samples]),
|
||||
'upper_bound_ms': describe([s['upperMs'] for s in samples]),
|
||||
'refresh_bracket_width_ms': describe([s['upperMs']-s['lowerMs'] for s in samples]),
|
||||
'decoded_to_draw_ms': describe([s['decodeToDrawMs'] for s in samples]),
|
||||
'marker_analysis_ms': describe([s['analysisMs'] for s in samples]),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--report', type=Path, required=True)
|
||||
parser.add_argument('--range', type=Path, action='append', required=True, dest='ranges')
|
||||
parser.add_argument('--out', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
rows = [json.loads(line) for line in args.report.read_text().splitlines() if line.strip()]
|
||||
trials = [summarize(rows, json.loads(path.read_text())) for path in args.ranges]
|
||||
result = {
|
||||
'method': '50 ms source marker events; first decodable echo on production VideoSurface; midpoint of adjacent-rAF brackets',
|
||||
'scope': 'Source-screen marker to camera exposure/capture, Wi-Fi/UDP, native reassembly, local WebSocket, JPEG decode and canvas draw',
|
||||
'limitations': [
|
||||
'rAF brackets estimate display scheduling; panel scanout and pixel response are not measured',
|
||||
'Mixed-exposure or unrecognized markers are excluded; first decodable echo can follow an earlier ambiguous image',
|
||||
'Not every displayed frame is a sample; accepted samples describe this calibration scene and network',
|
||||
'decodeToDrawMs starts after JPEG decoding, not at UDP or WebSocket arrival',
|
||||
],
|
||||
'physical_panel_response_measured': False,
|
||||
'trials': trials,
|
||||
}
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
for trial in trials:
|
||||
print(json.dumps({k: trial[k] for k in ('fb_count', 'samples', 'marker_event_to_paint_ms')}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
# Build the ESP32-S3 firmware from PowerShell without executing .env as code.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$EnvFile = '.env',
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$CargoArgs = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
Push-Location (Split-Path -Parent $PSScriptRoot)
|
||||
$buildEnvironment = @{}
|
||||
foreach ($name in @('CARGO_WORKSPACE_DIR', 'ESP_IDF_GLOB_BASE', 'ESP_IDF_GLOB_PARTITIONS')) {
|
||||
$buildEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
}
|
||||
try {
|
||||
if (Test-Path -LiteralPath $EnvFile) {
|
||||
$lineNumber = 0
|
||||
foreach ($line in Get-Content -LiteralPath $EnvFile -Encoding UTF8) {
|
||||
$lineNumber++
|
||||
if ($line -match '^\s*(#|$)') { continue }
|
||||
if ($line -notmatch '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$') {
|
||||
throw "Invalid assignment in ${EnvFile}:${lineNumber}"
|
||||
}
|
||||
$key = $Matches[1]
|
||||
$value = $Matches[2].Trim()
|
||||
if ($value.StartsWith("'") -or $value.StartsWith('"')) {
|
||||
$quote = $value.Substring(0, 1)
|
||||
if ($value.Length -lt 2 -or -not $value.EndsWith($quote)) {
|
||||
throw "Unclosed quote in ${EnvFile}:${lineNumber}"
|
||||
}
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
} else {
|
||||
$value = ($value -replace '\s+#.*$', '').TrimEnd()
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable($key, $value, 'Process')
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:WIFI_SSID) -or $env:WIFI_SSID -eq 'your-ssid') {
|
||||
throw 'Set WIFI_SSID in .env (copy .env.example) or the environment before building.'
|
||||
}
|
||||
if ($null -eq $env:WIFI_PASS) { $env:WIFI_PASS = '' }
|
||||
|
||||
$env:CARGO_WORKSPACE_DIR = (Get-Location).Path
|
||||
$env:ESP_IDF_GLOB_BASE = (Get-Location).Path
|
||||
$env:ESP_IDF_GLOB_PARTITIONS = 'partitions.csv'
|
||||
|
||||
& cargo build --release --locked @CargoArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit $LASTEXITCODE)." }
|
||||
} finally {
|
||||
foreach ($name in $buildEnvironment.Keys) {
|
||||
[Environment]::SetEnvironmentVariable($name, $buildEnvironment[$name], 'Process')
|
||||
}
|
||||
Pop-Location
|
||||
}
|
||||
+9
-5
@@ -1,14 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the firmware with the credentials from .env (see .env.example).
|
||||
#
|
||||
# ESP-IDF location comes from .cargo/config.toml (IDF_PATH /
|
||||
# ESP_IDF_TOOLS_INSTALL_DIR), so no ESP-IDF activation is needed here as long as
|
||||
# those point at an installation you already have. Drop them to let esp-idf-sys
|
||||
# fetch its own pinned ESP-IDF.
|
||||
# esp-idf-sys downloads the pinned ESP-IDF automatically. Set IDF_PATH in the
|
||||
# environment to reuse an existing installation (see README.md).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Resolve the SDK defaults/components from this repository even when the build
|
||||
# cache is elsewhere, and copy the partition table into the CMake project.
|
||||
export CARGO_WORKSPACE_DIR="$PWD"
|
||||
export ESP_IDF_GLOB_BASE="$PWD"
|
||||
export ESP_IDF_GLOB_PARTITIONS="partitions.csv"
|
||||
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
@@ -19,4 +23,4 @@ fi
|
||||
: "${WIFI_SSID:?set WIFI_SSID in .env (or export it) before building}"
|
||||
export WIFI_PASS="${WIFI_PASS:-}"
|
||||
|
||||
cargo build --release "$@"
|
||||
cargo build --release --locked "$@"
|
||||
|
||||
+5
-2
@@ -16,6 +16,9 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
readonly BAD_FLASH_ADDR="${BAD_FLASH_ADDR:-0xFADE7}"
|
||||
readonly ALIGN="${ALIGN:-0x10000}"
|
||||
# The same module also showed a high-bit write mismatch at 0x10863. Keep the application
|
||||
# above its sector; this does not remove the separate 0xFADE7 constraint.
|
||||
readonly MIN_APP_OFFSET="${MIN_APP_OFFSET:-0x20000}"
|
||||
readonly MAX_APP_SIZE="${MAX_APP_SIZE:-0x120000}"
|
||||
|
||||
MONITOR_SECS=25
|
||||
@@ -47,7 +50,7 @@ data = open("/tmp/esp32-cam-app.bin", "rb").read()
|
||||
bad = $BAD_FLASH_ADDR
|
||||
align = $ALIGN
|
||||
flash_size = 16 * 1024 * 1024
|
||||
for off in range(align, flash_size - len(data), align):
|
||||
for off in range(max(align, $MIN_APP_OFFSET), flash_size - len(data), align):
|
||||
at = bad - off
|
||||
if at < 0 or at >= len(data):
|
||||
continue
|
||||
@@ -125,4 +128,4 @@ while time.time() < end:
|
||||
sys.stdout.write(d.decode("utf-8", "replace"))
|
||||
sys.stdout.flush()
|
||||
PY
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure real UDP video throughput, complete-frame loss and receive jitter.
|
||||
|
||||
Uses an ephemeral port by default so it can run beside the desktop client.
|
||||
The timings are receive-side metrics, not camera-to-display latency.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
|
||||
from udp_probe import decode
|
||||
|
||||
PORT = 5600
|
||||
FRAGMENT = 1400
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
if not values:
|
||||
return None
|
||||
values = sorted(values)
|
||||
return round(values[int((len(values) - 1) * p)], 3)
|
||||
|
||||
|
||||
def summary(source, duration):
|
||||
counts = source['counts']
|
||||
frames = counts['complete_frames']
|
||||
dropped = counts['superseded_frames'] + counts['missing_frames'] + counts['invalid_jpeg'] + counts['timed_out_frames']
|
||||
span = source['last_complete'] - source['first_complete'] if frames > 1 else 0
|
||||
return dict(
|
||||
address=source['address'], counts=dict(counts),
|
||||
window_fps=round(frames / duration, 3) if duration else 0,
|
||||
active_fps=round((frames - 1) / span, 3) if span else 0,
|
||||
frame_loss_percent=round(100 * dropped / max(1, frames + dropped), 3),
|
||||
resolution=source.get('resolution'),
|
||||
mean_jpeg_bytes=round(counts['jpeg_bytes'] / frames) if frames else 0,
|
||||
inter_frame_p50_ms=percentile(source['intervals'], .5),
|
||||
inter_frame_p95_ms=percentile(source['intervals'], .95),
|
||||
inter_frame_max_ms=round(max(source['intervals']), 3) if source['intervals'] else None,
|
||||
assembly_p95_ms=percentile(source['assembly'], .95),
|
||||
pending_frames=int(source['pending'] is not None),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('host', help='Board IPv4 address; avoids VPN limited-broadcast routing')
|
||||
parser.add_argument('--seconds', type=float, default=30)
|
||||
parser.add_argument('--local-port', type=int, default=0)
|
||||
parser.add_argument('--local-ip', default='')
|
||||
parser.add_argument('--receive-buffer', type=int, default=1024 * 1024, help='0 keeps the OS default')
|
||||
parser.add_argument('--json', type=Path)
|
||||
parser.add_argument('--save-frame', type=Path)
|
||||
args = parser.parse_args()
|
||||
client_id = secrets.randbelow(0xFFFFFFFF) + 1
|
||||
if client_id == 0x0C110001:
|
||||
client_id += 1
|
||||
subscribe = struct.pack('<IBHIIHHIHHB', 0x4D564331, 2, 1, client_id, 0, 0, 0, 0, 0, 0, 2)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
if args.receive_buffer:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, args.receive_buffer)
|
||||
sock.bind((args.local_ip, args.local_port))
|
||||
local_port = sock.getsockname()[1]
|
||||
buffer_bytes = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
|
||||
sock.settimeout(.05)
|
||||
sources = {}
|
||||
invalid = 0
|
||||
saved = False
|
||||
started = time.perf_counter()
|
||||
renew = started
|
||||
report_at = started + 5
|
||||
previous_frames = 0
|
||||
previous_report = started
|
||||
try:
|
||||
while time.perf_counter() - started < args.seconds:
|
||||
now = time.perf_counter()
|
||||
for source in sources.values():
|
||||
if source['pending'] is not None and now-source['pending']['started'] > .5:
|
||||
source['counts']['timed_out_frames'] += 1
|
||||
source['pending'] = None
|
||||
if now >= renew:
|
||||
sock.sendto(subscribe, (args.host, PORT))
|
||||
renew = now + .8
|
||||
if now >= report_at:
|
||||
frames = sum(s['counts']['complete_frames'] for s in sources.values())
|
||||
print(f'{now-started:5.1f}s {(frames-previous_frames)/(now-previous_report):5.1f} fps complete={frames}', flush=True)
|
||||
previous_frames, previous_report, report_at = frames, now, now + 5
|
||||
try:
|
||||
data, address = sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
continue
|
||||
now = time.perf_counter()
|
||||
parsed = decode(data)
|
||||
if parsed is None:
|
||||
invalid += 1
|
||||
continue
|
||||
kind, h, payload = parsed
|
||||
if kind != 1:
|
||||
continue
|
||||
size = h['total_len']
|
||||
index, count = h['frag_index'], h['frag_count']
|
||||
offset = index * FRAGMENT
|
||||
if h['version'] != 1 or size > 4 * 1024 * 1024 or count != max(1, (size + FRAGMENT - 1) // FRAGMENT) or index >= count or offset > size or len(payload) != min(FRAGMENT, size-offset):
|
||||
invalid += 1
|
||||
continue
|
||||
sender, frame = h['sender_id'], h['frame_id']
|
||||
if sender not in sources:
|
||||
sources[sender] = dict(address=address[0], counts=Counter(), latest=None, last_seen=now, pending=None, intervals=[], assembly=[], first_complete=None, last_complete=None)
|
||||
s = sources[sender]
|
||||
counts = s['counts']
|
||||
counts['video_datagrams'] += 1
|
||||
latest = s['latest']
|
||||
if latest is not None:
|
||||
distance = (frame - latest) & 0xFFFFFFFF
|
||||
if distance >= 0x80000000 and now-s['last_seen'] >= 1:
|
||||
counts['restarts'] += 1
|
||||
latest = s['latest'] = None
|
||||
s['pending'] = None
|
||||
elif distance >= 0x80000000 or (distance == 0 and s['pending'] is None):
|
||||
counts['late_or_repeated_datagrams'] += 1
|
||||
continue
|
||||
elif distance > 0:
|
||||
counts['missing_frames'] += distance - 1
|
||||
counts['superseded_frames'] += int(s['pending'] is not None)
|
||||
s['pending'] = None
|
||||
s['latest'], s['last_seen'] = frame, now
|
||||
if s['pending'] is None:
|
||||
s['pending'] = dict(buffer=bytearray(size), seen=set(), count=count, size=size, width=h['width'], height=h['height'], started=now)
|
||||
pending = s['pending']
|
||||
if (size, count, h['width'], h['height']) != (pending['size'], pending['count'], pending['width'], pending['height']):
|
||||
counts['inconsistent_metadata'] += 1
|
||||
continue
|
||||
if index in pending['seen']:
|
||||
counts['duplicate_fragments'] += 1
|
||||
continue
|
||||
pending['seen'].add(index)
|
||||
pending['buffer'][offset:offset+len(payload)] = payload
|
||||
if len(pending['seen']) != count:
|
||||
continue
|
||||
jpeg = bytes(pending['buffer'])
|
||||
if jpeg[:2] == b'\xff\xd8' and jpeg[-2:] == b'\xff\xd9':
|
||||
completed = time.perf_counter()
|
||||
counts['complete_frames'] += 1
|
||||
counts['jpeg_bytes'] += size
|
||||
if s['last_complete'] is not None:
|
||||
s['intervals'].append((completed-s['last_complete'])*1000)
|
||||
s['last_complete'] = completed
|
||||
if s['first_complete'] is None:
|
||||
s['first_complete'] = completed
|
||||
s['assembly'].append((completed-pending['started'])*1000)
|
||||
s['resolution'] = [h['width'], h['height']]
|
||||
if args.save_frame and not saved:
|
||||
args.save_frame.write_bytes(jpeg)
|
||||
saved = True
|
||||
else:
|
||||
counts['invalid_jpeg'] += 1
|
||||
s['pending'] = None
|
||||
finally:
|
||||
sock.close()
|
||||
elapsed = time.perf_counter()-started
|
||||
result = dict(seconds=round(elapsed, 3), host=args.host, local_port=local_port, receive_buffer_bytes=buffer_bytes, invalid_datagrams=invalid, sources={f'0x{sender:08X}': summary(s, elapsed) for sender, s in sources.items()}, timing_scope='receiver inter-frame spacing and first-to-last fragment assembly; not camera-to-display latency')
|
||||
if args.json:
|
||||
args.json.write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if any(s['counts']['complete_frames'] for s in sources.values()) else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record firmware telemetry continuously (requires pyserial; no UDP subscriber)."""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
FIELDS = {
|
||||
'submitted_fps': (r'([\d.]+) fps submitted', float),
|
||||
'attempted_fps': (r'([\d.]+) fps attempted', float),
|
||||
'kilobytes_per_second': (r'([\d.]+) kB/s', float),
|
||||
'send_failures': (r'(\d+) send failures', int),
|
||||
'retries': (r'(\d+) retries', int),
|
||||
'peer_frames_dropped': (r'(\d+) peer frames dropped', int),
|
||||
'send_ms': (r'([\d.]+) ms/send', float),
|
||||
'subscribers': (r'(\d+) clients subscribed', int),
|
||||
'heap_free': (r'internal heap (\d+) free', int),
|
||||
'heap_largest': (r'free / (\d+) largest bytes', int),
|
||||
'rssi_dbm': (r'RSSI (-?\d+) dBm', int),
|
||||
'max_tx_power_dbm': (r'max TX power ([\w.+-]+) dBm', float),
|
||||
'chip_temperature_c': (r'chip temp ([\w.+-]+) C', float),
|
||||
'target_fps': (r'target (\d+) fps', int),
|
||||
'capture_errors': (r'capture errors (\d+)', int),
|
||||
'exposure_lines': (r'exposure (-?\d+) lines', int),
|
||||
'jpeg_quality': (r'jpeg quality (\d+)', int),
|
||||
'gain_multiplier': (r'gain ([\d.]+)x', float),
|
||||
'capture_queue_skipped': (r'capture queue skipped (\d+)', int),
|
||||
'stale_frames_dropped': (r'stale frames dropped (\d+)', int),
|
||||
'deadline_peer_drops': (r'deadline peer drops (\d+)', int),
|
||||
'max_submitted_capture_age_ms': (r'max submitted capture age ([\d.]+) ms', float),
|
||||
'max_send_hold_ms': (r'max send hold ([\d.]+) ms', float),
|
||||
}
|
||||
|
||||
|
||||
def parse_sample(line):
|
||||
if 'fps submitted' not in line:
|
||||
return None
|
||||
sample = {}
|
||||
match = re.search(r'\((\d+)\)', line)
|
||||
if match:
|
||||
sample['uptime_ms'] = int(match[1])
|
||||
for name, (pattern, convert) in FIELDS.items():
|
||||
match = re.search(pattern, line)
|
||||
if match:
|
||||
value = convert(match[1])
|
||||
sample[name] = value if not isinstance(value, float) or math.isfinite(value) else None
|
||||
sample['thermal_limited'] = 'thermal_limited true' in line
|
||||
return sample
|
||||
|
||||
|
||||
def write_json(path, data):
|
||||
temporary = path.with_suffix(path.suffix+'.tmp')
|
||||
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--port', default='COM7')
|
||||
parser.add_argument('--seconds', type=float, default=1860)
|
||||
parser.add_argument('--out', type=Path, required=True, help='Output path prefix')
|
||||
parser.add_argument('--reset', action='store_true')
|
||||
parser.add_argument('--stop-file', type=Path, help='Finish cleanly when this file exists')
|
||||
args = parser.parse_args()
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
port = serial.Serial()
|
||||
port.port, port.baudrate, port.timeout = args.port, 115200, .2
|
||||
port.dtr = port.rts = False
|
||||
records, events = [], []
|
||||
started = time.perf_counter()
|
||||
last_uptime, reboot_count, last_sample_at = None, 0, None
|
||||
serial_error = None
|
||||
try:
|
||||
port.open()
|
||||
if args.reset:
|
||||
port.rts = True
|
||||
time.sleep(.15)
|
||||
port.rts = False
|
||||
with args.out.with_suffix('.serial.log').open('w', encoding='utf-8') as raw, args.out.with_suffix('.telemetry.ndjson').open('w', encoding='utf-8') as telemetry:
|
||||
while time.perf_counter()-started < args.seconds:
|
||||
if args.stop_file and args.stop_file.exists():
|
||||
break
|
||||
line = port.readline().decode('utf-8', errors='replace')
|
||||
if not line:
|
||||
continue
|
||||
raw.write(line)
|
||||
raw.flush()
|
||||
elapsed = time.perf_counter()-started
|
||||
sample = parse_sample(line)
|
||||
if sample:
|
||||
sample.update(elapsed_seconds=round(elapsed, 3), utc=datetime.now(timezone.utc).isoformat())
|
||||
uptime = sample.get('uptime_ms')
|
||||
if uptime is not None and last_uptime is not None and uptime+1000 < last_uptime:
|
||||
reboot_count += 1
|
||||
last_uptime = uptime
|
||||
last_sample_at = elapsed
|
||||
records.append(sample)
|
||||
telemetry.write(json.dumps(sample)+'\n')
|
||||
telemetry.flush()
|
||||
write_json(args.out.with_suffix('.latest.json'), dict(elapsed_seconds=round(elapsed, 3), samples=len(records), reboot_count=reboot_count, latest=sample))
|
||||
if len(records) % 12 == 0:
|
||||
print(json.dumps(dict(minutes=round(elapsed/60, 1), reboot_count=reboot_count, latest=sample)), flush=True)
|
||||
elif re.search(r'panic|Guru Meditation|Brownout|watchdog|NO-EOI|OVF|thermal load limit|reset reason|capture cap|configured capture', line, re.I):
|
||||
events.append(dict(elapsed_seconds=round(elapsed, 3), text=line.strip()))
|
||||
except (serial.SerialException, OSError) as error:
|
||||
serial_error = str(error)
|
||||
finally:
|
||||
port.close()
|
||||
result = dict(elapsed_seconds=round(time.perf_counter()-started, 3), requested_seconds=args.seconds, stopped_by_request=bool(args.stop_file and args.stop_file.exists()), serial_error=serial_error, samples=len(records), reboot_count=reboot_count, last_sample_at=last_sample_at, events=events, records=records)
|
||||
write_json(args.out.with_suffix('.summary.json'), result)
|
||||
print(json.dumps({k:v for k,v in result.items() if k not in ('records', 'events')}))
|
||||
if serial_error:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile the actual driver handoff function with deterministic queue stubs."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = (root/'components/esp32-camera/driver/cam_hal.c').read_text()
|
||||
start = source.index('camera_fb_t *cam_take_latest(')
|
||||
end = source.index('\nvoid cam_give(', start)
|
||||
function = source[start:end]
|
||||
out = root/'target/latest-frame-test'
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
test = r'''
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
typedef unsigned TickType_t;
|
||||
typedef struct { int id; } camera_fb_t;
|
||||
static struct { size_t frame_cnt; } object = {3}, *cam_obj = &object;
|
||||
static camera_fb_t frames[] = {{0},{1},{2},{3},{4},{5}};
|
||||
static int calls, releases[6], mode;
|
||||
static camera_fb_t *cam_take(TickType_t wait) {
|
||||
assert(wait == (calls == 0 ? 100 : 0));
|
||||
++calls;
|
||||
if (mode == 1 || (mode == 2 && calls == 2)) return NULL;
|
||||
assert(calls < 6);
|
||||
return &frames[calls];
|
||||
}
|
||||
static void cam_give(camera_fb_t *frame) { releases[frame->id]++; }
|
||||
'''+function+r'''
|
||||
static void reset(int scenario) {
|
||||
mode=scenario; calls=0;
|
||||
for(int i=0;i<6;i++) releases[i]=0;
|
||||
}
|
||||
int main(void) {
|
||||
uint32_t skipped=99;
|
||||
// A producer that stays ahead cannot make the drain loop run forever.
|
||||
reset(0);
|
||||
camera_fb_t *latest=cam_take_latest(100,&skipped);
|
||||
assert(calls==3 && latest->id==3 && skipped==2);
|
||||
assert(releases[1]==1 && releases[2]==1 && releases[3]==0);
|
||||
// An unavailable/invalid newer candidate must not release our valid frame.
|
||||
reset(2);
|
||||
latest=cam_take_latest(100,&skipped);
|
||||
assert(calls==2 && latest->id==1 && skipped==0 && releases[1]==0);
|
||||
// A first-frame timeout has no handle to return, and resets the skip count.
|
||||
reset(1);
|
||||
assert(cam_take_latest(100,&skipped)==NULL && skipped==0 && calls==1);
|
||||
// A single capture buffer performs no second, nonblocking acquisition.
|
||||
reset(0); object.frame_cnt=1;
|
||||
latest=cam_take_latest(100,NULL);
|
||||
assert(calls==1 && latest->id==1 && releases[1]==0);
|
||||
puts("4 latest-frame ownership and bounded-drain scenarios passed");
|
||||
}
|
||||
'''
|
||||
(out/'latest-frame.c').write_text(test)
|
||||
compiler = shutil.which('clang') or shutil.which('gcc')
|
||||
if not compiler:
|
||||
sys.exit('A host clang or gcc is required for this optional driver test')
|
||||
binary = out/('latest-frame.exe' if sys.platform=='win32' else 'latest-frame')
|
||||
subprocess.run([compiler,'-std=c11','-Wall','-Wextra','-Werror',str(out/'latest-frame.c'),'-o',str(binary)],check=True)
|
||||
subprocess.run([str(binary)],check=True)
|
||||
@@ -106,8 +106,12 @@ def main():
|
||||
prev_frames = 0
|
||||
prev_pkts = 0
|
||||
prev_drops = 0
|
||||
last_subscribe = start
|
||||
|
||||
while time.time() - start < duration:
|
||||
if time.time() - last_subscribe >= 0.8:
|
||||
sock.sendto(sub, (host, PORT))
|
||||
last_subscribe = time.time()
|
||||
try:
|
||||
data, addr = sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
|
||||
Reference in New Issue
Block a user