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
+35
View File
@@ -0,0 +1,35 @@
#include "../cyd_ui.h"
#include <cassert>
#include <limits>
int main() {
using cyd::quantize_temperature;
assert(quantize_temperature(223, 160, 300, 5) == 225);
assert(quantize_temperature(221, 160, 300, 5) == 220);
assert(quantize_temperature(-1, 160, 300, 5) == 160);
assert(quantize_temperature(999, 160, 299, 5) == 299);
assert(quantize_temperature(201, 160, 300, 0) == 201);
cyd::TouchGuard g;
g.begin(100);
assert(!g.accept(120, 1)); // resistive noise pulse
assert(g.accept(180, 1));
assert(!g.accept(800, 1)); // same physical gesture cannot fire twice
g.begin(200);
assert(!g.accept(280, 1)); // contact bounce cannot repeat the action
g.begin(300);
assert(g.accept(380, 2)); // another tile is still responsive
g.begin(1000);
assert(g.accept(1080, 2));
cyd::TouchGuard moving;
moving.begin(100, 50, 50);
moving.update(52, 48);
assert(moving.accept(180, 1)); // small resistive jitter remains usable
moving.begin(1000, 50, 50);
moving.update(50, 80);
moving.update(50, 50);
assert(!moving.accept(1200, 2)); // excursion stays cancelled, even on return
cyd::TouchGuard rollover;
rollover.begin(std::numeric_limits<uint32_t>::max() - 30);
assert(rollover.accept(50, 1)); // millis wraps after 49 days
rollover.begin(60);
assert(!rollover.accept(130, 1));
}
+59
View File
@@ -0,0 +1,59 @@
"""Regression checks for the 320x240 page geometry and event guards."""
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[1]
SOURCE = (ROOT / 'home-like-2432s028.yaml').read_text()
VALUES = dict(re.findall(r'^ (\w+): "([^"]*)"', SOURCE, re.M))
class LayoutTests(unittest.TestCase):
def test_visible_rows_fit_viewport_and_leave_navigation_clear(self):
v = lambda k: int(VALUES[k])
rectangles = []
for row in range(1, 4):
for col in range(1, 3):
x, y = v(f'GRID_COL{col}_X'), v('SCROLL_Y') + v(f'GRID_ROW{row}_Y')
w, h = v('TILE_W'), v('TILE_H')
self.assertGreaterEqual(x, 0)
self.assertLessEqual(x + w, v('DISPLAY_W'))
self.assertLessEqual(y + h, v('SCROLL_Y') + v('SCROLL_H'))
self.assertLessEqual(y + h, v('DISPLAY_H') - 34)
for xx, yy, ww, hh in rectangles:
self.assertFalse(x < xx + ww and x + w > xx and y < yy + hh and y + h > yy)
rectangles.append((x, y, w, h))
def test_each_tile_filters_both_events_before_side_effects(self):
for n in range(1, 11):
start = SOURCE.index(f' id: tile{n}\n')
next_obj = re.search(r'^ - obj:', SOURCE[start:], re.M)
end = start + next_obj.start() if next_obj else len(SOURCE)
block = SOURCE[start:end]
for event in ('short_click', 'long_press'):
match = re.search(r'on_' + event + r':\s+- if:\s+condition:\s+lambda: (.*?)\n', block)
self.assertIsNotNone(match, (n, event))
self.assertIn(f'cyd::touch_guard.accept(millis(), {n})', match[1])
def test_navigation_is_above_grid_but_below_modal_overlays(self):
grid = SOURCE.index(' id: tile_scroll')
nav = SOURCE.index(' id: page_prev')
modal = SOURCE.index(' id: brightness_overlay')
self.assertLess(grid, nav)
self.assertLess(nav, modal)
def test_pagination_is_conditional_and_excess_tiles_are_hidden(self):
block = SOURCE.split(' - id: show_tile_page\n', 1)[1].split(' - id: wake_display', 1)[0]
self.assertIn('const bool paginated = count > 6;', block)
self.assertIn('i < count &&', block)
self.assertIn('if (!paginated) id(tile_page) = 0;', block)
self.assertIn('id(page_prev), id(page_next), id(page_number)', block)
self.assertIn('else lv_obj_add_flag(control, LV_OBJ_FLAG_HIDDEN);', block)
def test_self_test_cannot_call_a_home_assistant_action(self):
block = SOURCE.split(' - id: ui_self_test\n', 1)[1].split(' - id: show_tile_page', 1)[0]
self.assertNotIn('homeassistant.action:', block)
self.assertNotIn('do_tile_action', block)
self.assertNotIn('climate_target_commit', block)
if __name__ == '__main__':
unittest.main()
+126
View File
@@ -0,0 +1,126 @@
"""Offline regression tests for installation, calibration and safe export."""
import copy
from pathlib import Path
import re
import sys
import tempfile
import unittest
import zipfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import calibrate
import new_device
import export_bundle
def measurements():
points = []
for name, (x, y) in calibrate.TARGETS:
rx = round(280 + y * 3580 / 240)
ry = round(340 + x * 3520 / 320)
samples = [dict(raw_x=rx+d, raw_y=ry+d, native_x=239-y,
native_y=x, pressure=1500) for d in (-2, 0, 2)]
points.append(dict(name=name, screen=[x, y], samples=samples))
return dict(schema=1, screen=[320, 240], points=points)
class CalibrationTests(unittest.TestCase):
def test_identity_fit_and_independent_verification(self):
coefficients, report = calibrate.fit(measurements())
self.assertAlmostEqual(coefficients[0], 1, places=3)
self.assertAlmostEqual(coefficients[4], 1, places=3)
self.assertTrue(all(p['error_px'] < 1 for p in report))
self.assertEqual(len(calibrate.verify(measurements())), 5)
def test_affine_skew_is_corrected(self):
data = measurements()
for point in data['points']:
for s in point['samples']:
x, y = s['raw_x'], s['raw_y']
s['raw_x'] = round(.9*x - .03*y + 190)
s['raw_y'] = round(.04*x + .95*y + 50)
_, report = calibrate.fit(data)
self.assertTrue(all(p['error_px'] < 1 for p in report))
def test_center_is_not_used_to_hide_bad_fit(self):
data = measurements()
for s in data['points'][4]['samples']: s['raw_x'] += 600
with self.assertRaisesRegex(ValueError, 'midden'): calibrate.fit(data)
def test_jitter_rejected(self):
data = measurements()
data['points'][0]['samples'][0]['raw_x'] += 700
with self.assertRaises(ValueError): calibrate.fit(data)
def test_repeated_corner_rejected(self):
data = measurements()
for p in data['points']: p['samples'] = copy.deepcopy(data['points'][0]['samples'])
with self.assertRaises(ValueError): calibrate.fit(data)
def test_wrong_firmware_coordinates_rejected(self):
data = measurements()
for s in data['points'][0]['samples']: s['native_y'] += 40
with self.assertRaises(ValueError): calibrate.verify(data)
def test_capture_requires_isolated_mode(self):
line = 'press native=20,30 raw=500,600 pressure=1500 calibration='
self.assertEqual(calibrate.parse_touch(line+'1')['raw_x'], 500)
with self.assertRaises(ValueError): calibrate.parse_touch(line+'0')
self.assertIsNone(calibrate.parse_touch('unrelated log'))
def test_wrong_screen_or_order_rejected(self):
for field in ('screen', 'order'):
data = measurements()
if field == 'screen': data['screen'] = [240,320]
else: data['points'].reverse()
with self.assertRaises(ValueError): calibrate.fit(data)
def test_output_requires_explicit_replace_and_preserves_backup(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp)/'calibration.yaml'
calibrate.write_file(path, 'old')
with self.assertRaises(ValueError): calibrate.write_file(path, 'new')
calibrate.write_file(path, 'new', replace=True)
self.assertEqual(path.read_text(), 'new')
self.assertEqual(next(Path(tmp).glob('*.bak-*')).read_text(), 'old')
class InstallationTests(unittest.TestCase):
def test_unique_secrets_and_no_overwrite(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
new_device.create(a, 'display-a', 'Display A')
new_device.create(b, 'display-b', 'Display B')
original = (Path(a)/'secrets.yaml').read_text()
self.assertNotEqual(original, (Path(b)/'secrets.yaml').read_text())
self.assertNotIn('WORDT_UNIEK_GEGENEREERD', original)
with self.assertRaises(ValueError): new_device.create(a, 'new', 'New')
self.assertEqual(original, (Path(a)/'secrets.yaml').read_text())
def test_invalid_device_name(self):
with tempfile.TemporaryDirectory() as tmp:
for name in ('../oops', 'UPPER', 'has space', 'trailing-'):
with self.assertRaises(ValueError): new_device.create(tmp, name, 'Test')
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_export_is_neutral_and_has_required_sources(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp)/'starter.zip'
export_bundle.export(ROOT, output)
with zipfile.ZipFile(output) as archive:
names = archive.namelist()
for forbidden in ('secrets.yaml', 'device.yaml', 'calibration.yaml', '.git/', '.esphome/', '.log', '.bin', '.json'):
self.assertFalse(any(('/'+forbidden in n if forbidden.endswith('/') else n.endswith(forbidden) if forbidden.startswith('.') else n.endswith('/'+forbidden)) for n in names), forbidden)
self.assertIn('cyd-display/.gitignore', names)
base = archive.read('cyd-display/home-like-2432s028.yaml').decode()
values = dict(re.findall(r'^ (\w+): "([^"]*)"', base, re.M))
self.assertEqual(values['TOUCH_AFFINE_XX'], '1.000000000')
self.assertEqual(values['DIRECT_ACTIONS'], 'false')
for i in range(1,11):
self.assertRegex(values[f'TILE{i}_ENTITY'], r'\.(voorbeeld_|ongebruikt_)')
archive.extractall(tmp)
new_device.create(Path(tmp)/'cyd-display', 'fresh-board', 'Fresh board')
with self.assertRaises(FileExistsError): export_bundle.export(ROOT, output)
if __name__ == '__main__': unittest.main()
+43
View File
@@ -0,0 +1,43 @@
#include "../components/xpt2046/touchscreen/touch_filter.h"
#include <cassert>
int main() {
using F = esphome::xpt2046::TouchFilter;
esphome::xpt2046::RawCorrection correction;
assert(correction.x(100, 200) == 100 && correction.y(100, 200) == 200);
correction.set(1.073915801f, 0.065912977f, -345.080450614f, 0.044372873f, 0.983997898f, -69.394322384f);
const int corners[][4] = {{830,556,20,20}, {544,3461,299,20},
{3410,3391,299,219}, {3492,372,20,219}};
for (const auto &p : corners) {
const double sx = (correction.y(p[0], p[1]) - 274) * 320.0 / 3335;
const double sy = (correction.x(p[0], p[1]) - 225) * 240.0 / 3575;
assert(std::abs(sx - p[2]) < 5 && std::abs(sy - p[3]) < 5);
}
assert(correction.x(-30000, -30000) == 0);
assert(correction.y(30000, 30000) == 4095);
F f;
// Measured bad top-left first samples must not reach LVGL immediately.
assert(f.sample(true, 2067, 1873) == F::HOLD);
assert(f.sample(true, 535, 500) == F::HOLD);
assert(f.sample(true, 550, 510) == F::HOLD);
assert(f.sample(true, 530, 495) == F::POSITION);
assert(f.x() >= 530 && f.x() <= 550);
assert(f.y() >= 495 && f.y() <= 510);
// A far-away sample cannot move the pointer to another button.
assert(f.sample(true, 3000, 3000) == F::HOLD);
assert(f.sample(true, 540, 500) == F::POSITION);
assert(f.x() < 600);
// One dropped pressure sample doesn't create a second press/release.
assert(f.sample(false, 0, 0) == F::HOLD);
assert(f.sample(true, 550, 500) == F::POSITION);
// A deliberate drag still works, including a large consistent movement.
assert(f.sample(true, 700, 650) == F::POSITION);
assert(f.sample(true, 2000, 2000) == F::HOLD);
assert(f.sample(true, 2010, 2005) == F::HOLD);
assert(f.sample(true, 1990, 2000) == F::POSITION);
assert(f.x() > 1900);
assert(f.sample(false, 0, 0) == F::HOLD);
assert(f.sample(false, 0, 0) == F::RELEASE);
// A lone ghost pulse must never start a gesture.
assert(f.sample(true, 3900, 100) == F::HOLD);
assert(f.sample(false, 0, 0) == F::RELEASE);
}