Add stable CYD firmware, USB calibration wizard and complete setup guide

This commit is contained in:
Max Gramser
2026-09-12 10:18:49 +02:00
parent 1eb65d8f71
commit 94546cf727
43 changed files with 9312 additions and 45 deletions
+213
View File
@@ -0,0 +1,213 @@
"""USB calibration: guided capture, affine fit with center hold-out, verification.
No network or Home Assistant access. The device must already run the firmware
with CALIBRATION_ON_BOOT=true (or have its isolated diagnostic page opened).
"""
import argparse
from datetime import datetime, timezone
import json
import math
import os
from pathlib import Path
import re
import statistics
import time
TARGETS = [('linksboven', (20, 20)), ('rechtsboven', (299, 20)),
('rechtsonder', (299, 219)), ('linksonder', (20, 219)), ('midden', (160, 120))]
BOUNDS = (280, 3860, 340, 3860)
PATTERN = re.compile(r'press native=(\d+),(\d+) raw=(\d+),(\d+) pressure=(\d+) calibration=([01])')
def parse_touch(line):
match = PATTERN.search(line)
if not match:
return None
nx, ny, rx, ry, pressure, isolated = map(int, match.groups())
if not isolated:
raise ValueError('Geen geïsoleerd meetscherm: start met CALIBRATION_ON_BOOT=true. Geen data opgeslagen.')
return dict(native_x=nx, native_y=ny, raw_x=rx, raw_y=ry, pressure=pressure)
def write_file(path, contents, replace=False):
path = Path(path)
if path.exists():
if not replace:
raise ValueError(f'{path} bestaat al; kies een andere naam of expliciet --replace.')
backup = path.with_name(path.name + '.bak-' + datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
backup.write_bytes(path.read_bytes())
# Write completely before replacing an existing configuration.
temporary = path.with_name(path.name + '.tmp-' + str(os.getpid()))
try:
with temporary.open('x', encoding='utf-8') as handle:
handle.write(contents)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def validate(data):
if data.get('schema') != 1 or data.get('screen') != [320, 240]:
raise ValueError('Verwacht schema 1 en landschap 320×240; andere oriëntaties zijn niet ondersteund.')
points = data.get('points', [])
if len(points) != len(TARGETS):
raise ValueError('Alle vier hoeken én het onafhankelijke middenpunt zijn nodig.')
for point, (name, xy) in zip(points, TARGETS):
if point.get('name') != name or point.get('screen') != list(xy):
raise ValueError('Meetpunten/volgorde komen niet overeen met de wizard.')
if len(point.get('samples', [])) < 3:
raise ValueError(f'{name}: minimaal drie afzonderlijke tikken nodig.')
for sample in point['samples']:
for key, maximum in [('raw_x', 4095), ('raw_y', 4095), ('native_x', 239), ('native_y', 319), ('pressure', 8190)]:
value = sample.get(key)
if type(value) is not int or not 0 <= value <= maximum:
raise ValueError(f'{name}: ongeldige {key}.')
return points
def median_raw(point):
return tuple(statistics.median(s[key] for s in point['samples']) for key in ('raw_x', 'raw_y'))
def least_squares(rows, values):
matrix = [[sum(row[i] * row[j] for row in rows) for j in range(3)] +
[sum(row[i] * value for row, value in zip(rows, values))] for i in range(3)]
for column in range(3):
pivot = max(range(column, 3), key=lambda row: abs(matrix[row][column]))
matrix[column], matrix[pivot] = matrix[pivot], matrix[column]
scale = matrix[column][column]
if abs(scale) < 1e-9:
raise ValueError('Meetpunten zijn niet onafhankelijk; waarschijnlijk dezelfde hoek meermaals gemeten.')
matrix[column] = [v / scale for v in matrix[column]]
for row in range(3):
if row != column:
factor = matrix[row][column]
matrix[row] = [a - factor * b for a, b in zip(matrix[row], matrix[column])]
return [matrix[i][3] for i in range(3)]
def project(coefficients, rx, ry):
xx, xy, xc, yx, yy, yc = coefficients
xmin, xmax, ymin, ymax = BOUNDS
return ((yx * rx + yy * ry + yc - ymin) * 320 / (ymax - ymin),
(xx * rx + xy * ry + xc - xmin) * 240 / (xmax - xmin))
def fit(data, tolerance=12):
points = validate(data)
medians = [median_raw(p) for p in points]
# Scale inputs to avoid poorly conditioned normal equations on 12-bit ADC data.
rows = [(x / 4095, y / 4095, 1) for x, y in medians[:4]]
xmin, xmax, ymin, ymax = BOUNDS
values_x = [xmin + p['screen'][1] * (xmax - xmin) / 240 for p in points[:4]]
values_y = [ymin + p['screen'][0] * (ymax - ymin) / 320 for p in points[:4]]
cx, cy = least_squares(rows, values_x), least_squares(rows, values_y)
coefficients = [cx[0] / 4095, cx[1] / 4095, cx[2], cy[0] / 4095, cy[1] / 4095, cy[2]]
if not all(math.isfinite(v) for v in coefficients):
raise ValueError('Ongeldige kalibratiecoëfficiënten.')
# Reject captures that amplify small sensor jitter into huge screen movements.
if max(abs(v) for v in (coefficients[0], coefficients[1], coefficients[3], coefficients[4])) > 4:
raise ValueError('Te klein meetbereik; tik de echte schermhoeken aan.')
report = []
for point, median in zip(points, medians):
predicted = project(coefficients, *median)
error = math.dist(predicted, point['screen'])
spread = max(math.dist(project(coefficients, s['raw_x'], s['raw_y']), predicted) for s in point['samples'])
report.append(dict(name=point['name'], median=list(median), error_px=round(error, 2), spread_px=round(spread, 2)))
if error > tolerance or spread > 18:
raise ValueError(f"{point['name']}: fout {error:.1f}px, spreiding {spread:.1f}px. "
'Meet opnieuw; een instabiel paneel niet wegkalibreren.')
return coefficients, report
def calibration_yaml(coefficients):
names = ['TOUCH_AFFINE_XX', 'TOUCH_AFFINE_XY', 'TOUCH_AFFINE_XC', 'TOUCH_AFFINE_YX', 'TOUCH_AFFINE_YY', 'TOUCH_AFFINE_YC']
lines = ['# Gemeten met tools/calibrate.py; uitsluitend voor dit fysieke paneel.', 'substitutions:']
for name, value in zip(['TOUCH_CAL_X_MIN', 'TOUCH_CAL_X_MAX', 'TOUCH_CAL_Y_MIN', 'TOUCH_CAL_Y_MAX'], BOUNDS):
lines.append(f' {name}: "{value}"')
lines += [f' {name}: "{value:.9f}"' for name, value in zip(names, coefficients)]
return '\n'.join(lines) + '\n'
def verify(data, tolerance=12):
results = []
for point in validate(data):
measured = [(s['native_y'], 239 - s['native_x']) for s in point['samples']]
center = tuple(statistics.median(p[i] for p in measured) for i in (0, 1))
error = math.dist(center, point['screen'])
spread = max(math.dist(p, center) for p in measured)
results.append(dict(name=point['name'], error_px=round(error, 2), spread_px=round(spread, 2)))
if error > tolerance or spread > 18:
raise ValueError(f"{point['name']}: echte firmwarecoördinaten wijken {error:.1f}px af (spreiding {spread:.1f}px).")
return results
def capture(args):
import serial
if args.output.exists():
raise ValueError('Meetbestand bestaat al; kies een nieuwe naam.')
data = dict(schema=1, device_name=args.device_name, screen=[320, 240],
created_at=datetime.now(timezone.utc).isoformat(), points=[])
print('Alleen voor 320×240 / LVGL 90° / swap_xy=false / mirror_x=true / mirror_y=false.')
print('Het scherm moet de VIJF kruisjes tonen. Sluit andere seriële loglezers af.')
with serial.Serial(args.port, 115200, timeout=0.2) as port:
for name, xy in TARGETS:
input(f'\nVolgende punt: {name} {xy}. Druk ENTER en tik daarna alleen dit kruisje {args.samples} keer: ')
port.reset_input_buffer()
samples = []
deadline = time.monotonic() + args.timeout
last = -math.inf
while len(samples) < args.samples and time.monotonic() < deadline:
sample = parse_touch(port.readline().decode('utf-8', errors='replace'))
if sample and time.monotonic() - last >= 0.4:
samples.append(sample)
last = time.monotonic()
print(f' {name}: {len(samples)}/{args.samples}', flush=True)
if len(samples) != args.samples:
raise ValueError(f'{name}: te weinig geldige tikken. Controleer de firmware, het meetscherm en USB-poort.')
data['points'].append(dict(name=name, screen=list(xy), samples=samples))
write_file(args.output, json.dumps(data, indent=2) + '\n')
print(f'Meting opgeslagen: {args.output}. Het script heeft niets geflasht of aangestuurd.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest='command', required=True)
c = commands.add_parser('capture', help='Lees geleide tikken via USB; firmware moet al het meetscherm tonen.')
c.add_argument('--port', required=True)
c.add_argument('--output', type=Path, required=True)
c.add_argument('--device-name', default='onbekend')
c.add_argument('--samples', type=int, choices=range(3, 10), default=3)
c.add_argument('--timeout', type=float, default=60)
for name in ('fit', 'verify'):
sub = commands.add_parser(name)
sub.add_argument('input', type=Path)
sub.add_argument('--tolerance', type=float, default=12)
if name == 'fit':
sub.add_argument('--output', type=Path, default=Path('calibration.proposed.yaml'))
sub.add_argument('--replace', action='store_true')
args = parser.parse_args()
try:
if args.command == 'capture':
if args.timeout <= 0: raise ValueError('Timeout moet positief zijn.')
capture(args)
else:
if args.tolerance <= 0 or not math.isfinite(args.tolerance): raise ValueError('Tolerantie moet eindig en positief zijn.')
data = json.loads(args.input.read_text())
if args.command == 'fit':
coefficients, report = fit(data, args.tolerance)
write_file(args.output, calibration_yaml(coefficients), args.replace)
print(f'Kalibratie geschreven: {args.output}. Het middenpunt is onafhankelijk gecontroleerd.')
else:
report = verify(data, args.tolerance)
print('PASS: onafhankelijke fysieke controle van de geflashte coördinaten.')
for item in report:
print(f"{item['name']}: fout={item['error_px']}px, spreiding={item['spread_px']}px")
except (ValueError, OSError, KeyError, TypeError, EOFError) as exc:
parser.exit(1, f'Fout: {exc}\n')
except KeyboardInterrupt:
parser.exit(130, 'Afgebroken; geen gedeeltelijke meting opgeslagen.\n')
if __name__ == '__main__':
main()
+58
View File
@@ -0,0 +1,58 @@
"""Export an allowlisted, neutral starter ZIP (never copies local secrets/logs)."""
import argparse
from pathlib import Path
import re
import zipfile
ROOT = Path(__file__).resolve().parents[1]
FILES = ['.gitignore', 'README.md', 'AGENTS.md', 'requirements.txt', 'device.example.yaml',
'calibration.example.yaml', 'secrets.yaml.example', 'cyd_ui.h', 'LICENSE',
'TILE_CONFIGURATION.md', 'CYD_STABILITY.md', 'TEST_RESULTS.md']
FOLDERS = ['docs', 'tools', 'tests', 'components', 'diagnostics', 'fonts']
SUFFIXES = {'.py', '.cpp', '.h', '.md', '.ttf', '.txt'}
def neutral_base(root):
defaults = '\n'.join((root / name).read_text() for name in ['device.example.yaml', 'calibration.example.yaml'])
replacements = dict(re.findall(r'^ (\w+): ("[^"\n]*")', defaults, re.M))
base = (root / 'home-like-2432s028.yaml').read_text()
def replace(match):
key = match[1]
return f' {key}: {replacements.get(key, match[2])}'
return re.sub(r'^ (\w+): ("[^"\n]*")', replace, base, flags=re.M)
def export(root, output):
root, output = Path(root).resolve(), Path(output)
paths = [root / name for name in FILES]
for folder in FOLDERS:
for path in (root / folder).rglob('*'):
if path.is_file() and '__pycache__' not in path.parts and (path.suffix in SUFFIXES or path.name == 'LICENSE'):
paths.append(path)
for path in paths:
if path.is_symlink() or not path.resolve().is_relative_to(root):
raise ValueError(f'Geen bestanden buiten de bronmap of symlinks toegestaan: {path}')
if not path.is_file():
raise ValueError(f'Ontbrekend bundelbestand: {path}')
output.parent.mkdir(parents=True, exist_ok=True)
# Exclusive mode refuses to replace a previously delivered bundle.
with zipfile.ZipFile(output, 'x', zipfile.ZIP_DEFLATED) as archive:
archive.writestr('cyd-display/home-like-2432s028.yaml', neutral_base(root))
for path in sorted(set(paths)):
archive.write(path, 'cyd-display/' + path.relative_to(root).as_posix())
return output
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=ROOT / 'dist' / 'cyd-starter.zip')
args = parser.parse_args()
try:
print(f'Deel deze ZIP: {export(ROOT, args.output)}')
print('Geen lokale secrets, device.yaml, kalibratiemetingen, logs of firmwarebackups opgenomen.')
except (OSError, ValueError) as exc:
parser.exit(1, f'Fout: {exc}\n')
if __name__ == '__main__':
main()
+61
View File
@@ -0,0 +1,61 @@
"""Create local device, calibration and secrets files without overwriting any."""
import argparse
import base64
import os
from pathlib import Path
import re
import secrets
ROOT = Path(__file__).resolve().parents[1]
def create(directory, name, friendly_name):
if not re.fullmatch(r'[a-z][a-z0-9-]{0,30}', name) or name.endswith('-'):
raise ValueError('Gebruik een unieke naam van 1–31 tekens: kleine letters, cijfers en koppeltekens.')
import json
device = (ROOT / 'device.example.yaml').read_text().replace('"cyd-new"', json.dumps(name), 1)
device = device.replace('"Mijn CYD"', json.dumps(friendly_name, ensure_ascii=False), 1)
secret_text = (ROOT / 'secrets.yaml.example').read_text()
replacements = [base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(18)]
for value in replacements:
secret_text = secret_text.replace('WORDT_UNIEK_GEGENEREERD', value, 1)
files = {'device.yaml': device, 'calibration.yaml': (ROOT / 'calibration.example.yaml').read_text(),
'secrets.yaml': secret_text}
directory = Path(directory)
if not directory.is_dir():
raise ValueError('De doelmap moet al bestaan en de uitgepakte code bevatten.')
for name in files:
if (directory / name).exists():
raise ValueError(f'{directory / name} bestaat al; niets overschreven. Gebruik een nieuwe kopie van de map.')
created = []
try:
for name, contents in files.items():
path = directory / name
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
created.append(path)
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
handle.write(contents)
except Exception:
for path in created:
path.unlink(missing_ok=True)
raise
return created
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--name', required=True, help='Unieke ESPHome-naam, bijvoorbeeld display-keuken')
parser.add_argument('--friendly-name', default='Mijn CYD')
parser.add_argument('--directory', type=Path, default=ROOT)
args = parser.parse_args()
try:
for path in create(args.directory, args.name, args.friendly_name):
print(f'Gemaakt: {path}')
print('Vul wifi lokaal in secrets.yaml in. Sleutels zijn uniek gegenereerd en worden niet getoond.')
print('Volg nu README.md; flash device.yaml, niet rechtstreeks de basisconfiguratie.')
except (ValueError, OSError) as exc:
parser.exit(1, f'Fout: {exc}\n')
if __name__ == '__main__':
main()