feat: let a teacher ask the sanctuary to call a child's parent
The coordinator already existed on the control computer and its contract is frozen, so this is the classroom half of a contract that was read from its README and source rather than guessed: four fields out, the status topic subscribed before anything is published, and every word the teacher reads taken from the coordinator's own message field. It is a separate process, not a Plasma applet. A broker library, a network timeout and a text entry have no business inside the only way a teacher has of getting anywhere; if this dies the bar is untouched. The panel mark is Breeze's call-outgoing. The old one was a bell, sitting two inches from Plasma's notification bell in the same bar and meaning something entirely different. Two things it will not do: claim a call was displayed, and let a teacher type the room. The machine already knows which room it is. Sixty tests cover the contract without a broker. The live path was proven against the real coordinator running in the lab, on a loopback broker, with ProPresenter mocked and the church's networks rejected from inside the container first. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
380f031092
commit
7790003c03
Executable
+762
@@ -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())
|
||||
Reference in New Issue
Block a user