Relay 0.7.0: een weg naar binnen, en de melding bij elke klik weg

Vier punten van de gebruiker, opgekomen tijdens het beproeven met de testclient.

De getimede leerstand is eruit; de wachtlijst is de enige weg naar binnen. Zijn
redenering: allebei de wegen vragen iemand die bij de app kan, dus het is
dubbelop, en het venster is de zwakste omdat het iedereen toelaat die er
toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek wss-adres
kan staan. De oorspronkelijke reden voor de leerstand, dat je je eigen OwnerId
nergens kon aflezen, verviel toen de weigerlijst dat id ging tonen. Daarmee
verdwijnt ook de bug die hij dezelfde dag meldde: een geleerde eigenaar bleef in
de weigerlijst staan terwijl hij al kon schrijven en lezen, want decideOwner
haalde hem niet van die lijst af en de knop allow wel.

"Refused owners" heet "Waiting list", met de badge Waiting en een teller waar de
widget van het tijdvenster stond. Het veld op schijf blijft rejected: hernoemen
zou een migratie zijn voor een woord dat niemand ziet.

Het adres onderaan zei http:// en dat kan nergens werken, want de relay spreekt
WebSocket en nooit HTTP. Nu ws://<host>:3852, met een regel over wss://<domein>
zonder poort achter een reverse proxy. Dat is precies de fout die diezelfde dag
een ronde kostte bij het koppelen van de testclient.

De melding bij elke klik is weg. Die stond in de gewone stroom van de pagina,
dus alles eronder schoof omlaag en weer omhoog. Nu gaan de knoppen in de lijsten
even op slot tot de ronde de nieuwe stand heeft; foutmeldingen blijven wel staan,
want die zeggen iets wat je nergens anders ziet.

STATE_VERSION blijft 1 en een owners.json van 0.6.0 leest door: learning en
learningUntil worden gelezen, genegeerd en niet teruggeschreven. Een verhoging
zou store.js de allowlist van een werkende installatie opzij laten schuiven.

Twee toetsen bewaken dat de leerstand niet terugsluipt: een onbekende eigenaar
wordt geweigerd ook met learning: true in het bestand, en set-learning is een
onbekende actie. Beide mutatie-getest.

De compose staat op 0.7.0 zonder digest, zodat het hard faalt tot de image
bestaat. Bouwen, duwen en pinnen ligt bij de gebruiker.

Suite: 424 goed, 0 fout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-09-09 15:45:55 +02:00
co-authored by Claude Opus 5
parent 04c20a37f0
commit a092464e4d
13 changed files with 449 additions and 804 deletions
+1 -90
View File
@@ -23,7 +23,6 @@ import os
import socket
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -59,7 +58,7 @@ APP_VERSION = os.environ.get("RELAY_APP_VERSION", "")
# Wat de pagina mag vragen. Expliciet en niet doorgeven wat er binnenkomt: dit
# bestand wordt door een ander proces uitgevoerd, en een onbekende actie hoort
# hier te stranden en niet daar.
ALLOWED_ACTIONS = ("set-learning", "block", "allow", "forget")
ALLOWED_ACTIONS = ("block", "allow", "forget")
MAX_BODY_BYTES = 4096
MAX_OWNER_ID_LENGTH = 256
@@ -73,11 +72,6 @@ MAX_LABEL_LENGTH = 48
# ongeluk blijft schrijven. Ruim boven het aantal eigenaars dat iemand ooit heeft.
MAX_LABELS = 200
# Hoelang de pagina de deur voor nieuwe eigenaars openzet. Dezelfde waarde staat
# in de pagina; die stuurt hem mee en het relay-proces begrenst hem nog een keer.
# Hier staat hij omdat de agent hem moet toestaan, niet omdat hij hem kiest.
MAX_LEARNING_SECONDS = 3600
# Eén schrijver per bestand is de afspraak, maar de agent zelf is meerdradig:
# ThreadingHTTPServer geeft elk verzoek zijn eigen draad. Twee labels die op
# hetzelfde moment binnenkomen zouden elkaar dus kunnen overschrijven, want
@@ -179,59 +173,6 @@ def write_labels(labels):
temporary.replace(LABELS_FILE)
def parse_moment(value):
"""Een ISO-tijdstip uit owners.json als datetime, of None.
Het relay-proces schrijft `new Date().toISOString()`, dus met milliseconden en
met een Z erachter. `fromisoformat` neemt die Z sinds Python 3.11; de image is
python:3-alpine en dus nieuwer. Faalt het alsnog, dan is None het antwoord en
beslist de aanroeper.
"""
if not isinstance(value, str):
return None
try:
when = datetime.fromisoformat(value)
except ValueError:
return None
if when.tzinfo is None:
return when.replace(tzinfo=timezone.utc)
return when
def learning_facts(state):
"""De leerstand zoals de pagina hem hoort te zien.
Het veld in het bestand is niet het hele antwoord: staat er een tijdstip in dat
verstreken is, dan is de deur dicht, ook al staat `learning` nog op true. Het
relay-proces ruimt dat op in zijn eigen lus, en tussen het aflopen en die ronde
zit een seconde of twee. De pagina hoort daar niet "open" te tonen.
De resterende tijd wordt hier uitgerekend en niet in de browser. Dat is met
opzet: dan telt de klok van de Umbrel en niet die van de bezoeker, en die twee
lopen niet per definitie gelijk.
"""
learning = state.get("learning")
until = state.get("learningUntil")
if learning is not True:
return {"learning": learning, "learningUntil": None, "learningSecondsLeft": None}
when = parse_moment(until)
if when is None:
# Geen tijdslot: open tot de gebruiker hem zelf sluit. Dat is de
# begintoestand van een verse installatie.
return {"learning": True, "learningUntil": None, "learningSecondsLeft": None}
resterend = (when - datetime.now(timezone.utc)).total_seconds()
if resterend <= 0:
return {"learning": False, "learningUntil": None, "learningSecondsLeft": 0}
return {
"learning": True,
"learningUntil": until,
"learningSecondsLeft": int(resterend),
}
def database_facts():
try:
stat = DATABASE_FILE.stat()
@@ -262,11 +203,6 @@ def build_status():
state = owners["state"] or {}
labels = read_labels()
# Ontbreekt de staat, dan is 'learning' onbekend en niet 'false'. De pagina
# hoort dat verschil te tonen: onbekend is een reden om te kijken, uit is een
# keuze. learning_facts() geeft None door zoals het binnenkwam.
learning = learning_facts(state)
return {
"version": APP_VERSION or None,
"relay": {
@@ -276,9 +212,6 @@ def build_status():
},
"owners": {
"problem": owners["problem"],
"learning": learning["learning"],
"learningUntil": learning["learningUntil"],
"learningSecondsLeft": learning["learningSecondsLeft"],
"allowed": met_label(
[
entry
@@ -321,28 +254,6 @@ def valid_command(payload):
if action not in ALLOWED_ACTIONS:
return None, "onbekende actie"
if action == "set-learning":
value = payload.get("value")
if not isinstance(value, bool):
return None, "waarde moet true of false zijn"
seconds = payload.get("seconds")
if seconds is None:
# Zonder tijdslot: open tot de gebruiker hem zelf sluit. Dat pad blijft
# bestaan voor een verse installatie, waar een venster van twee minuten
# zou aflopen terwijl je nog aan het koppelen bent.
return {"action": action, "value": value}, None
# isinstance(True, int) is in Python waar, dus een boolean zou hier als
# aantal seconden doorglippen. Vandaar de uitsluiting.
if isinstance(seconds, bool) or not isinstance(seconds, int):
return None, "seconds moet een heel getal zijn"
if seconds <= 0 or seconds > MAX_LEARNING_SECONDS:
return None, "seconds valt buiten het toegestane bereik"
if value is not True:
return None, "seconds hoort alleen bij openzetten"
return {"action": action, "value": value, "seconds": seconds}, None
owner_id = payload.get("ownerId")
if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
return None, "ontbrekende of te lange ownerId"