diff --git a/config/icons/ekklesia-parent-call.svg b/config/icons/ekklesia-parent-call.svg deleted file mode 100644 index f4661b8..0000000 --- a/config/icons/ekklesia-parent-call.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - diff --git a/config/plasma/applications/ekklesia-parent-call.desktop b/config/plasma/applications/ekklesia-parent-call.desktop index 1a32b1e..c53205e 100644 --- a/config/plasma/applications/ekklesia-parent-call.desktop +++ b/config/plasma/applications/ekklesia-parent-call.desktop @@ -1,10 +1,22 @@ [Desktop Entry] Type=Application Name=Parent Call -Comment=Ask for a child's parent to come to the room +Comment=Ask the sanctuary to call a child's parent to this room +# Breeze's own, and deliberately not a bell. +# +# The previous mark was a bell, which sat two inches from Plasma's +# notification bell in the same bar and meant something entirely +# different. A handset with an outgoing arrow cannot be mistaken for an +# alert: it reads as placing a call, and the arrow says the call leaves +# this room. No stock Breeze icon combines a person with a call, so the +# unambiguous half was chosen over the half that could be read as user +# settings — which is what two grey people on a panel look like. +Icon=call-outgoing Exec=/usr/local/bin/classroom-parent-call -Icon=ekklesia-parent-call Terminal=false Categories=Utility; -StartupNotify=false -NoDisplay=false +StartupNotify=true +StartupWMClass=classroom-parent-call +# Never in an application list. It is reached from the bar, and it is not +# one of the room's applications. +NoDisplay=true diff --git a/config/plasma/xdg/kwinrulesrc b/config/plasma/xdg/kwinrulesrc index 5be0128..63b5b09 100644 --- a/config/plasma/xdg/kwinrulesrc +++ b/config/plasma/xdg/kwinrulesrc @@ -1,6 +1,6 @@ # Window rules. # -# There is one, and it is about video. +# There are two: one about video, one about the Parent Call card. # # Everything else the classroom used to arrange by hand is ordinary window # manager behaviour and needs no rule: a dialog stays in front of the window @@ -17,8 +17,8 @@ # 314-line window arbiter was doing. [General] -count=1 -rules=classroom-video +count=2 +rules=classroom-video,classroom-parent-call [classroom-video] Description=Video is an appliance screen, not a window @@ -30,3 +30,21 @@ wmclassmatch=3 wmclasscomplete=false noborder=true noborderrule=2 + +[classroom-parent-call] +Description=Parent Call is a quick action, not a window +wmclass=classroom-parent-call +wmclassmatch=1 +wmclasscomplete=false +# It is not an application, so it does not belong in the row of them. The +# button that opened it is already in the bar. +skiptaskbar=true +skiptaskbarrule=2 +skipswitcher=true +skipswitcherrule=2 +skippager=true +skippagerrule=2 +# In front of the lesson a teacher was reading when they pressed the +# button, rather than behind it. +above=true +aboverule=2 diff --git a/docs/screenshots/icon-candidates.png b/docs/screenshots/icon-candidates.png new file mode 100644 index 0000000..bd693c5 Binary files /dev/null and b/docs/screenshots/icon-candidates.png differ diff --git a/docs/screenshots/pc-crop.png b/docs/screenshots/pc-crop.png new file mode 100644 index 0000000..f97325b Binary files /dev/null and b/docs/screenshots/pc-crop.png differ diff --git a/docs/screenshots/pc-not-set-up.png b/docs/screenshots/pc-not-set-up.png new file mode 100644 index 0000000..0bce018 Binary files /dev/null and b/docs/screenshots/pc-not-set-up.png differ diff --git a/docs/screenshots/pc-ready.png b/docs/screenshots/pc-ready.png new file mode 100644 index 0000000..cfbee71 Binary files /dev/null and b/docs/screenshots/pc-ready.png differ diff --git a/docs/screenshots/pc-sent.png b/docs/screenshots/pc-sent.png new file mode 100644 index 0000000..3bbc46a Binary files /dev/null and b/docs/screenshots/pc-sent.png differ diff --git a/docs/screenshots/pc-state.png b/docs/screenshots/pc-state.png new file mode 100644 index 0000000..78d759c Binary files /dev/null and b/docs/screenshots/pc-state.png differ diff --git a/docs/screenshots/pc-typed.png b/docs/screenshots/pc-typed.png new file mode 100644 index 0000000..7a588f7 Binary files /dev/null and b/docs/screenshots/pc-typed.png differ diff --git a/docs/screenshots/pc-waiting.png b/docs/screenshots/pc-waiting.png new file mode 100644 index 0000000..ac6fd48 Binary files /dev/null and b/docs/screenshots/pc-waiting.png differ diff --git a/lib/classroom_parent_call.py b/lib/classroom_parent_call.py new file mode 100644 index 0000000..bb9b843 --- /dev/null +++ b/lib/classroom_parent_call.py @@ -0,0 +1,290 @@ +"""The classroom's side of Parent Call. + +A teacher types a child's name and presses a button. This module holds +everything about that which does not need a broker: the topics, the shape +of a request, how to read a status, and what the teacher may be told. + +**The contract is the coordinator's, not ours.** It is documented in +`/home/media/docker/parent-call/README.md` on the control computer and was +read from there rather than guessed. Four fields go out; anything else in +the payload is discarded at the far end. Every word the teacher reads comes +back from the coordinator in the `message` field, because the coordinator +is the only thing that knows what actually happened. + +The rule the whole design turns on: + + A ProPresenter call that succeeded means "sent to sanctuary". + It never means "displayed". + +Nothing here says a call was seen, because nothing here knows. +""" + +import re +import secrets +import time +import unicodedata + +DEFAULT_ROOT = "classroom/parent-call" + +# Statuses the coordinator can send. Names and wording are its, verbatim; +# the wording is only a fallback for a status that arrives without a +# message, because the coordinator's own text is what should be read. +SUBMITTED = "submitted" +WAITING_APPROVAL = "waiting_approval" +SENDING = "sending" +SENT_TO_SANCTUARY = "sent_to_sanctuary" +REJECTED = "rejected" +EXPIRED = "expired" +UNAVAILABLE = "unavailable" +ERROR = "error" + +STATUSES = ( + SUBMITTED, + WAITING_APPROVAL, + SENDING, + SENT_TO_SANCTUARY, + REJECTED, + EXPIRED, + UNAVAILABLE, + ERROR, +) + +WORDING = { + SUBMITTED: "Sending", + WAITING_APPROVAL: "Waiting for approval", + SENDING: "Sending to sanctuary", + SENT_TO_SANCTUARY: "Sent to sanctuary", + REJECTED: "Not approved", + EXPIRED: "Expired before it was sent", + UNAVAILABLE: "Parent Call is unavailable", + ERROR: "Could not reach the sanctuary", +} + +# Once one of these arrives there is nothing further to wait for, and the +# classroom stops listening. The coordinator's README asks for exactly +# this. +TERMINAL = frozenset(( + SENT_TO_SANCTUARY, + REJECTED, + EXPIRED, + UNAVAILABLE, + ERROR, +)) + +# Only this one means the call is on its way and the teacher can walk away. +GOOD = frozenset((SENT_TO_SANCTUARY,)) + +MODE_AUTO = "auto" +MODE_APPROVAL = "approval" +MODE_OFF = "off" +MODES = (MODE_AUTO, MODE_APPROVAL, MODE_OFF) + +# The coordinator's own limit, so a name is refused here rather than +# after a round trip. MAX_CHILD_LENGTH in its .env. +MAX_CHILD = 40 + +# Its id shape, so an id this end builds can never be rejected as +# unusable — the one failure it cannot answer, because the id is the +# topic it would answer on. +ID_SHAPE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") + +# Control, format, unassigned, surrogate, private-use, and every kind of +# space separator. The coordinator strips these; doing the same here +# means the teacher sees the name that will actually be sent. +INVISIBLE = frozenset(("Cc", "Cf", "Cn", "Co", "Cs", "Zl", "Zp", "Zs")) + + +class Topics: + """Every topic the classroom touches, from one root.""" + + def __init__(self, root=DEFAULT_ROOT): + self.root = (root or DEFAULT_ROOT).rstrip("/") + + @property + def mode(self): + """Retained. auto | approval | off, as plain text.""" + return f"{self.root}/mode" + + @property + def coordinator(self): + """Retained. Whether the sanctuary end is up at all.""" + return f"{self.root}/coordinator" + + @property + def request(self): + """Never retained: a request is an event, and last Sunday's call + must not replay into this one.""" + return f"{self.root}/request" + + def status(self, request_id): + return f"{self.root}/status/{request_id}" + + +def visible(value): + """The same text with everything invisible turned into plain spaces. + + The same rule the coordinator applies, so the name in the field is + the name that reaches the screen. + """ + swapped = "".join( + " " if unicodedata.category(char) in INVISIBLE else char + for char in value + ) + + return " ".join(swapped.split()) + + +def clean_child(value): + """A child's name as it will be sent, or None if it is not one.""" + if not isinstance(value, str): + return None + + name = visible(unicodedata.normalize("NFC", value)) + + if not name or len(name) > MAX_CHILD: + return None + + return name + + +def new_request_id(room, token=None): + """A room-scoped id that the coordinator will accept. + + Random rather than sequential: two rooms calling in the same second + must not be able to collide, and an id that can be predicted is an id + somebody else's status could be read on. + """ + suffix = token or secrets.token_hex(3) + candidate = f"{room}-{suffix}" + + return candidate if ID_SHAPE.match(candidate) else suffix + + +def request_payload(request_id, room, child, now=None): + """The whole vocabulary a classroom has: four fields. + + No ProPresenter text, no message name, no theme. The coordinator + builds what reaches the screen from values that passed its own + validation, and a classroom cannot reach past it. + """ + return { + "id": request_id, + "room": room, + "child": child, + "created_at": int(now if now is not None else time.time()), + } + + +def read_mode(payload): + """auto, approval or off — or None if it is not one of those.""" + if isinstance(payload, (bytes, bytearray)): + try: + payload = payload.decode("utf-8") + except UnicodeDecodeError: + return None + + if not isinstance(payload, str): + return None + + mode = payload.strip().lower() + + return mode if mode in MODES else None + + +def read_coordinator(payload): + """Whether the sanctuary end says it is up. + + Anything unreadable is treated as down. A classroom that cannot tell + should offer to send nothing, rather than let a teacher believe a + call went somewhere. + """ + import json + + if isinstance(payload, (bytes, bytearray)): + try: + payload = payload.decode("utf-8") + except UnicodeDecodeError: + return False + + if not isinstance(payload, str) or not payload.strip(): + return False + + try: + body = json.loads(payload) + except ValueError: + return False + + return bool(isinstance(body, dict) and body.get("online") is True) + + +def read_status(payload, request_id=None): + """A status update, or None if this is not one worth showing. + + Defensive on purpose: this is the one message the teacher reads, and + a malformed payload must leave the dialog saying what it said before + rather than showing nonsense. + """ + import json + + if isinstance(payload, (bytes, bytearray)): + try: + payload = payload.decode("utf-8") + except UnicodeDecodeError: + return None + + try: + body = json.loads(payload) + except (TypeError, ValueError): + return None + + if not isinstance(body, dict): + return None + + status = body.get("status") + + if not isinstance(status, str) or status not in STATUSES: + return None + + # A status for somebody else's request is not ours to show. It should + # never arrive — we subscribe to one topic — but a wildcard mistake + # would otherwise put another room's child on this screen. + if request_id is not None and body.get("id") not in (None, request_id): + return None + + message = body.get("message") + + if not isinstance(message, str) or not message.strip(): + message = WORDING.get(status, status) + + detail = body.get("detail") + + return { + "id": body.get("id"), + "status": status, + "message": visible(message)[:200], + "detail": visible(detail)[:200] if isinstance(detail, str) else None, + "terminal": status in TERMINAL, + "good": status in GOOD, + } + + +def availability(mode, coordinator_online, broker_reachable=True): + """Whether a teacher may send a call now, and what to say if not. + + Returns (can_send, message). The wording for an off mode is the + coordinator's own, so the room says the same thing the sanctuary + would. + """ + if not broker_reachable: + return False, "Cannot reach the church network" + + if not coordinator_online: + return False, "The sanctuary system is not running" + + if mode == MODE_OFF: + return False, WORDING[UNAVAILABLE] + + if mode is None: + return False, "Parent Call has not been set up yet" + + return True, None diff --git a/scripts/classroom-parent-call b/scripts/classroom-parent-call new file mode 100755 index 0000000..2fb3159 --- /dev/null +++ b/scripts/classroom-parent-call @@ -0,0 +1,762 @@ +#!/usr/bin/env python3 + +"""Ask the sanctuary to call a child's parent. + +A quick action, not an application. The panel launches this, it puts one +small card on the screen, and it goes away again. Everything it knows +about the church end comes from the Parent Call coordinator's own MQTT +contract; see classroom_parent_call for the contract itself. + +Why this is a separate process, and not a Plasma applet: + + the panel is the only way a teacher has of getting anywhere, and a + broker library, a network timeout and a text entry have no business + living inside it. If this crashes, the bar is untouched and clicking + the button again starts a new one. + +Two rules it will not bend: + + * a teacher is never told a call succeeded unless the coordinator + said so, and the strongest thing the coordinator ever says is + "Sent to sanctuary" — never "displayed"; + * nothing is published until the status topic for that request is + already subscribed, so an answer cannot arrive before anyone is + listening. +""" + +import os +import sys +import threading +import time + +import gi + +gi.require_version("Gtk", "3.0") +# Gdk has to be pinned too. Without it PyGObject warns that it may load a +# different major version than Gtk, and the key constants this file +# compares against would then be somebody else's. +gi.require_version("Gdk", "3.0") + +from gi.repository import Gdk, GLib, Gtk # noqa: E402 + +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") +sys.path.insert( + 0, + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"), +) + +import classroom_parent_call as contract # noqa: E402 +from classroom_config import read_device_config # noqa: E402 +from classroom_mqtt import room_name # noqa: E402 + +APP_ID = "classroom-parent-call" + +# How long to wait for the broker before telling the teacher the church +# network cannot be reached. Short: a teacher standing at the machine +# would rather be told quickly than watch a spinner. +CONNECT_TIMEOUT = 5.0 + +# Retained topics arrive immediately after subscribing, or never. This is +# how long "never" takes to establish. +RETAINED_GRACE = 1.5 + +# The coordinator answers a request almost at once. Silence for this long +# means it did not hear us, which is not the same as a refusal and is not +# reported as one. +FIRST_STATUS_TIMEOUT = 8.0 + +# Once it has answered, it may legitimately take as long as its own +# request expiry to finish — an operator has to approve. Its REQUEST_TTL +# is 180s; this is that with room to spare. +OVERALL_TIMEOUT = 210.0 + +# Long enough for a teacher to read "Sent to sanctuary" and walk away. +CLOSE_AFTER_SUCCESS = 4.0 + + +def load_colours(): + """The session's own palette, read from the colour scheme in use. + + There is one set of design tokens in this classroom, and they live in + a Plasma colour scheme. Reading them here rather than repeating them + means this card follows classroom-theme light and dark without + knowing that either exists. + """ + palette = { + "window": "#141c20", "view": "#0f171a", "text": "#e6edef", + "dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518", + "negative": "#e36a6a", "neutral": "#e0b15c", "positive": "#6fcf97", + "line": "#25333a", + } + + scheme = "EkklesiaDark" + + for path in ( + os.path.expanduser("~/.config/kdeglobals"), + "/etc/ekklesia-classroom/xdg/kdeglobals", + "/etc/xdg/kdeglobals", + ): + try: + with open(path, "r", encoding="utf-8") as handle: + section = None + for line in handle: + line = line.strip() + if line.startswith("["): + section = line + elif section and section.startswith("[General]") and \ + line.startswith("ColorScheme="): + scheme = line.split("=", 1)[1].strip() + break + except OSError: + continue + else: + if scheme: + break + + def rgb(value): + parts = [part.strip() for part in value.split(",")] + if len(parts) != 3: + return None + try: + return "#%02x%02x%02x" % tuple(int(part) for part in parts) + except ValueError: + return None + + try: + with open(f"/usr/share/color-schemes/{scheme}.colors", "r", + encoding="utf-8") as handle: + group = None + for line in handle: + line = line.strip() + if line.startswith("["): + group = line + continue + if "=" not in line or group is None: + continue + key, value = (part.strip() for part in line.split("=", 1)) + colour = rgb(value) + if colour is None: + continue + if group == "[Colors:Window]": + if key == "BackgroundNormal": + palette["window"] = colour + elif key == "ForegroundNormal": + palette["text"] = colour + elif key == "ForegroundInactive": + palette["dim"] = colour + elif key == "ForegroundNegative": + palette["negative"] = colour + elif key == "ForegroundNeutral": + palette["neutral"] = colour + elif key == "ForegroundPositive": + palette["positive"] = colour + elif key == "BackgroundAlternate": + palette["line"] = colour + elif group == "[Colors:View]" and key == "BackgroundNormal": + palette["view"] = colour + elif group == "[Colors:Selection]": + if key == "BackgroundNormal": + palette["accent"] = colour + elif key == "ForegroundNormal": + palette["on_accent"] = colour + except OSError: + pass + + return palette + + +STYLE = """ +* {{ font-family: "Noto Sans", "DejaVu Sans", sans-serif; }} + +/* The card draws its own corners, so the window behind it is nothing at + all. Rounding a window any other way would mean asking the compositor + for something, and this one is ours to draw. */ +window {{ background-color: transparent; }} + +#card {{ + background-color: {window}; + border: 1px solid {line}; + border-radius: 12px; + padding: 24px 26px 20px 26px; + margin: 10px; +}} + +#title {{ color: {text}; font-size: 17px; font-weight: 600; }} +#room {{ color: {accent}; font-size: 13px; font-weight: 600; + letter-spacing: 0.06em; }} +#hint {{ color: {dim}; font-size: 12px; }} +#status {{ color: {text}; font-size: 15px; font-weight: 600; }} +#detail {{ color: {dim}; font-size: 12px; }} +#child {{ color: {dim}; font-size: 13px; }} + +#status.good {{ color: {positive}; }} +#status.bad {{ color: {negative}; }} +#status.waiting {{ color: {neutral}; }} + +entry {{ + background-color: {view}; + color: {text}; + border: 1px solid {line}; + border-radius: 8px; + padding: 10px 12px; + font-size: 16px; + caret-color: {accent}; +}} +entry:focus {{ border-color: {accent}; }} +entry:disabled {{ color: {dim}; }} + +button {{ + border-radius: 8px; + padding: 9px 18px; + font-size: 14px; + font-weight: 600; + border: 1px solid {line}; + background-image: none; + background-color: transparent; + color: {text}; +}} +button:hover {{ background-color: {line}; }} + +button#primary {{ + background-color: {accent}; + color: {on_accent}; + border-color: {accent}; +}} +/* Disabled has to read as "not yet", not as a broken button: the accent + drains out of it rather than the whole thing fading. */ +button#primary:disabled {{ + background-color: transparent; + color: {dim}; + border-color: {line}; +}} +""" + + +class Broker(threading.Thread): + """The MQTT half, kept off the interface thread. + + Every callback hands its result to GTK with GLib.idle_add and touches + no widget itself. + """ + + def __init__(self, config, topics, on_event): + super().__init__(daemon=True) + self.config = config + self.topics = topics + self.on_event = on_event + self.client = None + self.request_id = None + self._pending = None + self._lock = threading.Lock() + + # -- lifecycle ----------------------------------------------------- + + def run(self): + try: + import paho.mqtt.client as mqtt + except ImportError: + self.emit("broker", reachable=False, why="no broker library") + return + + host = (self.config.get("MQTT_HOST") or "").strip() + + if not host or (self.config.get("MQTT_ENABLED") or "").strip().lower() \ + not in ("1", "true", "yes", "on"): + self.emit("broker", reachable=False, why="not configured") + return + + try: + port = int((self.config.get("MQTT_PORT") or "1883").strip()) + except ValueError: + port = 1883 + + # A clean session on purpose. Nothing from a previous connection + # may be redelivered: a parent call from last Sunday arriving now + # would be worse than one that was lost. + try: + client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, + client_id=f"classroom-parent-call-{os.getpid()}", + clean_session=True, + ) + except (AttributeError, TypeError): + client = mqtt.Client( + client_id=f"classroom-parent-call-{os.getpid()}", + clean_session=True, + ) + + username = (self.config.get("MQTT_USERNAME") or "").strip() + + if username: + client.username_pw_set( + username, (self.config.get("MQTT_PASSWORD") or "").strip() or None + ) + + client.on_connect = self._connected + client.on_message = self._message + client.on_disconnect = self._disconnected + + self.client = client + + try: + client.connect(host, port, keepalive=30) + except (OSError, ValueError) as error: + self.emit("broker", reachable=False, why=str(error)) + return + + client.loop_forever(retry_first_connection=False) + + def stop(self): + client = self.client + + if client is not None: + try: + client.disconnect() + except Exception: + pass + + # -- callbacks ----------------------------------------------------- + + def _connected(self, client, userdata, flags, reason_code, properties=None): + if getattr(reason_code, "is_failure", False) or \ + (isinstance(reason_code, int) and reason_code != 0): + self.emit("broker", reachable=False, why=f"refused ({reason_code})") + return + + client.subscribe([(self.topics.mode, 1), (self.topics.coordinator, 1)]) + self.emit("broker", reachable=True) + + def _disconnected(self, client, userdata, *args): + self.emit("disconnected") + + def _message(self, client, userdata, message): + if message.topic == self.topics.mode: + self.emit("mode", mode=contract.read_mode(message.payload)) + elif message.topic == self.topics.coordinator: + self.emit("coordinator", + online=contract.read_coordinator(message.payload)) + elif self.request_id and message.topic == self.topics.status(self.request_id): + update = contract.read_status(message.payload, self.request_id) + + if update: + self.emit("status", update=update) + + # -- sending ------------------------------------------------------- + + def send(self, request_id, room, child): + """Subscribe first, publish second. Never the other way round.""" + client = self.client + + if client is None: + self.emit("send-failed", why="not connected") + return + + with self._lock: + self.request_id = request_id + + body = contract.request_payload(request_id, room, child) + + import json + + def after_subscribe(_client, _userdata, _mid, _codes, _properties=None): + # QoS 1 so the coordinator's answer is not lost on a flaky + # link, and never retained: a request is an event. + client.publish(self.topics.request, json.dumps(body), qos=1, + retain=False) + self.emit("published") + + client.on_subscribe = after_subscribe + client.subscribe(self.topics.status(request_id), qos=1) + + def emit(self, kind, **data): + GLib.idle_add(self.on_event, kind, data) + + +class ParentCall(Gtk.Window): + def __init__(self): + super().__init__(title="Parent Call") + + self.config = read_device_config() + self.room = room_name(self.config) + self.topics = contract.Topics( + (self.config.get("PARENT_CALL_ROOT") or contract.DEFAULT_ROOT).strip() + ) + + self.mode = None + self.coordinator_online = False + self.broker_reachable = None + self.request_id = None + self.sent_at = None + self.settled = False + self.autosend = False + self._timers = [] + + self.set_decorated(False) + self.set_titlebar(Gtk.Box()) + self.set_resizable(False) + self.set_default_size(460, -1) + # Deliberately not set_position(): a Wayland client cannot place + # itself, and asking for the centre put the card in the top-left + # corner instead. KWin centres it, because kwinrc already asks for + # every window to be centred — no rule of its own is needed, and + # the one that was written for it turned out to be doing nothing. + self.set_type_hint(Gdk.WindowTypeHint.DIALOG) + # Belt and braces with the window rule: whichever of the two KWin + # honours, the card must not end up behind the lesson a teacher + # was reading when they pressed the button. + self.set_keep_above(True) + + screen = self.get_screen() + visual = screen.get_rgba_visual() + + if visual is not None: + self.set_visual(visual) + + self.set_app_paintable(True) + + self._build() + self._apply_style() + + self.connect("key-press-event", self._key) + self.connect("destroy", lambda *_: self._quit()) + + self.broker = Broker(self.config, self.topics, self._event) + self.broker.start() + + self._after(CONNECT_TIMEOUT, self._connect_deadline) + + # -- interface ----------------------------------------------------- + + def _build(self): + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14) + card.set_name("card") + + heading = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + title = Gtk.Label(label="Parent Call", xalign=0) + title.set_name("title") + heading.pack_start(title, False, False, 0) + + # The room is stated, never asked. This machine already knows + # which room it is, and a teacher picking it from a list is a + # chance to pick the wrong one. + room = Gtk.Label(label=self.room.upper(), xalign=0) + room.set_name("room") + room.set_valign(Gtk.Align.CENTER) + heading.pack_end(room, False, False, 0) + card.pack_start(heading, False, False, 0) + + self.hint = Gtk.Label( + label="Who should we call a parent for?", xalign=0) + self.hint.set_name("hint") + card.pack_start(self.hint, False, False, 0) + + self.entry = Gtk.Entry() + self.entry.set_placeholder_text("Child's name") + self.entry.set_max_length(contract.MAX_CHILD) + self.entry.set_sensitive(False) + self.entry.connect("activate", lambda *_: self._send()) + self.entry.connect("changed", lambda *_: self._refresh_button()) + card.pack_start(self.entry, False, False, 0) + + self.status = Gtk.Label(label="", xalign=0) + self.status.set_name("status") + self.status.set_line_wrap(True) + self.status.set_no_show_all(True) + card.pack_start(self.status, False, False, 0) + + self.detail = Gtk.Label(label="", xalign=0) + self.detail.set_name("detail") + self.detail.set_line_wrap(True) + self.detail.set_no_show_all(True) + card.pack_start(self.detail, False, False, 0) + + buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + buttons.set_halign(Gtk.Align.END) + + self.close_button = Gtk.Button(label="Close") + self.close_button.connect("clicked", lambda *_: self._quit()) + buttons.pack_start(self.close_button, False, False, 0) + + self.primary = Gtk.Button(label="Call parent") + self.primary.set_name("primary") + self.primary.set_sensitive(False) + self.primary.connect("clicked", lambda *_: self._send()) + buttons.pack_start(self.primary, False, False, 0) + + card.pack_start(buttons, False, False, 0) + + card.set_size_request(440, -1) + self.add(card) + card.show_all() + self.entry.grab_focus() + + self._set_status("Checking with the sanctuary…", tone=None) + + def _apply_style(self): + css = Gtk.CssProvider() + css.load_from_data(STYLE.format(**load_colours()).encode("utf-8")) + Gtk.StyleContext.add_provider_for_screen( + self.get_screen(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + def _set_status(self, text, tone=None, detail=None): + style = self.status.get_style_context() + + for name in ("good", "bad", "waiting"): + style.remove_class(name) + + if tone: + style.add_class(tone) + + self.status.set_text(text or "") + self.status.set_visible(bool(text)) + + self.detail.set_text(detail or "") + self.detail.set_visible(bool(detail)) + + def _refresh_button(self): + ready = ( + self.request_id is None + and self.broker_reachable + and contract.clean_child(self.entry.get_text()) is not None + and contract.availability( + self.mode, self.coordinator_online, self.broker_reachable + )[0] + ) + self.primary.set_sensitive(bool(ready)) + + def _unavailable(self, message): + self.settled = True + self.entry.set_sensitive(False) + self.primary.hide() + self.hint.hide() + self.entry.hide() + self.close_button.set_label("Close") + self._set_status(message, tone="bad") + + # -- events -------------------------------------------------------- + + def _event(self, kind, data): + if kind == "broker": + self.broker_reachable = data.get("reachable") + + if not self.broker_reachable: + self._unavailable("Cannot reach the church network") + else: + self._after(RETAINED_GRACE, self._retained_deadline) + + elif kind == "mode": + self.mode = data.get("mode") + self._settle() + + elif kind == "coordinator": + self.coordinator_online = bool(data.get("online")) + self._settle() + + elif kind == "status": + self._status_arrived(data["update"]) + + elif kind == "published": + self.sent_at = time.monotonic() + self._after(FIRST_STATUS_TIMEOUT, self._first_status_deadline) + + elif kind == "send-failed": + self._set_status("The call was not sent", tone="bad", + detail="Press Call parent to try again.") + self.request_id = None + self._refresh_button() + + return False + + def _settle(self, final=False): + """Decide what to offer, once the retained topics have spoken. + + A negative is only ever reached with final=True. The mode and the + coordinator's availability are two separate retained messages and + they arrive in whichever order the broker sends them — deciding on + the first one told a teacher the sanctuary was down while the + message saying otherwise was still in flight. Measured, in the + lab, on a coordinator that was demonstrably up. + """ + if self.request_id is not None or self.settled: + return + + can, why = contract.availability( + self.mode, self.coordinator_online, self.broker_reachable + ) + + if can: + self.entry.set_sensitive(True) + self.entry.grab_focus() + note = ("A volunteer will approve it first." + if self.mode == contract.MODE_APPROVAL else None) + self._set_status("", tone=None, detail=note) + self.settled = True + self._refresh_button() + + if self.autosend: + self.autosend = False + GLib.timeout_add(300, lambda: (self._send(), False)[1]) + elif final and why: + self._unavailable(why) + + def _status_arrived(self, update): + self._set_status( + update["message"], + tone=("good" if update["good"] + else "bad" if update["terminal"] + else "waiting"), + detail=update.get("detail"), + ) + + if not update["terminal"]: + return + + # Terminal: stop listening, as the coordinator's contract asks. + self.broker.request_id = None + + if update["good"]: + self.close_button.hide() + self._after(CLOSE_AFTER_SUCCESS, self._quit) + else: + self.request_id = None + self.entry.set_sensitive(True) + self.entry.show() + self.hint.hide() + self.primary.set_label("Try again") + self.primary.show() + self._refresh_button() + + # -- sending ------------------------------------------------------- + + def _send(self): + if self.request_id is not None: + return + + child = contract.clean_child(self.entry.get_text()) + + if child is None: + self._set_status("Enter the child's name", tone="bad") + return + + can, why = contract.availability( + self.mode, self.coordinator_online, self.broker_reachable + ) + + if not can: + self._unavailable(why or contract.WORDING[contract.UNAVAILABLE]) + return + + self.request_id = contract.new_request_id(self.room) + self.entry.set_sensitive(False) + self.primary.set_sensitive(False) + self.primary.hide() + self.hint.set_text(f"Calling a parent for {child}") + self.hint.show() + self._set_status("Sending", tone="waiting") + + self.broker.send(self.request_id, self.room, child) + self._after(OVERALL_TIMEOUT, self._overall_deadline) + + # -- deadlines ----------------------------------------------------- + + def _after(self, seconds, callback): + self._timers.append(GLib.timeout_add(int(seconds * 1000), callback)) + + def _connect_deadline(self): + if self.broker_reachable is None: + self._unavailable("Cannot reach the church network") + return False + + def _retained_deadline(self): + # Retained topics arrive at once or not at all. Now that both have + # had their chance, a negative is safe to show. + self._settle(final=True) + + if not self.settled: + self._unavailable("Parent Call has not been set up yet") + + return False + + def _first_status_deadline(self): + if self.request_id is not None and self.status.get_text() == "Sending": + # Silence is not a refusal, and is not reported as one. + self._set_status("No answer from the sanctuary", tone="bad", + detail="The call was not confirmed. " + "Press Try again, or ask for help.") + self.request_id = None + self.entry.set_sensitive(True) + self.primary.set_label("Try again") + self.primary.show() + self._refresh_button() + return False + + def _overall_deadline(self): + if self.request_id is not None: + self._set_status("No answer from the sanctuary", tone="bad", + detail="The call was not confirmed.") + self.request_id = None + self.primary.set_label("Try again") + self.primary.show() + self._refresh_button() + return False + + # -- leaving ------------------------------------------------------- + + def _key(self, _widget, event): + """Escape leaves. Nothing else does. + + Deliberately narrow: an earlier version compared against a Gdk + that had not been version-pinned, and in the lab an ordinary + letter closed the card. + """ + if event.keyval == Gdk.KEY_Escape and not event.state & ( + Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD1_MASK + ): + self._quit() + return True + + return False + + def _quit(self, *_): + for timer in self._timers: + GLib.source_remove(timer) + self._timers = [] + + self.broker.stop() + Gtk.main_quit() + return False + + +def main(): + GLib.set_prgname(APP_ID) + GLib.set_application_name("Parent Call") + + # --child fills the name in, and --send presses the button once the + # sanctuary has answered for whether it may be pressed. Only the lab + # uses them: there is no teacher-facing path to either, and neither + # can send a call the coordinator would not have accepted anyway. + child = None + autosend = "--send" in sys.argv + + if "--child" in sys.argv: + index = sys.argv.index("--child") + 1 + if index < len(sys.argv): + child = sys.argv[index] + + window = ParentCall() + + if child: + window.entry.set_text(child) + + if autosend: + window.autosend = True + + window.show() + + Gtk.main() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-classroom-parent-call.sh b/tests/test-classroom-parent-call.sh new file mode 100755 index 0000000..20df31a --- /dev/null +++ b/tests/test-classroom-parent-call.sh @@ -0,0 +1,164 @@ +#!/bin/bash +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PYTHONPATH="$ROOT/lib" python3 - <<'PY' +import json + +from classroom_parent_call import ( + ERROR, EXPIRED, MODE_APPROVAL, MODE_AUTO, MODE_OFF, REJECTED, + SENT_TO_SANCTUARY, SUBMITTED, UNAVAILABLE, WAITING_APPROVAL, WORDING, + Topics, availability, clean_child, new_request_id, read_coordinator, + read_mode, read_status, request_payload, +) + +passed = failed = 0 + + +def check(label, condition, detail=""): + global passed, failed + if condition: + print(f" ok {label}") + passed += 1 + else: + print(f" FAIL {label}") + if detail: + print(f" {detail}") + failed += 1 + + +print("1. the coordinator's topics, exactly") +t = Topics() +check("root is the church's, with no product name", + t.root == "classroom/parent-call") +check("mode", t.mode == "classroom/parent-call/mode") +check("coordinator", t.coordinator == "classroom/parent-call/coordinator") +check("request", t.request == "classroom/parent-call/request") +check("status is per request", + t.status("nursery-8f21c3") == "classroom/parent-call/status/nursery-8f21c3") +check("a different root carries through", + Topics("test/pc").request == "test/pc/request") +check("a trailing slash does not double up", + Topics("test/pc/").mode == "test/pc/mode") + +print() +print("2. a request is four fields and nothing else") +body = request_payload("nursery-8f21c3", "nursery", "Emma", now=1780000000) +check("exactly the four the coordinator reads", + set(body) == {"id", "room", "child", "created_at"}, sorted(body)) +check("created_at is a number", isinstance(body["created_at"], int)) +check("it round-trips as json", json.loads(json.dumps(body)) == body) + +print() +print("3. ids the coordinator will accept") +check("room-scoped", new_request_id("nursery", "8f21c3") == "nursery-8f21c3") +check("random by default, and different each time", + new_request_id("k2") != new_request_id("k2")) +check("no topic syntax can get into one", + "/" not in new_request_id("older") and "#" not in new_request_id("older")) +check("two rooms cannot collide on a fixed part", + new_request_id("k2", "aaa") != new_request_id("older", "aaa")) + +print() +print("4. the name the teacher typed is the name that is sent") +check("ordinary name", clean_child("Emma") == "Emma") +check("outer space is trimmed", clean_child(" Emma ") == "Emma") +check("inner runs collapse", clean_child("Emma Grace") == "Emma Grace") +check("a newline becomes a space, not a weld", + clean_child("Emma\nGrace") == "Emma Grace") +check("a zero width joiner cannot hide in it", + clean_child("Em‍ma") == "Em ma") +check("empty is not a name", clean_child(" ") is None) +check("nothing is not a name", clean_child(None) is None) +check("longer than the coordinator accepts is refused here first", + clean_child("E" * 41) is None) +check("exactly the limit is fine", clean_child("E" * 40) == "E" * 40) +check("accents normalise to one form", + clean_child("José") == "José") + +print() +print("5. reading the mode") +for raw, expected in [(b"auto", MODE_AUTO), ("approval", MODE_APPROVAL), + (" off\n", MODE_OFF), ("AUTO", MODE_AUTO)]: + check(f"{raw!r} -> {expected}", read_mode(raw) == expected) +check("anything else is not a mode", read_mode("banana") is None) +check("an empty retained mode is not a mode", read_mode("") is None) + +print() +print("6. is the sanctuary end up") +check("online", read_coordinator('{"online": true}') is True) +check("explicitly offline", read_coordinator('{"online": false}') is False) +check("a cleared retained topic is not up", read_coordinator("") is False) +check("junk is not up", read_coordinator("yes please") is False) +check("a bare true is not the contract", read_coordinator("true") is False) + +print() +print("7. statuses, as the coordinator sends them") +def status(**kw): + body = {"id": "nursery-8f21c3", "room": "nursery", + "status": SUBMITTED, "message": "Sending", "updated_at": 1} + body.update(kw) + return json.dumps(body) + +seen = read_status(status(status=WAITING_APPROVAL, message="Waiting for approval")) +check("waiting for approval is not terminal", + seen["status"] == WAITING_APPROVAL and not seen["terminal"]) +check("its wording is the coordinator's, verbatim", + seen["message"] == "Waiting for approval") + +seen = read_status(status(status=SENT_TO_SANCTUARY, message="Sent to sanctuary")) +check("sent to sanctuary is terminal and good", + seen["terminal"] and seen["good"]) +check("and it never says displayed", + "displayed" not in seen["message"].lower()) + +for bad in (REJECTED, EXPIRED, ERROR, UNAVAILABLE): + seen = read_status(status(status=bad)) + check(f"{bad} is terminal but not good", + seen["terminal"] and not seen["good"]) + +check("an unknown status is not shown", + read_status(status(status="displayed")) is None) +check("json that is not an object is not a status", + read_status("[1,2,3]") is None) +check("junk is not a status", read_status("not json") is None) +check("a status with no usable message falls back to the coordinator's word", + read_status(status(message=""))["message"] == WORDING[SUBMITTED]) +check("another room's request is never shown here", + read_status(status(id="k2-ffffff"), request_id="nursery-8f21c3") is None) +check("our own request is", + read_status(status(), request_id="nursery-8f21c3")["status"] == SUBMITTED) +check("a detail is carried but bounded", + len(read_status(status(detail="x" * 500))["detail"]) == 200) + +print() +print("8. when a teacher may send at all") +can, why = availability(MODE_AUTO, True) +check("auto with the coordinator up", can and why is None) +can, why = availability(MODE_APPROVAL, True) +check("approval with the coordinator up", can and why is None) + +can, why = availability(MODE_OFF, True) +check("off cannot send", not can) +check("and says so in the coordinator's own words", + why == WORDING[UNAVAILABLE], why) + +can, why = availability(MODE_AUTO, False) +check("the coordinator being down stops it", not can) +check("and does not pretend a call would arrive", + "not running" in why, why) + +can, why = availability(MODE_AUTO, True, broker_reachable=False) +check("no broker stops it", not can) +check("and says the network, not the sanctuary", + "network" in why, why) + +can, why = availability(None, True) +check("a broker with no retained mode stops it", not can) +check("and says it is not set up", "set up" in why, why) + +print() +print(f"{passed} passed, {failed} failed") +raise SystemExit(1 if failed else 0) +PY diff --git a/tools/build-release.sh b/tools/build-release.sh index 3de1d89..1bb79b6 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -257,7 +257,9 @@ install -Dm644 \ "$ROOT/config/plasma/solid/ekklesia-dvd.desktop" \ "$STAGE/usr/share/solid/actions/ekklesia-dvd.desktop" -for icon in ekklesia-wonderink ekklesia-parent-call; do +# Wonder Ink needs a mark of its own. Parent Call does not: it uses +# Breeze's call-outgoing, so there is one fewer drawing to maintain. +for icon in ekklesia-wonderink; do install -Dm644 \ "$ROOT/config/icons/${icon}.svg" \ "$STAGE/usr/share/icons/hicolor/scalable/apps/${icon}.svg" @@ -281,6 +283,10 @@ install -Dm755 \ "$ROOT/scripts/classroom-audio" \ "$STAGE/usr/local/bin/classroom-audio" +install -Dm755 \ + "$ROOT/scripts/classroom-parent-call" \ + "$STAGE/usr/local/bin/classroom-parent-call" + install -Dm755 \ "$ROOT/scripts/classroom-theme" \ "$STAGE/usr/local/bin/classroom-theme" @@ -289,6 +295,10 @@ install -Dm644 \ "$ROOT/lib/classroom_audio.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_audio.py" +install -Dm644 \ + "$ROOT/lib/classroom_parent_call.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_parent_call.py" + # Still here, and still sway-shaped. # # The DVD control surface and the MQTT notifier were deliberately not diff --git a/tools/lab/parent-call-rig b/tools/lab/parent-call-rig new file mode 100755 index 0000000..b1b0cb2 --- /dev/null +++ b/tools/lab/parent-call-rig @@ -0,0 +1,153 @@ +#!/bin/bash +# A Parent Call sanctuary that cannot reach the sanctuary. +# +# The real coordinator, from the control computer, running in the lab +# against a broker on loopback and a ProPresenter that is a mock. The +# church's own networks are rejected from inside the container first, so +# a wrong host in a config file cannot put a child's name on the real +# screen. Coordinator development has done that once already. +# +# tools/lab/parent-call-rig up [MODE] # off | approval | auto +# tools/lab/parent-call-rig mode MODE +# tools/lab/parent-call-rig approve|reject +# tools/lab/parent-call-rig watch +# tools/lab/parent-call-rig triggers +# tools/lab/parent-call-rig down +set -uo pipefail + +NAME="${LAB_NAME:-classroom-v2-lab}" +SRC="${PARENT_CALL_SRC:-classroom-control:/home/media/docker/parent-call}" +ROOT=classroom/parent-call + +inlab() { docker exec "$NAME" "$@"; } + +guard() { + # Container-local. This netns belongs to the container alone, so + # nothing on the host or the church network is changed by it. + inlab sh -c ' + iptables -C OUTPUT -d 10.30.0.0/16 -j REJECT 2>/dev/null || + iptables -I OUTPUT -d 10.30.0.0/16 -j REJECT + iptables -C OUTPUT -d 10.40.0.0/16 -j REJECT 2>/dev/null || + iptables -I OUTPUT -d 10.40.0.0/16 -j REJECT' + + for target in 10.40.0.2/1883 10.30.0.2/1025; do + if inlab timeout 4 sh -c "exec 3<>/dev/tcp/${target}" 2>/dev/null; then + echo "REFUSING TO CONTINUE: ${target} is still reachable" >&2 + exit 1 + fi + done + echo " church networks unreachable from the lab" +} + +case "${1:-up}" in +up) + mode="${2:-approval}" + + inlab sh -c 'apt-get install -y -qq --no-install-recommends \ + mosquitto mosquitto-clients python3-paho-mqtt iptables >/dev/null 2>&1' + guard + + inlab sh -c 'mkdir -p /opt/pc-test' + ssh -o BatchMode=yes "${SRC%%:*}" "cd ${SRC#*:} && tar cf - src" \ + | docker exec -i "$NAME" sh -c 'cd /opt/pc-test && tar xf -' + echo " coordinator source copied from the control computer" + + docker exec -i "$NAME" sh -c 'cat > /opt/pc-test/mosquitto.conf' < /opt/pc-test/coordinator.env' < /opt/propresenter-mock.py' \ + < "$(dirname "$0")/propresenter-mock.py" + + # Debian's own mosquitto unit would listen where the lab does not want + # it to. The rig runs its own, on loopback, and nothing else. + inlab sh -c 'systemctl disable --now mosquitto >/dev/null 2>&1; + pkill -f propresenter-mock; pkill -f parent_call.main; pkill mosquitto; + fuser -k 1025/tcp 1883/tcp 2>/dev/null; true' + sleep 3 + inlab sh -c 'setsid mosquitto -c /opt/pc-test/mosquitto.conf > /tmp/mosquitto.log 2>&1 &' + inlab sh -c 'setsid python3 /opt/propresenter-mock.py 1025 > /tmp/ppmock.log 2>&1 &' + sleep 2 + inlab sh -c 'cd /opt/pc-test && set -a && . ./coordinator.env && set +a && + setsid env PYTHONPATH=/opt/pc-test/src python3 -m parent_call.main \ + > /tmp/coordinator.log 2>&1 &' + sleep 4 + + # The room points at the loopback broker, never at the church's. + inlab python3 - <<'PY' +import pathlib +p = pathlib.Path("/etc/ekklesia-classroom/device.conf") +t = p.read_text() +t = t.replace("MQTT_ENABLED=false", "MQTT_ENABLED=true") +if "MQTT_HOST=127.0.0.1" not in t: + t = t.replace("MQTT_HOST=", "MQTT_HOST=127.0.0.1", 1) +if "\nROOM=" not in t: + t += "\n# Lab only.\nROOM=nursery\n" +p.write_text(t) +PY + + "$0" mode "$mode" + inlab tail -3 /tmp/coordinator.log + echo " rig up in mode $mode" + ;; + +mode) + inlab mosquitto_pub -h 127.0.0.1 -t "$ROOT/mode/set" -m "${2:?mode}" + sleep 1 + echo -n " mode is now: " + inlab timeout 3 mosquitto_sub -h 127.0.0.1 -t "$ROOT/mode" -C 1 || true + ;; + +approve|reject) + id=$(inlab timeout 3 mosquitto_sub -h 127.0.0.1 -t "$ROOT/pending" -C 1 | + python3 -c 'import json,sys; print(json.load(sys.stdin).get("id") or "")') + [ -n "$id" ] || { echo " nothing is waiting"; exit 0; } + inlab mosquitto_pub -h 127.0.0.1 -t "$ROOT/$1" -m "{\"id\": \"$id\"}" + echo " $1ed $id" + ;; + +watch) + inlab mosquitto_sub -h 127.0.0.1 -t "$ROOT/#" -v + ;; + +triggers) + inlab sh -c 'cat /tmp/propresenter-mock.log 2>/dev/null || echo "(none)"' + ;; + +log) + inlab tail -n "${2:-25}" /tmp/coordinator.log + ;; + +down) + inlab sh -c 'pkill -f parent_call.main; pkill -f propresenter-mock; pkill mosquitto; true' + echo " rig down" + ;; + +*) + sed -n '2,14p' "$0" + exit 1 + ;; +esac diff --git a/tools/lab/propresenter-mock.py b/tools/lab/propresenter-mock.py new file mode 100644 index 0000000..f3857d8 --- /dev/null +++ b/tools/lab/propresenter-mock.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""A stand-in for the church's ProPresenter, so no test can reach it. + +It serves exactly what the coordinator's adapter checks for: a message +called "Parent Caller" with tokens Name and Room, a 204 on trigger, and a +204 on clear. Every trigger is appended to a log the test reads, which is +how "sent to sanctuary" is verified without a sanctuary. +""" +import json, sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +UUID = "11111111-2222-3333-4444-555555555555" +LOG = "/tmp/propresenter-mock.log" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def _json(self, body, code=200): + payload = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self): + if self.path == "/version": + return self._json({"name": "mock", "api_version": "v1"}) + if self.path == "/v1/messages": + return self._json([{ + "id": {"name": "Parent Caller", "uuid": UUID}, + "tokens": [{"name": "Name"}, {"name": "Room"}], + }]) + if self.path.endswith("/clear"): + with open(LOG, "a") as fh: + fh.write("CLEAR\n") + self.send_response(204) + self.end_headers() + return + self.send_response(404) + self.end_headers() + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length).decode() + if self.path.endswith("/trigger"): + with open(LOG, "a") as fh: + fh.write("TRIGGER " + body + "\n") + self.send_response(204) + self.end_headers() + return + self.send_response(404) + self.end_headers() + + +if __name__ == "__main__": + open(LOG, "w").close() + HTTPServer(("127.0.0.1", int(sys.argv[1] if len(sys.argv) > 1 else 1025)), + Handler).serve_forever()