Update backend models, controllers, and session

This commit is contained in:
2026-01-16 22:31:24 +13:00
parent d41faddfca
commit 9c43a0a22b
16 changed files with 916 additions and 64 deletions

View File

@@ -1,15 +1,253 @@
from microdot import Microdot
from microdot import Microdot, send_file
from microdot.session import with_session
from models.tab import Tab
from models.profile import Profile
import json
import os
import time
controller = Microdot()
tabs = Tab()
profiles = Profile()
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
session_profile = session.get('current_profile')
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
def get_profile_tab_order(profile_id):
"""Get the tab order for a profile."""
if not profile_id:
return []
profile = profiles.read(profile_id)
if profile:
# Support both "tab_order" (old) and "tabs" (new) format
return profile.get("tabs", profile.get("tab_order", []))
return []
def get_current_tab_id(request, session=None):
"""Get the current tab ID from session."""
if session:
current_tab = session.get('current_tab')
if current_tab:
return current_tab
# Fallback to first tab in current profile if no session
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get("tabs", profile.get("tab_order", []))
if tabs_list:
return tabs_list[0]
return None
@controller.get('')
async def list_tabs(request):
"""List all tabs."""
return json.dumps(tabs), 200, {'Content-Type': 'application/json'}
# HTML Fragment endpoints for htmx - must be before /<id> route
@controller.get('/list-fragment')
@with_session
async def tabs_list_fragment(request, session):
"""Return HTML fragment for the tabs list."""
profile_id = get_current_profile_id(session)
# #region agent log
try:
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
_log.write(json.dumps({
"sessionId": "debug-session",
"runId": "tabs-pre-fix",
"hypothesisId": "H1",
"location": "src/controllers/tab.py:tabs_list_fragment",
"message": "tabs list fragment",
"data": {
"profile_id": profile_id,
"profile_count": len(profiles.list())
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except Exception:
pass
# #endregion
if not profile_id:
return '<div class="tabs-list">No profile selected</div>', 200, {'Content-Type': 'text/html'}
tab_order = get_profile_tab_order(profile_id)
current_tab_id = get_current_tab_id(request, session)
html = '<div class="tabs-list">'
for tab_id in tab_order:
tab_data = tabs.read(tab_id)
if tab_data:
active_class = 'active' if str(tab_id) == str(current_tab_id) else ''
tab_name = tab_data.get('name', 'Tab ' + str(tab_id))
html += (
'<button class="tab-button ' + active_class + '" '
'hx-get="/tabs/' + str(tab_id) + '/content-fragment" '
'hx-target="#tab-content" '
'hx-swap="innerHTML" '
'hx-push-url="true" '
'hx-trigger="click" '
'onclick="document.querySelectorAll(\'.tab-button\').forEach(b => b.classList.remove(\'active\')); this.classList.add(\'active\');">'
+ tab_name +
'</button>'
)
html += '</div>'
return html, 200, {'Content-Type': 'text/html'}
@controller.get('/create-form-fragment')
async def create_tab_form_fragment(request):
"""Return the create tab form HTML fragment."""
html = '''
<h2>Add New Tab</h2>
<form hx-post="/tabs"
hx-target="#tabs-list"
hx-swap="innerHTML"
hx-headers='{"Accept": "text/html"}'
hx-on::after-request="if(event.detail.successful) { document.getElementById('add-tab-modal').classList.remove('active'); document.body.dispatchEvent(new Event('tabs-updated')); }">
<label>Tab Name:</label>
<input type="text" name="name" placeholder="Enter tab name" required>
<label>Device IDs (comma-separated):</label>
<input type="text" name="ids" placeholder="1,2,3" value="1">
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Add</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('add-tab-modal').classList.remove('active')">Cancel</button>
</div>
</form>
'''
return html, 200, {'Content-Type': 'text/html'}
@controller.get('/current')
@with_session
async def get_current_tab(request, session):
"""Get the current tab from session."""
current_tab_id = get_current_tab_id(request, session)
if not current_tab_id:
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
if wants_html:
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "No current tab set"}), 404
return await tab_content_fragment.__wrapped__(request, session, current_tab_id)
@controller.get('/<id>/content-fragment')
@with_session
async def tab_content_fragment(request, session, id):
"""Return HTML fragment for tab content."""
# Handle 'current' as a special case
if id == 'current':
return await get_current_tab(request, session)
tab = tabs.read(id)
if not tab:
return '<div>Tab not found</div>', 404, {'Content-Type': 'text/html'}
# Set this tab as the current tab in session
session['current_tab'] = str(id)
session.save()
# If this is a direct page load (not HTMX), return full UI so CSS loads.
if not request.headers.get('HX-Request'):
return send_file('templates/index.html')
tab_name = tab.get('name', 'Tab ' + str(id))
device_ids = ', '.join(tab.get('names', []))
html = (
'<div class="left-panel">'
'<div class="left-panel-header">'
'<div class="ids-display">'
'<label>IDs: </label>'
'<span id="current-ids">' + device_ids + '</span>'
'</div>'
'<button id="toggle-left-panel" class="btn btn-small left-panel-toggle" title="Collapse/expand controls">◀</button>'
'</div>'
'<div class="left-panel-body">'
'<div class="color-palette-section">'
'<h3>Color Palette</h3>'
'<div id="color-palette" class="color-palette" data-tab-id="' + str(id) + '">'
'<!-- Colors will be loaded here -->'
'</div>'
'<div class="palette-actions">'
'<input type="color" id="tab-color-input" value="#ffffff">'
'<button class="btn btn-small" id="tab-color-add-btn">Add Color</button>'
'<button class="btn btn-small" id="tab-color-add-from-palette-btn">Add from Palette</button>'
'</div>'
'</div>'
'<div class="controls-section">'
'<div class="control-group">'
'<label for="brightness-slider">Brightness:</label>'
'<input type="range" id="brightness-slider" min="0" max="255" value="127" class="slider">'
'<span id="brightness-value" class="slider-value">127</span>'
'</div>'
'<div class="control-group">'
'<label for="delay-slider">Delay:</label>'
'<input type="range" id="delay-slider" min="0" max="1000" value="0" class="slider">'
'<span id="delay-value" class="slider-value">100 ms</span>'
'</div>'
'</div>'
'<div class="n-params-section">'
'<h3>N Parameters</h3>'
'<div class="n-params-grid">'
'<div class="n-param-group">'
'<label for="n1-input" id="n1-label">n1:</label>'
'<input type="number" id="n1-input" min="0" max="255" value="10" class="n-input">'
'</div>'
'<div class="n-param-group">'
'<label for="n2-input" id="n2-label">n2:</label>'
'<input type="number" id="n2-input" min="0" max="255" value="10" class="n-input">'
'</div>'
'<div class="n-param-group">'
'<label for="n3-input" id="n3-label">n3:</label>'
'<input type="number" id="n3-input" min="0" max="255" value="10" class="n-input">'
'</div>'
'<div class="n-param-group">'
'<label for="n4-input" id="n4-label">n4:</label>'
'<input type="number" id="n4-input" min="0" max="255" value="10" class="n-input">'
'</div>'
'<div class="n-param-group">'
'<label for="n5-input" id="n5-label">n5:</label>'
'<input type="number" id="n5-input" min="0" max="255" value="10" class="n-input">'
'</div>'
'<div class="n-param-group">'
'<label for="n6-input" id="n6-label">n6:</label>'
'<input type="number" id="n6-input" min="0" max="255" value="10" class="n-input">'
'</div>'
'<div class="n-param-group">'
'<label for="n7-input" id="n7-label">n7:</label>'
'<input type="number" id="n7-input" min="0" max="255" value="10" class="n-input">'
'</div>'
'<div class="n-param-group">'
'<label for="n8-input" id="n8-label">n8:</label>'
'<input type="number" id="n8-input" min="0" max="255" value="10" class="n-input">'
'</div>'
'</div>'
'</div>'
'</div>'
'</div>'
'<div class="right-panel">'
'<div class="presets-section">'
'<h3>Presets</h3>'
'<div id="presets-list-tab" class="presets-list">'
'<!-- Presets will be loaded here -->'
'</div>'
'</div>'
'</div>'
)
return html, 200, {'Content-Type': 'text/html'}
@controller.get('/<id>')
async def get_tab(request, id):
"""Get a specific tab by ID."""
@@ -18,21 +256,6 @@ async def get_tab(request, id):
return json.dumps(tab), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Tab not found"}), 404
@controller.post('')
async def create_tab(request):
"""Create a new tab."""
try:
data = request.json or {}
name = data.get("name", "")
names = data.get("names", None)
preset_ids = data.get("presets", None)
tab_id = tabs.create(name, names, preset_ids)
if data:
tabs.update(tab_id, data)
return json.dumps(tabs.read(tab_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_tab(request, id):
"""Update an existing tab."""
@@ -45,8 +268,147 @@ async def update_tab(request, id):
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_tab(request, id):
@with_session
async def delete_tab(request, id, session):
"""Delete a tab."""
# Check if this is an htmx request (wants HTML fragment)
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
# Handle 'current' tab ID
if id == 'current':
current_tab_id = get_current_tab_id(request, session)
if current_tab_id:
id = current_tab_id
else:
if wants_html:
return '<div class="error">No current tab to delete</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "No current tab to delete"}), 404
if tabs.delete(id):
return json.dumps({"message": "Tab deleted successfully"}), 200
# Remove from profile's tabs
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if id in tabs_list:
tabs_list.remove(id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# Clear session if the deleted tab was the current tab
current_tab_id = get_current_tab_id(request, session)
if current_tab_id == id:
if 'current_tab' in session:
session.pop('current_tab', None)
session.save()
if wants_html:
return await tabs_list_fragment.__wrapped__(request, session)
else:
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
if wants_html:
return '<div class="error">Tab not found</div>', 404, {'Content-Type': 'text/html'}
return json.dumps({"error": "Tab not found"}), 404
@controller.post('')
@with_session
async def create_tab(request, session):
"""Create a new tab."""
# Check if this is an htmx request (wants HTML fragment)
accept_header = request.headers.get('Accept', '')
wants_html = 'text/html' in accept_header
# #region agent log
try:
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
_log.write(json.dumps({
"sessionId": "debug-session",
"runId": "tabs-pre-fix",
"hypothesisId": "H3",
"location": "src/controllers/tab.py:create_tab_htmx",
"message": "create tab with session",
"data": {
"wants_html": wants_html,
"has_form": bool(request.form),
"accept": accept_header
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except Exception:
pass
# #endregion
try:
# Handle form data (htmx) or JSON
if request.form:
name = request.form.get('name', '').strip()
ids_str = request.form.get('ids', '1').strip()
names = [id.strip() for id in ids_str.split(',') if id.strip()]
preset_ids = None
else:
data = request.json or {}
name = data.get("name", "")
names = data.get("names", None)
preset_ids = data.get("presets", None)
if not name:
if wants_html:
return '<div class="error">Tab name cannot be empty</div>', 400, {'Content-Type': 'text/html'}
return json.dumps({"error": "Tab name cannot be empty"}), 400
tab_id = tabs.create(name, names, preset_ids)
# Add to current profile's tabs
profile_id = get_current_profile_id(session)
if profile_id:
profile = profiles.read(profile_id)
if profile:
# Support both "tabs" (new) and "tab_order" (old) format
tabs_list = profile.get('tabs', profile.get('tab_order', []))
if tab_id not in tabs_list:
tabs_list.append(tab_id)
profile['tabs'] = tabs_list
# Remove old tab_order if it exists
if 'tab_order' in profile:
del profile['tab_order']
profiles.update(profile_id, profile)
# #region agent log
try:
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
_log.write(json.dumps({
"sessionId": "debug-session",
"runId": "tabs-pre-fix",
"hypothesisId": "H4",
"location": "src/controllers/tab.py:create_tab_htmx",
"message": "tab created and profile updated",
"data": {
"tab_id": tab_id,
"profile_id": profile_id,
"profile_tabs": tabs_list if profile_id and profile else None
},
"timestamp": int(time.time() * 1000)
}) + "\n")
except Exception:
pass
# #endregion
if wants_html:
# Return HTML fragment for tabs list
return await tabs_list_fragment.__wrapped__(request, session)
else:
# Return JSON response
return json.dumps(tabs.read(tab_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
import sys
sys.print_exception(e)
if wants_html:
return f'<div class="error">Error: {str(e)}</div>', 400, {'Content-Type': 'text/html'}
return json.dumps({"error": str(e)}), 400