diff --git a/db/device.json b/db/device.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/db/device.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/controllers/device.py b/src/controllers/device.py new file mode 100644 index 0000000..d292ae2 --- /dev/null +++ b/src/controllers/device.py @@ -0,0 +1,68 @@ +from microdot import Microdot +from models.device import Device +import json + +controller = Microdot() +devices = Device() + + +@controller.get("") +async def list_devices(request): + """List all devices.""" + devices_data = {} + for dev_id in devices.list(): + d = devices.read(dev_id) + if d: + devices_data[dev_id] = d + return json.dumps(devices_data), 200, {"Content-Type": "application/json"} + + +@controller.get("/") +async def get_device(request, id): + """Get a device by ID.""" + dev = devices.read(id) + if dev: + return json.dumps(dev), 200, {"Content-Type": "application/json"} + return json.dumps({"error": "Device not found"}), 404 + + +@controller.post("") +async def create_device(request): + """Create a new device.""" + try: + data = request.json or {} + name = data.get("name", "").strip() + address = data.get("address") + default_pattern = data.get("default_pattern") + tabs = data.get("tabs") + if isinstance(tabs, list): + tabs = [str(t) for t in tabs] + else: + tabs = [] + dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs) + dev = devices.read(dev_id) + return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"} + except Exception as e: + return json.dumps({"error": str(e)}), 400 + + +@controller.put("/") +async def update_device(request, id): + """Update a device.""" + try: + data = request.json or {} + if "tabs" in data and isinstance(data["tabs"], list): + data["tabs"] = [str(t) for t in data["tabs"]] + if devices.update(id, data): + return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"} + return json.dumps({"error": "Device not found"}), 404 + except Exception as e: + return json.dumps({"error": str(e)}), 400 + + +@controller.delete("/") +async def delete_device(request, id): + """Delete a device.""" + if devices.delete(id): + return json.dumps({"message": "Device deleted successfully"}), 200 + return json.dumps({"error": "Device not found"}), 404 diff --git a/src/models/device.py b/src/models/device.py new file mode 100644 index 0000000..d0d2368 --- /dev/null +++ b/src/models/device.py @@ -0,0 +1,54 @@ +from models.model import Model + + +def _normalize_address(addr): + """Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons).""" + if addr is None: + return None + s = str(addr).strip().lower().replace(":", "").replace("-", "") + if len(s) == 12 and all(c in "0123456789abcdef" for c in s): + return s + return None + + +class Device(Model): + def __init__(self): + super().__init__() + + def create(self, name="", address=None, default_pattern=None, tabs=None): + next_id = self.get_next_id() + addr = _normalize_address(address) + self[next_id] = { + "name": name, + "address": addr, + "default_pattern": default_pattern if default_pattern else None, + "tabs": list(tabs) if tabs else [], + } + self.save() + return next_id + + def read(self, id): + id_str = str(id) + return self.get(id_str, None) + + def update(self, id, data): + id_str = str(id) + if id_str not in self: + return False + if "address" in data and data["address"] is not None: + data = dict(data) + data["address"] = _normalize_address(data["address"]) + self[id_str].update(data) + self.save() + return True + + def delete(self, id): + id_str = str(id) + if id_str not in self: + return False + self.pop(id_str) + self.save() + return True + + def list(self): + return list(self.keys()) diff --git a/src/static/devices.js b/src/static/devices.js new file mode 100644 index 0000000..567f420 --- /dev/null +++ b/src/static/devices.js @@ -0,0 +1,251 @@ +// Device management: list, create, edit, delete (name and 6-byte address) + +const HEX_BOX_COUNT = 12; + +function makeHexAddressBoxes(container) { + if (!container || container.querySelector('.hex-addr-box')) return; + container.innerHTML = ''; + for (let i = 0; i < HEX_BOX_COUNT; i++) { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'hex-addr-box'; + input.maxLength = 1; + input.autocomplete = 'off'; + input.setAttribute('data-index', i); + input.setAttribute('inputmode', 'numeric'); + input.setAttribute('aria-label', `Hex digit ${i + 1}`); + input.addEventListener('input', (e) => { + const v = e.target.value.replace(/[^0-9a-fA-F]/g, ''); + e.target.value = v; + if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) { + e.target.nextElementSibling.focus(); + } + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) { + e.target.previousElementSibling.focus(); + } + }); + input.addEventListener('paste', (e) => { + e.preventDefault(); + const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT); + const boxes = container.querySelectorAll('.hex-addr-box'); + for (let j = 0; j < pasted.length && j < boxes.length; j++) { + boxes[j].value = pasted[j]; + } + if (pasted.length > 0) { + const nextIdx = Math.min(pasted.length, boxes.length - 1); + boxes[nextIdx].focus(); + } + }); + container.appendChild(input); + } +} + +function getAddressFromBoxes(container) { + if (!container) return ''; + const boxes = container.querySelectorAll('.hex-addr-box'); + return Array.from(boxes).map((b) => b.value).join('').toLowerCase(); +} + +function setAddressToBoxes(container, addrStr) { + if (!container) return; + const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT); + const boxes = container.querySelectorAll('.hex-addr-box'); + boxes.forEach((b, i) => { + b.value = s[i] || ''; + }); +} + +async function loadDevicesModal() { + const container = document.getElementById('devices-list-modal'); + if (!container) return; + container.innerHTML = 'Loading...'; + try { + const response = await fetch('/devices', { headers: { Accept: 'application/json' } }); + if (!response.ok) throw new Error('Failed to load devices'); + const devices = await response.json(); + renderDevicesList(devices || {}); + } catch (e) { + console.error('loadDevicesModal:', e); + container.innerHTML = 'Failed to load devices.'; + } +} + +function renderDevicesList(devices) { + const container = document.getElementById('devices-list-modal'); + if (!container) return; + container.innerHTML = ''; + const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object'); + if (ids.length === 0) { + const p = document.createElement('p'); + p.className = 'muted-text'; + p.textContent = 'No devices. Create one above.'; + container.appendChild(p); + return; + } + ids.forEach((devId) => { + const dev = devices[devId]; + const row = document.createElement('div'); + row.className = 'profiles-row'; + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '0.5rem'; + row.style.flexWrap = 'wrap'; + + const label = document.createElement('span'); + label.textContent = (dev && dev.name) || devId; + label.style.flex = '1'; + label.style.minWidth = '100px'; + + const meta = document.createElement('span'); + meta.className = 'muted-text'; + meta.style.fontSize = '0.85em'; + const addr = (dev && dev.address) ? dev.address : '—'; + meta.textContent = `Address: ${addr}`; + + const editBtn = document.createElement('button'); + editBtn.className = 'btn btn-secondary btn-small'; + editBtn.textContent = 'Edit'; + editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev)); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-secondary btn-small'; + deleteBtn.textContent = 'Delete'; + deleteBtn.addEventListener('click', async () => { + if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return; + try { + const res = await fetch(`/devices/${devId}`, { method: 'DELETE' }); + if (res.ok) await loadDevicesModal(); + else { + const data = await res.json().catch(() => ({})); + alert(data.error || 'Delete failed'); + } + } catch (err) { + console.error(err); + alert('Delete failed'); + } + }); + + row.appendChild(label); + row.appendChild(meta); + row.appendChild(editBtn); + row.appendChild(deleteBtn); + container.appendChild(row); + }); +} + +function openEditDeviceModal(devId, dev) { + const modal = document.getElementById('edit-device-modal'); + const idInput = document.getElementById('edit-device-id'); + const nameInput = document.getElementById('edit-device-name'); + const addressBoxes = document.getElementById('edit-device-address-boxes'); + if (!modal || !idInput) return; + idInput.value = devId; + if (nameInput) nameInput.value = (dev && dev.name) || ''; + setAddressToBoxes(addressBoxes, (dev && dev.address) || ''); + modal.classList.add('active'); +} + +async function createDevice(name, address) { + try { + const res = await fetch('/devices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, address: address || null }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + await loadDevicesModal(); + return true; + } + alert(data.error || 'Failed to create device'); + return false; + } catch (e) { + console.error('createDevice:', e); + alert('Failed to create device'); + return false; + } +} + +async function updateDevice(devId, name, address) { + try { + const res = await fetch(`/devices/${devId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, address: address || null }), + }); + const data = await res.json().catch(() => ({})); + if (res.ok) { + await loadDevicesModal(); + return true; + } + alert(data.error || 'Failed to update device'); + return false; + } catch (e) { + console.error('updateDevice:', e); + alert('Failed to update device'); + return false; + } +} + +document.addEventListener('DOMContentLoaded', () => { + makeHexAddressBoxes(document.getElementById('new-device-address-boxes')); + makeHexAddressBoxes(document.getElementById('edit-device-address-boxes')); + + const devicesBtn = document.getElementById('devices-btn'); + const devicesModal = document.getElementById('devices-modal'); + const devicesCloseBtn = document.getElementById('devices-close-btn'); + const newName = document.getElementById('new-device-name'); + const createBtn = document.getElementById('create-device-btn'); + const editForm = document.getElementById('edit-device-form'); + const editCloseBtn = document.getElementById('edit-device-close-btn'); + const editDeviceModal = document.getElementById('edit-device-modal'); + + if (devicesBtn && devicesModal) { + devicesBtn.addEventListener('click', () => { + devicesModal.classList.add('active'); + loadDevicesModal(); + }); + } + if (devicesCloseBtn) { + devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active')); + } + const newAddressBoxes = document.getElementById('new-device-address-boxes'); + const doCreate = async () => { + const name = (newName && newName.value.trim()) || ''; + if (!name) { + alert('Device name is required.'); + return; + } + const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : ''; + const ok = await createDevice(name, address); + if (ok && newName) { + newName.value = ''; + setAddressToBoxes(newAddressBoxes, ''); + } + }; + if (createBtn) createBtn.addEventListener('click', doCreate); + if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); }); + + if (editForm) { + editForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const idInput = document.getElementById('edit-device-id'); + const nameInput = document.getElementById('edit-device-name'); + const addressBoxes = document.getElementById('edit-device-address-boxes'); + const devId = idInput && idInput.value; + if (!devId) return; + const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : ''; + const ok = await updateDevice( + devId, + nameInput ? nameInput.value.trim() : '', + address + ); + if (ok) editDeviceModal.classList.remove('active'); + }); + } + if (editCloseBtn) { + editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active')); + } +}); diff --git a/tests/models/run_all.py b/tests/models/run_all.py index 79b80bd..8189c06 100644 --- a/tests/models/run_all.py +++ b/tests/models/run_all.py @@ -12,6 +12,7 @@ from test_group import test_group from test_sequence import test_sequence from test_tab import test_tab from test_palette import test_palette +from test_device import test_device def run_all_tests(): """Run all model tests.""" @@ -27,6 +28,7 @@ def run_all_tests(): ("Sequence", test_sequence), ("Tab", test_tab), ("Palette", test_palette), + ("Device", test_device), ] passed = 0 diff --git a/tests/models/test_device.py b/tests/models/test_device.py new file mode 100644 index 0000000..db54ed7 --- /dev/null +++ b/tests/models/test_device.py @@ -0,0 +1,64 @@ +from models.device import Device +import os + +def test_device(): + """Test Device model CRUD operations.""" + db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db") + device_file = os.path.join(db_dir, "device.json") + if os.path.exists(device_file): + os.remove(device_file) + + devices = Device() + + print("Testing create device") + device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"]) + print(f"Created device with ID: {device_id}") + assert device_id is not None + assert device_id in devices + + print("\nTesting read device") + device = devices.read(device_id) + print(f"Read: {device}") + assert device is not None + assert device["name"] == "Test Device" + assert device["address"] == "aabbccddeeff" + assert device["default_pattern"] == "on" + assert device["tabs"] == ["1", "2"] + + print("\nTesting address normalization") + devices.update(device_id, {"address": "11:22:33:44:55:66"}) + updated = devices.read(device_id) + assert updated["address"] == "112233445566" + + print("\nTesting update device") + update_data = { + "name": "Updated Device", + "default_pattern": "rainbow", + "tabs": ["1", "2", "3"], + } + result = devices.update(device_id, update_data) + assert result is True + updated = devices.read(device_id) + assert updated["name"] == "Updated Device" + assert updated["default_pattern"] == "rainbow" + assert len(updated["tabs"]) == 3 + + print("\nTesting list devices") + device_list = devices.list() + print(f"Device list: {device_list}") + assert device_id in device_list + + print("\nTesting delete device") + deleted = devices.delete(device_id) + assert deleted is True + assert device_id not in devices + + print("\nTesting read after delete") + device = devices.read(device_id) + assert device is None + + print("\nAll device tests passed!") + + +if __name__ == "__main__": + test_device() diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 7a7b6d8..97a279a 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -499,6 +499,7 @@ def test_static_files(client: TestClient) -> bool: '/static/tabs.js', '/static/presets.js', '/static/profiles.js', + '/static/devices.js', ] for file_path in static_files: