3e959b3df9
Everything a user sees or reads is now English: both boards' labels, status replies and logs, the ESP Screens editor and Alerts cheatsheet, and the README and guides (docs/TILES.md, CALIBRATING.md, ACCEPTANCE.md and TROUBLESHOOTING.md replace the Dutch file names). Labels follow Home Assistant's own English names (Heat/Cool modes, Heating/Cooling actions, Away, Returning to dock, weather conditions), dates use English abbreviations and the top bar writes numbers the English way. Docs use HA 2026.9's menu names (Settings -> Apps, App store, Settings -> Tools). Unused legacy files (home-like.yaml, buttons.yaml, a.out) are removed. Firmware 0.2.34 names its entities in English. Home Assistant registers a renamed ESPHome entity under a new entity ID, so the inbox text.<screen>_tegelinstellingen becomes text.<screen>_tile_settings and the layout stored under the old ID would be lost. The manager now recognises the screen by its device, during a run or after a restart, and moves its layout and update history along; the Update button and the nightly round follow the screen through the rename. Screens that still run 0.2.33 or older keep working: both languages are matched. Main is the only release branch from now on. Tested on a Home Assistant Yellow (HA 2026.9.1) with both bench boards: see docs/TEST_RESULTS_0240.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
10 KiB
Python
214 lines
10 KiB
Python
"""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 = [('top-left', (20, 20)), ('top-right', (299, 20)),
|
||
('bottom-right', (299, 219)), ('bottom-left', (20, 219)), ('center', (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('Not an isolated measurement screen: start with CALIBRATION_ON_BOOT=true. No data saved.')
|
||
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} already exists; choose a different name or pass --replace explicitly.')
|
||
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('Expected schema 1 and landscape 320×240; other orientations are not supported.')
|
||
points = data.get('points', [])
|
||
if len(points) != len(TARGETS):
|
||
raise ValueError('All four corners and the independent center point are required.')
|
||
for point, (name, xy) in zip(points, TARGETS):
|
||
if point.get('name') != name or point.get('screen') != list(xy):
|
||
raise ValueError("Measurement points/order don't match the wizard.")
|
||
if len(point.get('samples', [])) < 3:
|
||
raise ValueError(f'{name}: at least three separate taps required.')
|
||
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}: invalid {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('Measurement points are not independent; likely the same corner was measured more than once.')
|
||
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('Invalid calibration coefficients.')
|
||
# 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('Measurement range too small; tap the actual screen corners.')
|
||
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']}: error {error:.1f}px, spread {spread:.1f}px. "
|
||
"Measure again; don't calibrate away an unstable panel.")
|
||
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 = ['# Measured with tools/calibrate.py; only valid for this physical panel.', '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']}: real firmware coordinates deviate {error:.1f}px (spread {spread:.1f}px).")
|
||
return results
|
||
|
||
|
||
def capture(args):
|
||
import serial
|
||
if args.output.exists():
|
||
raise ValueError('Measurement file already exists; choose a new name.')
|
||
data = dict(schema=1, device_name=args.device_name, screen=[320, 240],
|
||
created_at=datetime.now(timezone.utc).isoformat(), points=[])
|
||
print('Only for 320×240 / LVGL 90° / swap_xy=false / mirror_x=true / mirror_y=false.')
|
||
print('The screen must show the FIVE crosshairs. Close other serial log readers.')
|
||
with serial.Serial(args.port, 115200, timeout=0.2) as port:
|
||
for name, xy in TARGETS:
|
||
input(f'\nNext point: {name} {xy}. Press ENTER, then tap only this crosshair {args.samples} times: ')
|
||
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}: not enough valid taps. Check the firmware, the measurement screen, and the USB port.')
|
||
data['points'].append(dict(name=name, screen=list(xy), samples=samples))
|
||
write_file(args.output, json.dumps(data, indent=2) + '\n')
|
||
print(f'Measurement saved: {args.output}. The script did not flash or control anything.')
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
commands = parser.add_subparsers(dest='command', required=True)
|
||
c = commands.add_parser('capture', help='Read guided taps over USB; firmware must already show the measurement screen.')
|
||
c.add_argument('--port', required=True)
|
||
c.add_argument('--output', type=Path, required=True)
|
||
c.add_argument('--device-name', default='unknown')
|
||
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 must be positive.')
|
||
capture(args)
|
||
else:
|
||
if args.tolerance <= 0 or not math.isfinite(args.tolerance): raise ValueError('Tolerance must be finite and positive.')
|
||
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'Calibration written: {args.output}. The center point was independently checked.')
|
||
else:
|
||
report = verify(data, args.tolerance)
|
||
print('PASS: independent physical check of the flashed coordinates.')
|
||
for item in report:
|
||
print(f"{item['name']}: error={item['error_px']}px, spread={item['spread_px']}px")
|
||
except (ValueError, OSError, KeyError, TypeError, EOFError) as exc:
|
||
parser.exit(1, f'Error: {exc}\n')
|
||
except KeyboardInterrupt:
|
||
parser.exit(130, 'Aborted; no partial measurement saved.\n')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|