Compare commits

..
Author SHA1 Message Date
ChuckandClaude Opus 5.5 0577c807eb feat(plugins): request_on_demand() / end_on_demand() -- plugins ask for the screen in-process (#768)
* feat(plugins): request_on_demand() / end_on_demand() -- plugins ask for the screen in-process

Four plugins (birdnet-go, mqtt-notifications, on-air, pomodoro-timer) take
the screen by writing the display_on_demand_request mailbox, which the
display reads once a second while the control socket is up and which stage 5
removes. This is the in-process way in that stage needed.

- BasePlugin.request_on_demand(mode=None, duration=None, pinned=False) and
  end_on_demand(), safe from any thread, go through PluginManager to
  DisplayController.submit_plugin_on_demand, which only queues (at most 32)
  and wakes the render thread through ControlServer.wake(). The render
  thread applies them in _drain_control_commands, after socket commands,
  through _handle_on_demand_request, so they land within a frame; without a
  socket, on the next pending-changes pass.
- A plugin's stop ends only its own session; a mailbox stop still ends any.
- Both answer the request id, or None with no display in the process (web
  interface, check_plugin.py), a full queue, or a mock manager -- a plugin's
  cue to write the mailbox, which the display still reads.
- docs/PLUGIN_API_REFERENCE.md documents the hasattr pattern for plugins
  that must keep working on older cores; IPC_CONTROL_SOCKET.md and the
  CHANGELOG are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): wire the on-demand handler only on a manager that has it

Tests and the golden traces stand in simpler plugin managers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:39:02 -04:00
ChuckandClaude Opus 5.5 5a7893b11a feat(web): Schedule and General become ES-module pages (stage 3) (#767)
* feat(web): Schedule and General become ES-module pages (stage 3)

Schedule and General follow stage 2 (#727): no inline scripts or inline
handlers in either partial. Their code moves to static/v3/js/pages/schedule.js
and pages/general.js, started per swap-in by the page registry.

- Schedule: both pickers are drawn from the saved config carried as JSON in
  data-* attributes. The forms' hx-on save handlers become one
  htmx:afterRequest listener on the page; the forms are marked
  data-reports-result, which app.js now treats like an hx-on after-request
  handler, so a save still shows one notification.
- General: the timezone picker reads data-timezone. The Security section's
  forms and buttons are delegated data-actions; requests go through
  core/api.js, so the login redirect is quiet, and a change made just
  before a swap is still reported.
- handleScheduleResponse, handleDimScheduleResponse and webLogin stay as
  deprecated aliases through window.LEDMatrix.
- New DOM suites test_schedule_page.js and test_general_page.js; the web
  login unit suite imports the module; test_es_modules.py pins the pages,
  the aliases, and the schedule config's round trip through its attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): no unused catch bindings or computed writes in the stage 3 modules

Codacy flagged two unused catch variables and dynamic-key writes in
pages/schedule.js and boot.js. The schedule config is read with
getAttribute, and the default days and the webLogin alias object are built
with Object.fromEntries. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:18:02 -04:00
24 changed files with 1868 additions and 583 deletions
+52
View File
@@ -19,6 +19,58 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased
### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()`
The in-process way in that stage 5 of the control socket needed
(`docs/IPC_CONTROL_SOCKET.md`, "Plugins in the display process").
- **`BasePlugin.request_on_demand(mode=None, duration=None, pinned=False)`**
shows the plugin now, and **`BasePlugin.end_on_demand()`** gives the
screen back. Both are safe from any thread (an MQTT callback, a timer
thread): `PluginManager.request_on_demand()` / `end_on_demand()` hand the
request to `DisplayController.submit_plugin_on_demand()`, which only
queues it (at most 32) and wakes the render thread through the control
socket's flag (`ControlServer.wake()`). The render thread applies it with
the socket's commands, through the same handler as a web on-demand
request, so it lands within a frame rather than on the mailbox's
once-a-second look. Both return the request id, or `None` when no display
runs in the process (the web interface, `scripts/check_plugin.py`) or the
queue is full.
- **A plugin's stop ends only its own session.** A mailbox stop still ends
any session, whoever started it.
- **Older cores.** Plugins detect the methods with `hasattr` and write the
`display_on_demand_request` mailbox when they are missing or answer
`None`; the pattern is in `docs/PLUGIN_API_REFERENCE.md` ("On-demand
display"). The display still reads the mailbox for plugins that write it.
### Web UI: Schedule and General are ES-module pages (stage 3)
- The Schedule and General tabs follow stage 2 (#727): their inline
`<script>` blocks are now `static/v3/js/pages/schedule.js` and
`pages/general.js`, started once per swap-in by the page registry and
stopped on swap-out. Neither partial has an inline script, `onclick`,
`onsubmit` or `oninput` any more.
- Schedule: both pickers are drawn from the saved config the partial
carries as JSON in `data-schedule-config` / `data-dim-schedule-config`.
The forms' `hx-on` save handlers became one `htmx:afterRequest` listener
on the page; the forms are marked `data-reports-result`, which `app.js`
now honours like an `hx-on` after-request handler, so a save still shows
one notification.
- General: the timezone picker reads the saved zone from `data-timezone`.
The Security section's forms and buttons carry `data-action` and use one
delegated submit and one delegated click listener, so a token row added
after a create needs no listener of its own. Requests go through
`core/api.js`: the optional login's "sign in again" answer no longer
flashes an error while the page navigates to the login form. A login
change made just before a swap is still reported.
- Old globals keep working as deprecated aliases through `window.LEDMatrix`
(one console warning each): `handleScheduleResponse`,
`handleDimScheduleResponse`, and `webLogin` (its five methods).
- New DOM suites `test/js/dom/test_{schedule,general}_page.js`;
`unit/test_general_web_login_token.js` imports the module instead of
slicing the template, and `unit/test_restart_banner.js` covers
`data-reports-result`.
### The control socket carries every web command; the mailboxes are a fallback
Stage 4 of the web → display control socket (`docs/IPC_CONTROL_SOCKET.md`).
+25 -6
View File
@@ -489,7 +489,7 @@ restart banner, as before.
| Mailbox | Written by | Read by the display | While the socket is up |
|---|---|---|---|
| `display_on_demand_request` | the web interface, only on fallback; four plugins directly (birdnet-go, mqtt-notifications, on-air, pomodoro-timer) | the render thread, `_poll_on_demand_requests()` | looked at every 1 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), 0.25 s without a socket |
| `display_on_demand_request` | the web interface, only on fallback; plugins that predate `BasePlugin.request_on_demand()`, or run on a core without it | the render thread, `_poll_on_demand_requests()` | looked at every 1 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), 0.25 s without a socket |
| `plugin_error_clear_request` | the web interface, only on fallback | the error publisher's thread, every 5 s tick | unchanged rate |
A look is one `stat()` of the mailbox file (`CacheManager.file_signature`):
@@ -502,8 +502,25 @@ the mailbox instead of being re-read until it expires.
A request that comes through the on-demand mailbox while the socket is up
is logged once per writer (`came through the file mailbox although the
control socket is up`), which names the plugins that still need an
in-process way in before the mailbox is removed.
control socket is up`), which names the plugins that still write it.
### Plugins in the display process
A plugin asks for the screen with `BasePlugin.request_on_demand()` and gives
it back with `end_on_demand()` (see "On-demand display" in
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). Neither goes through
the socket or a file: `PluginManager` hands the mailbox-shaped request,
marked `source: 'plugin'`, to `DisplayController.submit_plugin_on_demand`,
which queues it in memory (at most `PLUGIN_ON_DEMAND_QUEUE_SIZE`, 32) from
whatever thread the plugin called on, and wakes the render thread through
the socket's queue flag (`ControlServer.wake()`). The render thread applies
it in `_drain_control_commands`, after the socket's commands, through the
same `_handle_on_demand_request`, so it lands within a frame like a socket
command. Without a socket it lands on the next pending-changes pass (typically
within 0.25 s). A plugin's stop ends only a session that plugin owns. The four
plugins that wrote the mailbox (birdnet-go, mqtt-notifications, on-air,
pomodoro-timer) use it where the core has it and write the mailbox
otherwise.
## Robustness
@@ -659,9 +676,11 @@ device never touches the live display.
running display (their routes say so); they are not mailboxes.
5. **Remove the mailboxes (next release).** Once every device has run a
display with stage 4, the web interface stops writing both mailboxes and
the display stops reading them. The four plugins that write
`display_on_demand_request` need an in-process way to ask for the screen
first. The display also stops writing `display_current_state`,
the display stops reading them. The four plugins that wrote
`display_on_demand_request` now have an in-process way to ask for the
screen (`BasePlugin.request_on_demand()` / `end_on_demand()`, see
"Plugins in the display process"); they keep the mailbox write only as
their fallback on older cores. The display also stops writing `display_current_state`,
`display_on_demand_state` and `plugin_runtime_snapshot` once the web
interface no longer falls back to them.
+80
View File
@@ -488,6 +488,78 @@ working for the plugin itself. `get_vegas_segment_width()` read the
`vegas_panel_count` config value, which has never affected Vegas — a card's
width comes from `get_vegas_content()` and `vegas_width_pct`.
### On-demand display
A plugin that reacts to something outside the rotation (an MQTT message, a
timer, a detection) can take the screen for it, and give it back. Both
methods are safe from any thread, including an MQTT callback: they only
queue the request, and the display applies it on its render thread within a
frame or so, exactly like an on-demand start or stop from the web interface.
#### `request_on_demand(mode=None, duration=None, pinned=False) -> Optional[str]`
Show this plugin now.
- `mode`: one of the plugin's display modes; `None` for its first.
- `duration`: seconds before the rotation resumes; `None` (or `0`) for no
limit, until `end_on_demand()` or the user stops it.
- `pinned`: stay on `mode` instead of cycling through the plugin's other
modes.
Returns the request id once the display has queued it, or `None` when
there is no display in this process to ask (the web interface's plugin
manager, `scripts/check_plugin.py`) or its queue is full. A bad argument
(a `mode` that is not a string, a `duration` that is not a number) raises
`ValueError`.
#### `end_on_demand() -> Optional[str]`
Give the screen back. Ends only a session this plugin owns: a session the
user started for another plugin, or one that already ended, is left alone.
Returns the request id once queued, or `None` as above.
#### Older cores: feature detection
These methods are new after core 3.8.0 (see `CHANGELOG.md`). Before them,
plugins wrote the `display_on_demand_request` cache key (the "mailbox")
themselves. The display reads it only once a second while the control
socket is up, and it will be removed in a future release (see
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5). A plugin that
must keep working on older cores checks for the method, and writes the
mailbox only when the method is missing or answers `None`:
```python
import time, uuid
def _show_alert(self):
if hasattr(self, "request_on_demand") and self.request_on_demand(
mode="my_alert", duration=15):
return
# Older core, or no display in this process: the mailbox, as before.
self.cache_manager.set("display_on_demand_request", {
"request_id": str(uuid.uuid4()), "action": "start",
"plugin_id": self.plugin_id, "mode": "my_alert",
"duration": 15, "pinned": False, "timestamp": time.time(),
})
def _release(self):
if hasattr(self, "end_on_demand") and self.end_on_demand():
return
self.cache_manager.set("display_on_demand_request", {
"request_id": str(uuid.uuid4()), "action": "stop",
"plugin_id": self.plugin_id, "timestamp": time.time(),
})
```
Keep `ledmatrix_min_version` where it is: the fallback is what keeps the
plugin working on older cores. A mailbox stop ends any on-demand session,
whoever started it; `end_on_demand()` ends only the plugin's own.
Both methods answer a request id only when the plugin manager returned a
string, so a test that gives the plugin a `MagicMock()` plugin manager gets
`None` and exercises the mailbox path. To test the new path, set
`plugin_manager.request_on_demand.return_value = "some-id"`.
> The full source for `BasePlugin` lives in
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
> source, the source wins — please open an issue or PR to fix the doc.
@@ -966,6 +1038,14 @@ if info:
self.logger.info(f"Plugin: {info['name']}, Version: {info.get('version')}")
```
#### `request_on_demand(plugin_id, mode=None, duration=None, pinned=False)` / `end_on_demand(plugin_id)`
What `BasePlugin.request_on_demand()` and `end_on_demand()` call, with the
plugin's own id. Call those instead; see
[On-demand display](#on-demand-display). The display controller routes them
to itself with `set_on_demand_handler()`; a plugin manager without a
display behind it answers `None`.
#### `get_all_plugin_info() -> List[Dict[str, Any]]`
Get information for all plugins.
+12 -2
View File
@@ -81,6 +81,14 @@ The conventions the converted pages share:
to the module's export of the same name and warns once.
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
undo by itself.
- **A page reports its own htmx saves.** A form whose result a page module
shows (an `htmx:afterRequest` listener on the page root, in place of an
`hx-on` attribute naming a global) carries `data-reports-result`. `app.js`
then leaves the server's message to the page, as it does for a form with
an `hx-on` after-request handler, so a save shows one notification.
- **Server data for the module goes in `data-*` attributes**, as JSON where
it is structured (`data-schedule-config='{{ schedule_config | tojson }}'`),
not templated into a script.
`core/registry.js` handles the rest:
@@ -239,8 +247,8 @@ are the inline script in each partial today.
| 3 | Operation History | 293 lines, now 0 | **Done in stage 2.** Read-only list; rows drawn with `textContent`, the search debounce cleared on destroy. The "Showing x to y" counters now also reset when nothing matches |
| 4 | Config Editor (`raw_json.html`) | 212 lines, now 0 | **Done in stage 2.** Plain textareas (no CodeMirror on this page). It defined 5 globals after all (`formatJson`, `manualValidateJson`, `validateJSON`, `saveMainConfig`, `saveSecretsConfig`); nothing else used them, and they are deprecated aliases now. The live "Invalid JSON" line no longer puts the parser's message into `innerHTML` |
| 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) |
| 6 | Schedule | 193 | 2 globals used as `hx-on` response handlers. Moves `hx-on` handlers into page listeners |
| 7 | General | 147 | `webLogin` global and the security section. The first page that touches login |
| 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script |
| 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does |
| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
| 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals |
| 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device |
@@ -284,6 +292,8 @@ Unit suites need only node. They import the shipped modules directly:
| `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text |
| `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text |
| `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points |
| `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points |
| `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points |
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more |
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
-6
View File
@@ -14,12 +14,6 @@ project_dir = os.path.dirname(os.path.abspath(__file__))
if project_dir not in sys.path:
sys.path.insert(0, project_dir)
# Cap glibc's malloc arenas before any thread exists (arenas already made
# stay): the in-process twin of the unit's MALLOC_ARENA_MAX=2, for units
# installed before that line. A no-op off glibc. See src/malloc_tuning.py.
from src import malloc_tuning
print('EXP cap_arenas=%s' % malloc_tuning.cap_arenas(), flush=True)
# Under systemd the watchdog clock is already running, and start-up (plugin
# loads, initial updates) takes far longer than the render loop's limit. Widen
# it before anything slow is imported; the render loop narrows it again once
+104 -21
View File
@@ -37,7 +37,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disab
import pytz
from src import display_watchdog
from src.malloc_tuning import MallocTrimmer
from src.display_arbiter import (
Arbiter, ArbiterInputs, ArbiterState, FramePolicy,
ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan,
@@ -395,6 +394,12 @@ class DisplayController:
plugin_time = time.time()
self.plugin_manager = None
self._plugin_runtime_publisher = None
# On-demand requests plugins make in this process (BasePlugin.
# request_on_demand / end_on_demand), from any thread; the render
# thread drains them with the socket's commands. Created before the
# plugins load, because a plugin may ask from its first thread.
self._plugin_on_demand: deque = deque()
self._plugin_on_demand_lock = threading.Lock()
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
self.mode_to_plugin_id: Dict[str, str] = {}
self.plugin_display_modes: Dict[str, List[str]] = {}
@@ -509,6 +514,12 @@ class DisplayController:
cache_manager=self.cache_manager,
font_manager=self.font_manager
)
# BasePlugin.request_on_demand() / end_on_demand() land here.
# Before any plugin loads: a plugin may ask from its first thread.
# getattr: tests and golden traces stand in simpler managers.
set_handler = getattr(self.plugin_manager, 'set_on_demand_handler', None)
if callable(set_handler):
set_handler(self.submit_plugin_on_demand)
# The web UI's loaded / state / error_info for each plugin read
# what this publishes. Started before loading, so the loads that
@@ -1883,6 +1894,10 @@ class DisplayController:
_on_demand_mailbox: Optional[MailboxWatch] = None
#: Writers whose mailbox requests have been logged (_note_mailbox_request).
_mailbox_writers_logged: FrozenSet[str] = frozenset()
#: Most plugin on-demand requests waiting for the render thread at once.
#: A plugin that asks faster than the display drains (four times a
#: second at worst) is refused, not queued without end.
PLUGIN_ON_DEMAND_QUEUE_SIZE = 32
def _service_pending_changes(self) -> None:
"""Apply changes made elsewhere while the display thread is busy.
@@ -1907,7 +1922,7 @@ class DisplayController:
# A command queued on the control socket skips the floor: it is in
# memory, so applying it now costs no disk read.
if (last is not None and now - last < self.PENDING_CHANGES_INTERVAL
and not (self._control_server and self._control_server.has_pending)):
and not self._control_command_pending()):
return
self._last_pending_service = now
@@ -2106,11 +2121,15 @@ class DisplayController:
here. A plugin reload waits for the top of the next loop pass, where
no plugin is on the stack (_apply_pending_plugin_reloads); until
then the current screen ends early (_plugin_reload_pending).
Plugins' own on-demand requests (submit_plugin_on_demand) are
applied here too, after the socket's, with or without a socket.
"""
server = self._control_server
if server is None or not server.has_pending:
return
for command in server.drain():
# drain() clears the wake flag before the plugin queue is read below,
# so a plugin request queued from here on wakes the next wait.
commands = server.drain() if server is not None and server.has_pending else []
for command in commands:
try:
if command.cmd == ControlCommand.BRIGHTNESS_SET:
self._apply_control_brightness(command)
@@ -2122,24 +2141,78 @@ class DisplayController:
logger.exception("Failed to apply control socket command %s",
command.request_id)
command.fail(ControlErrorCode.INTERNAL, 'the display failed to apply it')
self._drain_plugin_on_demand()
# -- plugins' in-process on-demand requests ---------------------------------
def submit_plugin_on_demand(self, request: Dict[str, Any]) -> bool:
"""Queue a plugin's on-demand request for the render thread. Any thread.
``PluginManager.request_on_demand`` / ``end_on_demand`` (which
BasePlugin's methods of the same names call) build ``request``: the
mailbox's shape, with ``source: 'plugin'`` and the asking plugin's
id. Nothing here touches the panel or the on-demand state; the render
thread applies the request where it applies a socket command
(_drain_control_commands), through _handle_on_demand_request, and is
woken for it when the control socket is up. True when it was queued;
False (logged) when the queue is full.
"""
pending = self.__dict__.get('_plugin_on_demand')
lock = self.__dict__.get('_plugin_on_demand_lock')
if pending is None or lock is None:
return False # a controller built without __init__ (tests)
with lock:
if len(pending) >= self.PLUGIN_ON_DEMAND_QUEUE_SIZE:
logger.warning("Plugin on-demand queue full; refusing %s %s from %s",
request.get('action'), request.get('request_id'),
request.get('plugin_id'))
return False
pending.append(dict(request))
server = self._control_server
if server is not None:
server.wake()
return True
def _plugin_on_demand_pending(self) -> bool:
"""A plugin's on-demand request is waiting. A length check: no lock."""
return bool(self.__dict__.get('_plugin_on_demand'))
def _drain_plugin_on_demand(self) -> None:
"""Apply the plugins' queued on-demand requests, oldest first. Render thread."""
pending = self.__dict__.get('_plugin_on_demand')
while pending:
try:
request = pending.popleft()
except IndexError:
break
try:
self._handle_on_demand_request(request)
except Exception: # pylint: disable=broad-except
logger.exception("Failed to apply on-demand request %s from plugin %s",
request.get('request_id'), request.get('plugin_id'))
def _wait_for_control(self, timeout: float) -> bool:
"""Sleep up to ``timeout``, waking early for a control socket command.
True when a command is waiting. Without a socket (Windows, switched
off, tests) this is the plain sleep it replaces.
off, tests) this is the plain sleep it replaces, unless a plugin's
on-demand request is already waiting.
"""
server = self._control_server
wait = getattr(server, 'wait_for_command', None) if server is not None else None
if wait is None:
if self._plugin_on_demand_pending():
return True
time.sleep(timeout)
return False
return bool(wait(timeout))
def _control_command_pending(self) -> bool:
"""A socket command is queued: Vegas checks this every frame."""
"""A socket command or a plugin's on-demand request is queued: Vegas
checks this every frame."""
server = self._control_server
return bool(server is not None and server.has_pending)
return bool((server is not None and server.has_pending)
or self._plugin_on_demand_pending())
def _wait_frame_interval(self, interval: float, screen: Screen) -> Optional[ScreenPlan]:
"""The static screen's sleep between frames, woken by socket commands.
@@ -2451,21 +2524,36 @@ class DisplayController:
def _handle_on_demand_request(self, request: Dict[str, Any]) -> None:
"""Process one on-demand request, from the mailbox or the control socket.
A socket command carries ``source: 'socket'``. Only a mailbox request
is removed from the mailbox afterwards: a socket command never put
anything there, so that would be a disk read and maybe a delete for
nothing.
A socket command carries ``source: 'socket'``, and a plugin's own
request (submit_plugin_on_demand) ``source: 'plugin'``. Only a
mailbox request is removed from the mailbox afterwards: the others
never put anything there, so that would be a disk read and maybe a
delete for nothing.
A plugin's stop ends only that plugin's own session: a plugin
releasing the screen must not end one the user started for
another plugin. (A stop through the mailbox ends any session, as it
always has.)
"""
request_id = request.get('request_id')
if not request_id:
return
from_mailbox = request.get('source') != 'socket'
source = request.get('source')
from_mailbox = source not in ('socket', 'plugin')
action = request.get('action')
# For stop requests, always process them (don't check processed_id)
# This allows stopping even if the same stop request was sent before
if action == 'stop':
if source == 'plugin' and not (
self.on_demand_active
and self.on_demand_plugin_id == request.get('plugin_id')):
logger.debug("On-demand stop %s from plugin %s ignored: it does not own "
"the screen (on-demand %s, plugin %s)", request_id,
request.get('plugin_id'), self.on_demand_status,
self.on_demand_plugin_id)
return
logger.info("Received on-demand stop request %s", request_id)
# Always process stop requests, even if same request_id (user might click multiple times)
if self.on_demand_active:
@@ -2508,8 +2596,9 @@ class DisplayController:
self._consume_on_demand_request(request_id)
return
logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s)",
request_id, action, request.get('plugin_id'), request.get('mode'))
logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s, via %s)",
request_id, action, request.get('plugin_id'), request.get('mode'),
'mailbox' if from_mailbox else source)
# Mark as processed BEFORE processing (to prevent duplicate processing)
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
@@ -4128,7 +4217,6 @@ class DisplayController:
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
self._publish_current_mode_state()
runner = ScreenRunner(_MODULE_CLOCK, _ScreenHost(self), logger)
trimmer = MallocTrimmer()
while True:
# Arms the watchdog after the first frame -- or after the
@@ -4136,11 +4224,6 @@ class DisplayController:
# it from then on.
display_watchdog.watchdog.loop_pass()
# Between screens, nothing being drawn: every few minutes hand
# the memory glibc is holding for freed images back to the OS
# (src/malloc_tuning.py). A clock read when none is due.
trimmer.maybe_trim()
# Apply plugin enable/disable edits saved via the web UI. The
# config-watcher thread only sets the flag; loading/unloading and
# rebuilding available_modes happens here on the render thread so
+15 -1
View File
@@ -772,8 +772,22 @@ class ControlServer:
"""
return self._pending.wait(timeout)
def wake(self) -> None:
"""Wake the render thread as a queued command would, with nothing queued.
For work that reaches the display another way in the same process (a
plugin's on-demand request, ``DisplayController.submit_plugin_on_demand``):
the render thread returns from :meth:`wait_for_command` and drains,
and reads the caller's own queue there. Safe from any thread.
"""
self._pending.set()
def drain(self) -> List[QueuedCommand]:
"""Every queued command, oldest first. Called from the render thread."""
"""Every queued command, oldest first. Called from the render thread.
Clears the wake flag first, so anything queued (or woken for) while
this runs wakes the next wait again.
"""
commands: List[QueuedCommand] = []
self._pending.clear()
while True:
-128
View File
@@ -1,128 +0,0 @@
"""Keep glibc's malloc from holding on to memory the display has freed.
The display process allocates and frees PIL images and numpy buffers all day
from a dozen threads. glibc gives each allocating thread its own malloc arena
(up to 8 x CPU count) and returns little of what is freed inside them to the
OS, so resident memory climbs for hours while the live data stays flat. Two
in-process remedies, both standard library only (ctypes) and both no-ops off
Linux/glibc:
* :func:`cap_arenas` -- ``mallopt(M_ARENA_MAX, 2)``, the in-process twin of the
unit's ``Environment=MALLOC_ARENA_MAX=2``. Units installed before that line
existed never got it (systemd runs the copy in /etc/systemd/system), so the
process applies it itself. Call it before any other thread starts: arenas
already created stay. A ``MALLOC_ARENA_MAX`` set in the environment wins.
* :class:`MallocTrimmer` -- ``malloc_trim(0)`` at most every few minutes,
called from the render loop between screens, where no frame is being drawn.
glibc 2.8+ releases free pages from the middle of every arena, not only the
top of the main heap.
Without glibc (macOS, Windows, musl, the dev server on any of them) nothing is
loaded and every call returns False.
"""
import ctypes
import logging
import os
import sys
import time
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
#: glibc's mallopt() parameter number for the arena cap (malloc.h).
M_ARENA_MAX = -8
#: The arena cap applied when the environment does not set one; the same value
#: as the unit's ``MALLOC_ARENA_MAX``.
DEFAULT_ARENA_MAX = 2
#: Seconds between malloc_trim() calls. A trim takes about 1-20 ms on a Pi 4,
#: so this keeps it far from frame timing while still returning memory long
#: before it piles up.
TRIM_INTERVAL_SECONDS = 300.0
_UNLOADED = object()
_libc: Any = _UNLOADED
def _load_libc() -> Optional[Any]:
"""The process's C library if it is glibc with malloc_trim, else None."""
global _libc
if _libc is _UNLOADED:
_libc = None
if sys.platform.startswith('linux'):
try:
libc = ctypes.CDLL(None)
# gnu_get_libc_version is glibc-only: musl also lacks
# malloc_trim, but this says why without guessing.
libc.gnu_get_libc_version
libc.malloc_trim.argtypes = [ctypes.c_size_t]
libc.malloc_trim.restype = ctypes.c_int
libc.mallopt.argtypes = [ctypes.c_int, ctypes.c_int]
libc.mallopt.restype = ctypes.c_int
_libc = libc
except (OSError, AttributeError, TypeError):
logger.debug("glibc malloc controls unavailable", exc_info=True)
return _libc
def cap_arenas(max_arenas: int = DEFAULT_ARENA_MAX) -> bool:
"""Cap glibc's malloc arenas at ``max_arenas``. True when the cap was set.
Skipped when ``MALLOC_ARENA_MAX`` is in the environment: glibc has read it
already, and an operator who set it chose that value.
"""
if os.environ.get('MALLOC_ARENA_MAX'):
return False
libc = _load_libc()
if libc is None:
return False
try:
return bool(libc.mallopt(M_ARENA_MAX, int(max_arenas)))
except Exception: # pylint: disable=broad-except
logger.debug("mallopt(M_ARENA_MAX) failed", exc_info=True)
return False
class MallocTrimmer:
"""Calls ``malloc_trim(0)`` at most once per ``interval`` seconds.
:meth:`maybe_trim` is meant for an idle point of the render loop; it costs
one clock read when no trim is due. The first trim comes one interval
after construction, so start-up's allocations have settled.
"""
def __init__(self, interval: float = TRIM_INTERVAL_SECONDS,
clock: Callable[[], float] = time.monotonic) -> None:
self._interval = interval
self._clock = clock
self._libc = _load_libc()
self._next = clock() + interval
@property
def available(self) -> bool:
return self._libc is not None
def maybe_trim(self) -> bool:
"""Trim if one is due. True when malloc_trim ran and released memory."""
if self._libc is None:
return False
now = self._clock()
if now < self._next:
return False
self._next = now + self._interval
try:
released = bool(self._libc.malloc_trim(0))
except Exception: # pylint: disable=broad-except
logger.debug("malloc_trim failed; not trying again", exc_info=True)
self._libc = None
return False
def _rss():
try:
with open('/proc/self/statm') as f:
return int(f.read().split()[1]) * 4096 // 1024
except Exception:
return -1
logger.info("EXP malloc_trim(0) took %.1f ms, released=%s rss_kb_after=%d",
(self._clock() - now) * 1000.0, released, _rss())
return released
+59
View File
@@ -1070,6 +1070,65 @@ class BasePlugin(ABC):
if callable(notify):
notify(self.plugin_id)
def request_on_demand(self, mode: Optional[str] = None,
duration: Optional[float] = None,
pinned: bool = False) -> Optional[str]:
"""
Take the screen now: show this plugin on demand. Safe from any thread.
For a plugin that reacts to something outside the rotation -- an MQTT
message, a timer, a detection -- and wants the panel for it. The
request goes straight to the display in this process and is applied
on its render thread within a frame or so, exactly like an on-demand
start from the web interface.
Args:
mode: One of this plugin's display modes; None for its first.
duration: Seconds to show it before the rotation resumes; None
(or zero) for no limit, until end_on_demand() or the user
stops it.
pinned: Stay on ``mode`` instead of cycling through the
plugin's other modes.
Returns:
The request id once the display has queued it, or None when
there is no display in this process to ask (the web interface,
scripts/check_plugin.py) or its queue is full. A plugin that
also runs on cores without this method writes the
``display_on_demand_request`` mailbox on None, as before; see
"On-demand display" in docs/PLUGIN_API_REFERENCE.md.
Example::
if not (hasattr(self, 'request_on_demand')
and self.request_on_demand(mode='my_alert', duration=15)):
self._write_on_demand_mailbox(...) # older cores
"""
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
if not callable(request):
return None
request_id = request(self.plugin_id, mode=mode, duration=duration, pinned=pinned)
# Only a real id counts: a test's MagicMock manager answers a mock,
# which must read as "not taken" so the plugin's fallback runs.
return request_id if isinstance(request_id, str) else None
def end_on_demand(self) -> Optional[str]:
"""
Give the screen back: end this plugin's on-demand session. Any thread.
Ends only a session this plugin owns. One the user started for
another plugin, or a session that already ended, is left alone. The
rotation resumes where it left off.
Returns:
The request id once queued, or None as request_on_demand() does.
"""
end = getattr(getattr(self, 'plugin_manager', None), 'end_on_demand', None)
if not callable(end):
return None
request_id = end(self.plugin_id)
return request_id if isinstance(request_id, str) else None
def get_vegas_participation(self) -> str:
"""
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
+74
View File
@@ -15,6 +15,7 @@ import sys
import time
import threading
import types
import uuid
from pathlib import Path
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
import logging
@@ -214,6 +215,9 @@ class PluginManager:
# add_update_listener(). A tuple, replaced rather than mutated, so the
# worker can iterate it without a lock.
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
# Where plugins' on-demand requests go: the display controller's
# submit_plugin_on_demand. See set_on_demand_handler().
self._on_demand_handler: Optional[Callable[[Dict[str, Any]], bool]] = None
# Config changes that found the plugin's lock busy, latest per plugin,
# with the instance they were meant for. See apply_config_change().
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
@@ -1844,3 +1848,73 @@ class PluginManager:
done = sorted(self._completed_updates)
self._completed_updates.clear()
return done
# -- on-demand requests from plugins -------------------------------------
def set_on_demand_handler(
self, handler: Optional[Callable[[Dict[str, Any]], bool]]) -> None:
"""Route plugins' on-demand requests to ``handler`` (None: nowhere).
The display controller sets its ``submit_plugin_on_demand`` here
before any plugin loads. The handler takes a mailbox-shaped request
from any thread, queues it for the render thread and returns True,
or False when it could not. A plugin manager with no handler (the
web interface's, a test's, scripts/check_plugin.py's) has no screen
to give, so request_on_demand() there answers None.
"""
self._on_demand_handler = handler
def request_on_demand(self, plugin_id: str, mode: Optional[str] = None,
duration: Optional[float] = None,
pinned: bool = False) -> Optional[str]:
"""Ask the display to show ``plugin_id`` now. Safe from any thread.
BasePlugin.request_on_demand() lands here; see it for the arguments.
Returns the request id once the display has queued the request (it
is applied on the render thread within a frame or so), or None when
this process has no display to ask or its queue is full.
"""
if not isinstance(plugin_id, str) or not plugin_id:
raise ValueError('plugin_id is required')
if mode is not None and (not isinstance(mode, str) or not mode):
raise ValueError('mode must be a non-empty string or None')
if duration is not None:
if isinstance(duration, bool) or not isinstance(duration, (int, float)):
raise ValueError('duration must be a number of seconds or None')
if not math.isfinite(duration) or duration <= 0:
duration = None # the display reads these as "no limit" too
else:
duration = float(duration)
return self._submit_on_demand({
'action': 'start', 'plugin_id': plugin_id, 'mode': mode,
'duration': duration, 'pinned': bool(pinned)})
def end_on_demand(self, plugin_id: str) -> Optional[str]:
"""Give the screen back, if ``plugin_id``'s on-demand session has it.
BasePlugin.end_on_demand() lands here. A session the plugin does not
own (the user started another plugin from the web interface, say) is
left alone. Returns the request id once queued, or None as
request_on_demand() does.
"""
if not isinstance(plugin_id, str) or not plugin_id:
raise ValueError('plugin_id is required')
return self._submit_on_demand({'action': 'stop', 'plugin_id': plugin_id})
def _submit_on_demand(self, request: Dict[str, Any]) -> Optional[str]:
# __dict__.get: tests build bare managers with PluginManager.__new__.
handler = self.__dict__.get('_on_demand_handler')
if handler is None:
return None
request_id = str(uuid.uuid4())
request.update({'request_id': request_id, 'timestamp': time.time(),
'source': 'plugin'})
try:
accepted = handler(request)
except Exception as exc: # pylint: disable=broad-except
self._warn_rate_limited(
"on-demand-handler",
"The on-demand request from plugin %s failed: %r",
request.get('plugin_id'), exc)
return None
return request_id if accepted else None
+3 -1
View File
@@ -60,7 +60,7 @@ server has none.
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
| `unit/test_general_web_login_token.js` | no | `window.webLogin.createToken` from `partials/general.html`, run in a vm: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
@@ -69,6 +69,8 @@ server has none.
| `dom/test_durations_page.js` | yes | The Rotation tab (`js/pages/durations.js`) with the real `plugin-order-list.js` widget: one plugin-list request per swap, one move per click after repeated swaps, a swap cancels the request in flight, a late widget is waited for |
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
+272
View File
@@ -0,0 +1,272 @@
// The General tab as a page module (static/v3/js/pages/general.js), in a real
// DOM (jsdom) with the real server-rendered partial, the real timezone
// widget and the real web-login endpoints' answer shapes. Built like
// test_cache_page.js:
//
// * the partial ships no <script> and no inline handlers; its root is
// data-page="general" and the Security section's forms and buttons name
// an action
// * the timezone picker is drawn once per swap-in, with the saved zone
// * after five swaps, each Security action makes exactly one request
// * a login change is a write: a swap does not cancel it, its result is
// still reported, and nothing is drawn into the page that has gone
// * token names reach the page as text
// * the settings form itself is left to htmx
// * window.webLogin's entry points still work
const http = require('http');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/general');
const realTokens = JSON.parse(await get('/api/v3/auth/tokens'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const generalPage = await load('pages/general.js');
console.log('\n── General tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline click or submit handlers', !/\son(click|submit|input)=/i.test(partial));
ok('the partial root is data-page="general"', /data-page="general"/.test(partial));
const security = /id="web-login-settings"/.test(partial);
ok('the server renders the Security section (it has a login store)', security);
ok('the real token list answers in the shape the section shows',
realTokens.status === 'success' && realTokens.data && Array.isArray(realTokens.data.tokens), realTokens);
ok('the Security forms name their action',
/<form[^>]*data-action="set-password"/.test(partial) && /<form[^>]*data-action="create-token"/.test(partial));
ok('the Copy button names its action', /data-action="copy-token"/.test(partial));
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="general-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('general-content');
require('../led_escape').install(window);
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
window.eval(fs.readFileSync(path.join(JS, 'widgets/timezone-selector.js'), 'utf8'));
const widgets = window.LEDMatrixWidgets;
ok('the widget scripts register timezone-selector', !!(widgets && widgets.get('timezone-selector')));
let confirmAnswer = true;
const confirms = [];
window.confirm = m => { confirms.push(m); return confirmAnswer; };
const reloads = [];
window.htmx = { ajax: (method, url, opts) => reloads.push([method, url, opts.target]) };
const HOSTILE = '<img src=x onerror="window.pwned=1">';
let mode = 'ok';
let nextId = 1;
const requests = [];
const pending = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
const respond = (status, body, headers) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: h => (headers || {})[h] || null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (mode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
if (mode === 'refuse') return respond(400, { status: 'error', message: 'Give the token a name.' });
if (url === '/api/v3/auth/tokens' && init.method === 'POST') {
const id = 'tok' + (nextId++);
const name = JSON.parse(init.body).name;
const answer = () => respond(201, {
status: 'success', message: 'Token created. Copy it now: it is not shown again.',
data: { token: 'lmx_' + id, record: { id, name, prefix: 'lmx_' + id.slice(0, 3), created_at: '2026-10-04T00:00:00' } },
});
if (mode === 'hang') return new Promise(resolve => pending.push(() => resolve(answer())));
return answer();
}
if (url.startsWith('/api/v3/auth/tokens/') && init.method === 'DELETE') {
return respond(200, { status: 'success', message: 'Token revoked.', data: { tokens: [] } });
}
if (url === '/api/v3/auth/password') {
return respond(200, { status: 'success', message: 'Login is on. Other browsers now need the password.' });
}
return respond(404, { status: 'error', message: 'unexpected ' + url });
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('general', generalPage);
const $ = id => doc.getElementById(id);
const root = () => doc.querySelector('[data-page="general"]');
const timezoneWidgets = () => $('timezone_container').querySelectorAll('.timezone-selector-widget').length;
const rows = () => doc.querySelectorAll('#web-login-tokens [data-token-id]');
const calls = (method, prefix) => requests.filter(r => r.method === method && r.url.startsWith(prefix));
const lastNote = () => notes[notes.length - 1] || [];
function submit(form) {
const event = new window.Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(event);
return event;
}
const form = action => root().querySelector(`form[data-action="${action}"]`);
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html === undefined ? partial : html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
await registry.start();
await tick(20);
// ── the timezone picker ─────────────────────────────────────────────────
const savedZone = $('timezone_container').dataset.timezone;
ok('the partial carries the saved timezone', !!savedZone, savedZone);
ok('the timezone picker is drawn once', timezoneWidgets() === 1, timezoneWidgets());
ok('...holding the saved zone', $('timezone_data') && $('timezone_data').value === savedZone,
$('timezone_data') && $('timezone_data').value);
ok('...posted as "timezone"', $('timezone_data') && $('timezone_data').name === 'timezone');
for (let i = 0; i < 5; i++) await swap();
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('the timezone picker is drawn once, not stacked', timezoneWidgets() === 1, timezoneWidgets());
// ── the settings form is htmx's ─────────────────────────────────────────
const settings = root().querySelector('form[hx-post="/api/v3/config/main"]');
ok('submitting the settings form is not prevented', settings && !submit(settings).defaultPrevented);
ok('...and makes no request of the page\'s own', requests.length === 0, requests.length);
if (security) {
// ── create a token ────────────────────────────────────────────────────
const before = rows().length;
const create = form('create-token');
create.querySelector('[name="name"]').value = HOSTILE;
create.setAttribute('data-dirty', '');
ok('Create token is handled by the page', submit(create).defaultPrevented);
await tick(20);
ok('one POST to /api/v3/auth/tokens', calls('POST', '/api/v3/auth/tokens').length === 1, requests);
ok('...with the name typed', calls('POST', '/api/v3/auth/tokens')[0].body.name === HOSTILE);
ok('a row is added', rows().length === before + 1, rows().length);
ok('the hostile token name is shown as text', root().querySelector('#web-login-tokens').textContent.includes(HOSTILE));
ok('...and created no element', !root().querySelector('#web-login-tokens img') && !window.pwned);
ok('the "No tokens yet" line is gone', !root().querySelector('#web-login-tokens [data-empty]'));
ok('the token is shown once', $('web-login-new-token-value').textContent === 'lmx_tok1'
&& !$('web-login-new-token').classList.contains('hidden'));
ok('the form is clean again (no "Leave site?")', !create.hasAttribute('data-dirty'));
ok('one success notification', lastNote()[1] === 'success' && /Token created/.test(lastNote()[0]), notes);
// ── copy it (plain http: not a secure context, so it is selected) ────
root().querySelector('button[data-action="copy-token"]').click();
ok('Copy selects the token where the clipboard API is unavailable',
window.getSelection().toString() === 'lmx_tok1' && /Selected/.test(lastNote()[0]), lastNote());
// ── revoke it (the row drawn by the page, so delegation covers it) ───
confirmAnswer = false;
const added = rows()[rows().length - 1];
added.querySelector('button[data-action="revoke-token"]').click();
await tick(20);
ok('a cancelled Revoke sends nothing', calls('DELETE', '/api/v3/auth/tokens/').length === 0);
ok('...after asking with the token name as written', confirms.length === 1 && confirms[0].includes(HOSTILE), confirms);
confirmAnswer = true;
added.querySelector('button[data-action="revoke-token"]').click();
await tick(20);
ok('Revoke sends one DELETE for that token',
calls('DELETE', '/api/v3/auth/tokens/').length === 1 && calls('DELETE', '/api/v3/auth/tokens/')[0].url === '/api/v3/auth/tokens/tok1',
calls('DELETE', '/api/v3/auth/tokens/'));
ok('...and removes its row', rows().length === before, rows().length);
// ── the password ──────────────────────────────────────────────────────
const pw = form('set-password');
pw.querySelector('[name="new_password"]').value = 'correct horse battery';
pw.querySelector('[name="confirm_password"]').value = 'correct horse batterY';
submit(pw);
await tick(20);
ok('mismatched passwords are never sent', calls('POST', '/api/v3/auth/password').length === 0);
ok('...and say so', lastNote()[1] === 'error' && /do not match/.test(lastNote()[0]), lastNote());
pw.querySelector('[name="confirm_password"]').value = 'correct horse battery';
submit(pw);
await tick(20);
const sent = calls('POST', '/api/v3/auth/password');
ok('a matching password is sent once', sent.length === 1, sent.length);
ok('...with the current password only when the form has one',
sent[0] && sent[0].body.new_password === 'correct horse battery'
&& (('current_password' in sent[0].body) === !!pw.querySelector('[name="current_password"]')), sent[0]);
ok('...and the section is reloaded once', reloads.length === 1 && reloads[0][1] === '/v3/partials/general'
&& reloads[0][2] === '#general-content', reloads);
// ── refused, network failure, login redirect ──────────────────────────
mode = 'refuse';
submit(form('create-token'));
await tick(20);
ok('a refused request shows the server message', lastNote()[1] === 'error' && lastNote()[0] === 'Give the token a name.', lastNote());
mode = 'network';
submit(form('create-token'));
await tick(20);
ok('a network failure says the request failed', lastNote()[1] === 'error' && /^Request failed: /.test(lastNote()[0]), lastNote());
mode = 'login';
const quiet = notes.length;
submit(form('create-token'));
await tick(20);
ok('the login redirect shows nothing (the page is leaving)', notes.length === quiet, notes.slice(quiet));
// ── a write survives a swap ───────────────────────────────────────────
mode = 'hang';
await swap();
const rowsBefore = rows().length;
form('create-token').querySelector('[name="name"]').value = 'Late';
submit(form('create-token'));
await tick(5);
await swap();
pending.shift()();
await tick(20);
ok('a token created before a swap is still reported', lastNote()[1] === 'success', lastNote());
ok('...and draws nothing into the new page', rows().length === rowsBefore
&& $('web-login-new-token').classList.contains('hidden'), rows().length);
mode = 'ok';
// ── window.webLogin ────────────────────────────────────────────────────
const viaAlias = calls('POST', '/api/v3/auth/tokens').length;
form('create-token').querySelector('[name="name"]').value = 'Alias';
await generalPage.webLogin.createToken(form('create-token'));
ok('webLogin.createToken(form) creates one token', calls('POST', '/api/v3/auth/tokens').length === viaAlias + 1);
ok('webLogin has the five old methods',
['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'].every(m => typeof generalPage.webLogin[m] === 'function'));
}
// ── the widget loads late ───────────────────────────────────────────────
delete window.LEDMatrixWidgets;
await swap();
ok('nothing drawn while the widget is missing', timezoneWidgets() === 0, timezoneWidgets());
window.LEDMatrixWidgets = widgets;
await tick(150);
ok('drawn once the widget arrives', timezoneWidgets() === 1, timezoneWidgets());
delete window.LEDMatrixWidgets;
await swap();
const kept = root();
await swap('<p>another tab</p>');
window.LEDMatrixWidgets = widgets;
await tick(250);
ok('a page swapped away while waiting draws nothing', kept.querySelectorAll('.timezone-selector-widget').length === 0);
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
ok('no console errors', logged.length === 0, logged);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+167
View File
@@ -0,0 +1,167 @@
// The Schedule tab as a page module (static/v3/js/pages/schedule.js), in a
// real DOM (jsdom) with the real server-rendered partial and the real
// widget registry and schedule-picker widget. Built like test_cache_page.js:
//
// * the partial ships no <script> and no inline handlers; its root is
// data-page="schedule" and carries both saved schedules as JSON
// * both pickers are drawn once per swap-in, from the saved config, however
// many swaps came first
// * each form's save is reported in exactly one notification (the forms
// are marked data-reports-result so app.js stays quiet)
// * the dim brightness label follows the slider
// * a widget that loads late is waited for, and a page swapped away while
// waiting draws nothing
// * the old globals' entry points still work
const http = require('http');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/schedule');
const { createRegistry } = await load('core/registry.js');
const schedulePage = await load('pages/schedule.js');
console.log('\n── Schedule tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial));
ok('the forms carry no hx-on handler', !/hx-on/i.test(partial));
ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial));
ok('both forms are marked data-reports-result',
(partial.match(/<form[^>]*data-reports-result/g) || []).length === 2);
const errs = [];
const logged = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => logged.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="schedule-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('schedule-content');
// base.html defines LEDEscape (app-early.js) before any tab loads; the
// widget escapes with it.
require('../led_escape').install(window);
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
window.eval(fs.readFileSync(path.join(JS, 'widgets/schedule-picker.js'), 'utf8'));
const widgets = window.LEDMatrixWidgets;
ok('the widget scripts register schedule-picker', !!(widgets && widgets.get('schedule-picker')));
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: null, notify: (m, t) => notes.push([m, t]) },
});
registry.register('schedule', schedulePage);
const $ = id => doc.getElementById(id);
const root = () => doc.querySelector('[data-page="schedule"]');
const saved = key => JSON.parse(root().dataset[key]);
const drawn = () => ['schedule_picker_container', 'dim_schedule_picker_container']
.map(id => $(id).querySelectorAll('.schedule-picker-widget').length);
async function swap(html) {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = html === undefined ? partial : html;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
function answer(formId, xhr) {
$(formId).dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
bubbles: true, detail: { xhr, elt: $(formId), successful: xhr.status < 300 } }));
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('both pickers drawn once', drawn().join() === '1,1', drawn());
const schedule = saved('scheduleConfig');
const dim = saved('dimScheduleConfig');
ok('the saved config reaches the page as JSON', schedule && typeof schedule === 'object' && dim && typeof dim === 'object');
const mode = cfg => cfg.mode ? cfg.mode.replace('-', '_') : (cfg.days ? 'per_day' : 'global');
ok('the display picker shows the saved mode', $('schedule_mode_value').value === mode(schedule),
[$('schedule_mode_value').value, schedule.mode]);
ok('the dim picker shows the saved mode', $('dim_schedule_mode_value').value === mode(dim),
[$('dim_schedule_mode_value').value, dim.mode]);
ok('the dim picker shows the saved start time',
$('dim_schedule_start_time_hidden').value === (dim.start_time || '20:00'), $('dim_schedule_start_time_hidden').value);
// ── repeated swaps ──────────────────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap();
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('each picker drawn once, not stacked', drawn().join() === '1,1', drawn());
const okXhr = body => ({ status: 200, responseText: JSON.stringify(body) });
answer('schedule_form', okXhr({ status: 'success', message: 'Schedule configuration saved successfully' }));
ok('a schedule save is reported once', notes.length === 1, notes);
ok('...with the server message and status',
notes[0] && notes[0][0] === 'Schedule configuration saved successfully' && notes[0][1] === 'success', notes[0]);
answer('dim_schedule_form', okXhr({ status: 'success' }));
ok('a dim schedule save without a message says so',
notes.length === 2 && notes[1][0] === 'Dim schedule settings saved' && notes[1][1] === 'success', notes[1]);
answer('schedule_form', { status: 400, responseText: JSON.stringify({ status: 'error' }) });
ok('a refused save without a message says so',
notes.length === 3 && notes[2][0] === 'Error saving schedule' && notes[2][1] === 'error', notes[2]);
answer('dim_schedule_form', { status: 502, responseText: '<html>Bad gateway</html>' });
ok('a non-JSON answer is an error',
notes.length === 4 && notes[3][0] === 'Invalid response from server' && notes[3][1] === 'error', notes[3]);
answer('schedule_form', { status: 200, responseText: 'null' });
ok('a JSON null answer is an error, not a crash',
notes.length === 5 && notes[4][1] === 'error', notes[4]);
// An htmx request from elsewhere on the page (outside both forms) is not a save.
root().querySelector('.settings-filter').dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
bubbles: true, detail: { xhr: okXhr({ status: 'success', message: 'x' }) } }));
ok('a request from outside the two forms reports nothing', notes.length === 5, notes.length);
// ── the brightness label ────────────────────────────────────────────────
$('dim_brightness').value = '42';
$('dim_brightness').dispatchEvent(new window.Event('input', { bubbles: true }));
ok('the dim brightness label follows the slider', $('dim_brightness_display').textContent === '42%',
$('dim_brightness_display').textContent);
// ── the widget loads late ───────────────────────────────────────────────
delete window.LEDMatrixWidgets;
await swap();
ok('nothing drawn while the widget is missing', drawn().join() === '0,0', drawn());
window.LEDMatrixWidgets = widgets;
await tick(150);
ok('drawn once the widget arrives', drawn().join() === '1,1', drawn());
delete window.LEDMatrixWidgets;
await swap();
const kept = root();
await swap('<p>another tab</p>');
window.LEDMatrixWidgets = widgets;
await tick(250);
ok('a page swapped away while waiting draws nothing',
kept.querySelectorAll('.schedule-picker-widget').length === 0);
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
// ── the old globals ─────────────────────────────────────────────────────
await swap();
const before = notes.length;
schedulePage.handleScheduleResponse({ target: $('schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
schedulePage.handleDimScheduleResponse({ target: $('dim_schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
ok('handleScheduleResponse(event) and handleDimScheduleResponse(event) report once each',
notes.length === before + 2 && notes[before][0] === 'Schedule settings saved'
&& notes[before + 1][0] === 'Dim schedule settings saved', notes.slice(before));
ok('no console errors', logged.length === 0, logged);
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+2 -1
View File
@@ -36,7 +36,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js'];
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js',
'dom/test_schedule_page.js', 'dom/test_general_page.js'];
function reachable(url) {
return new Promise(res => {
+31 -44
View File
@@ -3,32 +3,24 @@
// app.js marks a form data-dirty on any input in it and clears the mark only
// after a successful htmx request; its beforeunload handler then asks "Leave
// site?" while any visible form is still dirty. The token form posts with
// fetch (window.webLogin.createToken in partials/general.html), so after a
// token was created the form stayed dirty and reloading the page while the
// General tab was open prompted about changes that had been saved.
// fetch (createToken in static/v3/js/pages/general.js), so after a token was
// created the form stayed dirty and reloading the page while the General tab
// was open prompted about changes that had been saved.
//
// Runs the shipped inline script in a vm with a fake fetch and DOM -- no jsdom
// and no server needed.
// Imports the shipped page module and runs createToken with a fake fetch and
// DOM -- no jsdom and no server needed.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const { pathToFileURL } = require('url');
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/general.html');
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const load = f => import(pathToFileURL(path.join(JS, f)).href);
let pass = 0, fail = 0;
const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
function webLoginScript() {
const html = fs.readFileSync(PARTIAL, 'utf8');
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]);
const found = scripts.find(s => s.includes('window.webLogin = {'));
if (!found) throw new Error('webLogin script not found in general.html');
return found;
}
function el() {
const classes = new Set(['hidden']);
return {
@@ -38,29 +30,28 @@ function el() {
};
}
function load(answer) {
async function setup(answer) {
const { createApi } = await load('core/api.js');
const general = await load('pages/general.js');
const elements = {
'web-login-tokens': el(),
'web-login-new-token-value': el(),
'web-login-new-token': el(),
'#web-login-tokens': el(),
'#web-login-new-token-value': el(),
'#web-login-new-token': el(),
};
const notes = [];
const window = { showNotification: (m, t) => notes.push([m, t]), alert() {}, confirm: () => true };
const context = {
window, console,
document: {
getElementById: id => elements[id] || null,
createElement: () => el(),
querySelectorAll: () => [],
},
fetch: () => Promise.resolve({
ok: answer.ok, status: answer.ok ? 200 : 400,
json: () => Promise.resolve(answer.body),
}),
const doc = { createElement: () => el(), defaultView: { confirm: () => true } };
const root = { ownerDocument: doc, querySelector: sel => elements[sel] || null, querySelectorAll: () => [] };
const fetch = () => Promise.resolve({
ok: answer.ok, status: answer.ok ? 200 : 400,
headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify(answer.body)),
});
const ctx = {
root, state: {}, signal: { aborted: false },
api: createApi({ fetch }),
notify: (m, t) => notes.push([m, t]),
};
vm.createContext(context);
vm.runInContext(webLoginScript(), context);
return { webLogin: context.window.webLogin, elements, notes };
return { general, ctx, elements, notes };
}
function dirtyForm() {
@@ -74,29 +65,25 @@ function dirtyForm() {
};
}
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
(async () => {
console.log('\n── General tab: API token form ──');
{
const t = load({ ok: true, body: {
const t = await setup({ ok: true, body: {
status: 'success', message: 'Token created',
data: { token: 'lmx_secret', record: { id: 't1', name: 'Home Assistant', prefix: 'lmx_sec' } },
} });
const form = dirtyForm();
t.webLogin.createToken(form);
await flush();
ok('the new token is shown', t.elements['web-login-new-token-value'].textContent === 'lmx_secret');
await t.general.createToken(t.ctx, form);
ok('the new token is shown', t.elements['#web-login-new-token-value'].textContent === 'lmx_secret');
ok('a created token leaves the form clean (no "Leave site?" on reload)',
!form.hasAttribute('data-dirty'));
}
{
const t = load({ ok: false, body: { status: 'error', message: 'Name is required' } });
const t = await setup({ ok: false, body: { status: 'error', message: 'Name is required' } });
const form = dirtyForm();
t.webLogin.createToken(form);
await flush();
await t.general.createToken(t.ctx, form);
ok('a refused request reports the error', t.notes.some(([m, type]) => type === 'error' && /Name is required/.test(m)),
t.notes);
ok('...and keeps the form dirty: nothing was saved', form.hasAttribute('data-dirty'));
+23 -4
View File
@@ -19,7 +19,7 @@ const ok = (label, cond, extra) => cond
? (pass++, console.log(' ok ' + label))
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
function load() {
function load(notes) {
const handlers = {};
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
const banner = { style: { display: 'none' } };
@@ -43,14 +43,18 @@ function load() {
removeItem: (k) => { delete store[k]; },
getItem: (k) => (k in store ? store[k] : null),
},
showNotification: () => {},
showNotification: (m, t) => { if (notes) notes.push([m, t]); },
setTimeout: () => 0,
};
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
const elt = { closest: () => (reportsItself ? {} : null) };
// `marks`: the attribute selectors the requesting element (or its form)
// matches, for app.js's elt.closest(<selector list>).
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false, marks = [] }) => {
const elt = {
closest: (sel) => (reportsItself || sel.split(',').some(s => marks.includes(s.trim())) ? {} : null),
};
afterRequest({
target: { closest: () => null },
detail: {
@@ -100,5 +104,20 @@ console.log('\nhtmx after-request follows the flag, not the URL');
ok('a flagged answer raises it, even from a form that reports itself', t.banner.style.display === 'block');
}
console.log('\nthe server message toast');
{
const notes = [];
const t = load(notes);
const answer = { status: 'success', message: 'Schedule saved' };
t.fire({ body: answer });
ok('a plain htmx request shows the server message', notes.length === 1 && notes[0][0] === 'Schedule saved', notes);
t.fire({ body: answer, marks: ['[hx-on\\:htmx\\:after-request]'] });
ok('a form with its own hx-on after-request handler does not', notes.length === 1, notes);
// Page modules (js/pages/schedule.js) report a form's save from a listener
// and mark the form data-reports-result instead of an hx-on attribute.
t.fire({ body: answer, marks: ['[data-reports-result]'] });
ok('nor does a form a page module reports for (data-reports-result)', notes.length === 1, notes);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
+418
View File
@@ -0,0 +1,418 @@
"""Plugins asking for the screen in-process: BasePlugin.request_on_demand()
and end_on_demand().
A plugin running in the display process used to write the
``display_on_demand_request`` mailbox, which the display reads once a second
while the control socket is up. These tests pin the way in that replaces it:
* BasePlugin -> PluginManager -> DisplayController.submit_plugin_on_demand,
which only queues, from any thread;
* the render thread applies the queue where it applies socket commands,
through the mailbox's own handler, without the mailbox's read floor, and
woken by the control socket when it is up;
* a plugin's stop ends only its own session;
* no display to ask (the web interface's plugin manager, an old core's
plugin manager) answers None, which is a plugin's cue to fall back to the
mailbox;
* the mailbox still works for plugins that write it.
"""
import logging
import threading
import time
from unittest.mock import MagicMock
import pytest
from src.ipc.server import ControlServer
from src.plugin_system.base_plugin import BasePlugin
from src.plugin_system.plugin_manager import PluginManager
class _Plugin(BasePlugin):
def update(self):
pass
def display(self, force_clear=False):
pass
def _plugin(plugin_id, manager):
plugin = _Plugin.__new__(_Plugin)
plugin.plugin_id = plugin_id
plugin.plugin_manager = manager
return plugin
def _manager(handler=None):
manager = PluginManager.__new__(PluginManager)
manager.logger = logging.getLogger('test.plugin_on_demand')
if handler is not None:
manager.set_on_demand_handler(handler)
return manager
class _WakeServer:
"""The parts of ControlServer the controller uses, with no socket."""
def __init__(self):
self.woken = 0
self.has_pending = False
def wake(self):
self.woken += 1
self.has_pending = True
def drain(self):
self.has_pending = False
return []
@pytest.fixture
def controller(test_display_controller):
c_ = test_display_controller
c_.on_demand_active = False
c_.on_demand_request_id = None
c_._last_on_demand_poll = None
mailbox = {'value': None}
def fake_get(key, *a, **kw):
if key == 'display_on_demand_request':
return mailbox['value']
return None
c_.cache_manager.get = MagicMock(side_effect=fake_get)
c_.cache_manager.set = MagicMock()
c_.cache_manager.delete = MagicMock()
c_._activate_on_demand = MagicMock()
c_.mailbox = mailbox
return c_
@pytest.fixture
def wired(controller):
"""A real PluginManager wired to the controller, as __init__ wires it."""
manager = _manager(controller.submit_plugin_on_demand)
return controller, manager
class TestWiring:
def test_the_controller_wires_its_plugin_manager(self, controller):
controller.plugin_manager.set_on_demand_handler.assert_called_once_with(
controller.submit_plugin_on_demand)
def test_a_start_reaches_the_mailbox_handler(self, wired):
controller, manager = wired
rid = _plugin('pomodoro-timer', manager).request_on_demand(
mode='pomodoro', duration=30, pinned=True)
assert isinstance(rid, str) and rid
controller._activate_on_demand.assert_not_called() # only queued
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
request = controller._activate_on_demand.call_args.args[0]
assert request['request_id'] == rid
assert request['action'] == 'start'
assert request['plugin_id'] == 'pomodoro-timer'
assert request['mode'] == 'pomodoro'
assert request['duration'] == 30.0 and request['pinned'] is True
assert request['source'] == 'plugin'
assert controller.on_demand_request_id == rid
def test_a_plugin_request_never_touches_the_mailbox(self, wired):
controller, manager = wired
_plugin('on-air', manager).request_on_demand(mode='on_air')
controller._drain_control_commands()
controller._activate_on_demand.assert_called_once()
mailbox_reads = [call for call in controller.cache_manager.get.call_args_list
if call.args[0] == 'display_on_demand_request']
assert mailbox_reads == []
controller.cache_manager.delete.assert_not_called()
def test_requests_apply_in_order(self, wired):
controller, manager = wired
seen = []
controller._activate_on_demand = MagicMock(
side_effect=lambda r: seen.append(r['mode']))
plugin = _plugin('p', manager)
for mode in ('a', 'b', 'c'):
plugin.request_on_demand(mode=mode)
controller._poll_on_demand_requests()
assert seen == ['a', 'b', 'c']
def test_the_mailbox_still_works_for_older_plugins(self, wired):
controller, manager = wired
controller.mailbox['value'] = {'request_id': 'mb', 'action': 'start',
'plugin_id': 'birdnet-go'}
_plugin('on-air', manager).request_on_demand()
controller._poll_on_demand_requests()
ids = [call.args[0]['request_id'] for call in controller._activate_on_demand.call_args_list]
assert 'mb' in ids and len(ids) == 2
def test_a_failing_request_is_contained(self, wired):
controller, manager = wired
calls = []
def activate(request):
calls.append(request['mode'])
if request['mode'] == 'bad':
raise RuntimeError('plugin exploded')
controller._activate_on_demand = MagicMock(side_effect=activate)
plugin = _plugin('p', manager)
plugin.request_on_demand(mode='bad')
plugin.request_on_demand(mode='good')
controller._poll_on_demand_requests()
assert calls == ['bad', 'good']
class TestPromptness:
def test_a_plugin_request_skips_the_pending_changes_floor(self, wired):
controller, manager = wired
controller._control_server = None
controller._service_pending_changes()
_plugin('p', manager).request_on_demand()
controller._service_pending_changes() # well inside the 0.25 s floor
controller._activate_on_demand.assert_called_once()
def test_a_plugin_request_skips_the_mailbox_floor(self, wired):
controller, manager = wired
controller._control_server = _WakeServer()
controller._poll_on_demand_requests() # sets the 1 s mailbox floor
_plugin('p', manager).request_on_demand()
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
def test_it_wakes_the_control_socket_wait(self, wired):
controller, manager = wired
server = ControlServer('/nonexistent/control.sock') # never started
controller._control_server = server
assert not server.wait_for_command(0)
_plugin('p', manager).request_on_demand()
assert server.has_pending
assert controller._wait_for_control(5.0) is True # returns at once
assert controller._control_command_pending()
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
assert not server.has_pending
assert not controller._control_command_pending()
def test_without_a_socket_a_waiting_request_cuts_the_sleep(self, wired):
controller, manager = wired
controller._control_server = None
_plugin('p', manager).request_on_demand()
started = time.monotonic()
assert controller._wait_for_control(5.0) is True
assert time.monotonic() - started < 1.0
assert controller._control_command_pending()
def test_nothing_waiting_keeps_the_floor(self, controller):
controller._control_server = None
controller._poll_on_demand_requests = MagicMock()
controller._service_pending_changes()
controller._service_pending_changes()
assert controller._poll_on_demand_requests.call_count == 1
class TestThreads:
def test_requests_from_many_threads_all_land_in_order_per_thread(self, wired):
controller, manager = wired
seen = []
controller._activate_on_demand = MagicMock(
side_effect=lambda r: seen.append(r['mode']))
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 10_000
threads_n, each = 8, 50
barrier = threading.Barrier(threads_n)
def ask(n):
plugin = _plugin(f'p{n}', manager)
barrier.wait()
for i in range(each):
assert plugin.request_on_demand(mode=f'{n}:{i}')
threads = [threading.Thread(target=ask, args=(n,)) for n in range(threads_n)]
for t in threads:
t.start()
# Drain while they ask, as the render thread would.
while any(t.is_alive() for t in threads):
controller._drain_control_commands()
for t in threads:
t.join()
controller._drain_control_commands()
assert len(seen) == threads_n * each
for n in range(threads_n):
mine = [int(m.split(':')[1]) for m in seen if m.startswith(f'{n}:')]
assert mine == list(range(each))
def test_a_full_queue_refuses(self, wired, caplog):
controller, manager = wired
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 2
plugin = _plugin('p', manager)
assert plugin.request_on_demand()
assert plugin.request_on_demand()
assert plugin.request_on_demand() is None
assert 'queue full' in caplog.text
controller._poll_on_demand_requests()
assert controller._activate_on_demand.call_count == 2
assert plugin.request_on_demand() # room again
class TestStop:
def test_a_plugin_ends_its_own_session(self, wired):
controller, manager = wired
controller.on_demand_active = True
controller.on_demand_plugin_id = 'on-air'
controller._clear_on_demand = MagicMock()
assert _plugin('on-air', manager).end_on_demand()
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
controller.cache_manager.delete.assert_not_called()
def test_a_plugin_cannot_end_another_plugins_session(self, wired):
controller, manager = wired
controller.on_demand_active = True
controller.on_demand_plugin_id = 'clock' # the user started it
controller.on_demand_request_id = 'user'
controller._clear_on_demand = MagicMock()
_plugin('pomodoro-timer', manager).end_on_demand()
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_not_called()
assert controller.on_demand_request_id == 'user'
def test_a_stop_with_no_session_does_nothing(self, wired):
controller, manager = wired
controller.on_demand_status = 'error'
controller._clear_on_demand = MagicMock()
_plugin('on-air', manager).end_on_demand()
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_not_called()
def test_a_mailbox_stop_still_ends_any_session(self, wired):
controller, _ = wired
controller.on_demand_active = True
controller.on_demand_plugin_id = 'clock'
controller._clear_on_demand = MagicMock()
controller.mailbox['value'] = {'request_id': 's', 'action': 'stop',
'plugin_id': 'on-air'}
controller._poll_on_demand_requests()
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
def test_start_then_stop_from_one_thread_ends_the_session(self, wired):
controller, manager = wired
def activate(request):
controller.on_demand_active = True
controller.on_demand_plugin_id = request['plugin_id']
controller._activate_on_demand = MagicMock(side_effect=activate)
controller._clear_on_demand = MagicMock()
plugin = _plugin('pomodoro-timer', manager)
plugin.request_on_demand(mode='pomodoro', pinned=True)
plugin.end_on_demand()
controller._poll_on_demand_requests()
controller._activate_on_demand.assert_called_once()
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
class TestNoDisplay:
"""None is a plugin's cue to write the mailbox instead."""
def test_a_manager_with_no_handler_answers_none(self):
plugin = _plugin('p', _manager())
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_no_plugin_manager_answers_none(self):
plugin = _plugin('p', None)
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_an_old_cores_plugin_manager_answers_none(self):
class OldManager:
plugin_manifests = {}
plugin = _plugin('p', OldManager())
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_a_handler_that_raises_answers_none(self):
def broken(request):
raise RuntimeError('boom')
plugin = _plugin('p', _manager(broken))
assert plugin.request_on_demand() is None
assert plugin.end_on_demand() is None
def test_a_handler_that_refuses_answers_none(self):
plugin = _plugin('p', _manager(lambda request: False))
assert plugin.request_on_demand() is None
def test_a_controller_built_without_init_refuses(self):
from src.display_controller import DisplayController
bare = DisplayController.__new__(DisplayController)
assert bare.submit_plugin_on_demand({'action': 'start'}) is False
assert bare._plugin_on_demand_pending() is False
bare._drain_plugin_on_demand() # nothing to do, no error
def test_the_feature_detection_pattern(self):
"""The hasattr pattern from docs/PLUGIN_API_REFERENCE.md."""
writes = []
class OldCorePlugin: # an older core's BasePlugin has no such method
pass
for plugin, expect_mailbox in ((OldCorePlugin(), True),
(_plugin('p', _manager()), True),
(_plugin('p', _manager(lambda r: True)), False)):
writes.clear()
if not (hasattr(plugin, 'request_on_demand')
and plugin.request_on_demand(mode='m')):
writes.append('mailbox')
assert (writes == ['mailbox']) is expect_mailbox
class TestArguments:
def test_the_manager_shapes_the_request(self):
got = []
plugin = _plugin('p', _manager(lambda r: got.append(r) or True))
plugin.request_on_demand()
plugin.end_on_demand()
start, stop = got
assert start['plugin_id'] == 'p' and start['mode'] is None
assert start['duration'] is None and start['pinned'] is False
assert start['source'] == 'plugin' and start['timestamp'] > 0
assert stop == {'action': 'stop', 'plugin_id': 'p', 'request_id': stop['request_id'],
'timestamp': stop['timestamp'], 'source': 'plugin'}
assert start['request_id'] != stop['request_id']
@pytest.mark.parametrize('duration', [0, -5, float('inf'), float('nan')])
def test_no_positive_duration_means_no_limit(self, duration):
got = []
_plugin('p', _manager(lambda r: got.append(r) or True)).request_on_demand(
duration=duration)
assert got[0]['duration'] is None
@pytest.mark.parametrize('kwargs', [{'mode': 5}, {'mode': ''}, {'duration': '30'},
{'duration': True}])
def test_bad_arguments_raise(self, kwargs):
plugin = _plugin('p', _manager(lambda r: True))
with pytest.raises(ValueError):
plugin.request_on_demand(**kwargs)
class TestMockManagers:
def test_a_magicmock_manager_reads_as_not_taken(self):
"""A plugin's test with a MagicMock manager keeps its mailbox path."""
plugin = _plugin('p', MagicMock())
assert plugin.request_on_demand(mode='m') is None
assert plugin.end_on_demand() is None
plugin.plugin_manager.request_on_demand.assert_called_once_with(
'p', mode='m', duration=None, pinned=False)
plugin.plugin_manager.end_on_demand.assert_called_once_with('p')
def test_a_mocked_id_is_passed_through(self):
manager = MagicMock()
manager.request_on_demand.return_value = 'rid'
manager.end_on_demand.return_value = 'rid2'
plugin = _plugin('p', manager)
assert plugin.request_on_demand() == 'rid'
assert plugin.end_on_demand() == 'rid2'
+38 -2
View File
@@ -43,7 +43,7 @@ def _url(path):
def test_the_module_directories_hold_modules():
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
"durations.js", "operation-history.js", "raw-json.js",
"backup-restore.js"}
"backup-restore.js", "schedule.js", "general.js"}
for directory in MODULE_DIRS:
# node needs this to import them in the JS tests; browsers ignore it.
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
@@ -104,6 +104,8 @@ CONVERTED = {
"operation-history": ("operation_history.html", "/partials/operation-history"),
"raw-json": ("raw_json.html", "/partials/raw-json"),
"backup-restore": ("backup_restore.html", "/partials/backup-restore"),
"schedule": ("schedule.html", "/partials/schedule"),
"general": ("general.html", "/partials/general"),
}
# Old window.* names that moved into a page module. Each stays as a
@@ -114,6 +116,8 @@ ALIASES = {
"saveMainConfig", "saveSecretsConfig"],
"backup-restore": ["exportBackup", "loadBackupList", "validateRestoreFile",
"clearRestore", "runRestore"],
"schedule": ["handleScheduleResponse", "handleDimScheduleResponse"],
"general": ["webLogin"],
}
@@ -155,12 +159,15 @@ def test_moved_globals_stay_as_aliases(name):
assert f"'{global_name}'" in boot, f"boot.js does not alias {global_name}"
assert re.search(rf"^export (?:function|const) {global_name}\b", module, re.M), (
f"pages/{name}.js does not export {global_name}")
# No template defines them any more.
# No template defines them any more, or calls them (an inline handler
# would reach the page only through the deprecated alias).
for partial in PARTIALS.glob("*.html"):
text = partial.read_text(encoding="utf-8")
for global_name in ALIASES[name]:
assert f"window.{global_name} =" not in text, partial.name
assert f"function {global_name}(" not in text, partial.name
assert not re.search(rf"\b{global_name}\b", text), (
f"{partial.name} still names {global_name}")
def test_every_registered_page_has_its_module_and_partial():
@@ -180,3 +187,32 @@ def test_converted_partials_carry_no_inline_script():
if "data-page=" in text:
assert "<script" not in text.lower(), (
f"{partial.name} is a page module now; its code belongs in js/pages/")
def test_the_schedule_page_reads_its_config_back_intact():
"""schedule.html hands both saved schedules to pages/schedule.js as JSON in
single-quoted data attributes; a value with quotes or markup must neither
end the attribute nor change on the way."""
from html.parser import HTMLParser
import json
import web_interface.app as web_app
from flask import render_template
hostile = {"mode": "per-day", "start_time": "07:00", "note": "it's a \"<b>\" & '</div>"}
class Root(HTMLParser):
attrs = None
def handle_starttag(self, tag, attrs):
if dict(attrs).get("data-page") == "schedule":
self.attrs = dict(attrs)
with web_app.app.test_request_context():
html = render_template("v3/partials/schedule.html", schedule_config=hostile,
dim_schedule_config=None, normal_brightness=90)
parser = Root()
parser.feed(html)
assert parser.attrs is not None
assert json.loads(parser.attrs["data-schedule-config"]) == hostile
# A missing dim schedule arrives as null; the page treats that as {}.
assert json.loads(parser.attrs["data-dim-schedule-config"]) is None
+4 -2
View File
@@ -57,11 +57,13 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
// Show the server's message, unless the element that made the request
// (or its form) has its own after-request handler: every such handler in
// the templates reports the result itself, and this used to repeat it,
// so each save showed two toasts.
// so each save showed two toasts. A form whose page module reports the
// result instead (a js/pages/*.js listener for htmx:afterRequest) is
// marked data-reports-result.
const response = event.detail.xhr;
const elt = event.detail.elt;
const reportsItself = elt && elt.closest &&
elt.closest('[hx-on\\:\\:after-request], [hx-on\\:htmx\\:after-request]');
elt.closest('[hx-on\\:\\:after-request], [hx-on\\:htmx\\:after-request], [data-reports-result]');
if (!reportsItself && response && response.responseText) {
try {
const data = JSON.parse(response.responseText);
+26 -6
View File
@@ -43,18 +43,33 @@ const pages = {
'operation-history': page(function() { return import('../pages/operation-history.js'); }),
'raw-json': page(function() { return import('../pages/raw-json.js'); }),
'backup-restore': page(function() { return import('../pages/backup-restore.js'); }),
'schedule': page(function() { return import('../pages/schedule.js'); }),
'general': page(function() { return import('../pages/general.js'); }),
};
Object.keys(pages).forEach(function(name) { registry.register(name, pages[name]); });
/** Keep window[name] working: forward to the page module's export of the same name. */
function alias(pageName, name, replacement) {
/** A function that calls `pick(module)` with its arguments, loading the page's module if needed. */
function forward(pageName, pick) {
const loader = pages[pageName];
facade.deprecate(name, function() {
return function() {
const args = arguments;
const module = loader.loaded();
if (module) return module[name].apply(null, args);
return loader().then(function(loaded) { return loaded[name].apply(null, args); });
}, replacement);
if (module) return pick(module).apply(null, args);
return loader().then(function(loaded) { return pick(loaded).apply(null, args); });
};
}
/** Keep window[name] working: forward to the page module's export of the same name. */
function alias(pageName, name, replacement) {
facade.deprecate(name, forward(pageName, function(module) { return module[name]; }), replacement);
}
/** The same for an old global object of functions: each method forwards to the export's method. */
function aliasObject(pageName, name, methods, replacement) {
const target = Object.fromEntries(methods.map(function(method) {
return [method, forward(pageName, function(module) { return module[name][method]; })];
}));
facade.deprecate(name, Object.freeze(target), replacement);
}
// Old globals the converted pages used to define.
@@ -65,5 +80,10 @@ alias('cache', 'deleteCacheFile', "the Cache tab's Delete buttons");
['exportBackup', 'loadBackupList', 'validateRestoreFile', 'clearRestore', 'runRestore'].forEach(function(name) {
alias('backup-restore', name, "the Backup & Restore tab's buttons");
});
['handleScheduleResponse', 'handleDimScheduleResponse'].forEach(function(name) {
alias('schedule', name, "the Schedule tab's own save handling");
});
aliasObject('general', 'webLogin', ['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'],
"the General tab's Security buttons");
registry.start();
+262
View File
@@ -0,0 +1,262 @@
/*
* pages/general.js -- the General tab (templates/v3/partials/general.html).
*
* The settings form stays plain htmx (hx-post to /api/v3/config/main; its
* hx-on calls the shared showSaveResult). This module:
* - draws the timezone-selector widget into #timezone_container, with the
* saved zone from its data-timezone attribute
* - runs the optional Security section (web login, #683): three forms and
* two kinds of button, all marked data-action and handled by one
* delegated submit and one delegated click listener on the page root, so
* a token row added later needs no listener of its own
*
* Login changes are writes: they are not cancelled when the page is swapped
* away, so the server always finishes and the result is still reported, but
* nothing is drawn into a page that has gone. Token names reach the page as
* text.
*
* window.webLogin (setPassword, disable, createToken, copyToken, revoke) is a
* deprecated alias made in core/boot.js; it forwards to the webLogin export
* at the bottom of this file.
*/
const API = '/api/v3/auth';
const RETRY_MS = 50;
const MAX_TRIES = 200;
// The mounted page, for the deprecated alias (boot.js).
let active = null;
function windowOf(ctx) {
return ctx.root.ownerDocument.defaultView;
}
function find(ctx, id) {
return ctx.root.querySelector('#' + id);
}
function field(form, name) {
const el = form.querySelector('[name="' + name + '"]');
return el ? el.value : '';
}
/**
* A login request. Resolves to {ok, d}: d is the JSON body, also for a
* refused request (its message is shown). A network failure rejects; the
* optional login's own "sign in again" answer resolves to null, since the
* page is already on its way to the login form.
*/
function send(ctx, method, url, body) {
return ctx.api.request(method, url, body === undefined ? {} : { json: body }).then(function(d) {
return { ok: true, d: d };
}, function(err) {
if (err && err.loginRequired) return null;
// Only an HTTP answer is a refusal; anything else is a failure.
if (!err || err.name !== 'ApiError' || err.network || !err.status) throw err;
return { ok: false, d: err.body && typeof err.body === 'object' ? err.body : {} };
});
}
function failed(ctx) {
return function(err) { ctx.notify('Request failed: ' + ((err && err.message) || err), 'error'); };
}
function reloadSection(ctx) {
if (ctx.signal.aborted) return; // already swapped for a fresh copy
const win = windowOf(ctx);
if (win.htmx) {
win.htmx.ajax('GET', '/v3/partials/general', { target: '#general-content', swap: 'innerHTML' });
} else {
win.location.reload();
}
}
function tokenRow(doc, record) {
const row = doc.createElement('div');
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
row.dataset.tokenId = record.id;
const text = doc.createElement('div');
text.className = 'text-sm';
[['font-semibold text-gray-900', record.name],
['font-mono text-gray-600 ml-2', record.prefix + '…'],
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
const span = doc.createElement('span');
span.className = part[0];
span.textContent = part[1];
text.appendChild(span);
});
const button = doc.createElement('button');
button.type = 'button';
button.className = 'text-sm text-red-600 hover:underline';
button.dataset.action = 'revoke-token';
button.dataset.tokenName = record.name;
button.textContent = 'Revoke';
row.appendChild(text);
row.appendChild(button);
return row;
}
/** The password form: set (or change) the password, then redraw the section. */
export function setPassword(ctx, form) {
const next = field(form, 'new_password');
if (next !== field(form, 'confirm_password')) {
ctx.notify('The two new passwords do not match.', 'error');
return Promise.resolve(false);
}
const body = { new_password: next };
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
return send(ctx, 'POST', API + '/password', body).then(function(res) {
if (!res) return false;
ctx.notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection(ctx);
return res.ok;
}).catch(failed(ctx));
}
/** The "Turn login off" form. */
export function disableLogin(ctx, form) {
if (!windowOf(ctx).confirm('Turn login off? Anyone on your network will be able to open this page.')) {
return Promise.resolve(false);
}
return send(ctx, 'POST', API + '/disable', { current_password: field(form, 'current_password') }).then(function(res) {
if (!res) return false;
ctx.notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection(ctx);
return res.ok;
}).catch(failed(ctx));
}
/** The "Create token" form: add the row and show the token once. */
export function createToken(ctx, form) {
return send(ctx, 'POST', API + '/tokens', { name: field(form, 'name') }).then(function(res) {
if (!res) return false;
if (!res.ok) {
ctx.notify(res.d.message || 'Could not create the token', 'error');
return false;
}
const data = res.d.data || {};
// app.js marks a form dirty on input and clears the mark only after
// an htmx save; this one posts with fetch, so clear it here or a
// reload asks "Leave site?" about a saved token.
form.reset();
form.removeAttribute('data-dirty');
if (!ctx.signal.aborted) {
const list = find(ctx, 'web-login-tokens');
if (list && data.record) {
const empty = list.querySelector('[data-empty]');
if (empty) empty.remove();
list.appendChild(tokenRow(ctx.root.ownerDocument, data.record));
}
const value = find(ctx, 'web-login-new-token-value');
const box = find(ctx, 'web-login-new-token');
if (value) value.textContent = data.token;
if (box) box.classList.remove('hidden');
}
ctx.notify(res.d.message || 'Token created', 'success');
return true;
}).catch(failed(ctx));
}
/** The Copy button next to a new token. */
export function copyToken(ctx) {
const win = windowOf(ctx);
const box = find(ctx, 'web-login-new-token-value');
if (!box) return;
if (win.navigator.clipboard && win.isSecureContext) {
win.navigator.clipboard.writeText(box.textContent).then(function() { ctx.notify('Token copied', 'success'); });
return;
}
// Plain http on a LAN is not a secure context: select it instead.
const range = ctx.root.ownerDocument.createRange();
range.selectNodeContents(box);
const selection = win.getSelection();
selection.removeAllRanges();
selection.addRange(range);
ctx.notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
}
/** A token row's Revoke button. */
export function revokeToken(ctx, id, name) {
if (!windowOf(ctx).confirm('Revoke the token "' + name + '"? Anything using it stops working.')) {
return Promise.resolve(false);
}
return send(ctx, 'DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
if (!res) return false;
ctx.notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
if (!res.ok) return false;
ctx.root.querySelectorAll('#web-login-tokens [data-token-id]').forEach(function(row) {
if (row.dataset.tokenId === id) row.remove();
});
return true;
}).catch(failed(ctx));
}
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
function drawTimezone(root, ctx) {
const win = root.ownerDocument.defaultView;
let tries = 0;
function attempt() {
ctx.state.timer = null;
if (ctx.signal.aborted) return;
const container = root.querySelector('#timezone_container');
if (!container) return;
const widget = win.LEDMatrixWidgets && win.LEDMatrixWidgets.get('timezone-selector');
if (!widget) {
if (++tries < MAX_TRIES) ctx.state.timer = win.setTimeout(attempt, RETRY_MS);
else win.console.error('[General] timezone-selector widget not available');
return;
}
// Only render if container is empty (not already rendered)
if (container.children.length > 0) return;
widget.render(container, {
'x-options': { showOffset: true, placeholder: 'Select your timezone...' },
}, container.dataset.timezone || 'America/Chicago', {
fieldId: 'timezone',
name: 'timezone',
});
}
attempt();
}
export function init(root, ctx) {
const on = { signal: ctx.signal };
root.addEventListener('submit', function(event) {
const form = event.target;
const action = form && form.dataset ? FORM_ACTIONS[form.dataset.action] : null;
if (!action) return; // the settings form: htmx posts it
event.preventDefault();
action(ctx, form);
}, on);
root.addEventListener('click', function(event) {
const button = event.target.closest('button[data-action]');
if (!button || !root.contains(button)) return;
if (button.dataset.action === 'copy-token') {
copyToken(ctx);
} else if (button.dataset.action === 'revoke-token') {
const row = button.closest('[data-token-id]');
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
}
}, on);
drawTimezone(root, ctx);
active = ctx;
}
export function destroy(root, ctx) {
if (ctx.state.timer) {
root.ownerDocument.defaultView.clearTimeout(ctx.state.timer);
ctx.state.timer = null;
}
if (active === ctx) active = null;
}
// ── the old global, kept as a deprecated alias (boot.js) ─────────────────────
// window.webLogin's methods took the form (or the token's id and name) and
// acted on the General tab on screen.
export const webLogin = Object.freeze({
setPassword: function(form) { return active ? setPassword(active, form) : undefined; },
disable: function(form) { return active ? disableLogin(active, form) : undefined; },
createToken: function(form) { return active ? createToken(active, form) : undefined; },
copyToken: function() { return active ? copyToken(active) : undefined; },
revoke: function(id, name) { return active ? revokeToken(active, id, name) : undefined; },
});
@@ -0,0 +1,176 @@
/*
* pages/schedule.js -- the Schedule tab (templates/v3/partials/schedule.html):
* when the display is on, and when it dims.
*
* Both forms stay plain htmx (hx-post with json-enc). This module:
* - draws the two schedule-picker widgets from the saved config the
* partial carries in data-schedule-config / data-dim-schedule-config
* - reports each form's save in one notification. The forms used to call
* window.handleScheduleResponse / handleDimScheduleResponse from an
* hx-on attribute; now one htmx:afterRequest listener on the page root
* does it, and the forms carry data-reports-result so app.js does not
* show the server's message a second time
* - keeps the "30%" label next to the dim brightness slider current
*
* The widget registry (LEDMatrixWidgets) and the schedule-picker widget are
* classic deferred scripts and normally load before this module runs; the
* short retry covers a page that arrives first anyway.
*
* The old globals are deprecated aliases made in core/boot.js; they forward
* to the exports of the same name at the bottom of this file.
*/
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const RETRY_MS = 100;
const MAX_TRIES = 50;
const PICKERS = [
{
label: 'Schedule', container: 'schedule_picker_container', data: 'data-schedule-config',
fieldId: 'schedule', start: '07:00', end: '23:00',
// The display schedule fills in days only when it has no "days" at
// all; the dim schedule also when "days" is empty.
fillEmptyDays: false,
},
{
label: 'DimSchedule', container: 'dim_schedule_picker_container', data: 'data-dim-schedule-config',
fieldId: 'dim_schedule', start: '20:00', end: '07:00',
fillEmptyDays: true,
},
];
const FORMS = {
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' },
};
const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false };
// The mounted page, for the deprecated aliases (boot.js).
let active = null;
function readConfig(root, attribute) {
try {
const parsed = JSON.parse(root.getAttribute(attribute) || 'null');
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
/** The saved config, in the shape the schedule-picker widget takes. */
export function widgetValue(config, spec) {
config = config || {};
let mode = 'global';
if (config.mode) {
// Normalize mode value (handle both 'per_day' and 'per-day')
mode = config.mode.replace('-', '_');
} else if (config.days) {
mode = 'per_day';
}
const value = {
enabled: config.enabled || false,
mode: mode,
start_time: config.start_time || spec.start,
end_time: config.end_time || spec.end,
days: config.days || {},
};
const noDays = !config.days || (spec.fillEmptyDays && Object.keys(config.days).length === 0);
if (noDays) {
value.days = Object.fromEntries(DAYS.map(function(day) {
return [day, { enabled: true, start_time: spec.start, end_time: spec.end }];
}));
}
return value;
}
/** One notification for a schedule form's answer. */
function report(notify, xhr, labels) {
let response;
try {
response = JSON.parse(xhr.responseText);
} catch {
response = { status: 'error', message: 'Invalid response from server' };
}
if (!response || typeof response !== 'object') {
response = { status: 'error', message: 'Invalid response from server' };
}
const message = response.message || (response.status === 'success' ? labels.saved : labels.failed);
notify(message, response.status || 'info');
}
function drawPickers(root, ctx) {
const win = root.ownerDocument.defaultView;
let tries = 0;
function attempt() {
ctx.state.timer = null;
if (ctx.signal.aborted) return;
const registry = win.LEDMatrixWidgets;
const widget = registry && registry.get('schedule-picker');
if (!widget) {
if (++tries < MAX_TRIES) {
ctx.state.timer = win.setTimeout(attempt, RETRY_MS);
} else {
win.console.error(registry
? '[Schedule] schedule-picker widget not registered'
: '[Schedule] LEDMatrixWidgets registry not available');
}
return;
}
PICKERS.forEach(function(spec) {
const container = root.querySelector('#' + spec.container);
if (!container) {
win.console.error('[' + spec.label + '] Container not found');
return;
}
widget.render(container, { 'x-options': WIDGET_OPTIONS },
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
});
}
attempt();
}
export function init(root, ctx) {
const on = { signal: ctx.signal };
root.addEventListener('htmx:afterRequest', function(event) {
const form = event.target && event.target.closest ? event.target.closest('form') : null;
const labels = form && root.contains(form) ? FORMS[form.id] : null;
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
}, on);
const slider = root.querySelector('#dim_brightness');
const label = root.querySelector('#dim_brightness_display');
if (slider && label) {
slider.addEventListener('input', function() { label.textContent = slider.value + '%'; }, on);
}
drawPickers(root, ctx);
active = ctx;
}
export function destroy(root, ctx) {
if (ctx.state.timer) {
root.ownerDocument.defaultView.clearTimeout(ctx.state.timer);
ctx.state.timer = null;
}
if (active === ctx) active = null;
}
// ── the old globals, kept as deprecated aliases (boot.js) ────────────────────
function notifier(event) {
if (active) return active.notify;
const win = event && event.target && event.target.ownerDocument
? event.target.ownerDocument.defaultView : globalThis;
return function(message, type) { return win.showNotification(message, type); };
}
/** window.handleScheduleResponse(event): an htmx:afterRequest event. */
export function handleScheduleResponse(event) {
report(notifier(event), event.detail.xhr, FORMS.schedule_form);
}
/** window.handleDimScheduleResponse(event): an htmx:afterRequest event. */
export function handleDimScheduleResponse(event) {
report(notifier(event), event.detail.xhr, FORMS.dim_schedule_form);
}
+12 -160
View File
@@ -1,4 +1,9 @@
{% import 'v3/partials/_macros.html' as ui %}
{# No inline script: static/v3/js/pages/general.js draws the timezone picker
and runs the Security section's forms and buttons (data-action). The page
registry (static/v3/js/core/registry.js) starts it when this root appears
and stops it when the partial is swapped away. #}
<div data-page="general">
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">General Settings</h2>
@@ -88,7 +93,7 @@
<!-- Timezone -->
<div class="form-group" id="setting-general-timezone" data-setting-key="timezone">
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
<div id="timezone_container" class="mt-1"></div>
<div id="timezone_container" class="mt-1" data-timezone="{{ main_config.timezone or 'America/Chicago' }}"></div>
</div>
<!-- Legacy scroll frame rate (device-wide). Core scrolling no longer reads it. -->
@@ -102,32 +107,6 @@
max="200"
class="form-control">
</div>
<script>
(function() {
function initTimezoneWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initTimezoneWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('timezone-selector');
if (!widget) { setTimeout(initTimezoneWidget, 50); return; }
var container = document.getElementById('timezone_container');
if (!container) return;
// Only render if container is empty (not already rendered)
if (container.children.length > 0) return;
widget.render(container, {
'x-options': { showOffset: true, placeholder: 'Select your timezone...' }
}, {{ (main_config.timezone or "America/Chicago")|tojson }}, {
fieldId: 'timezone',
name: 'timezone'
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTimezoneWidget);
} else {
setTimeout(initTimezoneWidget, 50);
}
})();
</script>
<!-- Location Information -->
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
@@ -216,7 +195,7 @@
the password inputs keep their own labels. #}
<label>{% if web_login.enabled %}Change the web interface password{% else %}Web interface password{% endif %}{{ ui.help_tip('Optional. With a password set, every page and API call needs a login or an API token.\nAlways allowed without one: this Pi itself (localhost), and the Wi-Fi setup page while the Pi is in access-point mode.\nForgot it? On the Pi run: sudo python3 ~/LEDMatrix/scripts/reset_web_password.py', 'Web interface password') }}</label>
</h3>
<form class="space-y-4" onsubmit="window.webLogin.setPassword(this); return false;" autocomplete="off">
<form class="space-y-4" data-action="set-password" autocomplete="off">
{% if web_login.enabled %}
<div>
<label for="web-login-current" class="block text-sm font-medium text-gray-700">Current password</label>
@@ -251,7 +230,7 @@
{% if web_login.enabled %}
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Turn login off</h3>
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.disable(this); return false;" autocomplete="off">
<form class="flex flex-wrap items-end gap-2" data-action="disable-login" autocomplete="off">
<div class="flex-1">
<label for="web-login-disable-current" class="block text-sm font-medium text-gray-700">Current password</label>
<input type="password" id="web-login-disable-current" name="current_password" required
@@ -280,8 +259,7 @@
<span class="text-gray-600 ml-2">created {{ (token.created_at or '')[:10] }}</span>
</div>
<button type="button" class="text-sm text-red-600 hover:underline"
data-token-name="{{ token.name }}"
onclick="window.webLogin.revoke(this.closest('[data-token-id]').dataset.tokenId, this.dataset.tokenName)">
data-action="revoke-token" data-token-name="{{ token.name }}">
Revoke
</button>
</div>
@@ -289,7 +267,7 @@
<p class="text-sm text-gray-600" data-empty>No tokens yet.</p>
{% endfor %}
</div>
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.createToken(this); return false;" autocomplete="off">
<form class="flex flex-wrap items-end gap-2" data-action="create-token" autocomplete="off">
<div class="flex-1">
<label for="web-login-token-name" class="block text-sm font-medium text-gray-700">New token name</label>
<input type="text" id="web-login-token-name" name="name" required maxlength="60"
@@ -304,7 +282,7 @@
<div class="flex flex-wrap items-center gap-2">
<code id="web-login-new-token-value" class="font-mono break-all bg-white border border-gray-200 rounded-md px-4 py-2 flex-1"></code>
<button type="button" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md"
onclick="window.webLogin.copyToken()">
data-action="copy-token">
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy
</button>
</div>
@@ -312,131 +290,5 @@
</div>
</div>
</div>
<script>
(function() {
var API = '/api/v3/auth';
function notify(message, type) {
if (typeof window.showNotification === 'function') window.showNotification(message, type);
else window.alert(message);
}
function send(method, url, body) {
return fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: body === undefined ? undefined : JSON.stringify(body)
}).then(function(r) {
return r.json().catch(function() { return {}; }).then(function(d) { return {ok: r.ok, d: d}; });
});
}
function reloadSection() {
if (window.htmx) {
window.htmx.ajax('GET', '/v3/partials/general', {target: '#general-content', swap: 'innerHTML'});
} else {
window.location.reload();
}
}
function field(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : '';
}
function tokenRow(record) {
var row = document.createElement('div');
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
row.dataset.tokenId = record.id;
var text = document.createElement('div');
text.className = 'text-sm';
[['font-semibold text-gray-900', record.name],
['font-mono text-gray-600 ml-2', record.prefix + '…'],
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
var span = document.createElement('span');
span.className = part[0];
span.textContent = part[1];
text.appendChild(span);
});
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'text-sm text-red-600 hover:underline';
btn.textContent = 'Revoke';
btn.addEventListener('click', function() { window.webLogin.revoke(record.id, record.name); });
row.appendChild(text);
row.appendChild(btn);
return row;
}
window.webLogin = {
setPassword: function(form) {
var next = field(form, 'new_password');
if (next !== field(form, 'confirm_password')) {
notify('The two new passwords do not match.', 'error');
return;
}
var body = {new_password: next};
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
send('POST', API + '/password', body).then(function(res) {
notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection();
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
disable: function(form) {
if (!window.confirm('Turn login off? Anyone on your network will be able to open this page.')) return;
send('POST', API + '/disable', {current_password: field(form, 'current_password')}).then(function(res) {
notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
if (res.ok) reloadSection();
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
createToken: function(form) {
send('POST', API + '/tokens', {name: field(form, 'name')}).then(function(res) {
if (!res.ok) {
notify(res.d.message || 'Could not create the token', 'error');
return;
}
var list = document.getElementById('web-login-tokens');
if (list) {
var empty = list.querySelector('[data-empty]');
if (empty) empty.remove();
list.appendChild(tokenRow(res.d.data.record));
}
document.getElementById('web-login-new-token-value').textContent = res.d.data.token;
document.getElementById('web-login-new-token').classList.remove('hidden');
form.reset();
// app.js marks a form dirty on input and clears the mark only
// after an htmx save; this one posts with fetch, so clear it
// here or a reload asks "Leave site?" about a saved token.
form.removeAttribute('data-dirty');
notify(res.d.message || 'Token created', 'success');
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
},
copyToken: function() {
var box = document.getElementById('web-login-new-token-value');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(box.textContent).then(function() { notify('Token copied', 'success'); });
return;
}
// Plain http on a LAN is not a secure context: select it instead.
var range = document.createRange();
range.selectNodeContents(box);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
},
revoke: function(id, name) {
if (!window.confirm('Revoke the token "' + name + '"? Anything using it stops working.')) return;
send('DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
if (!res.ok) return;
var rows = document.querySelectorAll('#web-login-tokens [data-token-id]');
Array.prototype.forEach.call(rows, function(row) {
if (row.dataset.tokenId === id) row.remove();
});
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
}
};
})();
</script>
{% endif %}
</div>
+13 -199
View File
@@ -1,4 +1,13 @@
{% import 'v3/partials/_macros.html' as ui %}
{# No inline script: static/v3/js/pages/schedule.js draws both schedule
pickers from the data-*-config attributes, reports each form's save and
keeps the dim brightness label current. The page registry
(static/v3/js/core/registry.js) starts it when this root appears and stops
it when the partial is swapped away. The forms carry data-reports-result so
app.js leaves the save notification to the page. #}
<div data-page="schedule"
data-schedule-config='{{ schedule_config | tojson }}'
data-dim-schedule-config='{{ dim_schedule_config | tojson }}'>
<div class="bg-white rounded-lg shadow p-6">
<div class="border-b border-gray-200 pb-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900">Schedule Settings</h2>
@@ -12,7 +21,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="handleScheduleResponse(event)"
data-reports-result
class="space-y-6">
<!-- Schedule Picker Widget Container -->
@@ -41,7 +50,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="handleDimScheduleResponse(event)"
data-reports-result
class="space-y-6">
<!-- Dim Brightness Level -->
@@ -56,8 +65,7 @@
min="0"
max="100"
value="{{ dim_schedule_config.dim_brightness | default(30) }}"
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
oninput="document.getElementById('dim_brightness_display').textContent = this.value + '%'">
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600">
<span id="dim_brightness_display" class="text-sm font-medium text-gray-700 w-12 text-right">
{{ dim_schedule_config.dim_brightness | default(30) }}%
</span>
@@ -77,198 +85,4 @@
</div>
</form>
</div>
<script>
(function() {
'use strict';
// Initialize schedule picker widget when DOM is ready
function initSchedulePicker() {
const container = document.getElementById('schedule_picker_container');
if (!container) {
console.error('[Schedule] Container not found');
return;
}
// Check if widget registry is available
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[Schedule] LEDMatrixWidgets registry not available');
return;
}
const widget = window.LEDMatrixWidgets.get('schedule-picker');
if (!widget) {
console.error('[Schedule] schedule-picker widget not registered');
return;
}
// Get schedule config from template data (injected by Jinja2)
// Default to empty object if null/undefined
const scheduleConfig = {{ schedule_config | tojson | safe }} || {};
// Determine mode: prefer explicit mode, then infer from days, then default to global
let mode = 'global';
if (scheduleConfig.mode) {
// Normalize mode value (handle both 'per_day' and 'per-day')
mode = scheduleConfig.mode.replace('-', '_');
} else if (scheduleConfig.days) {
mode = 'per_day';
}
// Convert flat config to nested format expected by widget
const widgetValue = {
enabled: scheduleConfig.enabled || false,
mode: mode,
start_time: scheduleConfig.start_time || '07:00',
end_time: scheduleConfig.end_time || '23:00',
days: scheduleConfig.days || {}
};
// If we have per-day data in the old flat format, convert it
if (!scheduleConfig.days) {
widgetValue.days = {};
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
days.forEach(function(day) {
widgetValue.days[day] = {
enabled: true,
start_time: '07:00',
end_time: '23:00'
};
});
}
// Render the widget
widget.render(container, {
'x-options': {
showModeToggle: true,
showEnableToggle: true,
compactMode: false
}
}, widgetValue, {
fieldId: 'schedule'
});
debugLog('[Schedule] Schedule picker widget initialized');
}
// Handle form submission response
window.handleScheduleResponse = function(event) {
const xhr = event.detail.xhr;
let response;
try {
response = JSON.parse(xhr.responseText);
} catch (e) {
response = { status: 'error', message: 'Invalid response from server' };
}
const message = response.message || (response.status === 'success' ? 'Schedule settings saved' : 'Error saving schedule');
const type = response.status || 'info';
// Use global notification function if available
window.showNotification(message, type);
};
// Initialize when DOM is ready or if already loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSchedulePicker);
} else {
// Small delay to ensure widget scripts are loaded
setTimeout(initSchedulePicker, 50);
}
})();
// Dim Schedule Picker initialization
(function() {
'use strict';
function initDimSchedulePicker() {
const container = document.getElementById('dim_schedule_picker_container');
if (!container) {
console.error('[DimSchedule] Container not found');
return;
}
// Check if widget registry is available
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[DimSchedule] LEDMatrixWidgets registry not available');
return;
}
const widget = window.LEDMatrixWidgets.get('schedule-picker');
if (!widget) {
console.error('[DimSchedule] schedule-picker widget not registered');
return;
}
// Get dim schedule config from template data (injected by Jinja2)
const dimScheduleConfig = {{ dim_schedule_config | tojson | safe }} || {};
// Determine mode
let mode = 'global';
if (dimScheduleConfig.mode) {
mode = dimScheduleConfig.mode.replace('-', '_');
} else if (dimScheduleConfig.days) {
mode = 'per_day';
}
// Convert config to widget format
const widgetValue = {
enabled: dimScheduleConfig.enabled || false,
mode: mode,
start_time: dimScheduleConfig.start_time || '20:00',
end_time: dimScheduleConfig.end_time || '07:00',
days: dimScheduleConfig.days || {}
};
// If no days config, initialize with defaults for dim schedule (20:00-07:00)
if (!dimScheduleConfig.days || Object.keys(dimScheduleConfig.days).length === 0) {
widgetValue.days = {};
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
days.forEach(function(day) {
widgetValue.days[day] = {
enabled: true,
start_time: '20:00',
end_time: '07:00'
};
});
}
// Render the widget with a different field ID
widget.render(container, {
'x-options': {
showModeToggle: true,
showEnableToggle: true,
compactMode: false
}
}, widgetValue, {
fieldId: 'dim_schedule'
});
debugLog('[DimSchedule] Dim schedule picker widget initialized');
}
// Handle dim schedule form submission response
window.handleDimScheduleResponse = function(event) {
const xhr = event.detail.xhr;
let response;
try {
response = JSON.parse(xhr.responseText);
} catch (e) {
response = { status: 'error', message: 'Invalid response from server' };
}
const message = response.message || (response.status === 'success' ? 'Dim schedule settings saved' : 'Error saving dim schedule');
const type = response.status || 'info';
// Use global notification function if available
window.showNotification(message, type);
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDimSchedulePicker);
} else {
setTimeout(initDimSchedulePicker, 100);
}
})();
</script>
</div>