"""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()