Files
Classroom_Desktop/lib/classroom_config.py
ekklesiaandClaude Opus 5 55f4c183c7 feat: Presentations, for a teacher who downloaded tonight's session
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]>
2026-09-22 20:21:19 -05:00

430 lines
15 KiB
Python

"""Device configuration for the Ekklesia Classroom shell.
Everything that has to agree about /etc/ekklesia-classroom/device.conf
reads it through here, so the dock, the notification daemon and the DVD
commands cannot drift apart over what a setting means.
"""
import math
from pathlib import Path
CONFIG_FILE = Path("/etc/ekklesia-classroom/device.conf")
TRUTHY = {"1", "true", "yes", "on", "enabled"}
# Tuned for a 1080p classroom television at 1.0. A 4K set wants 2.0,
# which is the same interface at the same physical size on a display with
# twice the pixels.
UI_SCALE_DEFAULT = 1.0
UI_SCALE_MIN = 0.8
# 2.0 is a 4K television drawn at 1080p sizes, which is the setting the
# large classroom screens actually need. The old ceiling of 1.5 could not
# express it, so a room asking for 2.0 silently got 1.5.
UI_SCALE_MAX = 3.0
DEVICE_CONFIG_TEMPLATE = """# Ekklesia Classroom device configuration
UPDATE_URL=https://updates.riverofglorymn.org
CHANNEL=stable
# Show the Sanctuary LIVE placeholder in the dock. Live viewing is not
# implemented yet; this only controls whether the current placeholder appears.
LIVE_ENABLED=false
# Which output the room plays through.
#
# auto prefer a connected HDMI or DisplayPort television, and fall
# back to the analog jack. This is right for almost every room.
# hdmi always the television, even if it does not report itself.
# analog always the jack.
#
# auto re-decides whenever the hardware changes, so a television switched
# on after the computer is picked up without anybody touching anything,
# and sound that is already playing is moved across to it.
AUDIO_OUTPUT=auto
# How large everything in the room is drawn: the panel, the clock, the
# browser, Wonder Ink, the disc player, notifications and the volume
# display, all from this one number. 1.0 to 3.0.
#
# 1.0 suits a 1080p television at ordinary classroom distance. 2.0 is
# right for a 4K set, where everything is otherwise half the size it
# looks. This is an administrator's setting: there is no way to reach it
# from the room, on purpose.
UI_SCALE=1.0
# Church message broker. Every classroom uses the same one, so the address
# is a default rather than something to fill in room by room. The room is
# this machine's hostname unless ROOM is set. A username and password are
# only needed if the broker asks for them.
MQTT_ENABLED=true
MQTT_HOST=10.40.0.2
MQTT_PORT=1883
MQTT_USERNAME=
MQTT_PASSWORD=
# Whether this device has Wonder Ink at all.
#
# Every classroom does. A laptop that goes out with a volunteer does not,
# and a room that is offered a lesson application it has no licence for is
# a button that can only disappoint.
#
# false takes the launcher off the panel and stops the command starting.
# The ordinary browser, Parent Call and everything else are untouched.
WONDERINK_ENABLED=true
# What kind of machine this is.
#
# desktop a classroom tower. This is every existing room.
# laptop a machine with a lid and an internal panel.
#
# This says what the hardware IS. It decides nothing about which
# applications the device offers: a laptop running children's church needs
# Wonder Ink, and a desktop used for Bible study does not. Every
# application below is its own setting, on purpose, so that moving a
# machine between rooms is a matter of saying what it is for rather than
# what shape it is.
DEVICE_TYPE=desktop
# Whether this device offers the disc player.
#
# The machine is asked as well: a room with no optical drive never shows
# the player whatever this says, because a button that cannot work is
# worse than no button. This setting is how a device that HAS a drive is
# told not to offer it anyway.
DVD_ENABLED=true
# Whether this device offers Parent Call.
#
# A room full of children needs it. A Bible study does not, and the button
# in the corner is then one more thing to explain.
PARENT_CALL_ENABLED=true
# Whether this device offers Presentations.
#
# A teacher downloads a session in Chrome and it appears here, ready to
# show on the television: no file manager, no Impress, nothing to
# explain. Off by default, because every existing classroom upgrading
# into this release has never had it and a new button nobody asked for
# is a new thing to support.
#
# It depends on nothing else. A machine may have Presentations with no
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
# laptop or a tower.
PRESENTATIONS_ENABLED=false
# Which network this device is meant to be on.
#
# wired an Ethernet cable is the intended connection.
# wifi this device is meant to be on Wi-Fi. Normal and expected; the
# room is not warned about a missing cable.
#
# This changes what the network indicator offers, never what
# NetworkManager is allowed to connect to. Wi-Fi hardware is not disabled
# on a wired device, and a wired device is not stopped from falling back.
NETWORK_PREFERENCE=wired
# Which displays this device drives.
#
# all every connected display, which is what a tower with one
# television wants and what every existing room gets.
# external-only the external display only, with an internal laptop
# panel switched off. For a laptop installed as a fixed
# appliance with its lid shut.
#
# external-only is a setting rather than something inferred from having a
# lid, because another laptop may want its own screen or both.
DISPLAY_MODE=all
"""
# Sections added after the first classroom release. Keeping them separate lets
# an upgrade make a setting discoverable without replacing anything an
# administrator has already put in device.conf.
DEVICE_CONFIG_SECTIONS = (
(
"LIVE_ENABLED",
"""# Show the Sanctuary LIVE placeholder in the dock. Live viewing is not
# implemented yet; this only controls whether the current placeholder appears.
LIVE_ENABLED=false
""",
),
(
"AUDIO_OUTPUT",
"""# Which output the room plays through.
#
# auto prefer a connected HDMI or DisplayPort television, and fall
# back to the analog jack. This is right for almost every room.
# hdmi always the television, even if it does not report itself.
# analog always the jack.
#
# auto re-decides whenever the hardware changes, so a television switched
# on after the computer is picked up without anybody touching anything,
# and sound that is already playing is moved across to it.
AUDIO_OUTPUT=auto
""",
),
(
"UI_SCALE",
"""# How large everything in the room is drawn: the panel, the clock, the
# browser, Wonder Ink, the disc player, notifications and the volume
# display, all from this one number. 1.0 to 3.0.
#
# 1.0 suits a 1080p television at ordinary classroom distance. 2.0 is
# right for a 4K set, where everything is otherwise half the size it
# looks. This is an administrator's setting: there is no way to reach it
# from the room, on purpose.
UI_SCALE=1.0
""",
),
(
"WONDERINK_ENABLED",
'''# Whether this device has Wonder Ink at all.
#
# Every classroom does. A laptop that goes out with a volunteer does not,
# and a room that is offered a lesson application it has no licence for is
# a button that can only disappoint.
#
# false takes the launcher off the panel and stops the command starting.
# The ordinary browser, Parent Call and everything else are untouched.
WONDERINK_ENABLED=true
''',
),
(
"DEVICE_TYPE",
"""# What kind of machine this is.
#
# desktop a classroom tower. This is every existing room.
# laptop a machine with a lid and an internal panel.
#
# This says what the hardware IS. It decides nothing about which
# applications the device offers: a laptop running children's church needs
# Wonder Ink, and a desktop used for Bible study does not. Every
# application below is its own setting, on purpose, so that moving a
# machine between rooms is a matter of saying what it is for rather than
# what shape it is.
DEVICE_TYPE=desktop
""",
),
(
"DVD_ENABLED",
"""# Whether this device offers the disc player.
#
# The machine is asked as well: a room with no optical drive never shows
# the player whatever this says, because a button that cannot work is
# worse than no button. This setting is how a device that HAS a drive is
# told not to offer it anyway.
DVD_ENABLED=true
""",
),
(
"PARENT_CALL_ENABLED",
"""# Whether this device offers Parent Call.
#
# A room full of children needs it. A Bible study does not, and the button
# in the corner is then one more thing to explain.
PARENT_CALL_ENABLED=true
""",
),
(
"PRESENTATIONS_ENABLED",
"""# Whether this device offers Presentations.
#
# A teacher downloads a session in Chrome and it appears here, ready to
# show on the television: no file manager, no Impress, nothing to
# explain. Off by default, because every existing classroom upgrading
# into this release has never had it and a new button nobody asked for
# is a new thing to support.
#
# It depends on nothing else. A machine may have Presentations with no
# Wonder Ink, no disc drive, no Parent Call, on Wi-Fi or a cable, as a
# laptop or a tower.
PRESENTATIONS_ENABLED=false
""",
),
(
"NETWORK_PREFERENCE",
"""# Which network this device is meant to be on.
#
# wired an Ethernet cable is the intended connection.
# wifi this device is meant to be on Wi-Fi. Normal and expected; the
# room is not warned about a missing cable.
#
# This changes what the network indicator offers, never what
# NetworkManager is allowed to connect to. Wi-Fi hardware is not disabled
# on a wired device, and a wired device is not stopped from falling back.
NETWORK_PREFERENCE=wired
""",
),
(
"DISPLAY_MODE",
"""# Which displays this device drives.
#
# all every connected display, which is what a tower with one
# television wants and what every existing room gets.
# external-only the external display only, with an internal laptop
# panel switched off. For a laptop installed as a fixed
# appliance with its lid shut.
#
# external-only is a setting rather than something inferred from having a
# lid, because another laptop may want its own screen or both.
DISPLAY_MODE=all
""",
),
(
"MQTT_ENABLED",
"""# Church message broker. Every classroom uses the same one, so the address
# is a default rather than something to fill in room by room. The room is
# this machine's hostname unless ROOM is set. A username and password are
# only needed if the broker asks for them.
MQTT_ENABLED=true
MQTT_HOST=10.40.0.2
MQTT_PORT=1883
MQTT_USERNAME=
MQTT_PASSWORD=
""",
),
)
def read_device_config(path=None):
config = {}
path = Path(path) if path is not None else CONFIG_FILE
if not path.exists():
return config
for raw_line in path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
config[key.strip()] = value.strip()
return config
def enabled(config, key, default=False):
if key not in config:
return default
return config[key].lower() in TRUTHY
def ensure_device_config(path=None):
"""Create device.conf or append any missing settings exactly once.
Existing bytes are otherwise left alone: per-room settings, comments and
even keys this release does not know about all survive an upgrade.
"""
path = Path(path) if path is not None else CONFIG_FILE
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(DEVICE_CONFIG_TEMPLATE)
return
text = path.read_text()
config = read_device_config(path)
additions = []
for key, section in DEVICE_CONFIG_SECTIONS:
if key not in config:
additions.append(section.rstrip("\n"))
if not additions:
return
separator = "" if not text or text.endswith("\n") else "\n"
path.write_text(text + separator + "\n\n".join(additions) + "\n")
DEVICE_TYPES = ("desktop", "laptop")
NETWORK_PREFERENCES = ("wired", "wifi")
DISPLAY_MODES = ("all", "external-only")
# Every optional application, and what a device that has never heard of
# the setting should do. All true: an existing classroom upgrading to this
# release must come up exactly as it was, and every existing classroom has
# all of these.
FEATURES = {
"WONDERINK_ENABLED": True,
"DVD_ENABLED": True,
"PARENT_CALL_ENABLED": True,
# The one that defaults to OFF, and the only one that can. Every
# existing classroom has Wonder Ink, a drive and Parent Call, so a
# room upgrading into this release must come up with all three. None
# of them has ever had Presentations, so a room upgrading into this
# release must come up without it.
"PRESENTATIONS_ENABLED": False,
}
def feature(config, name):
"""Whether an optional application is offered on this device.
Deliberately reads one key per application and never consults
DEVICE_TYPE. A laptop taking children's church needs Wonder Ink and a
tower used for Bible study does not, so shape cannot be allowed to
decide purpose — that rule is the whole reason these are separate
settings rather than a profile.
"""
return enabled(config, name, default=FEATURES.get(name, False))
def _choice(config, key, allowed, default):
value = (config.get(key) or "").strip().lower()
return value if value in allowed else default
def device_type(config):
"""What the hardware is. Never what it is for."""
return _choice(config, "DEVICE_TYPE", DEVICE_TYPES, "desktop")
def network_preference(config):
return _choice(config, "NETWORK_PREFERENCE", NETWORK_PREFERENCES, "wired")
def display_mode(config):
return _choice(config, "DISPLAY_MODE", DISPLAY_MODES, "all")
def ui_scale(config):
"""UI_SCALE, clamped to something a classroom can actually use."""
raw = config.get("UI_SCALE", "")
try:
value = float(raw)
except (TypeError, ValueError):
return UI_SCALE_DEFAULT
if not math.isfinite(value):
return UI_SCALE_DEFAULT
return min(max(value, UI_SCALE_MIN), UI_SCALE_MAX)
# The scale the classroom's own GTK surfaces render at.
#
# UI_SCALE is applied to the whole output (see classroom-scale),
# which scales Chrome, VLC and every other application along with the
# panel. Our surfaces are measured in the same logical pixels the
# compositor is
# already scaling, so scaling them here as well would apply UI_SCALE
# twice and the dock would come out half again too big.
#
# This is the seam: if compositor scaling ever has to be abandoned,
# changing this back to ui_scale(config) restores per-surface scaling
# without touching the templates.
SHELL_SCALE = 1.0
def scaled(value, scale):
"""A pixel measurement at the given scale, never smaller than 1."""
return max(1, int(round(value * scale)))