working camera movement

This commit is contained in:
marcinator_2 committed 2026-10-16 01:38:10 +02:00
1 parent fce8f62534
commit 11e0875a20
21 files changed
+2117 -334

No files matched your search

+50
View File
@@ -74,3 +74,53 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
- Follow-Einrichttests prüfen Hold/Release, ablaufende Bewegungslease, verspätete
Tastenbefehle, falsche/alte Sitzung, Grenzen jenseits gespeicherter Followwerte,
absolute Pulsgrenzen, Richtungsumkehr und persistente Grenze mit Innenabstand.
- `test_pages.py` prüft getrennte Seiten/Poller und unabhängige Grab-/Kamera-
Bedienung. Vorschau darf parallel zur Automatik laufen; echte Bewegung bleibt
gesperrt. Globale Abschaltung muss weiterhin beide Bereiche stoppen.
- `node tests/test_endpoints_ui.js` prüft gespeicherte Werte vs. Speicherkandidaten,
Freigabe der richtigen Seite, Richtungsumkehr und Schutz vor ungefragtem
Speichern beim Start beziehungsweise alten Min-/Max-Formularwerten.
- Endpunkt-UI-Test zusätzlich: eigene numerische Grenzspeicherung, Erhalt der
Farb-/Richtungsparameter, Ablehnung von Nachführgrenzen außerhalb des Einrichtbereichs und Startblockade
bei offenem Eingabeentwurf. Erweiterung des Einrichtbereichs explizit speichern.
- Followtest: alte Konfiguration migrieren ohne Erweiterung; Einrichtgrenzen
dauerhaft speichern, Jog dort begrenzen, Endpunkt mit Innenabstand übernehmen
und Nachführung auf gespeicherte Endpunkte begrenzen. Keine echte Hardware.
- Reine Dokumentationspflege: lokale Links/Anker, konsistente Revisionsangaben
und git diff --check prüfen. Frühere Testzahlen als damaligen Prüfstand
kennzeichnen; daraus keinen neuen Testlauf oder Pi-Test ableiten.
- Startmitten: asymmetrische persistierte Endpunkte, beide Achsen getrennt,
halbe µs aufgerundet, Richtungsumkehr, Bereiche außerhalb 1500 und gesperrte
Achse prüfen. Beide Startmodi nutzen dieselbe Berechnung; Dienstkonstruktion
bewegt nichts. Endpunktspeichern nutzt festen Sitzungsbezug ohne Bewegung,
erneuter Start berechnet neu. JS prüft dieselbe Speicherfreigabe.
- Mittelstellungsbutton: gezielte Service-/API-/JS-Tests für langsame Bewegung
beider Achsen zu den zuletzt gespeicherten Endpunkten, Ziel ohne Überschwingen,
unveränderte Konfiguration, Sitzungs-/Modussperre, Stopp und alte Sequenzen,
Richtungsübernahme und Watchdog-Abbruch. Echte Servos bleiben ungetestet.
- Nachführtempo: Altdateien mit 80 µs/s ohne Endpunktänderung migrieren;
80/400/800 bei 15/20 Auswertungen pro Sekunde, begrenztes Nachholen bei
Bildlücken und FPS-/Laufzeitanzeige mit künstlicher Uhr prüfen. JS prüft
ungespeichertes Tempo beim Start und Erhalt beim Endpunktspeichern.
- `test_camera.py` prüft gemessene Producer-FPS und veraltete Frames mit
künstlicher Uhr. Kein Nachweis der Picamera2-Konfiguration oder Pi-Leistung.
- Suchtests: Opt-in/Altdateimigration, kein Suchen vor Erstfund oder in Vorschau,
Schwenks mit Achsumkehr innerhalb asymmetrischer Follow-Endpunkte, Tempo,
gesperrte Achse, endlicher Durchlauf und 60-s-Limit. Zwei aufeinanderfolgende
Funde, Zwischenverlust, erneute Suche, expliziter Stopp sowie alle Watchdogs
mit künstlicher Uhr/Treiber prüfen. JS: Suchoption speichern/bei Grenzänderung
erhalten, Start mit ungespeicherter Option ablehnen. Keine echte Suchfahrt.
- Zielverlust-Wartezustand: drei Minuten ohne Punkt bei gesunder Sitzung ohne
Bewegungsbefehle, Wiederaufnahme nach zwei Funden, nach Suchende kein weiterer
Durchlauf bis erneutem Fund. Farbe bleibt nach Halt/Dienstneukonstruktion
erhalten, neue Farbwahl ersetzt sie. Fehlende Bilder/Heartbeat/Auswertung
beenden weiterhin Sitzung/PWM; reine Abwesenheit des Punkts nicht.
+19
View File
@@ -0,0 +1,19 @@
"""Measured producer rate without camera hardware or wall-clock sleeps."""
import unittest
from unittest.mock import patch
from halloween.camera import CameraStream
class CameraRateTest(unittest.TestCase):
def test_measured_rate_and_stale_frames(self):
camera = CameraStream()
with patch('halloween.camera.time.monotonic') as clock:
clock.return_value = 10.
self.assertEqual(camera.status()['fps'], 0)
for i in range(60):
clock.return_value = 10+i/15
camera._publish(b'test')
self.assertEqual(camera.status()['fps'], 15.)
clock.return_value += 2
self.assertEqual(camera.status()['fps'], 0)
self.assertEqual(CameraStream(demo=True).status()['target_fps'], 5)
+115
View File
@@ -0,0 +1,115 @@
// Verify endpoint presentation and that starting never saves stray form limits.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync(require('node:path').join(__dirname,'../halloween/camera.html'),'utf8');
const state = html.slice(html.indexOf(' function endpointState('),html.indexOf(' async function pollFollow()'));
const begin = html.slice(html.indexOf(' async function beginFollow('),html.indexOf(" $('follow-preview').onclick"));
const save = html.slice(html.indexOf(' async function saveFollow()'),html.indexOf(" for (const [id,hue]"));
const config={search_enabled:false,follow_speed:80,hue:85,tolerance:18,pan_min:1400,pan_max:1600,tilt_min:1400,tilt_max:1600,pan_reverse:false,tilt_reverse:false,pan_setup_min:1100,pan_setup_max:1900,tilt_setup_min:1100,tilt_setup_max:1900};
const nodes={'follow-speed':{value:80}}, calls=[];
const ctx=vm.createContext({
$:id => nodes[id] || (nodes[id]={checked:false,value:18}),
setupReady:true, manualLimitsDirty:false, configEpoch:0, followConfig:{...config}, followLatest:null,
releaseJog:()=>{}, jogSequence:0, followSession:null, followSample:null, followEpoch:0,
api:async (url,body) => {calls.push({url,body}); return url.endsWith('/start') ? {session:'test'} : body;},
fillFollow:c=>{ctx.followConfig=c;}
});
vm.runInContext(state+begin+save,ctx);
(async()=>{
ctx.sample={config:{...config},pan_us:1100,tilt_us:1500};
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-value-left'].textContent,'Gespeichert: 1400 µs');
assert.equal(nodes['setup-save-left'].disabled,false);
assert.match(nodes['setup-hint-left'].textContent,/1110/);
assert.equal(nodes['setup-save-right'].disabled,true);
assert.equal(nodes['setup-save-up'].disabled,true);
ctx.sample={config:{...config,pan_reverse:true},pan_us:1100,tilt_us:1900};
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-left'].disabled,true);
assert.equal(nodes['setup-save-right'].disabled,false);
assert.equal(nodes['setup-save-down'].disabled,false);
ctx.sample={config:{...config,pan_min:1728,pan_max:1800},pan_us:1718,tilt_us:1500,setup_origin:{pan:1750,tilt:1500}};
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-left'].disabled,false); // above 1500 is valid
assert.equal(nodes['setup-save-right'].disabled,true);
ctx.sample.pan_us=1751;
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-left'].disabled,true); // original session centre
ctx.sample.centering=true;
ctx.sample.pan_us=1718;
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-left'].disabled,true);
assert.match(nodes['setup-hint-left'].textContent,/Mittelstellungsfahrt/);
ctx.sample.centering=false;
ctx.setupReady=false;
vm.runInContext('renderEndpoints(sample)',ctx);
assert.equal(nodes['setup-save-right'].disabled,true);
assert.match(nodes['setup-hint-right'].textContent,/Einrichtmodus/);
await vm.runInContext("beginFollow(false,'setup')",ctx);
assert.deepEqual(calls.map(c=>c.url),['/api/follow/start']);
assert.equal(ctx.followSession,'test');
nodes['follow-pan-reverse'].checked=true;
await assert.rejects(vm.runInContext("beginFollow(false)",ctx),/zuerst dort speichern/);
assert.equal(calls.length,1);
nodes['follow-pan-reverse'].checked=false;
nodes['follow-speed'].value=400;
await assert.rejects(vm.runInContext("beginFollow(false)",ctx),/zuerst dort speichern/);
nodes['follow-speed'].value=80;
nodes['follow-search'].checked=true;
await assert.rejects(vm.runInContext("beginFollow(false)",ctx),/zuerst dort speichern/);
nodes['follow-speed'].value=400;
nodes['follow-pan-reverse'].checked=true;
// A settings save only changes colour/direction/speed, never endpoint values.
nodes['follow-pan-min']={value:500}; nodes['follow-pan-max']={value:500};
await vm.runInContext('saveFollow()',ctx);
assert.equal(calls[1].body.pan_min,1400);
assert.equal(calls[1].body.pan_max,1600);
assert.equal(calls[1].body.pan_reverse,true);
assert.equal(calls[1].body.follow_speed,400);
assert.equal(calls[1].body.search_enabled,true);
assert.ok(html.includes('id="follow-pan-min"'));
assert.ok(html.includes('Diese Position als Links speichern'));
ctx.manualLimitsDirty=true;
await assert.rejects(vm.runInContext("beginFollow(false)",ctx),/noch nicht gespeichert/);
ctx.manualLimitsDirty=false;
const manual=html.slice(html.indexOf(' function fillManualLimits('),html.indexOf(' // Manual limit form bindings.'));
vm.runInContext(manual,ctx);
vm.runInContext('fillManualLimits(followConfig)',ctx);
nodes['follow-pan-min'].value=1200; nodes['follow-pan-max'].value=1800;
nodes['follow-tilt-min']={value:1300}; nodes['follow-tilt-max']={value:1700};
await vm.runInContext('saveManualLimits()',ctx);
assert.equal(calls.at(-1).body.pan_min,1200);
assert.equal(calls.at(-1).body.tilt_max,1700);
assert.equal(calls.at(-1).body.pan_reverse,true);
assert.match(nodes['manual-limits-status'].textContent,/gespeichert/);
assert.equal(calls.at(-1).body.follow_speed,400);
assert.equal(calls.at(-1).body.search_enabled,true);
nodes['follow-tilt-min'].value=1700; nodes['follow-tilt-max'].value=1800;
await vm.runInContext('saveManualLimits()',ctx);
assert.equal(calls.at(-1).body.tilt_min,1700); // range need not contain 1500
const count=calls.length;
nodes['follow-pan-max'].value=2500;
await assert.rejects(vm.runInContext('saveManualLimits()',ctx),/500–2500/);
assert.equal(calls.length,count);
nodes['follow-pan-setup-max'].value=2500;
await vm.runInContext('saveManualLimits()',ctx);
assert.equal(calls.at(-1).body.pan_max,2500);
assert.equal(calls.at(-1).body.pan_setup_max,2500);
nodes['follow-pan-setup-max'].value=2501;
await assert.rejects(vm.runInContext('saveManualLimits()',ctx),/500–2500/);
const centerCode=html.slice(html.indexOf(' async function centerSetup()'),html.indexOf(" $('setup-center').onclick"));
vm.runInContext(centerCode,ctx);
ctx.setupReady=false;
await assert.rejects(vm.runInContext('centerSetup()',ctx),/Einrichtmodus/);
ctx.setupReady=true; ctx.followSession='test'; ctx.jogSequence=5;
ctx.api=async (url,body)=>{calls.push({url,body});return {accepted:true};};
ctx.sendJog=async direction=>{calls.push({url:'/api/follow/jog',body:{direction,sequence:++ctx.jogSequence}});};
await vm.runInContext('centerSetup()',ctx);
assert.equal(calls.at(-1).url,'/api/follow/center');
assert.equal(calls.at(-1).body.sequence,6);
await vm.runInContext('stopSetupMotion()',ctx);
assert.equal(calls.at(-1).body.direction,null);
assert.equal(calls.at(-1).body.sequence,7);
console.log('Endpoint UI tests passed');
})().catch(e=>{console.error(e);process.exit(1);});
+317 -6
View File
@@ -56,6 +56,18 @@ class FollowTest(unittest.TestCase):
self.assertIsNone(locate(frame('red'), DEFAULT))
self.assertIsNotNone(locate(frame('red'), dict(DEFAULT,hue=0)))
def test_similar_coloured_paper_does_not_hide_saturated_marker(self):
image = Image.new('RGB', (640,480), '#181818')
draw = ImageDraw.Draw(image)
draw.rectangle((200,0,560,330), fill=(125,158,204)) # bluish paper, hue ~152, S ~95
draw.rectangle((250,250,520,330), fill=(120,153,198)) # slightly stronger paper shade
draw.ellipse((300,30,350,80), fill=(68,73,177))
data = BytesIO(); image.save(data, 'JPEG'); data = data.getvalue()
for tolerance in (10, 18, 35):
target = locate(data, dict(DEFAULT, hue=165, tolerance=tolerance))
self.assertIsNotNone(target)
self.assertLess(target['y'], .2)
def test_photo_selection_and_no_persistent_image(self):
shot = self.follow.capture()
config = self.follow.select(shot['token'],.74,.45)
@@ -77,9 +89,9 @@ class FollowTest(unittest.TestCase):
def test_rate_limits_reverse_deadband_and_stop(self):
self.start(); driver=self.drivers[0]
self.step({'x':.9,'y':.5})
self.assertAlmostEqual(self.follow.pan,1512.8)
self.assertAlmostEqual(self.follow.pan,1524)
self.assertEqual(self.follow.tilt,1500)
self.assertLessEqual(abs(driver.moves[-1][0]-driver.moves[-2][0]),16)
self.assertLessEqual(abs(driver.moves[-1][0]-driver.moves[-2][0]),40)
for _ in range(30): self.step({'x':1.,'y':1.})
self.assertEqual(self.follow.pan,1600)
self.follow.halt(); self.assertTrue(driver.closed)
@@ -90,6 +102,209 @@ class FollowTest(unittest.TestCase):
self.start(); self.step({'x':.9,'y':.5})
self.assertLess(self.follow.pan,1500)
def test_legacy_speed_migration_preserves_endpoints(self):
import json
legacy = dict(DEFAULT, pan_min=1234, pan_max=1789)
del legacy['follow_speed']
Path(self.folder.name, 'follow.json').write_text(json.dumps(legacy))
restored = FollowService(self.camera, Path(self.folder.name))
self.assertEqual(restored.config, dict(legacy, follow_speed=80))
ancient = {k:v for k,v in legacy.items() if '_setup_' not in k}
self.assertEqual(validate(ancient), restored.config)
for speed in (39, 801, float('nan'), True):
with self.assertRaises(ValueError): validate(dict(DEFAULT, follow_speed=speed))
def test_speed_is_time_based_and_delayed_frame_is_capped(self):
config = dict(DEFAULT, pan_min=500, pan_max=2500,
pan_setup_min=500, pan_setup_max=2500)
for speed in (80, 400, 800):
for fps in (15, 20):
self.follow.halt()
self.follow.configure(dict(config, follow_speed=speed))
self.start()
for number in range(fps):
self.now += 1/fps; self.camera.captured = self.now
self.follow.heartbeat(self.follow.session)
self.follow.process(number, self.camera.jpeg, self.now,
self.follow.revision, {'x':1., 'y':.5}, 3.2)
self.assertAlmostEqual(self.follow.pan, 1500+min(speed, 300))
self.assertEqual(self.follow.status()['fps'], fps)
self.assertEqual(self.follow.status()['analysis_ms'], 3.2)
before = self.follow.pan
self.now += .5; self.camera.captured = self.now
self.follow.process(99, self.camera.jpeg, self.now,
self.follow.revision, {'x':1., 'y':.5})
self.assertAlmostEqual(self.follow.pan-before, min(speed, 300)*.1)
self.follow.halt()
self.assertEqual(self.follow.status()['fps'], 0)
def test_search_is_opt_in_and_requires_prior_target_and_real_session(self):
legacy = dict(DEFAULT); del legacy['search_enabled']
self.assertFalse(validate(legacy)['search_enabled'])
with self.assertRaises(ValueError): validate(dict(DEFAULT, search_enabled=1))
self.follow.configure(dict(DEFAULT, search_enabled=True))
self.assertTrue(FollowService(self.camera,Path(self.folder.name)).config['search_enabled'])
self.start()
for _ in range(6): self.step(None)
self.assertTrue(self.follow.active) # no blind search on start
self.assertTrue(self.follow.waiting_for_target)
self.follow.halt()
self.follow.begin(False)
self.step({'x':.7,'y':.7})
for _ in range(10): self.step(None)
self.assertFalse(self.follow.status()['searching'])
self.assertEqual(len(self.drivers),1) # preview creates no driver
def test_search_sweep_limits_speed_and_finite_completion(self):
for reverse in (False, True):
self.follow.halt()
self.follow.configure(dict(DEFAULT,search_enabled=True,pan_reverse=reverse,
tilt_reverse=reverse,pan_min=1420,pan_max=1580,tilt_min=1430,tilt_max=1570,
follow_speed=800))
self.start(); self.step({'x':.8,'y':.8})
driver=self.drivers[-1]; before=self.follow.pan
self.step(None); self.step(None)
self.assertEqual(self.follow.pan,before) # brief occlusion: hold
for _ in range(6): self.step(None)
self.assertTrue(self.follow.status()['searching'])
self.assertGreater((self.follow.pan-before)*(-1 if reverse else 1),0)
start=len(driver.moves)-1
for _ in range(200):
if not self.follow.active: break
self.step(None)
self.assertTrue(self.follow.active)
self.assertFalse(driver.closed)
self.assertTrue(self.follow.waiting_for_target)
for pan,tilt in driver.moves:
self.assertTrue(1420 <= pan <= 1580)
self.assertTrue(1430 <= tilt <= 1570)
for a,b in zip(driver.moves[start:],driver.moves[start+1:]):
self.assertLessEqual(abs(a[0]-b[0]),20.0001)
self.assertLessEqual(abs(a[1]-b[1]),20.0001)
self.assertEqual({p for p,t in driver.moves} & {1420,1580},{1420,1580})
self.assertEqual({t for p,t in driver.moves} & {1430,1570},{1430,1570})
def test_search_reacquisition_holds_then_resumes_and_new_loss_searches_again(self):
self.follow.configure(dict(DEFAULT,search_enabled=True))
self.start(); self.step({'x':.7,'y':.5})
for _ in range(8): self.step(None)
before=(self.follow.pan,self.follow.tilt)
self.step({'x':.3,'y':.5})
self.assertEqual((self.follow.pan,self.follow.tilt),before)
self.assertTrue(self.follow.status()['reacquiring'])
self.step(None) # isolated detection must not end search
self.assertFalse(self.follow.status()['reacquiring'])
self.step({'x':.5,'y':.5}); self.step({'x':.5,'y':.5})
self.assertFalse(self.follow.status()['searching'])
self.assertTrue(self.follow.active)
for _ in range(8): self.step(None)
self.assertTrue(self.follow.status()['searching'])
self.follow.halt()
self.assertFalse(self.follow.status()['searching'])
self.start()
self.assertIsNone(self.follow.last_target)
def test_search_timeout_locked_axes_and_failure_watchdogs(self):
self.follow.configure(dict(DEFAULT,search_enabled=True,follow_speed=40,
pan_min=500,pan_max=2500,pan_setup_min=500,pan_setup_max=2500,
tilt_min=1500,tilt_max=1500))
self.start(); self.step({'x':.9,'y':.8})
for _ in range(310):
if not self.follow.active: break
self.step(None)
self.assertTrue(self.follow.active)
self.assertTrue(self.follow.waiting_for_target)
self.assertFalse(self.follow.status()['searching'])
self.follow.halt()
self.assertTrue(all(t==1500 for p,t in self.drivers[-1].moves))
for reason in ('camera','heartbeat','inference'):
self.camera.captured=self.now
self.start(); self.step({'x':.9,'y':.5})
for _ in range(8): self.step(None)
self.assertTrue(self.follow.status()['searching'])
self.now+=2
if reason!='camera': self.camera.captured=self.now
if reason=='inference': self.follow.heartbeat(self.follow.session)
self.follow.watchdog()
self.assertFalse(self.follow.active,reason)
self.assertTrue(self.drivers[-1].closed,reason)
def test_missing_target_waits_indefinitely_and_reacquires_without_restarting(self):
self.follow.configure(dict(DEFAULT,hue=179))
session=self.start()
self.step({'x':.7,'y':.5})
driver=self.drivers[-1]; moves=len(driver.moves)
for _ in range(900): self.step(None) # three minutes, healthy connection
self.assertTrue(self.follow.active)
self.assertEqual(self.follow.session,session)
self.assertEqual(len(driver.moves),moves)
self.assertTrue(self.follow.status()['waiting_for_target'])
self.step({'x':.3,'y':.5})
self.assertEqual(len(driver.moves),moves)
self.step({'x':.3,'y':.5})
self.assertFalse(self.follow.waiting_for_target)
self.assertEqual(len(driver.moves),moves+1)
self.assertEqual(self.follow.config['hue'],179)
self.follow.halt()
restored=FollowService(self.camera,Path(self.folder.name))
self.assertFalse(restored.active)
self.assertEqual(restored.config['hue'],179)
self.follow.configure(dict(self.follow.config,hue=0))
self.assertEqual(FollowService(self.camera,Path(self.folder.name)).config['hue'],0)
def test_finished_search_waits_without_repeating_and_reacquires(self):
self.follow.configure(dict(DEFAULT,search_enabled=True))
self.start(); self.step({'x':.7,'y':.5})
for _ in range(500): self.step(None)
self.assertTrue(self.follow.waiting_for_target)
count=len(self.drivers[-1].moves)
for _ in range(100): self.step(None)
self.assertEqual(len(self.drivers[-1].moves),count)
self.step({'x':.3,'y':.5}); self.step({'x':.3,'y':.5})
self.assertFalse(self.follow.waiting_for_target)
self.assertTrue(self.follow.active)
self.assertGreater(len(self.drivers[-1].moves),count)
self.now+=2; self.camera.captured=self.now
self.follow.watchdog()
self.assertFalse(self.follow.active)
self.assertTrue(self.drivers[-1].closed)
def test_start_uses_persisted_independent_endpoint_midpoints(self):
config = dict(DEFAULT, pan_min=1000, pan_max=1800,
pan_setup_min=500, pan_setup_max=2500,
tilt_min=1700, tilt_max=2001, tilt_setup_max=2300,
pan_reverse=True, tilt_reverse=True)
self.follow.configure(config)
for operation in ('setup', 'follow'):
driver = Driver()
service = FollowService(self.camera, Path(self.folder.name),
lambda: driver, lambda: self.now)
self.assertFalse(service.active)
self.assertEqual(driver.moves, []) # restart does not move anything
service.begin(True, True, operation)
self.assertEqual(driver.moves, [(1400,1851)])
self.assertEqual(service.status()['setup_origin'], {'pan':1400,'tilt':1851})
service.halt()
self.follow.configure(dict(config,pan_min=1200,pan_max=1200))
self.start()
self.assertEqual(self.drivers[-1].moves, [(1200,1851)])
def test_remember_uses_fixed_session_origin_and_next_start_recalculates(self):
self.follow.configure(dict(DEFAULT,pan_min=1700,pan_max=1800))
session = self.follow.begin(True,True,'setup')
self.assertEqual(self.follow.pan,1750)
for seq in range(1,5):
self.follow.jog(session,'left',seq); self.step(None)
self.follow.jog(session,None,5)
count = len(self.drivers[-1].moves)
with self.assertRaises(ValueError): self.follow.remember(session,'right')
saved = self.follow.remember(session,'left')
self.assertEqual(saved['pan_min'],1728)
self.assertEqual(self.follow.status()['setup_origin']['pan'],1750)
self.assertEqual(len(self.drivers[-1].moves),count)
self.follow.halt(); self.start()
self.assertEqual(self.drivers[-1].moves,[(1764,1500)])
def test_stale_results_cannot_move_new_session(self):
self.start(); revision=self.follow.revision
self.follow.halt(); self.start(); driver=self.drivers[-1]
@@ -97,7 +312,7 @@ class FollowTest(unittest.TestCase):
self.assertEqual(driver.moves,[(1500,1500)])
def test_lost_marker_heartbeat_and_camera_latch_off(self):
for reason in ('marker','heartbeat','camera','inference'):
for reason in ('heartbeat','camera','inference'):
self.follow.halt(); self.camera.captured=self.now
self.start(); driver=self.drivers[-1]
if reason=='marker':
@@ -112,13 +327,41 @@ class FollowTest(unittest.TestCase):
self.assertIsNotNone(self.follow.error)
def test_invalid_limits_and_corrupt_saved_config(self):
for config in (dict(DEFAULT,pan_min=1600), dict(DEFAULT,pan_max=2000),
for config in (dict(DEFAULT,pan_min=1601), dict(DEFAULT,pan_max=2000),
dict(DEFAULT,hue=float('inf')), dict(DEFAULT,pan_reverse='false')):
with self.assertRaises(ValueError): validate(config)
Path(self.folder.name,'follow.json').write_text('bad')
follow=FollowService(self.camera,Path(self.folder.name))
with self.assertRaises(ValueError): follow.begin()
def test_custom_setup_envelope_persistence_migration_and_follow_limits(self):
old = {k:v for k,v in DEFAULT.items() if '_setup_' not in k}
self.assertEqual(validate(old), DEFAULT)
config = dict(DEFAULT, pan_setup_min=700, pan_setup_max=2300,
tilt_setup_min=1000, tilt_setup_max=2000)
self.follow.configure(config)
self.assertEqual(FollowService(self.camera,Path(self.folder.name)).config, config)
session = self.follow.begin(True,True,'setup')
with self.assertRaises(ValueError): self.follow.configure(DEFAULT)
for seq in range(1,130):
self.follow.jog(session,'right',seq); self.step(None)
self.assertEqual(self.follow.pan,2300)
self.assertEqual(self.follow.tilt,1500)
self.follow.jog(session,None,130)
self.assertEqual(self.follow.remember(session,'right')['pan_max'],2290)
for seq in range(131,350):
self.follow.jog(session,'left',seq); self.step(None)
self.assertEqual(self.follow.pan,700)
self.follow.jog(session,None,350)
self.assertEqual(self.follow.remember(session,'left')['pan_min'],710)
self.follow.halt(); self.start()
for _ in range(100): self.step({'x':1,'y':1})
self.assertEqual(self.follow.pan,2290)
self.assertEqual(self.follow.tilt,1600)
for bad in (dict(config,pan_setup_min=499), dict(config,tilt_setup_max=2501),
dict(config,pan_min=600), dict(config,tilt_max=2100)):
with self.assertRaises(ValueError): validate(bad)
def test_close_error_is_visible_and_restart_is_explicit(self):
self.start()
def bad_close(): raise OSError('I2C ausgefallen')
@@ -184,6 +427,73 @@ class FollowTest(unittest.TestCase):
self.follow.jog(session,None,80)
self.assertEqual(self.follow.remember(session,'up')['tilt_max'],1890)
def test_center_moves_slowly_to_current_saved_limits_without_saving(self):
self.follow.configure(dict(DEFAULT,pan_min=1200,pan_max=1700,
tilt_min=1400,tilt_max=1800))
session = self.follow.begin(True,True,'setup')
for seq in range(1,11):
self.follow.jog(session,'right',seq); self.step(None)
self.follow.jog(session,None,11)
self.follow.remember(session,'right')
for seq in range(12,22):
self.follow.jog(session,'up',seq); self.step(None)
self.follow.jog(session,None,22)
saved = self.follow.path.read_bytes()
before = (self.follow.pan,self.follow.tilt)
self.assertTrue(self.follow.center(session,23)['accepted'])
self.assertEqual((self.follow.pan,self.follow.tilt),before) # no sudden jump
with self.assertRaises(ValueError): self.follow.remember(session,'left')
self.step(None)
self.assertAlmostEqual(self.follow.pan,before[0]-8)
self.assertAlmostEqual(self.follow.tilt,before[1]+8)
for _ in range(40): self.step(None)
self.assertEqual((self.follow.pan,self.follow.tilt),(1360,1600))
self.assertFalse(self.follow.status()['centering'])
self.assertEqual(self.follow.path.read_bytes(),saved)
self.assertEqual(self.follow.setup_origin,{'pan':1450,'tilt':1600})
def test_center_cancellation_stale_commands_watchdog_and_session(self):
with self.assertRaises(ValueError): self.follow.center('none',1)
session = self.start()
with self.assertRaises(ValueError): self.follow.center(session,1)
self.follow.halt(); session = self.follow.begin(True,True,'setup')
with self.assertRaises(ValueError): self.follow.center('wrong',1)
self.follow.jog(session,'right',1); self.step(None)
self.follow.center(session,2)
self.follow.jog(session,None,3)
stopped = self.follow.pan
self.assertFalse(self.follow.center(session,2)['accepted'])
self.step(None); self.assertEqual(self.follow.pan,stopped)
self.follow.center(session,4)
self.follow.jog(session,'right',5); self.step(None)
self.assertGreater(self.follow.pan,stopped)
self.assertFalse(self.follow.status()['centering'])
self.follow.center(session,6)
self.now+=2; self.camera.captured=self.now
self.follow.watchdog()
self.assertFalse(self.follow.active)
self.assertFalse(self.follow.status()['centering'])
self.assertTrue(self.drivers[-1].closed)
with self.assertRaises(ValueError): self.follow.center(session,7)
def test_center_api_requires_header_and_setup_session(self):
async def scenario():
app=create_app(demo=True,state_dir=Path(self.folder.name))
app.state.follow.camera=self.camera
app.state.follow.clock=lambda:self.now
h={'X-Halloween-UI':'1'}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://local') as c:
self.assertEqual((await c.post('/api/follow/center',json={})).status_code,403)
self.assertEqual((await c.post('/api/follow/center',headers=h,json={})).status_code,422)
start=await c.post('/api/follow/start',headers=h,json={'operation':'setup'})
session=start.json()['session']
result=await c.post('/api/follow/center',headers=h,json={'session':session,'sequence':1})
self.assertEqual(result.status_code,200)
self.assertTrue(result.json()['accepted'])
await c.post('/api/follow/stop',headers=h,json={})
self.assertFalse(app.state.follow.status()['centering'])
asyncio.run(scenario())
def test_setup_cannot_be_controlled_by_wrong_session_or_follow_mode(self):
session=self.start()
with self.assertRaises(ValueError): self.follow.jog(session,'up',1)
@@ -220,7 +530,7 @@ class FollowTest(unittest.TestCase):
h={'X-Halloween-UI':'1'}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://local') as c:
self.assertEqual((await c.post('/api/follow/start',json={})).status_code,403)
self.assertEqual((await c.post('/api/follow/start',headers=h,json={'hardware':False})).status_code,200)
self.assertEqual((await c.post('/api/follow/start',headers=h,json={'hardware':True,'acknowledged':True})).status_code,200)
cfg=app.state.detection.get_config(); cfg['enabled']=True
self.assertEqual((await c.post('/api/detection/config',headers=h,json=cfg)).status_code,409)
await c.post('/api/stop',headers=h)
@@ -229,7 +539,8 @@ class FollowTest(unittest.TestCase):
await c.post('/api/reset',headers=h)
self.assertFalse(app.state.follow.active)
await c.post('/api/detection/config',headers=h,json=cfg)
self.assertEqual((await c.post('/api/follow/start',headers=h,json={})).status_code,422)
self.assertEqual((await c.post('/api/follow/start',headers=h,json={})).status_code,200)
self.assertFalse(app.state.controller.stopped)
asyncio.run(scenario())
if __name__=='__main__': unittest.main()
+90
View File
@@ -0,0 +1,90 @@
"""Separate operating pages and independent controls; no real hardware."""
import asyncio
from io import BytesIO
from pathlib import Path
import tempfile
import time
import unittest
import httpx
from PIL import Image
from halloween.web import create_app
class PagesTest(unittest.TestCase):
def test_pages_contain_only_their_own_controls_and_pollers(self):
async def scenario():
with tempfile.TemporaryDirectory() as folder:
app=create_app(demo=True,state_dir=Path(folder))
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://local') as c:
root=(await c.get('/')).text
grave=(await c.get('/grab')).text
camera=(await c.get('/kamera')).text
self.assertEqual(root,grave)
for page in (grave,camera):
self.assertIn('href="/grab"',page)
self.assertIn('href="/kamera"',page)
self.assertIn('Alles stoppen',page)
self.assertIn('id="events-list"',grave)
self.assertNotIn('id="follow-panel"',grave)
self.assertNotIn('/api/follow',grave)
self.assertIn('id="setup-start"',camera)
self.assertIn('src="/stream.mjpg"',camera)
self.assertNotIn('id="detection-enabled"',camera)
for endpoint in ('/api/events','/api/profiles','/api/detection'):
self.assertNotIn(endpoint,camera)
asyncio.run(scenario())
def test_preview_and_grave_controls_are_independent_hardware_is_interlocked(self):
async def scenario():
with tempfile.TemporaryDirectory() as folder:
app=create_app(demo=True,state_dir=Path(folder))
jpeg=BytesIO(); Image.new('RGB',(64,48),'green').save(jpeg,'JPEG')
class Camera:
demo=False
error=None
def latest_timed(self): return 1,jpeg.getvalue(),time.monotonic()
class Driver:
closed=False
def move(self,*args): pass
def close(self): self.closed=True
driver=Driver()
app.state.follow.camera=Camera()
app.state.follow.factory=lambda:driver
h={'X-Halloween-UI':'1'}
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://local') as c:
cfg=app.state.detection.get_config(); cfg['enabled']=True
await c.post('/api/detection/config',headers=h,json=cfg)
start=await c.post('/api/follow/start',headers=h,json={})
self.assertEqual(start.status_code,200)
self.assertFalse(app.state.controller.stopped)
self.assertTrue(app.state.detection.get_config()['enabled'])
await c.post('/api/follow/stop',headers=h,json={})
blocked=await c.post('/api/follow/start',headers=h,json={'hardware':True,'acknowledged':True})
self.assertEqual(blocked.status_code,409)
self.assertFalse(app.state.follow.active)
cfg['enabled']=False
await c.post('/api/detection/config',headers=h,json=cfg)
started=await c.post('/api/follow/start',headers=h,json={'hardware':True,'acknowledged':True,'operation':'setup'})
self.assertEqual(started.status_code,200)
session=started.json()['session']
self.assertFalse(app.state.controller.stopped)
self.assertEqual((await c.post('/api/grave/stop')).status_code,403)
await c.post('/api/grave/stop',headers=h)
self.assertTrue(app.state.controller.stopped)
self.assertTrue(app.state.follow.active)
await c.post('/api/grave/reset',headers=h)
app.state.follow.heartbeat(session)
self.assertFalse(driver.closed)
await c.post('/api/mode',headers=h,json={'mode':'sanft'})
self.assertTrue(app.state.follow.active)
self.assertEqual(app.state.controller.mode,'sanft')
cfg['enabled']=True
self.assertEqual((await c.post('/api/detection/config',headers=h,json=cfg)).status_code,409)
await c.post('/api/stop',headers=h)
self.assertFalse(app.state.follow.active)
self.assertTrue(driver.closed)
self.assertTrue(app.state.controller.stopped)
asyncio.run(scenario())
if __name__=='__main__': unittest.main()