mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 10:36:35 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85d75cdd70 | ||
|
|
886e199cae |
@@ -19,6 +19,27 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- General tab: a **Start Display on Boot** toggle turns
|
||||||
|
`ledmatrix.service`'s autostart on or off right away (no Save needed),
|
||||||
|
through the existing `enable_autostart` / `disable_autostart` system
|
||||||
|
actions. It shows systemd's own state, so nothing is stored in
|
||||||
|
`config.json`; the default stays on (the installer enables the unit). The
|
||||||
|
web interface still starts on boot either way. Hidden when the unit's state
|
||||||
|
cannot be read (not installed, or no systemd).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- `SportsRankingsMixin` (#789's deferred review points, now that it is the
|
||||||
|
only copy): a poll entry whose `current` cannot be converted to an
|
||||||
|
integer (`None`, a non-numeric string) is skipped instead of throwing
|
||||||
|
away the whole poll -- which left
|
||||||
|
every team unranked and re-fetched the standings on every update -- and
|
||||||
|
`_ranked_positions()` decides id-or-abbreviation per side, so one side
|
||||||
|
without an id no longer drops the other side's id and lets a shared
|
||||||
|
abbreviation rank the wrong school. Well-formed ESPN data ranks as before.
|
||||||
|
|
||||||
## 3.8.5
|
## 3.8.5
|
||||||
|
|
||||||
Fewer ESPN requests on the hourly schedule refresh (#754), and sports
|
Fewer ESPN requests on the hourly schedule refresh (#754), and sports
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ ufc's MMA managers override `update()` and never build the pools, so the
|
|||||||
rotation is dormant there. `_best_rank`, `_is_ranked_game` and
|
rotation is dormant there. `_best_rank`, `_is_ranked_game` and
|
||||||
`_passes_other_filters` were family 8.
|
`_passes_other_filters` were family 8.
|
||||||
|
|
||||||
### Family 8: rankings (reconciled; promoted, core 3.8.5)
|
### Family 8: rankings (done: core 3.8.5, adopted)
|
||||||
|
|
||||||
ledmatrix-plugins `scripts/test_rankings.py` (#644) pinned 79 rows across the
|
ledmatrix-plugins `scripts/test_rankings.py` (#644) pinned 79 rows across the
|
||||||
nine plugins first, every ESPN request answered offline from recorded-shape
|
nine plugins first, every ESPN request answered offline from recorded-shape
|
||||||
@@ -502,7 +502,7 @@ release.
|
|||||||
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; shipped in 3.8.1 and adopted. See [Family 5](#family-5-the-game-over-check-done-core-381-adopted) |
|
| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; shipped in 3.8.1 and adopted. See [Family 5](#family-5-the-game-over-check-done-core-381-adopted) |
|
||||||
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; shipped in 3.8.2 and adopted. See [Family 6](#family-6-favourite-matching-done-core-382-adopted) |
|
| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; shipped in 3.8.2 and adopted. See [Family 6](#family-6-favourite-matching-done-core-382-adopted) |
|
||||||
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; done: core 3.8.4, adopted (ledmatrix-plugins #643). See [Family 7](#family-7-the-other-games-rotation-done-core-384-adopted) |
|
| 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc), with `_attach_odds_to_rotated_games` (3; ufc had none) | One outlier each; football carried two fixes the other eight lacked. Reconciled to one body each, on a `_rankings_loaded` seam, and promoted as `sports_rotation`; done: core 3.8.4, adopted (ledmatrix-plugins #643). See [Family 7](#family-7-the-other-games-rotation-done-core-384-adopted) |
|
||||||
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings` (core 3.8.5). See [Family 8](#family-8-rankings-reconciled-promoted-core-385) |
|
| 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it. Reconciled to one body each and promoted as `sports_rankings`; done: core 3.8.5, adopted (ledmatrix-plugins #648). See [Family 8](#family-8-rankings-done-core-385-adopted) |
|
||||||
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
|
| 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins |
|
||||||
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` |
|
| 10 | View model | `_extract_game_details_common` (9 of 9) | Every renderer reads it; its keys are additive-only, so reconcile to the superset and leave sport extras in `_extract_game_details` |
|
||||||
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
|
| 11 | Switch scorebug | `_load_fonts` (4), `_load_custom_font_from_element_config` (6), `_get_layout_offset` (5), `_fit_score_font` (2: football), `_load_and_resize_logo` (9), `_draw_dynamic_odds` (9), then `_draw_scorebug_layout` (20: Upcoming 9, Recent 8, Live 2, ufc's Core 1) | Needs 10 and the twin decisions below. Several releases: fonts and offsets, logos, odds, then one mode's layout per release |
|
||||||
|
|||||||
@@ -216,7 +216,12 @@ class SportsRankingsMixin:
|
|||||||
for team_data in poll.get("ranks", []):
|
for team_data in poll.get("ranks", []):
|
||||||
team_info = team_data.get("team", {})
|
team_info = team_data.get("team", {})
|
||||||
team_abbr = team_info.get("abbreviation", "")
|
team_abbr = team_info.get("abbreviation", "")
|
||||||
current_rank = team_data.get("current", 0)
|
# One malformed entry (a None or non-numeric "current") is
|
||||||
|
# skipped, not allowed to throw away the whole poll.
|
||||||
|
try:
|
||||||
|
current_rank = int(team_data.get("current"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
if current_rank <= 0:
|
if current_rank <= 0:
|
||||||
continue
|
continue
|
||||||
if team_abbr:
|
if team_abbr:
|
||||||
@@ -337,28 +342,30 @@ class SportsRankingsMixin:
|
|||||||
return self._division_team_ids
|
return self._division_team_ids
|
||||||
|
|
||||||
def _ranked_positions(self, game: Dict) -> List[int]:
|
def _ranked_positions(self, game: Dict) -> List[int]:
|
||||||
"""Poll positions held by either side: ids first, abbreviations second.
|
"""Poll positions held by either side: by id, else by abbreviation.
|
||||||
|
|
||||||
The id table is authoritative whenever the game carries ids and a poll
|
The id table is authoritative whenever a side carries an id and a poll
|
||||||
has been parsed, because it is built from a top-division poll alone --
|
has been parsed, because it is built from a top-division poll alone --
|
||||||
so an FCS side cannot appear in it however it is abbreviated, and an
|
so an FCS side cannot appear in it however it is abbreviated, and an
|
||||||
abbreviation shared across divisions cannot promote the wrong school.
|
abbreviation shared across divisions cannot promote the wrong school.
|
||||||
The abbreviation table stays as the fallback for a game dict with no
|
Decided per side: a side without a usable id falls back to its
|
||||||
ids on it.
|
abbreviation, without costing the other side its id.
|
||||||
"""
|
"""
|
||||||
by_id = getattr(self, "_ranked_team_ids", None) or {}
|
by_id = getattr(self, "_ranked_team_ids", None) or {}
|
||||||
ids = []
|
|
||||||
for side in ("home_id", "away_id"):
|
|
||||||
try:
|
|
||||||
ids.append(int(game.get(side))) # type: ignore[arg-type] # None: caught below
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
ids = []
|
|
||||||
break
|
|
||||||
if by_id and ids:
|
|
||||||
return [rank for rank in (by_id.get(i, 0) for i in ids) if rank]
|
|
||||||
by_abbr = getattr(self, "_team_rankings_cache", None) or {}
|
by_abbr = getattr(self, "_team_rankings_cache", None) or {}
|
||||||
return [rank for rank in (by_abbr.get(game.get("home_abbr"), 0),
|
found = []
|
||||||
by_abbr.get(game.get("away_abbr"), 0)) if rank]
|
for side in ("home", "away"):
|
||||||
|
rank = None
|
||||||
|
if by_id:
|
||||||
|
try:
|
||||||
|
rank = by_id.get(int(game.get(f"{side}_id")), 0) # type: ignore[arg-type] # None: caught below
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if rank is None:
|
||||||
|
rank = by_abbr.get(game.get(f"{side}_abbr"), 0)
|
||||||
|
if rank:
|
||||||
|
found.append(rank)
|
||||||
|
return found
|
||||||
|
|
||||||
def _is_ranked_game(self, game: Dict) -> bool:
|
def _is_ranked_game(self, game: Dict) -> bool:
|
||||||
return bool(self._ranked_positions(game))
|
return bool(self._ranked_positions(game))
|
||||||
|
|||||||
+2
-1
@@ -32,7 +32,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
|||||||
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
'unit/test_page_registry.js', 'unit/test_core_modules.js',
|
||||||
'unit/test_overview_reconciliation_poll.js',
|
'unit/test_overview_reconciliation_poll.js',
|
||||||
'unit/test_display_partial_ids.js',
|
'unit/test_display_partial_ids.js',
|
||||||
'unit/test_general_web_login_token.js'];
|
'unit/test_general_web_login_token.js',
|
||||||
|
'unit/test_general_display_autostart.js'];
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.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_tools_sections.js', 'dom/test_cache_page.js',
|
||||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// The General tab's "Start Display on Boot" checkbox (setDisplayAutostart in
|
||||||
|
// static/v3/js/pages/general.js) posts enable_autostart / disable_autostart
|
||||||
|
// to /api/v3/system/action and puts the box back when that is refused --
|
||||||
|
// including the route's HTTP-200 {status: "error"} answer for a failed
|
||||||
|
// systemctl (e.g. the sudo rule is missing).
|
||||||
|
//
|
||||||
|
// Fake fetch, no jsdom and no server needed.
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { pathToFileURL } = require('url');
|
||||||
|
|
||||||
|
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) : '')));
|
||||||
|
|
||||||
|
async function run(checked, answer) {
|
||||||
|
const { createApi } = await load('core/api.js');
|
||||||
|
const general = await load('pages/general.js');
|
||||||
|
const sent = [];
|
||||||
|
const notes = [];
|
||||||
|
const fetch = (url, init) => {
|
||||||
|
sent.push([url, JSON.parse(init.body)]);
|
||||||
|
if (answer === 'network') return Promise.reject(new TypeError('Failed to fetch'));
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: answer.ok, status: answer.ok ? 200 : 500,
|
||||||
|
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]) };
|
||||||
|
const box = { checked, disabled: false };
|
||||||
|
await general.setDisplayAutostart(ctx, box);
|
||||||
|
return { box, sent, notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log('\n── General tab: Start Display on Boot ──');
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(false, { ok: true, body: { status: 'success', message: 'ok' } });
|
||||||
|
ok('unchecking posts disable_autostart',
|
||||||
|
t.sent.length === 1 && t.sent[0][0] === '/api/v3/system/action' && t.sent[0][1].action === 'disable_autostart', t.sent);
|
||||||
|
ok('...stays unchecked and re-enabled', t.box.checked === false && t.box.disabled === false);
|
||||||
|
ok('...and reports success', t.notes.some(([, type]) => type === 'success'), t.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(true, { ok: true, body: { status: 'success' } });
|
||||||
|
ok('checking posts enable_autostart', t.sent[0][1].action === 'enable_autostart', t.sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(false, { ok: true, body: { status: 'error', message: 'sudo: a password is required' } });
|
||||||
|
ok('a failed systemctl (HTTP 200, status error) puts the box back', t.box.checked === true && !t.box.disabled);
|
||||||
|
ok('...and shows the route\'s message',
|
||||||
|
t.notes.some(([m, type]) => type === 'error' && /sudo/.test(m)), t.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(true, { ok: false, body: { status: 'error', message: 'boom' } });
|
||||||
|
ok('an HTTP error puts the box back', t.box.checked === false && !t.box.disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const t = await run(true, 'network');
|
||||||
|
ok('a network failure puts the box back', t.box.checked === false && !t.box.disabled);
|
||||||
|
ok('...and reports it', t.notes.some(([, type]) => type === 'error'), t.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""The General tab's "Start Display on Boot" toggle reads systemd's state.
|
||||||
|
|
||||||
|
ledmatrix.service enabled -> checked, disabled -> unchecked, unreadable (no
|
||||||
|
systemctl, unit not installed) -> no toggle. The toggle itself posts the
|
||||||
|
existing enable_autostart / disable_autostart system actions, whose sudo
|
||||||
|
grants test_systemctl_sudoers_alignment.py already checks.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from test.test_web_settings_ui import client # noqa: F401,E402
|
||||||
|
from web_interface.blueprints import pages_v3 # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
REAL_RUN = subprocess.run
|
||||||
|
UNIT_QUERY = ['systemctl', 'is-enabled', 'ledmatrix.service']
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_systemctl(monkeypatch, answer):
|
||||||
|
"""Answer the unit query with `answer` (stdout, or an exception to
|
||||||
|
raise); every other command (the update channel's git) runs for real."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def run(cmd, *args, **kwargs):
|
||||||
|
if cmd != UNIT_QUERY:
|
||||||
|
return REAL_RUN(cmd, *args, **kwargs)
|
||||||
|
calls.append(cmd)
|
||||||
|
if isinstance(answer, Exception):
|
||||||
|
raise answer
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, stdout=answer, stderr='')
|
||||||
|
monkeypatch.setattr(pages_v3.subprocess, 'run', run)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle(body):
|
||||||
|
start = body.find('data-action="display-autostart"')
|
||||||
|
return None if start < 0 else body[start:body.find('>', start)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('stdout, checked', [('enabled\n', True), ('disabled\n', False)])
|
||||||
|
def test_toggle_reflects_the_unit(client, monkeypatch, stdout, checked):
|
||||||
|
calls = _fake_systemctl(monkeypatch, stdout)
|
||||||
|
tag = _toggle(client.get('/v3/partials/general').get_data(as_text=True))
|
||||||
|
assert calls == [UNIT_QUERY]
|
||||||
|
assert tag is not None
|
||||||
|
assert ('checked' in tag) is checked
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('outcome', [FileNotFoundError(), 'not-found\n'])
|
||||||
|
def test_toggle_hidden_when_state_unreadable(client, monkeypatch, outcome):
|
||||||
|
_fake_systemctl(monkeypatch, outcome)
|
||||||
|
body = client.get('/v3/partials/general').get_data(as_text=True)
|
||||||
|
assert _toggle(body) is None
|
||||||
|
assert 'name="plugins_directory"' in body # rest of the tab still renders
|
||||||
@@ -173,6 +173,11 @@ def test_only_a_league_with_a_poll_is_asked(clock, league, asked):
|
|||||||
{"current": 3, "team": {"id": "abc", "abbreviation": "OSU"}},
|
{"current": 3, "team": {"id": "abc", "abbreviation": "OSU"}},
|
||||||
{"current": 4, "team": {"abbreviation": "TEX"}}]}]},
|
{"current": 4, "team": {"abbreviation": "TEX"}}]}]},
|
||||||
{"OSU": 3, "TEX": 4}, {87: 2}),
|
{"OSU": 3, "TEX": 4}, {87: 2}),
|
||||||
|
({"rankings": [{"name": "AP Top 25", "type": "ap", "ranks": [ # #789: one bad entry
|
||||||
|
{"current": None, "team": {"id": "61", "abbreviation": "UGA"}},
|
||||||
|
{"current": "n/a", "team": {"id": "87", "abbreviation": "ND"}},
|
||||||
|
{"current": 3, "team": {"id": "194", "abbreviation": "OSU"}}]}]},
|
||||||
|
{"OSU": 3}, {194: 3}),
|
||||||
])
|
])
|
||||||
def test_payload_shapes(clock, payload, abbr, ids):
|
def test_payload_shapes(clock, payload, abbr, ids):
|
||||||
host = Host(payload=payload)
|
host = Host(payload=payload)
|
||||||
@@ -320,6 +325,17 @@ def test_ranked_and_best_rank(clock, label, best):
|
|||||||
assert host._is_ranked_game(game(label)) is (best != 99)
|
assert host._is_ranked_game(game(label)) is (best != 99)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("home, away, best", [
|
||||||
|
(NEWARK, (None, "VAN"), 99), # Newark's id is unranked; "OSU" must not rank it
|
||||||
|
(("61", None), (None, "VAN"), 1), # UGA's id still counts with no id on the other side
|
||||||
|
])
|
||||||
|
def test_each_side_is_ranked_by_its_own_id(clock, home, away, best):
|
||||||
|
"""#789: one side's missing id no longer sends both sides to abbreviations."""
|
||||||
|
g = {f"{side}_{key}": value for side, team in (("home", home), ("away", away))
|
||||||
|
for key, value in zip(("id", "abbr"), team) if value is not None}
|
||||||
|
assert loaded()._best_rank(g) == best
|
||||||
|
|
||||||
|
|
||||||
#: filter -> (quality, divisions, poll loaded?, kept: one Y/. per GAMES entry)
|
#: filter -> (quality, divisions, poll loaded?, kept: one Y/. per GAMES entry)
|
||||||
FILTERS = {
|
FILTERS = {
|
||||||
"any": ("any", (), True, "YYYYYYYY"),
|
"any": ("any", (), True, "YYYYYYYY"),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from html.parser import HTMLParser
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Strict allowlists for URL-derived values used in path and script operations.
|
# Strict allowlists for URL-derived values used in path and script operations.
|
||||||
@@ -487,9 +488,25 @@ def _load_general_partial():
|
|||||||
main_config=main_config,
|
main_config=main_config,
|
||||||
auto_update_status=auto_update_status,
|
auto_update_status=auto_update_status,
|
||||||
update_channel_status=update_channel_status,
|
update_channel_status=update_channel_status,
|
||||||
|
display_autostart=_display_autostart_state(),
|
||||||
web_login=_web_login_state())
|
web_login=_web_login_state())
|
||||||
|
|
||||||
|
|
||||||
|
def _display_autostart_state():
|
||||||
|
"""Whether ledmatrix.service starts at boot; None hides the toggle.
|
||||||
|
|
||||||
|
systemd's enablement is the setting itself (the installer enables the
|
||||||
|
unit), so nothing is stored in config.json. None when it cannot be read:
|
||||||
|
no systemctl (a dev machine) or the unit is not installed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['systemctl', 'is-enabled', 'ledmatrix.service'],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return {'enabled': True, 'disabled': False}.get(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
def _web_login_state():
|
def _web_login_state():
|
||||||
"""What the General tab's Security section shows; None hides it.
|
"""What the General tab's Security section shows; None hides it.
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,28 @@ export function revokeToken(ctx, id, name) {
|
|||||||
}).catch(failed(ctx));
|
}).catch(failed(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Start Display on Boot" checkbox: enable or disable ledmatrix.service
|
||||||
|
* at once. A refusal (e.g. the sudo rule is missing) puts the box back.
|
||||||
|
*/
|
||||||
|
export function setDisplayAutostart(ctx, box) {
|
||||||
|
const on = box.checked;
|
||||||
|
box.disabled = true;
|
||||||
|
return send(ctx, 'POST', '/api/v3/system/action', { action: on ? 'enable_autostart' : 'disable_autostart' }).then(function(res) {
|
||||||
|
if (!res) return false;
|
||||||
|
const ok = res.ok && res.d.status === 'success';
|
||||||
|
if (!ok) box.checked = !on;
|
||||||
|
ctx.notify(ok ? (on ? 'The display will start on boot' : 'The display will not start on boot')
|
||||||
|
: (res.d.message || 'Could not change display autostart'), ok ? 'success' : 'error');
|
||||||
|
return ok;
|
||||||
|
}).catch(function(err) {
|
||||||
|
box.checked = !on;
|
||||||
|
failed(ctx)(err);
|
||||||
|
}).finally(function() {
|
||||||
|
box.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
|
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
|
||||||
|
|
||||||
function drawTimezone(root, ctx) {
|
function drawTimezone(root, ctx) {
|
||||||
@@ -238,6 +260,10 @@ export function init(root, ctx) {
|
|||||||
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
|
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
|
||||||
}
|
}
|
||||||
}, on);
|
}, on);
|
||||||
|
root.addEventListener('change', function(event) {
|
||||||
|
const box = event.target;
|
||||||
|
if (box && box.dataset && box.dataset.action === 'display-autostart') setDisplayAutostart(ctx, box);
|
||||||
|
}, on);
|
||||||
drawTimezone(root, ctx);
|
drawTimezone(root, ctx);
|
||||||
active = ctx;
|
active = ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,22 @@
|
|||||||
|
|
||||||
{{ ui.settings_filter() }}
|
{{ ui.settings_filter() }}
|
||||||
|
|
||||||
|
{# Display autostart is systemd's enable/disable of ledmatrix.service, not a
|
||||||
|
config.json key: outside the form so Save never posts it. general.js
|
||||||
|
applies it on change. Hidden when the unit's state cannot be read. #}
|
||||||
|
{% if display_autostart is not none %}
|
||||||
|
<div class="form-group mb-6" id="setting-general-display_autostart" data-setting-key="display_autostart">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
data-action="display-autostart"
|
||||||
|
{% if display_autostart %}checked{% endif %}
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<span class="ml-2 text-sm font-medium text-gray-900">Start Display on Boot</span>
|
||||||
|
{{ ui.help_tip('Start the LED display automatically when the device powers on.\nApplies right away; no Save needed.\nWhen off, start it with Start Display on the Overview tab. The web interface still starts on boot.\nDefault: on.', 'Start Display on Boot') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form hx-post="/api/v3/config/main"
|
<form hx-post="/api/v3/config/main"
|
||||||
hx-ext="json-enc"
|
hx-ext="json-enc"
|
||||||
hx-headers='{"Content-Type": "application/json"}'
|
hx-headers='{"Content-Type": "application/json"}'
|
||||||
|
|||||||
Reference in New Issue
Block a user