feat: device model, API, static UI, and endpoint tests
Made-with: Cursor
This commit is contained in:
1
db/device.json
Normal file
1
db/device.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
68
src/controllers/device.py
Normal file
68
src/controllers/device.py
Normal file
@@ -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("/<id>")
|
||||||
|
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("/<id>")
|
||||||
|
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("/<id>")
|
||||||
|
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
|
||||||
54
src/models/device.py
Normal file
54
src/models/device.py
Normal file
@@ -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())
|
||||||
251
src/static/devices.js
Normal file
251
src/static/devices.js
Normal file
@@ -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 = '<span class="muted-text">Loading...</span>';
|
||||||
|
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 = '<span class="muted-text">Failed to load devices.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ from test_group import test_group
|
|||||||
from test_sequence import test_sequence
|
from test_sequence import test_sequence
|
||||||
from test_tab import test_tab
|
from test_tab import test_tab
|
||||||
from test_palette import test_palette
|
from test_palette import test_palette
|
||||||
|
from test_device import test_device
|
||||||
|
|
||||||
def run_all_tests():
|
def run_all_tests():
|
||||||
"""Run all model tests."""
|
"""Run all model tests."""
|
||||||
@@ -27,6 +28,7 @@ def run_all_tests():
|
|||||||
("Sequence", test_sequence),
|
("Sequence", test_sequence),
|
||||||
("Tab", test_tab),
|
("Tab", test_tab),
|
||||||
("Palette", test_palette),
|
("Palette", test_palette),
|
||||||
|
("Device", test_device),
|
||||||
]
|
]
|
||||||
|
|
||||||
passed = 0
|
passed = 0
|
||||||
|
|||||||
64
tests/models/test_device.py
Normal file
64
tests/models/test_device.py
Normal file
@@ -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()
|
||||||
@@ -499,6 +499,7 @@ def test_static_files(client: TestClient) -> bool:
|
|||||||
'/static/tabs.js',
|
'/static/tabs.js',
|
||||||
'/static/presets.js',
|
'/static/presets.js',
|
||||||
'/static/profiles.js',
|
'/static/profiles.js',
|
||||||
|
'/static/devices.js',
|
||||||
]
|
]
|
||||||
|
|
||||||
for file_path in static_files:
|
for file_path in static_files:
|
||||||
|
|||||||
Reference in New Issue
Block a user