All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
451 lines
14 KiB
HTML
451 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Device Admin</title>
|
|
<style>
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
padding: 20px;
|
|
max-width: 700px;
|
|
margin: auto;
|
|
}
|
|
|
|
/* Navigation */
|
|
nav button {
|
|
margin: 5px;
|
|
padding: 10px 16px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Screens */
|
|
.screen {
|
|
display: none;
|
|
margin-top: 20px;
|
|
}
|
|
.screen.active {
|
|
display: block;
|
|
}
|
|
|
|
/* Config editor UI */
|
|
label {
|
|
font-weight: bold;
|
|
margin-top: 12px;
|
|
display: block;
|
|
}
|
|
input {
|
|
width: 100%;
|
|
padding: 6px;
|
|
box-sizing: border-box;
|
|
margin-top: 4px;
|
|
}
|
|
.nested {
|
|
margin-left: 20px;
|
|
border-left: 2px solid #ddd;
|
|
padding-left: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
/* Tree view */
|
|
.tree ul {
|
|
list-style: none;
|
|
padding-left: 1rem;
|
|
}
|
|
|
|
.tree li {
|
|
margin: 2px 0;
|
|
}
|
|
|
|
.caret {
|
|
cursor: pointer;
|
|
display: inline-block;
|
|
width: 1em;
|
|
height: 1em;
|
|
user-select: none;
|
|
}
|
|
|
|
.caret::before {
|
|
content: "▶";
|
|
opacity: 0.6;
|
|
}
|
|
|
|
li.expanded > .caret::before {
|
|
content: "▼";
|
|
}
|
|
|
|
li:not(:has(ul)) > .caret::before {
|
|
content: "";
|
|
}
|
|
|
|
.node {
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
user-select: none;
|
|
}
|
|
|
|
.node:hover {
|
|
background: #eee;
|
|
}
|
|
|
|
.node.selected {
|
|
background: #0078d7;
|
|
color: white;
|
|
}
|
|
|
|
.tree ul ul {
|
|
display: none;
|
|
}
|
|
|
|
li.expanded > ul {
|
|
display: block;
|
|
}
|
|
|
|
.scroll-tree-container {
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
|
|
height: 300px;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>Device Admin</h1>
|
|
|
|
<nav>
|
|
<button onclick="showScreen('menu')">🏠 Main Menu</button>
|
|
<button onclick="showScreen('config')">⚙️ Config Editor</button>
|
|
<button onclick="showScreen('playlist')">🖹 Playlist Editor</button>
|
|
</nav>
|
|
|
|
<!-- MAIN MENU -->
|
|
<div id="screen-menu" class="screen active">
|
|
<h2>Main Menu</h2>
|
|
<p>Select a tool:</p>
|
|
|
|
<ul>
|
|
<li><button onclick="showScreen('config')">Open Config Editor</button></li>
|
|
<li><button onclick="showScreen('playlist')">Open Playlist Editor</button></li>
|
|
<!-- More screens can be added later -->
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- CONFIG EDITOR SCREEN -->
|
|
<div id="screen-config" class="screen">
|
|
<h2>Configuration Editor</h2>
|
|
<div id="config-container">Loading…</div>
|
|
<button id="config-save-btn" disabled>Save</button>
|
|
</div>
|
|
|
|
<!-- PLAYLIST EDITOR SCREEN -->
|
|
<div id="screen-playlist" class="screen">
|
|
<h2>Playlist Editor</h2>
|
|
<div id="playlist-container">Loading…</div>
|
|
<div class="scroll-tree-container">
|
|
<div class="tree" id="tree">
|
|
<ul>
|
|
<li>
|
|
<span class="caret"></span>
|
|
<span class="node">Fruits</span>
|
|
<ul>
|
|
<li>
|
|
<span class="caret"></span>
|
|
<span class="node">Apple</span>
|
|
</li>
|
|
<li>
|
|
<span class="caret"></span>
|
|
<span class="node">Citrus</span>
|
|
<ul>
|
|
<li>
|
|
<span class="caret"></span>
|
|
<span class="node">Orange</span>
|
|
</li>
|
|
<li>
|
|
<span class="caret"></span>
|
|
<span class="node">Lemon</span>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
<li>
|
|
<span class="caret"></span>
|
|
<span class="node">Strawberry</span>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<button id="playlist-save-btn" disabled>Save</button>
|
|
</div>
|
|
|
|
<script>
|
|
const Screens = {};
|
|
let activeScreen = null;
|
|
/* -----------------------------
|
|
Screen switching
|
|
----------------------------- */
|
|
function showScreen(name) {
|
|
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
|
document.getElementById('screen-' + name).classList.add('active');
|
|
|
|
activeScreen = name;
|
|
Screens[name]?.onShow?.();
|
|
}
|
|
|
|
/* -----------------------------
|
|
CONFIG EDITOR LOGIC
|
|
----------------------------- */
|
|
Screens.config = (() => {
|
|
let screenroot = null;
|
|
function init() {
|
|
screenroot = document.getElementById('screen-config');
|
|
document.getElementById('config-save-btn').addEventListener('click', async () => {
|
|
const res = await fetch('/api/v1/config');
|
|
const original = await res.json();
|
|
const updated = serialize(original);
|
|
|
|
try {
|
|
const saveRes = await fetch('/api/v1/config', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updated, null, 2)
|
|
});
|
|
|
|
if (!saveRes.ok) {
|
|
alert("Failed to save config: " + await saveRes.text());
|
|
return;
|
|
}
|
|
|
|
alert("Configuration saved successfully!");
|
|
} catch (err) {
|
|
alert("Error saving configuration: " + err);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function onShow() {
|
|
const container = document.getElementById('config-container');
|
|
container.innerHTML = "Loading…";
|
|
document.getElementById('config-save-btn').disabled = true;
|
|
|
|
try {
|
|
const res = await fetch('/api/v1/config');
|
|
const config = await res.json();
|
|
render(config);
|
|
} catch (err) {
|
|
container.innerHTML = "Failed to load config: " + err;
|
|
}
|
|
}
|
|
|
|
function render(config) {
|
|
const container = document.getElementById('config-container');
|
|
container.innerHTML = "";
|
|
container.appendChild(renderObject(config, "root"));
|
|
document.getElementById('config-save-btn').disabled = false;
|
|
}
|
|
|
|
const config_names = {
|
|
'root.IDLE_TIMEOUT_SECS': 'Idle Timeout (seconds)',
|
|
'root.BUTTON_MAP': 'Button map',
|
|
'root.BUTTON_MAP.NEXT': 'Next track',
|
|
'root.BUTTON_MAP.PREV': 'Previous track',
|
|
'root.BUTTON_MAP.VOLUP': 'Volume up',
|
|
'root.BUTTON_MAP.VOLDOWN': 'Volume down',
|
|
'root.BUTTON_MAP.PLAY_PAUSE': 'Play/Pause',
|
|
'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)',
|
|
'root.TAGMODE': 'Tag mode',
|
|
'root.LED_COUNT': 'Length of WS2182 (Neopixel) LED chain'
|
|
};
|
|
const config_input_override = {
|
|
'root.TAGMODE': {
|
|
'element': 'select',
|
|
'values': {
|
|
'tagremains': 'Play until tag is removed',
|
|
'tagstartstop': 'Present tag once to start, present again to stop playback'
|
|
}
|
|
},
|
|
'root.IDLE_TIMEOUT_SECS': {
|
|
'input-type': 'number'
|
|
},
|
|
'root.TAG_TIMEOUT_SECS': {
|
|
'input-type': 'number'
|
|
},
|
|
'root.LED_COUNT': {
|
|
'input-type': 'number'
|
|
},
|
|
};
|
|
|
|
function renderObject(obj, path) {
|
|
const wrapper = document.createElement('div');
|
|
|
|
Object.entries(obj).forEach(([key, value]) => {
|
|
const currentPath = path + '.' + key;
|
|
const label = document.createElement('label');
|
|
if (currentPath in config_names) {
|
|
label.textContent = config_names[currentPath];
|
|
} else {
|
|
label.textContent = key;
|
|
}
|
|
|
|
if (value !== null && typeof value === 'object') {
|
|
wrapper.appendChild(label);
|
|
const nested = document.createElement('div');
|
|
nested.className = "nested";
|
|
nested.appendChild(renderObject(value, currentPath));
|
|
wrapper.appendChild(nested);
|
|
} else {
|
|
wrapper.appendChild(label);
|
|
if (currentPath in config_input_override && 'element' in config_input_override[currentPath]) {
|
|
const override = config_input_override[currentPath];
|
|
if (override['element'] === 'select') {
|
|
const input = document.createElement('select');
|
|
input.dataset.path = currentPath;
|
|
|
|
for (const val in override.values) {
|
|
const option = document.createElement('option');
|
|
option.value = val;
|
|
option.textContent = override.values[val];
|
|
if (val === value) {
|
|
option.selected = true;
|
|
}
|
|
|
|
input.appendChild(option);
|
|
}
|
|
|
|
wrapper.appendChild(input);
|
|
}
|
|
} else {
|
|
const input = document.createElement('input');
|
|
if (currentPath in config_input_override && 'input-type' in config_input_override[currentPath]) {
|
|
input.type = config_input_override[currentPath]['input-type'];
|
|
}
|
|
input.value = value === null ? "" : value;
|
|
input.dataset.path = currentPath;
|
|
|
|
wrapper.appendChild(input);
|
|
}
|
|
}
|
|
});
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
function serialize(rootObj) {
|
|
const inputs = screenroot.querySelectorAll("input[data-path], select[data-path]");
|
|
|
|
inputs.forEach(input => {
|
|
const path = input.dataset.path.split('.').slice(1); // remove "root"
|
|
let current = rootObj;
|
|
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
current = current[path[i]];
|
|
}
|
|
|
|
let val = input.value.trim();
|
|
if (val === "") val = null;
|
|
else if (!isNaN(val)) val = Number(val);
|
|
|
|
current[path[path.length - 1]] = val;
|
|
});
|
|
|
|
return rootObj;
|
|
}
|
|
|
|
return { init, onShow };
|
|
})();
|
|
|
|
/* -----------------------------
|
|
PLAYLIST EDITOR LOGIC
|
|
----------------------------- */
|
|
Screens.playlist = (() => {
|
|
let lastSelected = null;
|
|
|
|
function init() {
|
|
document.getElementById("tree").addEventListener("click", (e) => {
|
|
// CARET CLICK → expand/collapse
|
|
const caret = e.target.closest(".caret");
|
|
if (caret) {
|
|
const li = caret.parentElement;
|
|
if (li.querySelector("ul")) {
|
|
li.classList.toggle("expanded");
|
|
}
|
|
return; // IMPORTANT: don't affect selection
|
|
}
|
|
|
|
// NODE LABEL CLICK → selection only
|
|
const node = e.target.closest(".node");
|
|
if (!node) return;
|
|
|
|
if (e.shiftKey && lastSelected) {
|
|
selectRange(lastSelected, node);
|
|
} else if (e.ctrlKey || e.metaKey) {
|
|
node.classList.toggle("selected");
|
|
} else {
|
|
clearSelection();
|
|
node.classList.add("selected");
|
|
}
|
|
|
|
lastSelected = node;
|
|
});
|
|
}
|
|
|
|
async function onShow() {
|
|
const container = document.getElementById('playlist-container');
|
|
container.innerHTML = "Loading…";
|
|
document.getElementById('playlist-save-btn').disabled = true;
|
|
|
|
try {
|
|
const res = await fetch('/api/v1/playlist');
|
|
const config = await res.json();
|
|
renderPlaylistForm(config);
|
|
} catch (err) {
|
|
container.innerHTML = "Failed to load config: " + err;
|
|
}
|
|
}
|
|
|
|
function renderPlaylistForm(config) {
|
|
const container = document.getElementById('playlist-container');
|
|
container.innerHTML = "";
|
|
container.appendChild(renderPlaylistObject(config, "root"));
|
|
document.getElementById('playlist-save-btn').disabled = false;
|
|
}
|
|
|
|
function clearSelection() {
|
|
tree.querySelectorAll(".selected").forEach(n =>
|
|
n.classList.remove("selected")
|
|
);
|
|
}
|
|
|
|
function selectRange(start, end) {
|
|
const nodes = [...tree.querySelectorAll(".node")];
|
|
const startIndex = nodes.indexOf(start);
|
|
const endIndex = nodes.indexOf(end);
|
|
|
|
const [from, to] = startIndex < endIndex
|
|
? [startIndex, endIndex]
|
|
: [endIndex, startIndex];
|
|
|
|
clearSelection();
|
|
nodes.slice(from, to + 1).forEach(n =>
|
|
n.classList.add("selected")
|
|
);
|
|
}
|
|
|
|
return { init, onShow };
|
|
})();
|
|
|
|
// Initialization
|
|
Object.values(Screens).forEach(screen => {
|
|
screen.init?.();
|
|
});
|
|
|
|
showScreen("menu");
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|