Private
Public Access
working camera movement
This commit is contained in:
1 parent
fce8f62534
commit
11e0875a20
21 files changed
+2117
-334
No files matched your search
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user