diff --git a/__pycache__/build_ota_package.cpython-313.pyc b/__pycache__/build_ota_package.cpython-313.pyc
new file mode 100644
index 0000000..56c46a6
Binary files /dev/null and b/__pycache__/build_ota_package.cpython-313.pyc differ
diff --git a/__pycache__/upload_device.cpython-313.pyc b/__pycache__/upload_device.cpython-313.pyc
new file mode 100644
index 0000000..6917f8d
Binary files /dev/null and b/__pycache__/upload_device.cpython-313.pyc differ
diff --git a/data/admin.html b/data/admin.html
deleted file mode 100644
index eb943d4..0000000
--- a/data/admin.html
+++ /dev/null
@@ -1,181 +0,0 @@
-
-
-
-
- ESP32-C3 Admin
-
-
-
- ESP32-C3 Admin
- Working...
-
-
-
Login
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- User Overview
-
-
-
-
-
-
-
- Role Overview
-
-
-
- Role Detail
-
-
-
-
-
-
-
-
-
- API Overview
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/data/setup.html b/data/setup.html
deleted file mode 100644
index ab893df..0000000
--- a/data/setup.html
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
-
- ESP32-C3 first-run setup
-
-
-
- ESP32-C3 first-run setup
-
-
-
-
-
-
diff --git a/data/www/admin.html b/data/www/admin.html
new file mode 100644
index 0000000..94eb290
--- /dev/null
+++ b/data/www/admin.html
@@ -0,0 +1,108 @@
+
+
+
+
+ ESP32-C3 Admin
+
+
+
+
+
+
+
+
+ ESP32-C3 Admin
+ Working...
+
+
+
Login
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ User Overview
+
+
+
+
+
+
+
+ Role Overview
+
+
+
+ Role Detail
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/data/www/components/add-card.js b/data/www/components/add-card.js
new file mode 100644
index 0000000..7fc8af7
--- /dev/null
+++ b/data/www/components/add-card.js
@@ -0,0 +1,26 @@
+class AddCard extends HTMLElement {
+ connectedCallback() {
+ this.innerHTML = `
+
+ `;
+ this.querySelector('#add').addEventListener('click', () => this.addNums());
+ }
+
+ async addNums() {
+ const a = +this.querySelector('#a').value;
+ const b = +this.querySelector('#b').value;
+ this.querySelector('#out').textContent = await window.App.req('POST', '/api/add', {a, b});
+ }
+}
+
+customElements.define('add-card', AddCard);
diff --git a/data/www/components/led-card.js b/data/www/components/led-card.js
new file mode 100644
index 0000000..0262b02
--- /dev/null
+++ b/data/www/components/led-card.js
@@ -0,0 +1,32 @@
+class LedCard extends HTMLElement {
+ connectedCallback() {
+ this.innerHTML = `
+
+ `;
+ this.brightness = this.querySelector('#brightness');
+ this.value = this.querySelector('#value');
+ this.brightness.addEventListener('input', () => this.value.value = this.brightness.value);
+ this.querySelector('#apply').addEventListener('click', () => this.setLed());
+ window.addEventListener('app:settings-loaded', event => this.applySettings(event.detail.settings));
+ }
+
+ applySettings(settings) {
+ if (!settings || settings.ledBrightness === undefined) return;
+ this.brightness.value = settings.ledBrightness;
+ this.value.value = settings.ledBrightness;
+ }
+
+ async setLed() {
+ this.querySelector('#out').textContent = await window.App.req('POST', '/api/led', {brightness: +this.brightness.value});
+ }
+}
+
+customElements.define('led-card', LedCard);
diff --git a/data/www/components/status-card.js b/data/www/components/status-card.js
new file mode 100644
index 0000000..59ce631
--- /dev/null
+++ b/data/www/components/status-card.js
@@ -0,0 +1,26 @@
+class StatusCard extends HTMLElement {
+ connectedCallback() {
+ this.innerHTML = `
+
+ `;
+ this.querySelector('#ping').addEventListener('click', () => this.call('GET', '/api/ping'));
+ this.querySelector('#me').addEventListener('click', () => this.loadMe());
+ }
+
+ async call(method, url) {
+ this.querySelector('#out').textContent = await window.App.req(method, url);
+ }
+
+ async loadMe() {
+ await this.call('GET', '/api/me');
+ }
+}
+
+customElements.define('status-card', StatusCard);
diff --git a/data/www/components/uptime-card.js b/data/www/components/uptime-card.js
new file mode 100644
index 0000000..8cda3a8
--- /dev/null
+++ b/data/www/components/uptime-card.js
@@ -0,0 +1,59 @@
+class UptimeCard extends HTMLElement {
+ constructor() {
+ super();
+ this.source = null;
+ }
+
+ connectedCallback() {
+ this.innerHTML = `
+
+
+ Live uptime
+ Server-Sent Events
+ --s
+ Disconnected
+ `;
+ window.addEventListener('app:login', event => this.start(event.detail.token));
+ window.addEventListener('beforeunload', () => this.stop());
+ if (window.App && window.App.token()) this.start(window.App.token());
+ }
+
+ disconnectedCallback() {
+ this.stop();
+ }
+
+ setState(text, connected) {
+ this.querySelector('#state').textContent = text;
+ this.querySelector('#dot').classList.toggle('on', !!connected);
+ }
+
+ start(token) {
+ this.stop();
+ if (!token || !window.EventSource) {
+ this.setState('Unavailable', false);
+ return;
+ }
+ this.setState('Connecting', false);
+ this.source = new EventSource('/api/uptime/events?token=' + encodeURIComponent(token));
+ this.source.addEventListener('open', () => this.setState('Connected', true));
+ this.source.addEventListener('uptime', event => {
+ try {
+ const json = JSON.parse(event.data);
+ this.querySelector('#seconds').textContent = json.uptimeSeconds;
+ this.setState('Connected', true);
+ } catch (error) {
+ this.setState('Invalid event', false);
+ }
+ });
+ this.source.addEventListener('error', () => this.setState('Reconnecting', false));
+ }
+
+ stop() {
+ if (this.source) this.source.close();
+ this.source = null;
+ }
+}
+
+customElements.define('uptime-card', UptimeCard);
diff --git a/data/www/css/app.css b/data/www/css/app.css
new file mode 100644
index 0000000..7ab6940
--- /dev/null
+++ b/data/www/css/app.css
@@ -0,0 +1,26 @@
+:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8}
+body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650}
+main{max-width:1040px;margin:0 auto;padding:18px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
+section,.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px}
+h3{margin-top:0}label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit}
+input,select,textarea{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px}
+textarea{min-height:110px;resize:vertical}button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
+button:disabled{opacity:.65;cursor:wait}.spinner{display:inline-block;width:1em;height:1em;border:2px solid rgba(255,255,255,.55);border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px;margin-right:6px}@keyframes spin{to{transform:rotate(360deg)}}
+button.secondary{background:#607d8b}button.danger{background:#b3261e}
+pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:300px;overflow:auto}
+.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}.hidden{display:none!important}.muted{color:#607d8b;font-size:13px}
+.tabs{display:flex;gap:6px;border-bottom:1px solid #cfd8dc;margin:0 0 14px;overflow-x:auto}
+.tab{background:transparent;color:#263238;border-radius:6px 6px 0 0;margin:0;padding:10px 14px;white-space:nowrap}
+.tab.active{background:#1261a6;color:white}.panel{display:none}.panel.active{display:block}
+.subtabs{margin-top:4px}.subtab{font-size:14px;padding:8px 12px}.subpanel{display:none}.subpanel.active{display:block}
+.rangeRow{display:flex;gap:12px;align-items:center}.rangeRow input{flex:1}.rangeRow output{min-width:3ch;text-align:right;font-weight:650}
+.list{display:flex;flex-direction:column;gap:6px;margin-top:10px}.item{background:#eef3f6;color:#263238;text-align:left;margin:0;padding:9px 12px;box-sizing:border-box}.item:hover{background:#dbe7ed}
+.meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}.badge{background:#eef3f6;border-radius:6px;padding:4px 7px;font-size:12px;color:#263238}
+.checkGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-top:8px}.checkGrid label{display:flex;gap:6px;align-items:center;margin:0}.checkGrid input{width:auto}
+.pill{display:inline-flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:5px 8px;margin:4px 4px 0 0}.pill button{margin:0;padding:3px 7px;background:#b3261e}
+.detailTitle{margin:0 0 8px}.inlineCheck{display:flex;gap:8px;align-items:center;margin-top:10px}.inlineCheck input{width:auto}
+.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}.toolbar button{margin-top:0}.detailActions{display:flex;gap:8px;flex-wrap:wrap}.detailActions button{flex:0 1 auto}
+.fullWidth{grid-column:1/-1}.busyChip{position:fixed;right:14px;top:10px;z-index:10;background:#1261a6;color:white;border-radius:6px;padding:7px 10px;box-shadow:0 2px 10px rgba(0,0,0,.22);font-size:13px}
+.fileList{gap:3px}.fileRow{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:8px;align-items:center;background:#eef3f6;border-radius:6px;color:#263238;min-height:30px;padding:3px 8px;box-sizing:border-box}.fileRow[data-path]{cursor:pointer}.fileRow:hover{background:#dbe7ed}.filePath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.filePathChip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px;color:#263238;background:#eef3f6;border-radius:6px;padding:6px 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.fileSize{color:#607d8b;font-size:12px;white-space:nowrap}.iconBtn,.folderIcon{width:24px;height:24px;display:inline-grid;place-items:center}.iconBtn{border:0;border-radius:5px;background:transparent;color:#1261a6;margin:0;padding:0}.iconBtn:hover{background:#c9dbe5}.iconBtn svg,.folderIcon svg{width:16px;height:16px}.folderIcon{color:#607d8b}
+.apiRow{display:grid;grid-template-columns:minmax(180px,1fr) minmax(220px,auto);gap:10px;align-items:center;background:#eef3f6;border-radius:6px;padding:7px 9px}.apiPath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.apiVerbs{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.verbBadge{display:inline-flex;gap:6px;align-items:center;background:white;color:#263238;border:1px solid #cfd8dc;border-radius:6px;margin:0;padding:5px 7px;font-size:12px}.verbBadge:hover{background:#dbe7ed}.verbBadge span{color:#607d8b}
+.usageGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:12px 0}.usageGrid h3{font-size:14px;margin:0 0 6px}.usageList{display:flex;flex-direction:column;gap:4px}.usageItem{display:flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:6px 8px;min-height:28px}.usageItem code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
diff --git a/data/www/css/setup.css b/data/www/css/setup.css
new file mode 100644
index 0000000..830e05d
--- /dev/null
+++ b/data/www/css/setup.css
@@ -0,0 +1,7 @@
+:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8}
+body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650}
+main{max-width:520px;margin:0 auto;padding:18px}.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px}
+label{display:block;font-size:13px;margin:10px 0 4px}input,button{font:inherit}
+input{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px}
+button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
+pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:220px;overflow:auto}
diff --git a/data/www/js/app.js b/data/www/js/app.js
new file mode 100644
index 0000000..4711408
--- /dev/null
+++ b/data/www/js/app.js
@@ -0,0 +1,599 @@
+let token = '';
+let roles = [];
+let users = [];
+let apis = [];
+let selectedUser = '';
+let selectedRole = '';
+let selectedApi = -1;
+let currentFilePath = '/';
+let filesLoaded = false;
+let busyCount = 0;
+
+const el = id => document.getElementById(id);
+
+function setGlobalBusy(on) {
+ busyCount += on ? 1 : -1;
+ if (busyCount < 0) busyCount = 0;
+ el('busyChip').classList.toggle('hidden', busyCount == 0);
+}
+
+async function tracked(fn) {
+ setGlobalBusy(true);
+ try {
+ return await fn();
+ } finally {
+ setGlobalBusy(false);
+ }
+}
+
+async function apiFetch(url, options) {
+ return await tracked(() => fetch(url, options));
+}
+
+async function req(method, url, body) {
+ const options = {method, headers: {'Content-Type': 'application/json'}};
+ if (token) options.headers.Authorization = 'Bearer ' + token;
+ if (body !== undefined && method != 'GET') options.body = JSON.stringify(body);
+ const response = await apiFetch(url, options);
+ return await response.text();
+}
+
+window.App = {
+ apiFetch,
+ req,
+ token: () => token
+};
+
+function showTab(id) {
+ document.querySelectorAll('.tab[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab == id));
+ document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id == id));
+ if (id == 'network') loadSettings();
+ if (id == 'ops' && !filesLoaded) loadFiles('/');
+}
+
+function showAccessTab(id) {
+ document.querySelectorAll('.subtab').forEach(button => button.classList.toggle('active', button.dataset.subtab == id));
+ document.querySelectorAll('.subpanel').forEach(panel => panel.classList.toggle('active', panel.id == id));
+ if (id == 'users') showUserOverview();
+ if (id == 'roles') showRoleOverview();
+ if (id == 'apis') showApiOverview();
+}
+
+async function doLogin() {
+ const text = await req('POST', '/api/login', {username: el('user').value, password: el('pass').value});
+ el('loginOut').textContent = text;
+ const json = JSON.parse(text);
+ if (!json.success) return;
+ token = json.token;
+ el('loginPanel').classList.add('hidden');
+ el('app').classList.remove('hidden');
+ window.dispatchEvent(new CustomEvent('app:login', {detail: {token}}));
+ loadSettings();
+ loadAccess();
+ loadFiles('/');
+}
+
+function esc(value) {
+ return String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
+}
+
+function roleNames() {
+ return roles.map(role => role.name);
+}
+
+function userHasRole(user, role) {
+ return (user.roles || '').split('|').includes(role);
+}
+
+function roleChecks(id, selected) {
+ el(id).innerHTML = roleNames().map(role => '').join('');
+}
+
+function selectedRoles() {
+ return [...el('userRoles').querySelectorAll('input:checked')].map(input => input.value);
+}
+
+function showUserOverview() {
+ el('usersOverview').classList.remove('hidden');
+ el('userDetail').classList.add('hidden');
+}
+
+function showRoleOverview() {
+ el('rolesOverview').classList.remove('hidden');
+ el('roleDetail').classList.add('hidden');
+}
+
+function showApiOverview() {
+ el('apisOverview').classList.remove('hidden');
+ el('apiDetail').classList.add('hidden');
+}
+
+function renderRoles() {
+ el('rolesOut').innerHTML = roles.map((role, index) => '').join('');
+}
+
+function renderRoleUsage(role) {
+ const roleUsers = users.filter(user => userHasRole(user, role.name)).sort((a, b) => a.username.localeCompare(b.username));
+ const roleApis = apis.filter(api => api.role == role.name).sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
+ const usersHtml = roleUsers.length
+ ? roleUsers.map(user => '' + esc(user.username) + '' + (user.active ? 'Active' : 'Inactive') + '
').join('')
+ : 'No users
';
+ const apisHtml = roleApis.length
+ ? roleApis.map(api => '' + esc(api.method) + '' + esc(api.path) + '
').join('')
+ : 'No API endpoints
';
+ el('roleUsage').innerHTML = 'API endpoints
' + apisHtml + '
';
+}
+
+async function loadRoles() {
+ const json = JSON.parse(await req('GET', '/api/roles'));
+ if (!json.success) return;
+ roles = json.roles;
+ renderRoles();
+ roleOptions();
+ if (selectedUser && !el('userDetail').classList.contains('hidden')) selectUser(selectedUser);
+}
+
+function newRoleRecord() {
+ selectedRole = '';
+ el('rolesOverview').classList.add('hidden');
+ el('roleDetail').classList.remove('hidden');
+ el('roleTitle').textContent = 'New role';
+ el('roleName').value = '';
+ el('roleName').readOnly = false;
+ el('roleMeta').innerHTML = '';
+ el('roleUsage').innerHTML = '';
+ el('roleSave').classList.remove('hidden');
+ el('roleDelete').classList.add('hidden');
+ el('roleMsg').textContent = '';
+}
+
+function selectRole(index) {
+ const role = roles[index];
+ if (!role) return;
+ selectedRole = role.name;
+ el('rolesOverview').classList.add('hidden');
+ el('roleDetail').classList.remove('hidden');
+ el('roleTitle').textContent = role.name;
+ el('roleName').value = role.name;
+ el('roleName').readOnly = true;
+ el('roleMeta').innerHTML = '' + (role.system ? 'System role' : 'Custom role') + '';
+ renderRoleUsage(role);
+ el('roleSave').classList.add('hidden');
+ el('roleDelete').classList.toggle('hidden', role.system);
+ el('roleMsg').textContent = '';
+}
+
+async function addRole() {
+ const text = await req('POST', '/api/roles', {role: el('roleName').value});
+ el('roleMsg').textContent = text;
+ await loadAccess();
+ showRoleOverview();
+}
+
+async function deleteSelectedRole() {
+ if (!selectedRole) return;
+ const text = await req('DELETE', '/api/roles', {role: selectedRole});
+ el('roleMsg').textContent = text;
+ selectedRole = '';
+ await loadAccess();
+ showRoleOverview();
+}
+
+function renderUsers() {
+ el('userList').innerHTML = users.map(user => '').join('');
+}
+
+async function loadUsers() {
+ const json = JSON.parse(await req('GET', '/api/users'));
+ if (!json.success) return;
+ users = json.users;
+ renderUsers();
+ if (selectedUser && !el('userDetail').classList.contains('hidden')) selectUser(selectedUser);
+}
+
+function newUserRecord() {
+ selectedUser = '';
+ el('usersOverview').classList.add('hidden');
+ el('userDetail').classList.remove('hidden');
+ el('userTitle').textContent = 'New user';
+ el('userName').value = '';
+ el('userName').readOnly = false;
+ el('userPass').value = '';
+ el('userActive').checked = true;
+ roleChecks('userRoles', []);
+ el('usersOut').textContent = '';
+}
+
+function selectUser(name) {
+ selectedUser = name;
+ const user = users.find(item => item.username == name);
+ if (!user) return;
+ el('usersOverview').classList.add('hidden');
+ el('userDetail').classList.remove('hidden');
+ el('userTitle').textContent = name;
+ el('userName').value = name;
+ el('userName').readOnly = true;
+ el('userPass').value = '';
+ el('userActive').checked = user.active;
+ roleChecks('userRoles', (user.roles || '').split('|'));
+ el('usersOut').textContent = '';
+}
+
+async function saveSelectedUser() {
+ const text = await req('POST', '/api/users', {username: el('userName').value, password: el('userPass').value, active: el('userActive').checked, roles: selectedRoles()});
+ el('usersOut').textContent = text;
+ selectedUser = el('userName').value;
+ await loadUsers();
+ showUserOverview();
+}
+
+function roleOptions() {
+ const options = '' + roleNames().map(role => '').join('');
+ el('apiRole').innerHTML = options;
+}
+
+function renderApis() {
+ const groups = new Map();
+ apis.forEach((api, index) => {
+ if (!groups.has(api.path)) groups.set(api.path, []);
+ groups.get(api.path).push({...api, index});
+ });
+ const methodOrder = {GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5};
+ el('apiList').innerHTML = [...groups.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([path, items]) => {
+ items.sort((a, b) => (methodOrder[a.method] || 99) - (methodOrder[b.method] || 99) || a.method.localeCompare(b.method));
+ const verbs = items.map(api => '').join('');
+ return '' + esc(path) + '
' + verbs + '
';
+ })
+ .join('');
+}
+
+async function loadApis() {
+ const json = JSON.parse(await req('GET', '/api/apis'));
+ if (!json.success) return;
+ apis = json.apis;
+ renderApis();
+ if (selectedApi >= 0 && !el('apiDetail').classList.contains('hidden')) selectApi(selectedApi);
+}
+
+function selectApi(index) {
+ selectedApi = index;
+ const api = apis[index];
+ if (!api) return;
+ el('apisOverview').classList.add('hidden');
+ el('apiDetail').classList.remove('hidden');
+ el('apiTitle').textContent = api.method + ' ' + api.path;
+ el('apiPath').value = api.path;
+ el('apiMethod').value = api.method;
+ roleOptions();
+ el('apiRole').value = api.role;
+ el('apisOut').textContent = '';
+ el('apiTestOut').textContent = '';
+}
+
+async function saveSelectedApi() {
+ if (selectedApi < 0) return;
+ const text = await req('POST', '/api/apis', {path: el('apiPath').value, method: el('apiMethod').value, role: el('apiRole').value});
+ el('apisOut').textContent = text;
+ await loadApis();
+}
+
+function clearApiTestBody() {
+ el('apiTestBody').value = '';
+}
+
+async function testSelectedApi() {
+ if (selectedApi < 0) return;
+ const api = apis[selectedApi];
+ const body = el('apiTestBody').value.trim();
+ let payload;
+ if (body) {
+ try {
+ payload = JSON.parse(body);
+ } catch (error) {
+ el('apiTestOut').textContent = 'Invalid JSON body';
+ return;
+ }
+ }
+ el('apiTestOut').textContent = await req(api.method, api.path, payload);
+}
+
+async function loadAccess() {
+ await loadRoles();
+ await loadUsers();
+ await loadApis();
+}
+
+function fileName(path) {
+ const index = String(path).lastIndexOf('/');
+ return index >= 0 ? path.substring(index + 1) : path;
+}
+
+function fileIcon(file) {
+ if (file.directory) return '';
+ return '';
+}
+
+function fileSize(file) {
+ if (file.directory) return 'Folder';
+ return file.size + ' B';
+}
+
+function parentPath(path) {
+ if (!path || path == '/') return '/';
+ const end = path.lastIndexOf('/');
+ return end <= 0 ? '/' : path.substring(0, end);
+}
+
+function renderFiles(files) {
+ el('fileList').classList.add('fileList');
+ files.sort((a, b) => Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path));
+ const rows = currentFilePath == '/' ? [] : [{name: '..', path: parentPath(currentFilePath), directory: true, parent: true}];
+ rows.push(...files);
+ el('fileList').innerHTML = rows.map(file => '' + fileIcon(file) + '
' + esc(file.name || file.path) + '
' + (file.parent ? '' : fileSize(file)) + '
').join('');
+}
+
+async function loadFiles(path = currentFilePath) {
+ const text = await req('GET', '/api/files?path=' + encodeURIComponent(path));
+ el('filesOut').textContent = '';
+ try {
+ const json = JSON.parse(text);
+ if (json.success) {
+ currentFilePath = json.path || path || '/';
+ filesLoaded = true;
+ el('filesPath').textContent = currentFilePath;
+ renderFiles(json.files || []);
+ if (!(json.files || []).length) el('filesOut').textContent = 'No files found';
+ } else {
+ el('filesOut').textContent = text;
+ }
+ } catch (error) {
+ el('filesOut').textContent = text;
+ }
+}
+
+async function downloadFile(path) {
+ const response = await apiFetch('/api/files/download?path=' + encodeURIComponent(path), {headers: {Authorization: 'Bearer ' + token}});
+ if (!response.ok) {
+ el('filesOut').textContent = await response.text();
+ return;
+ }
+ const blob = await response.blob();
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = fileName(path) || 'download';
+ document.body.appendChild(link);
+ link.click();
+ URL.revokeObjectURL(link.href);
+ link.remove();
+}
+
+async function downloadConfig() {
+ const response = await apiFetch('/api/config/export', {headers: {Authorization: 'Bearer ' + token}});
+ if (!response.ok) {
+ el('maintOut').textContent = await response.text();
+ return;
+ }
+ const blob = await response.blob();
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = 'config-backup.json';
+ document.body.appendChild(link);
+ link.click();
+ URL.revokeObjectURL(link.href);
+ link.remove();
+ el('maintOut').textContent = 'Configuration backup downloaded';
+}
+
+async function restoreConfig() {
+ if (!el('configFile').files.length) {
+ el('maintOut').textContent = 'Select a configuration JSON file first';
+ return;
+ }
+ if (!confirm('Restore configuration from this file? WiFi settings will not change.')) return;
+ setBusy('restoreConfigBtn', true, 'Restoring');
+ try {
+ await tracked(async () => {
+ const body = await el('configFile').files[0].text();
+ const response = await fetch('/api/config/import', {method: 'POST', headers: {'Content-Type': 'application/json', Authorization: 'Bearer ' + token}, body});
+ el('maintOut').textContent = await response.text();
+ if (response.ok) {
+ loadSettings();
+ loadAccess();
+ }
+ });
+ } catch (error) {
+ el('maintOut').textContent = 'Restore failed: ' + error.message;
+ } finally {
+ setBusy('restoreConfigBtn', false, 'Restore config');
+ }
+}
+
+async function loadCert() {
+ const text = await req('GET', '/api/cert');
+ try {
+ const json = JSON.parse(text);
+ el('certOut').textContent = json.success ? (json.certificate || 'No certificate stored') : text;
+ } catch (error) {
+ el('certOut').textContent = text;
+ }
+}
+
+async function saveCertFile() {
+ if (!el('certFile').files.length) {
+ el('certOut').textContent = 'Select a PEM/CER/CRT file first';
+ return;
+ }
+ setBusy('certSaveBtn', true, 'Saving');
+ try {
+ await tracked(async () => {
+ const certificate = await el('certFile').files[0].text();
+ el('certOut').textContent = await req('POST', '/api/cert', {certificate});
+ });
+ } catch (error) {
+ el('certOut').textContent = 'Certificate save failed: ' + error.message;
+ } finally {
+ setBusy('certSaveBtn', false, 'Save certificate');
+ }
+}
+
+async function factoryResetDevice() {
+ if (!confirm('Factory reset this device and restart?')) return;
+ el('maintOut').textContent = await req('POST', '/api/factory-reset');
+}
+
+async function loadLogs() {
+ const text = await req('GET', '/api/logs');
+ try {
+ const json = JSON.parse(text);
+ el('logsOut').textContent = json.success ? (json.logs || '') : text;
+ } catch (error) {
+ el('logsOut').textContent = text;
+ }
+}
+
+async function clearLogs() {
+ const text = await req('POST', '/api/logs/clear');
+ el('logsOut').textContent = text;
+ try {
+ if (JSON.parse(text).success) el('logsOut').textContent = 'Logs cleared';
+ } catch (error) {}
+}
+
+function syncNetworkMode() {
+ const staticMode = el('netDhcp').value == 'false';
+ ['netIp', 'netGateway', 'netSubnet', 'netDns1', 'netDns2'].forEach(id => el(id).disabled = !staticMode);
+}
+
+async function loadSettings() {
+ const text = await req('GET', '/api/settings');
+ el('settingsOut').textContent = text;
+ const json = JSON.parse(text);
+ if (!json.success) return;
+ const settings = json.settings;
+ const projectName = settings.projectName || 'ESP32-C3';
+ el('appTitle').textContent = projectName + ' Admin';
+ document.title = projectName + ' Admin';
+ el('logLevel').value = settings.logLevel;
+ el('logMax').value = settings.maxLogBytes;
+ el('upd').value = settings.updateUrl;
+ el('netHost').value = settings.hostname || '';
+ el('netHost').placeholder = settings.defaultHostname || 'Default host name';
+ el('netDhcp').value = String(settings.dhcp !== false);
+ el('netIp').value = settings.ip || '';
+ el('netGateway').value = settings.gateway || '';
+ el('netSubnet').value = settings.subnet || '';
+ el('netDns1').value = settings.dns1 || '';
+ el('netDns2').value = settings.dns2 || '';
+ el('netCurrent').textContent = 'Current IP: ' + (settings.currentIp || 'not connected');
+ syncNetworkMode();
+ window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}}));
+}
+
+async function saveSettings() {
+ el('settingsOut').textContent = await req('POST', '/api/settings', {logLevel: +el('logLevel').value, maxLogBytes: +el('logMax').value, updateUrl: el('upd').value});
+}
+
+async function saveNetwork() {
+ setBusy('networkSaveBtn', true, 'Saving');
+ try {
+ const body = {hostname: el('netHost').value, dhcp: el('netDhcp').value == 'true', ip: el('netIp').value, gateway: el('netGateway').value, subnet: el('netSubnet').value, dns1: el('netDns1').value, dns2: el('netDns2').value};
+ const text = await req('POST', '/api/settings', body);
+ el('networkOut').textContent = text + '\nNetwork changes apply on the next WiFi reconnect or reboot.';
+ if (JSON.parse(text).success) await loadSettings();
+ } catch (error) {
+ el('networkOut').textContent = 'Network save failed: ' + error.message;
+ } finally {
+ setBusy('networkSaveBtn', false, 'Save network');
+ }
+}
+
+function setBusy(id, busy, label) {
+ const button = el(id);
+ if (!button) return;
+ button.disabled = busy;
+ button.innerHTML = busy ? '' + label : label;
+}
+
+async function uploadPkg() {
+ if (!el('pkg').files.length) {
+ el('fwOut').textContent = 'Select an update package first';
+ return;
+ }
+ setBusy('uploadPkgBtn', true, 'Uploading');
+ el('fwOut').textContent = 'Uploading update package...';
+ try {
+ const formData = new FormData();
+ formData.append('package', el('pkg').files[0]);
+ const response = await apiFetch('/api/update', {method: 'POST', headers: {Authorization: 'Bearer ' + token}, body: formData});
+ el('fwOut').textContent = await response.text();
+ } catch (error) {
+ el('fwOut').textContent = 'Upload failed: ' + error.message;
+ } finally {
+ setBusy('uploadPkgBtn', false, 'Upload package');
+ }
+}
+
+async function checkOta() {
+ el('fwOut').textContent = await req('POST', '/api/ota/check');
+}
+
+async function runOta() {
+ el('fwOut').textContent = await req('POST', '/api/ota/run');
+}
+
+function bindEvents() {
+ el('loginBtn').addEventListener('click', doLogin);
+ document.querySelectorAll('.tab[data-tab]').forEach(button => button.addEventListener('click', () => showTab(button.dataset.tab)));
+ document.querySelectorAll('.subtab').forEach(button => button.addEventListener('click', () => showAccessTab(button.dataset.subtab)));
+ el('usersRefresh').addEventListener('click', loadUsers);
+ el('userNew').addEventListener('click', newUserRecord);
+ el('userBack').addEventListener('click', showUserOverview);
+ el('userSave').addEventListener('click', saveSelectedUser);
+ el('rolesRefresh').addEventListener('click', loadRoles);
+ el('roleNew').addEventListener('click', newRoleRecord);
+ el('roleBack').addEventListener('click', showRoleOverview);
+ el('roleSave').addEventListener('click', addRole);
+ el('roleDelete').addEventListener('click', deleteSelectedRole);
+ el('apiBack').addEventListener('click', showApiOverview);
+ el('apiSave').addEventListener('click', saveSelectedApi);
+ el('apiTest').addEventListener('click', testSelectedApi);
+ el('apiTestClear').addEventListener('click', clearApiTestBody);
+ el('rolesOut').addEventListener('click', event => {
+ const button = event.target.closest('[data-role-index]');
+ if (button) selectRole(+button.dataset.roleIndex);
+ });
+ el('userList').addEventListener('click', event => {
+ const button = event.target.closest('[data-user]');
+ if (button) selectUser(button.dataset.user);
+ });
+ el('apiList').addEventListener('click', event => {
+ const button = event.target.closest('[data-api-index]');
+ if (button) selectApi(+button.dataset.apiIndex);
+ });
+ el('netDhcp').addEventListener('change', syncNetworkMode);
+ el('networkSaveBtn').addEventListener('click', saveNetwork);
+ el('certSaveBtn').addEventListener('click', saveCertFile);
+ el('certLoad').addEventListener('click', loadCert);
+ el('settingsSave').addEventListener('click', saveSettings);
+ el('uploadPkgBtn').addEventListener('click', uploadPkg);
+ el('otaCheck').addEventListener('click', checkOta);
+ el('otaRun').addEventListener('click', runOta);
+ el('configDownload').addEventListener('click', downloadConfig);
+ el('restoreConfigBtn').addEventListener('click', restoreConfig);
+ el('factoryReset').addEventListener('click', factoryResetDevice);
+ el('fileList').addEventListener('click', event => {
+ const button = event.target.closest('[data-download]');
+ if (button) {
+ downloadFile(button.dataset.download);
+ return;
+ }
+ const directory = event.target.closest('.fileRow[data-path]');
+ if (directory) loadFiles(directory.dataset.path);
+ });
+ el('logsLoad').addEventListener('click', loadLogs);
+ el('logsClear').addEventListener('click', clearLogs);
+}
+
+bindEvents();
diff --git a/data/www/js/setup.js b/data/www/js/setup.js
new file mode 100644
index 0000000..2ed17e5
--- /dev/null
+++ b/data/www/js/setup.js
@@ -0,0 +1,20 @@
+const ssid = document.getElementById('ssid');
+const wifiPass = document.getElementById('wifiPass');
+const admin = document.getElementById('admin');
+const adminPass = document.getElementById('adminPass');
+const out = document.getElementById('out');
+
+async function scan() {
+ const response = await fetch('/api/wifi/scan');
+ const json = await response.json();
+ ssid.innerHTML = json.networks.map(network => ``).join('');
+}
+
+async function save() {
+ const body = {ssid: ssid.value, wifiPass: wifiPass.value, admin: admin.value, adminPass: adminPass.value};
+ const response = await fetch('/api/setup', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
+ out.textContent = await response.text();
+}
+
+document.getElementById('saveSetup').addEventListener('click', save);
+scan();
diff --git a/data/www/setup.html b/data/www/setup.html
new file mode 100644
index 0000000..f869408
--- /dev/null
+++ b/data/www/setup.html
@@ -0,0 +1,27 @@
+
+
+
+
+ ESP32-C3 first-run setup
+
+
+
+
+ ESP32-C3 first-run setup
+
+
+
+
+
diff --git a/dist/TSL-Embedded_2026-06-28T11-50-01-05-00.tslpkg b/dist/TSL-Embedded_2026-06-28T11-50-01-05-00.tslpkg
new file mode 100644
index 0000000..bbd06d4
Binary files /dev/null and b/dist/TSL-Embedded_2026-06-28T11-50-01-05-00.tslpkg differ
diff --git a/dist/TSL-Embedded_2026-06-28T11-57-14-05-00.tslpkg b/dist/TSL-Embedded_2026-06-28T11-57-14-05-00.tslpkg
new file mode 100644
index 0000000..9abe8bb
Binary files /dev/null and b/dist/TSL-Embedded_2026-06-28T11-57-14-05-00.tslpkg differ
diff --git a/include/app.h b/include/app.h
index 57fbec8..e9945e6 100644
--- a/include/app.h
+++ b/include/app.h
@@ -34,6 +34,9 @@ extern const byte DNS_PORT;
extern const IPAddress SETUP_AP_IP;
extern const IPAddress SETUP_AP_GATEWAY;
extern const IPAddress SETUP_AP_SUBNET;
+extern const char *WWW_ROOT;
+extern const char *LOG_DIR;
+extern const char *LOG_FILE_PATH;
enum LogLevel : uint8_t {
LOG_ERROR = 0,
diff --git a/include/custom_api.h b/include/custom_api.h
index dbd8c34..8dd766e 100644
--- a/include/custom_api.h
+++ b/include/custom_api.h
@@ -3,3 +3,5 @@
void handlePing();
void handleAdd();
void handleLed();
+void handleUptime();
+void handleUptimeEvents();
diff --git a/scripts/__pycache__/build_ota_package.cpython-313.pyc b/scripts/__pycache__/build_ota_package.cpython-313.pyc
new file mode 100644
index 0000000..07a01f7
Binary files /dev/null and b/scripts/__pycache__/build_ota_package.cpython-313.pyc differ
diff --git a/scripts/__pycache__/build_upload_device.cpython-313.pyc b/scripts/__pycache__/build_upload_device.cpython-313.pyc
new file mode 100644
index 0000000..82a1a95
Binary files /dev/null and b/scripts/__pycache__/build_upload_device.cpython-313.pyc differ
diff --git a/scripts/__pycache__/upload_device.cpython-313.pyc b/scripts/__pycache__/upload_device.cpython-313.pyc
new file mode 100644
index 0000000..890d419
Binary files /dev/null and b/scripts/__pycache__/upload_device.cpython-313.pyc differ
diff --git a/scripts/build_ota_package.py b/scripts/build_ota_package.py
new file mode 100644
index 0000000..61992b4
--- /dev/null
+++ b/scripts/build_ota_package.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+import argparse
+import re
+import shutil
+import subprocess
+import sys
+from datetime import datetime
+from pathlib import Path
+
+
+DEFAULT_ENV = "seeed_xiao_esp32c3"
+
+
+def find_pio():
+ pio = shutil.which("pio")
+ if pio:
+ return pio
+ candidate = Path.home() / ".platformio" / "penv" / "Scripts" / "pio.exe"
+ if candidate.exists():
+ return str(candidate)
+ candidate = Path.home() / ".platformio" / "penv" / "bin" / "pio"
+ if candidate.exists():
+ return str(candidate)
+ return "pio"
+
+
+def run(command, cwd):
+ print("+ " + " ".join(command), flush=True)
+ subprocess.run(command, check=True, cwd=cwd)
+
+
+def project_name(root):
+ app_h = root / "include" / "app.h"
+ if app_h.exists():
+ match = re.search(r'^\s*#define\s+PROJECT_NAME\s+"([^"]+)"', app_h.read_text(encoding="utf-8"), re.MULTILINE)
+ if match:
+ return match.group(1)
+ return root.name
+
+
+def safe_filename_part(value):
+ value = value.strip().replace(" ", "-")
+ return re.sub(r"[^A-Za-z0-9._+-]+", "-", value).strip("-") or "project"
+
+
+def package_timestamp():
+ return datetime.now().astimezone().isoformat(timespec="seconds").replace(":", "-")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Build firmware and LittleFS, then create a timestamped OTA update package.")
+ parser.add_argument("-e", "--environment", default=DEFAULT_ENV, help="PlatformIO environment to build.")
+ parser.add_argument("-o", "--output-dir", default="dist", help="Directory for the generated OTA package.")
+ parser.add_argument("--pio", default=find_pio(), help="Path to the PlatformIO executable.")
+ args = parser.parse_args()
+
+ root = Path(__file__).resolve().parent.parent
+ build_dir = root / ".pio" / "build" / args.environment
+ firmware = build_dir / "firmware.bin"
+ filesystem = build_dir / "littlefs.bin"
+ output_dir = (root / args.output_dir).resolve()
+ output = output_dir / f"{safe_filename_part(project_name(root))}_{package_timestamp()}.tslpkg"
+
+ try:
+ run([args.pio, "run", "-e", args.environment, "-t", "buildfs"], root)
+ run([args.pio, "run", "-e", args.environment], root)
+ run([
+ sys.executable,
+ str(Path(__file__).resolve().parent / "create_update_package.py"),
+ "--firmware",
+ str(firmware),
+ "--filesystem",
+ str(filesystem),
+ "--output",
+ str(output),
+ ], root)
+ except subprocess.CalledProcessError as error:
+ sys.exit(error.returncode)
+
+ print(f"OTA update file: {output.resolve()}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/build_upload_device.py b/scripts/build_upload_device.py
new file mode 100644
index 0000000..d513685
--- /dev/null
+++ b/scripts/build_upload_device.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+import argparse
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+DEFAULT_ENV = "seeed_xiao_esp32c3"
+
+
+def find_pio():
+ pio = shutil.which("pio")
+ if pio:
+ return pio
+ candidate = Path.home() / ".platformio" / "penv" / "Scripts" / "pio.exe"
+ if candidate.exists():
+ return str(candidate)
+ candidate = Path.home() / ".platformio" / "penv" / "bin" / "pio"
+ if candidate.exists():
+ return str(candidate)
+ return "pio"
+
+
+def run(command, cwd):
+ print("+ " + " ".join(command), flush=True)
+ subprocess.run(command, check=True, cwd=cwd)
+
+
+def pio_command(args, pio, environment, upload_port):
+ command = [pio, "run", "-e", environment] + args
+ if upload_port:
+ command += ["--upload-port", upload_port]
+ return command
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Build firmware and LittleFS, then upload both to a connected device.")
+ parser.add_argument("-e", "--environment", default=DEFAULT_ENV, help="PlatformIO environment to build and upload.")
+ parser.add_argument("-p", "--upload-port", help="Upload port, for example COM3.")
+ parser.add_argument("--pio", default=find_pio(), help="Path to the PlatformIO executable.")
+ args = parser.parse_args()
+
+ root = Path(__file__).resolve().parent.parent
+ try:
+ run(pio_command(["-t", "buildfs"], args.pio, args.environment, None), root)
+ run(pio_command([], args.pio, args.environment, None), root)
+ run(pio_command(["-t", "uploadfs"], args.pio, args.environment, args.upload_port), root)
+ run(pio_command(["-t", "upload"], args.pio, args.environment, args.upload_port), root)
+ except subprocess.CalledProcessError as error:
+ sys.exit(error.returncode)
+
+ print(f"Uploaded filesystem and firmware for {args.environment} from {root}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/config/api_definitions_custom.cpp b/src/config/api_definitions_custom.cpp
index 2a25f60..b93fe57 100644
--- a/src/config/api_definitions_custom.cpp
+++ b/src/config/api_definitions_custom.cpp
@@ -5,6 +5,8 @@ ApiDef customApiDefs[] = {
{"/api/ping", "GET", "", true, handlePing, nullptr},
{"/api/add", "POST", "", true, handleAdd, nullptr},
{"/api/led", "POST", "", true, handleLed, nullptr},
+ {"/api/uptime", "GET", "WebUIConnect", false, handleUptime, nullptr},
+ {"/api/uptime/events", "GET", "WebUIConnect", false, handleUptimeEvents, nullptr},
};
const size_t CUSTOM_API_DEF_COUNT = sizeof(customApiDefs) / sizeof(customApiDefs[0]);
diff --git a/src/core/device.cpp b/src/core/device.cpp
index 6b0e56a..b6855ea 100644
--- a/src/core/device.cpp
+++ b/src/core/device.cpp
@@ -12,7 +12,7 @@ void applyLed() {
void factoryReset() {
prefs.clear();
- LittleFS.remove("/logs.txt");
+ LittleFS.remove(LOG_FILE_PATH);
appendLog(LOG_SECURITY_AUDIT, "factory reset requested");
}
diff --git a/src/core/logging.cpp b/src/core/logging.cpp
index 86efa44..ecd3a44 100644
--- a/src/core/logging.cpp
+++ b/src/core/logging.cpp
@@ -16,12 +16,13 @@ void appendLog(LogLevel level, const String &message) {
if (level > currentLogLevel) return;
const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"};
String line = timestamp() + " " + names[level] + " " + message + "\n";
- File f = LittleFS.open("/logs.txt", "a");
+ if (!LittleFS.exists(LOG_DIR)) LittleFS.mkdir(LOG_DIR);
+ File f = LittleFS.open(LOG_FILE_PATH, "a");
if (f) {
f.print(line);
f.close();
}
- f = LittleFS.open("/logs.txt", "r");
+ f = LittleFS.open(LOG_FILE_PATH, "r");
if (!f) return;
size_t size = f.size();
if (size <= maxLogBytes) {
@@ -34,7 +35,7 @@ void appendLog(LogLevel level, const String &message) {
f.close();
int newline = tail.indexOf('\n');
if (newline >= 0) tail = tail.substring(newline + 1);
- f = LittleFS.open("/logs.txt", "w");
+ f = LittleFS.open(LOG_FILE_PATH, "w");
if (f) {
f.print(tail);
f.close();
diff --git a/src/core/state.cpp b/src/core/state.cpp
index 2dd3346..aeda008 100644
--- a/src/core/state.cpp
+++ b/src/core/state.cpp
@@ -14,6 +14,9 @@ const byte DNS_PORT = 53;
const IPAddress SETUP_AP_IP(192, 168, 1, 1);
const IPAddress SETUP_AP_GATEWAY(192, 168, 1, 1);
const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0);
+const char *WWW_ROOT = "/www";
+const char *LOG_DIR = "/log";
+const char *LOG_FILE_PATH = "/log/logs.txt";
WebServer server(80);
DNSServer dnsServer;
diff --git a/src/handlers/handlers_admin.cpp b/src/handlers/handlers_admin.cpp
index 375dd2e..c49f223 100644
--- a/src/handlers/handlers_admin.cpp
+++ b/src/handlers/handlers_admin.cpp
@@ -26,7 +26,7 @@ static bool validIpValue(const String &value) {
void handleSettingsGet() {
if (!authorize()) return;
- String out = "\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}";
+ String out = "\"settings\":{\"projectName\":\"" + jsonEscape(PROJECT_NAME_VALUE) + "\",\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}";
sendJson(200, jsonOk(out));
}
@@ -83,7 +83,7 @@ void handleSettingsPost() {
void handleLogsGet() {
if (!authorize()) return;
- File f = LittleFS.open("/logs.txt", "r");
+ File f = LittleFS.open(LOG_FILE_PATH, "r");
String logs = f ? f.readString() : "";
if (f) f.close();
sendJson(200, jsonOk("\"logs\":\"" + jsonEscape(logs) + "\""));
@@ -91,37 +91,41 @@ void handleLogsGet() {
void handleLogsClear() {
if (!authorize()) return;
- LittleFS.remove("/logs.txt");
+ LittleFS.remove(LOG_FILE_PATH);
appendLog(LOG_SECURITY_AUDIT, "logs cleared");
sendJson(200, jsonOk());
}
static bool validFsPath(const String &path) {
- return path.length() && path.startsWith("/") && path.indexOf("..") < 0;
+ return path.length() && path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
}
static String fsPathArg(const String &fallback = "/") {
String path = server.arg("path");
if (!path.length()) path = fallback;
+ if (!path.startsWith("/")) path = "/" + path;
+ while (path.length() > 1 && path.endsWith("/")) path.remove(path.length() - 1);
return path;
}
-static void appendFileJson(String &out, bool &first, File &file) {
+static String joinedFsPath(const String &parent, const String &name) {
+ if (name.startsWith("/")) return name;
+ if (parent == "/") return "/" + name;
+ return parent + "/" + name;
+}
+
+static void appendFsEntryJson(String &out, bool &first, const String &parent, File &file) {
String path = file.name();
- if (!path.startsWith("/")) path = "/" + path;
+ path = joinedFsPath(parent, path);
if (!first) out += ",";
- out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.size()) + "}";
+ out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.isDirectory() ? 0 : file.size()) + ",\"directory\":" + String(file.isDirectory() ? "true" : "false") + "}";
first = false;
}
-static void appendFilesJson(String &out, bool &first, File &dir) {
+static void appendDirectoryJson(String &out, bool &first, const String &path, File &dir) {
File file = dir.openNextFile();
while (file) {
- if (file.isDirectory()) {
- appendFilesJson(out, first, file);
- } else {
- appendFileJson(out, first, file);
- }
+ appendFsEntryJson(out, first, path, file);
file.close();
file = dir.openNextFile();
}
@@ -129,13 +133,20 @@ static void appendFilesJson(String &out, bool &first, File &dir) {
void handleFilesList() {
if (!authorize()) return;
- File root = LittleFS.open("/", "r");
- if (!root) return sendJson(500, jsonError("Filesystem root not available"));
+ String path = fsPathArg("/");
+ if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path"));
+ File dir = LittleFS.open(path, "r");
+ if (!dir && path != "/") dir = LittleFS.open(path + "/", "r");
+ if (!dir) return sendJson(404, jsonError("Directory not found"));
+ if (!dir.isDirectory()) {
+ dir.close();
+ return sendJson(400, jsonError("Path is not a directory"));
+ }
- String out = "\"files\":[";
+ String out = "\"path\":\"" + jsonEscape(path) + "\",\"files\":[";
bool first = true;
- appendFilesJson(out, first, root);
- root.close();
+ appendDirectoryJson(out, first, path, dir);
+ dir.close();
out += "]";
sendJson(200, jsonOk(out));
}
diff --git a/src/handlers/handlers_custom_api.cpp b/src/handlers/handlers_custom_api.cpp
index 0cc2893..7c87e7c 100644
--- a/src/handlers/handlers_custom_api.cpp
+++ b/src/handlers/handlers_custom_api.cpp
@@ -1,6 +1,10 @@
#include "app.h"
#include "custom_api.h"
+static String uptimeJsonFields() {
+ return "\"uptimeSeconds\":" + String(millis() / 1000);
+}
+
void handlePing() {
if (!authorize()) return;
sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\""));
@@ -22,3 +26,18 @@ void handleLed() {
appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness));
sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness)));
}
+
+void handleUptime() {
+ if (!authorize()) return;
+ sendJson(200, jsonOk(uptimeJsonFields()));
+}
+
+void handleUptimeEvents() {
+ if (!authorize()) return;
+ String payload = "retry: 1000\n";
+ payload += "event: uptime\n";
+ payload += "data: {" + uptimeJsonFields() + "}\n\n";
+ server.sendHeader("Cache-Control", "no-store");
+ server.sendHeader("Connection", "close");
+ server.send(200, "text/event-stream", payload);
+}
diff --git a/src/web/ui.cpp b/src/web/ui.cpp
index 7eb8ce1..dd95acd 100644
--- a/src/web/ui.cpp
+++ b/src/web/ui.cpp
@@ -2,25 +2,46 @@
#include
-static bool serveHtmlFile(const String &path) {
- if (!path.startsWith("/") || path.indexOf("..") >= 0 || !path.endsWith(".html")) return false;
- File file = LittleFS.open(path, "r");
- if (!file) return false;
- server.streamFile(file, "text/html");
+static bool validPublicPath(const String &path) {
+ return path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
+}
+
+static String contentTypeFor(const String &path) {
+ if (path.endsWith(".html")) return "text/html";
+ if (path.endsWith(".css")) return "text/css";
+ if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
+ if (path.endsWith(".json")) return "application/json";
+ if (path.endsWith(".svg")) return "image/svg+xml";
+ if (path.endsWith(".png")) return "image/png";
+ if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
+ if (path.endsWith(".ico")) return "image/x-icon";
+ return "application/octet-stream";
+}
+
+static bool serveWwwFile(const String &path) {
+ if (!validPublicPath(path)) return false;
+ String fsPath = String(WWW_ROOT) + path;
+ File file = LittleFS.open(fsPath, "r");
+ if (!file || file.isDirectory()) {
+ if (file) file.close();
+ return false;
+ }
+ server.sendHeader("Cache-Control", path.endsWith(".html") ? "no-store" : "max-age=3600");
+ server.streamFile(file, contentTypeFor(path));
file.close();
return true;
}
void handleSetupPage() {
- if (!serveHtmlFile("/setup.html")) {
- sendJson(500, jsonError("Missing /setup.html in LittleFS"));
+ if (!serveWwwFile("/setup.html")) {
+ sendJson(500, jsonError("Missing /www/setup.html in LittleFS"));
}
}
bool handleHtmlFileRequest() {
String path = server.uri();
if (path == "/") path = setupMode ? "/setup.html" : "/admin.html";
- return serveHtmlFile(path);
+ return serveWwwFile(path);
}
void redirectToSetupPage() {
@@ -38,11 +59,11 @@ void handleCaptiveProbe() {
}
void handleFavicon() {
- server.send(204, "image/x-icon", "");
+ if (!serveWwwFile("/favicon.ico")) server.send(204, "image/x-icon", "");
}
void handleAdminPage() {
- if (!serveHtmlFile("/admin.html")) {
- sendJson(500, jsonError("Missing /admin.html in LittleFS"));
+ if (!serveWwwFile("/admin.html")) {
+ sendJson(500, jsonError("Missing /www/admin.html in LittleFS"));
}
}