Add stable CYD firmware, USB calibration wizard and complete setup guide
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user