Add in-app settings menu and fix settings API
Move WiFi and device name configuration into a modal menu, reuse existing settings endpoints, and harden settings serialization and startup for MicroPython.
This commit is contained in:
2
Pipfile
2
Pipfile
@@ -20,4 +20,4 @@ python_version = "3.12"
|
||||
[scripts]
|
||||
web = "python /home/pi/led-controller/tests/web.py"
|
||||
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
||||
install = "pipenv install"
|
||||
install = "pipenv install"
|
||||
@@ -9,7 +9,9 @@ settings = Settings()
|
||||
@controller.get('')
|
||||
async def get_settings(request):
|
||||
"""Get all settings."""
|
||||
return json.dumps(dict(settings)), 200, {'Content-Type': 'application/json'}
|
||||
# Settings is already a dict subclass; avoid dict() wrapper which can
|
||||
# trigger MicroPython's "dict update sequence has wrong length" quirk.
|
||||
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/wifi/station')
|
||||
async def get_station_status(request):
|
||||
|
||||
@@ -25,7 +25,8 @@ async def main(port=80):
|
||||
settings = Settings()
|
||||
print("Starting")
|
||||
|
||||
network.WLAN(network.STA_IF).active(True)
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
sta.active(True)
|
||||
|
||||
# Initialize ESPNow singleton (config + peers)
|
||||
esp = ESPNow()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Help modal
|
||||
const helpBtn = document.getElementById('help-btn');
|
||||
const helpModal = document.getElementById('help-modal');
|
||||
const helpCloseBtn = document.getElementById('help-close-btn');
|
||||
@@ -22,6 +23,219 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Settings modal wiring (reusing existing settings endpoints).
|
||||
const settingsButton = document.getElementById('settings-btn');
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
const settingsCloseButton = document.getElementById('settings-close-btn');
|
||||
|
||||
const showSettingsMessage = (text, type = 'success') => {
|
||||
const messageEl = document.getElementById('settings-message');
|
||||
if (!messageEl) return;
|
||||
messageEl.textContent = text;
|
||||
messageEl.className = `message ${type} show`;
|
||||
setTimeout(() => {
|
||||
messageEl.classList.remove('show');
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
async function loadDeviceSettings() {
|
||||
try {
|
||||
const response = await fetch('/settings');
|
||||
const data = await response.json();
|
||||
const nameInput = document.getElementById('device-name-input');
|
||||
if (nameInput && data && typeof data === 'object') {
|
||||
nameInput.value = data.device_name || 'led-controller';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading device settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStationStatus() {
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/station');
|
||||
const status = await response.json();
|
||||
const statusEl = document.getElementById('station-status');
|
||||
if (!statusEl) return;
|
||||
if (status.connected) {
|
||||
statusEl.innerHTML = `
|
||||
<h4>Connection Status: <span class="status-connected">Connected</span></h4>
|
||||
<p><strong>SSID:</strong> ${status.ssid || 'N/A'}</p>
|
||||
<p><strong>IP Address:</strong> ${status.ip || 'N/A'}</p>
|
||||
<p><strong>Gateway:</strong> ${status.gateway || 'N/A'}</p>
|
||||
<p><strong>Netmask:</strong> ${status.netmask || 'N/A'}</p>
|
||||
<p><strong>DNS:</strong> ${status.dns || 'N/A'}</p>
|
||||
`;
|
||||
} else {
|
||||
statusEl.innerHTML = `
|
||||
<h4>Connection Status: <span class="status-disconnected">Disconnected</span></h4>
|
||||
<p>Not connected to any WiFi network</p>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading station status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStationCredentials() {
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/station/credentials');
|
||||
const creds = await response.json();
|
||||
if (creds.ssid) document.getElementById('station-ssid').value = creds.ssid;
|
||||
if (creds.ip) document.getElementById('station-ip').value = creds.ip;
|
||||
if (creds.gateway) document.getElementById('station-gateway').value = creds.gateway;
|
||||
} catch (error) {
|
||||
console.error('Error loading station credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAPStatus() {
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/ap');
|
||||
const config = await response.json();
|
||||
const statusEl = document.getElementById('ap-status');
|
||||
if (!statusEl) return;
|
||||
if (config.active) {
|
||||
statusEl.innerHTML = `
|
||||
<h4>AP Status: <span class="status-connected">Active</span></h4>
|
||||
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
|
||||
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
|
||||
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
|
||||
`;
|
||||
} else {
|
||||
statusEl.innerHTML = `
|
||||
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
|
||||
<p>Access Point is not currently active</p>
|
||||
`;
|
||||
}
|
||||
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
|
||||
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
|
||||
} catch (error) {
|
||||
console.error('Error loading AP status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsButton && settingsModal) {
|
||||
settingsButton.addEventListener('click', () => {
|
||||
settingsModal.classList.add('active');
|
||||
// Load current WiFi status/config when opening
|
||||
loadDeviceSettings();
|
||||
loadStationStatus();
|
||||
loadStationCredentials();
|
||||
loadAPStatus();
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsCloseButton && settingsModal) {
|
||||
settingsCloseButton.addEventListener('click', () => {
|
||||
settingsModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsModal) {
|
||||
settingsModal.addEventListener('click', (event) => {
|
||||
if (event.target === settingsModal) {
|
||||
settingsModal.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const stationForm = document.getElementById('station-form');
|
||||
if (stationForm) {
|
||||
stationForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = {
|
||||
ssid: document.getElementById('station-ssid').value,
|
||||
password: document.getElementById('station-password').value,
|
||||
ip: document.getElementById('station-ip').value || null,
|
||||
gateway: document.getElementById('station-gateway').value || null,
|
||||
};
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/station', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
showSettingsMessage('WiFi station connected successfully!', 'success');
|
||||
setTimeout(loadStationStatus, 1000);
|
||||
} else {
|
||||
showSettingsMessage(`Error: ${result.error || 'Failed to connect'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showSettingsMessage(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const deviceForm = document.getElementById('device-form');
|
||||
if (deviceForm) {
|
||||
deviceForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const nameInput = document.getElementById('device-name-input');
|
||||
const deviceName = nameInput ? nameInput.value.trim() : '';
|
||||
if (!deviceName) {
|
||||
showSettingsMessage('Device name is required', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/settings/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_name: deviceName }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
showSettingsMessage('Device name saved. It will be used on next restart.', 'success');
|
||||
} else {
|
||||
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showSettingsMessage(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
const apForm = document.getElementById('ap-form');
|
||||
if (apForm) {
|
||||
apForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = {
|
||||
ssid: document.getElementById('ap-ssid').value,
|
||||
password: document.getElementById('ap-password').value,
|
||||
channel: document.getElementById('ap-channel').value || null,
|
||||
};
|
||||
|
||||
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
|
||||
showSettingsMessage('AP password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.channel) {
|
||||
formData.channel = parseInt(formData.channel, 10);
|
||||
if (formData.channel < 1 || formData.channel > 11) {
|
||||
showSettingsMessage('Channel must be between 1 and 11', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/ap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
showSettingsMessage('Access Point configured successfully!', 'success');
|
||||
setTimeout(loadAPStatus, 1000);
|
||||
} else {
|
||||
showSettingsMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showSettingsMessage(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -355,6 +355,122 @@ header h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Settings modal layout */
|
||||
.settings-section {
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.status-info h3,
|
||||
.status-info h4 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-info p {
|
||||
color: #aaa;
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background-color: #1b5e20;
|
||||
color: #4caf50;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background-color: #5e1b1b;
|
||||
color: #f44336;
|
||||
border: 1px solid #f44336;
|
||||
}
|
||||
|
||||
.message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.patterns-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<button class="btn btn-secondary" id="presets-btn">Presets</button>
|
||||
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
|
||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary" id="settings-btn">Settings</button>
|
||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -230,6 +231,111 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 900px; max-height: 90vh; overflow-y: auto;">
|
||||
<h2>Device Settings</h2>
|
||||
<p class="muted-text" style="margin-bottom: 1rem;">Configure WiFi and device settings.</p>
|
||||
|
||||
<div id="settings-message" class="message" style="display:none;"></div>
|
||||
|
||||
<!-- Device Name -->
|
||||
<div class="settings-section">
|
||||
<h3>Device</h3>
|
||||
<form id="device-form">
|
||||
<div class="form-group">
|
||||
<label for="device-name-input">Device Name</label>
|
||||
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
|
||||
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Save Name</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Station Settings -->
|
||||
<div class="settings-section">
|
||||
<h3>WiFi Station (Client)</h3>
|
||||
|
||||
<div id="station-status" class="status-info">
|
||||
<h4>Connection Status</h4>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
|
||||
<form id="station-form">
|
||||
<div class="form-group">
|
||||
<label for="station-ssid">SSID (Network Name)</label>
|
||||
<input type="text" id="station-ssid" name="ssid" placeholder="Enter WiFi network name" required>
|
||||
<small>The name of the WiFi network to connect to</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="station-password">Password</label>
|
||||
<input type="password" id="station-password" name="password" placeholder="Enter WiFi password">
|
||||
<small>Leave empty for open networks</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="station-ip">IP Address (Optional)</label>
|
||||
<input type="text" id="station-ip" name="ip" placeholder="192.168.1.100">
|
||||
<small>Static IP address (leave empty for DHCP)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="station-gateway">Gateway (Optional)</label>
|
||||
<input type="text" id="station-gateway" name="gateway" placeholder="192.168.1.1">
|
||||
<small>Gateway/router IP address</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Connect</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Access Point Settings -->
|
||||
<div class="settings-section" style="margin-top: 1.5rem;">
|
||||
<h3>WiFi Access Point</h3>
|
||||
|
||||
<div id="ap-status" class="status-info">
|
||||
<h4>AP Status</h4>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
|
||||
<form id="ap-form">
|
||||
<div class="form-group">
|
||||
<label for="ap-ssid">AP SSID (Network Name)</label>
|
||||
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
|
||||
<small>The name of the WiFi access point this device creates</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ap-password">AP Password</label>
|
||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)">
|
||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ap-channel">Channel (1-11)</label>
|
||||
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
||||
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="settings-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user