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]>
763 lines
26 KiB
Python
Executable File
763 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:
|
|
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())
|