Little Waders took the Plasma build and reported what a container cannot. A disc player on a machine with no optical drive, a clock nobody at the back of the room could read, a browser at half the size of the panel around it, and Parent Call with no broker to call. The broker is the same for every classroom, so it is a default now and not something to fill in room by room. classroom-migrate brings an existing room to the same place without touching its name, its username or its password, and Parent Call distinguishes a broker it cannot reach from one that was never set up: those send somebody to look at completely different things. The disc player's detection was right and ran too late — alongside the session rather than before it, and the panel is built from the layout the moment a session starts. It runs before the display manager now, and again from udev when a drive is plugged in. UI_SCALE had a ceiling of 1.5, which cannot express a 4K television; a clock pinned at 15 points, which is the same size at every scale; and a browser that was never told the number at all. All three are fixed and the room doubles together at 200%. This also brings 0.1.47's work into the repository, where it had only ever existed in a build directory under /tmp: the hidden display dialog, its authenticated writer, the polkit action, and the church's own wallpaper. One piece of it is deliberately left out. The custom volume display needed a shell package of our own, and with one selected plasmashell builds no panel at all — measured twice, with that build's own files, on a classroom that deletes its panel configuration at every login by design. The lab connected to the real church broker the moment the package defaulted to it. Nothing was left behind, which was checked, but the guard that should have stopped it had never worked: it used iptables, which the lab image does not have, and probed with /dev/tcp under dash, where that always fails. Both reported success. Co-Authored-By: Claude Opus 5 <[email protected]>
766 lines
26 KiB
Python
Executable File
766 lines
26 KiB
Python
Executable File
#!/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:
|
|
# Why it is not there decides who the teacher should
|
|
# fetch, so the reason the broker thread already knows is
|
|
# carried through to the card rather than flattened.
|
|
self._unavailable(contract.broker_message(data.get("why")))
|
|
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())
|