Files
lighting-controller/static/app.js
Jimmy 5aa500a7fb Convert app to Flask web application with color pickers
- Created Flask backend with REST API endpoints
- Built HTML/CSS/JavaScript frontend
- Replaced RGB sliders with color pickers for each palette color
- Reorganized layout: color palette on left, patterns on right
- Added persistence for color changes
- Integrated WebSocket client for lighting controller communication
- Added tab management, profile support, and pattern selection
2026-01-04 15:59:19 +13:00

572 lines
21 KiB
JavaScript

// Lighting Controller Web App
class LightingController {
constructor() {
this.currentTab = null;
this.state = {
lights: {},
patterns: {},
tab_order: []
};
this.selectedColorIndex = 0;
this.updateTimeouts = {};
this.init();
}
async init() {
await this.loadState();
this.setupEventListeners();
this.renderTabs();
if (this.state.tab_order.length > 0) {
this.selectTab(this.state.tab_order[0]);
}
}
async loadState() {
try {
const response = await fetch('/api/state');
const data = await response.json();
this.state = data;
} catch (error) {
console.error('Failed to load state:', error);
}
}
setupEventListeners() {
// Tab management
document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal());
document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal());
document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab());
document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
// Modal actions
document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab());
document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal'));
document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab());
document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal'));
// Brightness and delay sliders
document.getElementById('brightness-slider').addEventListener('input', (e) => {
document.getElementById('brightness-value').textContent = e.target.value;
this.debounceUpdate('brightness', () => this.updateBrightness());
});
document.getElementById('delay-slider').addEventListener('input', (e) => {
this.updateDelayValue(e.target.value);
this.debounceUpdate('delay', () => this.updateDelay());
});
// N parameters
for (let i = 1; i <= 4; i++) {
document.getElementById(`n${i}-input`).addEventListener('input', (e) => {
this.debounceUpdate('nparams', () => this.updateNParams());
});
}
// Color palette
document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette());
document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor());
// Close modals on outside click
document.getElementById('add-tab-modal').addEventListener('click', (e) => {
if (e.target.id === 'add-tab-modal') this.hideModal('add-tab-modal');
});
document.getElementById('edit-tab-modal').addEventListener('click', (e) => {
if (e.target.id === 'edit-tab-modal') this.hideModal('edit-tab-modal');
});
}
renderTabs() {
const tabsList = document.getElementById('tabs-list');
tabsList.innerHTML = '';
this.state.tab_order.forEach(tabName => {
const tabButton = document.createElement('button');
tabButton.className = 'tab-button';
tabButton.textContent = tabName;
tabButton.addEventListener('click', () => this.selectTab(tabName));
if (tabName === this.currentTab) {
tabButton.classList.add('active');
}
tabsList.appendChild(tabButton);
});
}
async selectTab(tabName) {
if (!this.state.lights[tabName]) return;
this.currentTab = tabName;
this.renderTabs();
await this.loadTabContent(tabName);
}
async loadTabContent(tabName) {
const light = this.state.lights[tabName];
if (!light) {
return;
}
const settings = light.settings;
const pattern = settings.pattern || 'on';
// Get pattern-specific settings
const patternSettings = this.getPatternSettings(tabName, pattern);
// Update IDs display
document.getElementById('current-ids').textContent = light.names.join(', ');
// Colors are handled by the color palette with individual color pickers
// Update brightness slider
const brightness = settings.brightness || 127;
document.getElementById('brightness-slider').value = brightness;
document.getElementById('brightness-value').textContent = brightness;
// Update delay slider
const patternConfig = this.state.patterns[pattern] || {};
const minDelay = patternConfig.min_delay || 10;
const maxDelay = patternConfig.max_delay || 10000;
const delaySliderPos = this.delayToSlider(patternSettings.delay, minDelay, maxDelay);
document.getElementById('delay-slider').value = delaySliderPos;
this.updateDelayValue(delaySliderPos, minDelay, maxDelay);
// Update n parameters
for (let i = 1; i <= 4; i++) {
document.getElementById(`n${i}-input`).value = patternSettings[`n${i}`] || 10;
}
// Render patterns
this.renderPatterns(tabName, pattern);
// Render color palette
this.renderColorPalette(tabName, colors);
}
renderPatterns(tabName, activePattern) {
const patternsList = document.getElementById('patterns-list');
patternsList.innerHTML = '';
Object.keys(this.state.patterns).forEach(patternName => {
const button = document.createElement('button');
button.className = 'pattern-button';
button.textContent = patternName;
if (patternName === activePattern) {
button.classList.add('active');
}
button.addEventListener('click', () => this.setPattern(tabName, patternName));
patternsList.appendChild(button);
});
}
renderColorPalette(tabName, colors) {
const palette = document.getElementById('color-palette');
if (!palette) {
return;
}
palette.innerHTML = '';
colors.forEach((hexColor, index) => {
const swatch = document.createElement('div');
swatch.className = 'color-swatch';
if (index === this.selectedColorIndex) {
swatch.classList.add('selected');
}
const preview = document.createElement('div');
preview.className = 'color-swatch-preview';
preview.style.backgroundColor = hexColor;
const label = document.createElement('span');
label.className = 'color-swatch-label';
label.textContent = `Color ${index + 1}`;
// Color picker input
const colorPicker = document.createElement('input');
colorPicker.type = 'color';
colorPicker.value = hexColor;
colorPicker.className = 'color-picker-input';
colorPicker.addEventListener('change', (e) => {
const newColor = e.target.value;
this.updateColorInPalette(tabName, index, newColor);
});
swatch.appendChild(preview);
swatch.appendChild(label);
swatch.appendChild(colorPicker);
swatch.addEventListener('click', (e) => {
// Don't trigger selection if clicking on the color picker
if (e.target !== colorPicker && !colorPicker.contains(e.target)) {
this.selectColor(tabName, index, hexColor);
}
});
palette.appendChild(swatch);
});
}
getPatternSettings(tabName, patternName) {
const light = this.state.lights[tabName];
if (!light) return { colors: ['#000000'], delay: 100, n1: 10, n2: 10, n3: 10, n4: 10 };
const lightSettings = light.settings;
if (!lightSettings.patterns) lightSettings.patterns = {};
if (!lightSettings.patterns[patternName]) lightSettings.patterns[patternName] = {};
const patternSettings = lightSettings.patterns[patternName];
return {
colors: patternSettings.colors || ['#000000'],
delay: patternSettings.delay || 100,
n1: patternSettings.n1 || 10,
n2: patternSettings.n2 || 10,
n3: patternSettings.n3 || 10,
n4: patternSettings.n4 || 10
};
}
async setPattern(tabName, patternName) {
const patternSettings = this.getPatternSettings(tabName, patternName);
try {
const response = await fetch('/api/pattern', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tab_name: tabName,
pattern: patternName,
delay: patternSettings.delay,
colors: patternSettings.colors,
n1: patternSettings.n1,
n2: patternSettings.n2,
n3: patternSettings.n3,
n4: patternSettings.n4
})
});
if (response.ok) {
await this.loadState();
await this.loadTabContent(tabName);
}
} catch (error) {
console.error('Failed to set pattern:', error);
}
}
selectColor(tabName, index, hexColor) {
this.selectedColorIndex = index;
if (this.state.lights[tabName]) {
const pattern = this.state.lights[tabName].settings.pattern;
const patternSettings = this.getPatternSettings(tabName, pattern);
this.renderColorPalette(tabName, patternSettings.colors);
}
}
async updateColorInPalette(tabName, index, hexColor) {
if (!this.currentTab || tabName !== this.currentTab) return;
const pattern = this.state.lights[tabName].settings.pattern;
const patternSettings = this.getPatternSettings(tabName, pattern);
patternSettings.colors[index] = hexColor;
// Update the preview
const palette = document.getElementById('color-palette');
if (palette && palette.children[index]) {
const preview = palette.children[index].querySelector('.color-swatch-preview');
if (preview) {
preview.style.backgroundColor = hexColor;
}
}
// Send update to backend to persist changes
const rgb = this.hexToRgb(hexColor);
try {
const response = await fetch('/api/parameters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tab_name: this.currentTab,
red: rgb.r,
green: rgb.g,
blue: rgb.b,
color_index: index
})
});
if (response.ok) {
// Reload state to ensure persistence
await this.loadState();
// Re-render palette to reflect persisted state
const updatedPatternSettings = this.getPatternSettings(tabName, pattern);
this.renderColorPalette(tabName, updatedPatternSettings.colors);
}
} catch (error) {
console.error('Failed to update color:', error);
}
}
async updateBrightness() {
if (!this.currentTab) return;
const brightness = parseInt(document.getElementById('brightness-slider').value) || 0;
try {
await fetch('/api/parameters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tab_name: this.currentTab,
brightness: brightness
})
});
} catch (error) {
console.error('Failed to update brightness:', error);
}
}
async updateDelay() {
if (!this.currentTab) return;
const sliderValue = parseInt(document.getElementById('delay-slider').value);
const pattern = this.state.lights[this.currentTab].settings.pattern;
const patternConfig = this.state.patterns[pattern] || {};
const minDelay = patternConfig.min_delay || 10;
const maxDelay = patternConfig.max_delay || 10000;
try {
await fetch('/api/parameters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tab_name: this.currentTab,
delay_slider: sliderValue
})
});
} catch (error) {
console.error('Failed to update delay:', error);
}
}
async updateNParams() {
if (!this.currentTab) return;
const nParams = {};
for (let i = 1; i <= 4; i++) {
nParams[`n${i}`] = parseInt(document.getElementById(`n${i}-input`).value) || 0;
}
try {
await fetch('/api/parameters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tab_name: this.currentTab,
...nParams
})
});
} catch (error) {
console.error('Failed to update n params:', error);
}
}
debounceUpdate(key, callback) {
if (this.updateTimeouts[key]) {
clearTimeout(this.updateTimeouts[key]);
}
this.updateTimeouts[key] = setTimeout(callback, 100);
}
delayToSlider(delayMs, minDelay = 10, maxDelay = 10000) {
if (delayMs <= minDelay) return 0;
if (delayMs >= maxDelay) return 1000;
if (minDelay === maxDelay) return 0;
return Math.floor(1000 * Math.log(delayMs / minDelay) / Math.log(maxDelay / minDelay));
}
sliderToDelay(sliderValue, minDelay = 10, maxDelay = 10000) {
if (sliderValue <= 0) return minDelay;
if (sliderValue >= 1000) return maxDelay;
if (minDelay === maxDelay) return minDelay;
return Math.floor(minDelay * Math.pow(maxDelay / minDelay, sliderValue / 1000));
}
updateDelayValue(sliderValue, minDelay, maxDelay) {
if (!minDelay || !maxDelay) {
const pattern = this.currentTab ? this.state.lights[this.currentTab]?.settings?.pattern : 'on';
const patternConfig = this.state.patterns[pattern] || {};
minDelay = patternConfig.min_delay || 10;
maxDelay = patternConfig.max_delay || 10000;
}
const delay = this.sliderToDelay(parseInt(sliderValue), minDelay, maxDelay);
document.getElementById('delay-value').textContent = `${delay} ms`;
}
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 0, g: 0, b: 0 };
}
rgbToHex(r, g, b) {
return `#${[r, g, b].map(x => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
}).join('')}`;
}
showAddTabModal() {
document.getElementById('new-tab-name').value = '';
document.getElementById('new-tab-ids').value = '1';
document.getElementById('add-tab-modal').classList.add('active');
}
async createTab() {
const name = document.getElementById('new-tab-name').value.trim();
const idsStr = document.getElementById('new-tab-ids').value.trim();
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
if (!name) {
alert('Tab name cannot be empty');
return;
}
try {
const response = await fetch('/api/tabs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, ids })
});
if (response.ok) {
await this.loadState();
this.renderTabs();
this.selectTab(name);
this.hideModal('add-tab-modal');
} else {
const error = await response.json();
alert(error.error || 'Failed to create tab');
}
} catch (error) {
console.error('Failed to create tab:', error);
alert('Failed to create tab');
}
}
showEditTabModal() {
if (!this.currentTab) {
alert('Please select a tab first');
return;
}
const light = this.state.lights[this.currentTab];
document.getElementById('edit-tab-name').value = this.currentTab;
document.getElementById('edit-tab-ids').value = light.names.join(', ');
document.getElementById('edit-tab-modal').classList.add('active');
}
async updateTab() {
const newName = document.getElementById('edit-tab-name').value.trim();
const idsStr = document.getElementById('edit-tab-ids').value.trim();
const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
if (!newName) {
alert('Tab name cannot be empty');
return;
}
try {
const response = await fetch(`/api/tabs/${this.currentTab}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, ids })
});
if (response.ok) {
await this.loadState();
this.renderTabs();
this.selectTab(newName);
this.hideModal('edit-tab-modal');
} else {
const error = await response.json();
alert(error.error || 'Failed to update tab');
}
} catch (error) {
console.error('Failed to update tab:', error);
alert('Failed to update tab');
}
}
async deleteCurrentTab() {
if (!this.currentTab) {
alert('Please select a tab first');
return;
}
if (!confirm(`Are you sure you want to delete the tab '${this.currentTab}'?`)) {
return;
}
try {
const response = await fetch(`/api/tabs/${this.currentTab}`, {
method: 'DELETE'
});
if (response.ok) {
await this.loadState();
this.renderTabs();
if (this.state.tab_order.length > 0) {
this.selectTab(this.state.tab_order[0]);
} else {
this.currentTab = null;
document.getElementById('tab-content').innerHTML = '<p>No tabs available. Create a new tab to get started.</p>';
}
}
} catch (error) {
console.error('Failed to delete tab:', error);
alert('Failed to delete tab');
}
}
async addColorToPalette() {
if (!this.currentTab) return;
const pattern = this.state.lights[this.currentTab].settings.pattern;
const patternSettings = this.getPatternSettings(this.currentTab, pattern);
patternSettings.colors.push('#000000');
this.selectedColorIndex = patternSettings.colors.length - 1;
this.renderColorPalette(this.currentTab, patternSettings.colors);
}
async removeSelectedColor() {
if (!this.currentTab) return;
const pattern = this.state.lights[this.currentTab].settings.pattern;
const patternSettings = this.getPatternSettings(this.currentTab, pattern);
if (patternSettings.colors.length <= 1) {
alert('There must be at least one color in the palette');
return;
}
patternSettings.colors.splice(this.selectedColorIndex, 1);
if (this.selectedColorIndex >= patternSettings.colors.length) {
this.selectedColorIndex = patternSettings.colors.length - 1;
}
this.renderColorPalette(this.currentTab, patternSettings.colors);
}
showProfiles() {
alert('Profiles feature coming soon');
}
hideModal(modalId) {
document.getElementById(modalId).classList.remove('active');
}
}
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
new LightingController();
});