PRESENTATIONS_ENABLED, off by default, independent of everything else. A teacher downloads a session in ordinary Chrome and it is on the Presentations screen a few seconds later, ready for the television. No file manager, no import button, no Impress. WHY NOTHING LAUNCHES A DOCUMENT APPLICATION. The brief was to use existing presentation software, so the existing software was tried first, on Debian 13, before any of this was designed. `soffice --show` does start a real fullscreen slideshow — and needs XDG_SESSION_TYPE plus a VCL plugin or it dies with "Failed to open display", shows a splash, opened a "Tip of the Day: 1/225" dialog over the slides, and leaves the Impress editor when the show ends. `okular --presentation` opens THREE windows: the presentation, the ordinary document window behind it, and an information dialog. zathura is not a presenter. pdfpc is the closest and is another dependency with a presenter-console model. Every one of them leaves an application on screen that can open any file on the machine, and none of them knows there is a library to go back to. So LibreOffice converts once, headless, at import, with no window on any screen; poppler renders the pages; the classroom shows the pictures. Measured: 0.34s for a ten-slide PPTX, 0.03s for its ten thumbnails, 0.10s for one slide at 1920px. That is not a new renderer — it is the same engine Impress would have used live — and it means Escape is ours, presenting is instant, and a PDF exported from Google Slides skips the conversion entirely and keeps the appearance Google gave it. THE THREE THINGS KEPT APART. The library thumbnail, the starting slide and the retention clock are independent, because the first slide of a Bible study session is a generic opening shared by every session and the one that names it is usually the second. Quick Look chooses the picture; it does not change where the slideshow starts and it does not count as having presented anything. mark_presented() is called when a slide is actually drawn, not when Present is pressed: an intention is not a presentation, and a viewer that failed to start must not buy another thirty days. IMPORTING. The teacher's download is copied, never moved, renamed or deleted. A file with a .crdownload beside it is still arriving. Wonder Ink's own folder is not looked at. Two downloads of the same name are two presentations, the second titled (2) — found in the lab, where a PDF and a PPTX of one session both came out with the same name because the import being named was excluded from the taken-titles set BY TITLE, and a set holds one copy of a repeated string. Metadata is written saying "importing" before any work, so a reboot mid-conversion leaves a directory the next startup sweeps and imports again. A file that cannot be converted gets a card saying so, offering only Remove, with the teacher's download untouched. Retention: 180 days never presented, 30 days from the last presentation, every presentation starting the thirty over. The cleanup never touches a presentation being shown, never leaves the library directory, and never looks at Downloads. The dock order is unchanged: Presentations is appended after the disc player, so Wonder Ink, the browser and the player keep the places teachers have been reaching for. There is a test for exactly that. classroom-wonderink now asks feature() rather than carrying its own copy of the default, so the panel and the command cannot disagree. Co-Authored-By: Claude Opus 5 <[email protected]>
765 lines
27 KiB
Python
Executable File
765 lines
27 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
|
|
|
|
# Where the card sits on the screen.
|
|
#
|
|
# A Wayland client cannot place its own window, and the card used to be
|
|
# centred because KWin centres everything. On a classroom television that
|
|
# puts it in the middle of whatever the room is watching, a long way from
|
|
# the button that opened it. Layer shell is the compositor's own answer:
|
|
# the surface is anchored to an edge and the compositor does the
|
|
# arithmetic, so bottom-left is bottom-left at 1080p and at 4K, at 100%
|
|
# and at 200%, with no coordinates written down anywhere.
|
|
#
|
|
# The classroom already draws the disc player's cover and control bar
|
|
# this way under KWin, so the mechanism is one this room has run for
|
|
# months rather than a new dependency.
|
|
#
|
|
# Optional on purpose. If the library is missing the card still opens,
|
|
# centred, exactly as it did before: a teacher can always reach it.
|
|
try:
|
|
gi.require_version("GtkLayerShell", "0.1")
|
|
from gi.repository import GtkLayerShell # noqa: E402
|
|
except (ImportError, ValueError):
|
|
GtkLayerShell = None
|
|
|
|
# Logical pixels, so they mean the same thing at every scale.
|
|
#
|
|
# The bar is 76 logical pixels tall and reserves that space, so the
|
|
# compositor already keeps a bottom-anchored surface clear of it; this
|
|
# gap is the breathing room above it. The left margin lines the card up
|
|
# near the launcher that opened it rather than hard against the edge.
|
|
CARD_GAP_BOTTOM = 16
|
|
CARD_GAP_LEFT = 16
|
|
|
|
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 feature, 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
|
|
|
|
|
|
# The palette lives in classroom_style, so this card, the Presentations
|
|
# library and anything else the classroom draws cannot drift apart over
|
|
# what "the accent colour" means.
|
|
from classroom_style import load_colours # noqa: E402,F401
|
|
|
|
|
|
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)
|
|
self._anchor_bottom_left()
|
|
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 _anchor_bottom_left(self):
|
|
"""Put the card in the corner its launcher is in.
|
|
|
|
Anchored rather than positioned: the compositor is told which two
|
|
edges to hold the surface against and works the rest out itself,
|
|
so this is right on a 1080p television at 100% and on a 4K one at
|
|
200% without either number appearing here.
|
|
|
|
Keyboard interactivity is the part that matters and the part that
|
|
has to be asked for: a teacher types a child's name into this.
|
|
"""
|
|
if GtkLayerShell is None:
|
|
return
|
|
|
|
try:
|
|
GtkLayerShell.init_for_window(self)
|
|
# TOP, not OVERLAY. The disc player's cover is an overlay and
|
|
# is meant to be in front of everything including this; a
|
|
# teacher fetching a parent during a film is not a reason to
|
|
# put a text box over the film's own controls.
|
|
GtkLayerShell.set_layer(self, GtkLayerShell.Layer.TOP)
|
|
GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.BOTTOM, True)
|
|
GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.LEFT, True)
|
|
GtkLayerShell.set_margin(
|
|
self, GtkLayerShell.Edge.BOTTOM, CARD_GAP_BOTTOM)
|
|
GtkLayerShell.set_margin(
|
|
self, GtkLayerShell.Edge.LEFT, CARD_GAP_LEFT)
|
|
# Reserve nothing. The card is a moment, not furniture, and a
|
|
# surface that reserved space would push the lesson around
|
|
# every time somebody fetched a parent.
|
|
GtkLayerShell.set_exclusive_zone(self, 0)
|
|
GtkLayerShell.set_keyboard_interactivity(self, True)
|
|
except Exception as error: # noqa: BLE001
|
|
# Any failure here is cosmetic. A centred card a teacher can
|
|
# type into beats a perfectly placed one that never opened.
|
|
print(f"classroom-parent-call: layer shell unavailable: {error}",
|
|
file=sys.stderr)
|
|
|
|
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,
|
|
assume_default=self.settled,
|
|
)[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,
|
|
assume_default=final,
|
|
)
|
|
|
|
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
|
|
|
|
# The teacher is pressing the button, so the retained topics have
|
|
# long since had their chance.
|
|
can, why = contract.availability(
|
|
self.mode, self.coordinator_online, self.broker_reachable,
|
|
assume_default=True,
|
|
)
|
|
|
|
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, and a mode nobody
|
|
# has ever set is the coordinator's default rather than a room
|
|
# that was never configured.
|
|
self._settle(final=True)
|
|
|
|
if not self.settled:
|
|
# Only reachable if nothing at all came back from the
|
|
# sanctuary end, which the wording should say rather than
|
|
# blaming this room's setup.
|
|
self._unavailable("No answer from the sanctuary")
|
|
|
|
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")
|
|
|
|
# A device that does not offer Parent Call does not offer it. The
|
|
# panel has no button, so nothing should reach here; saying it out
|
|
# loud is for the technician who ran the command by hand and wants to
|
|
# know why. Defaults to true, so every existing room is unaffected.
|
|
if not feature(read_device_config(), "PARENT_CALL_ENABLED"):
|
|
print("classroom-parent-call: PARENT_CALL_ENABLED is false on this "
|
|
"device; not starting", flush=True)
|
|
return 0
|
|
|
|
# --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())
|