dimming and fix for manual add
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s

This commit is contained in:
2026-03-30 22:16:17 -04:00
parent 70c98af759
commit da2693ae68
5 changed files with 342 additions and 75 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
- name: Redeploy stack in Portainer - name: Redeploy stack in Portainer
run: | run: |
# Read stack file content # Read stack file content
STACK_FILE_CONTENT=$(echo "$(<docker-compose.prod.yml )") STACK_FILE_CONTENT=$(echo "$(<web-compose.yml )")
# Read existing environment variables from the fetched stack # Read existing environment variables from the fetched stack
ENV_VARS=$(cat stack_env.json) ENV_VARS=$(cat stack_env.json)
+192 -63
View File
@@ -134,6 +134,62 @@
.icon-btn:hover { border-color: var(--accent); color: var(--accent); } .icon-btn:hover { border-color: var(--accent); color: var(--accent); }
.icon-btn.del:hover { border-color: var(--danger); color: var(--danger); } .icon-btn.del:hover { border-color: var(--danger); color: var(--danger); }
/* ── Brightness Slider ── */
.brightness-control {
background: var(--bg);
border-radius: 8px;
padding: 8px;
border: 1px solid var(--border);
}
.brightness-slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--off) 0%, var(--on) 100%);
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.brightness-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--card);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: all .15s;
}
.brightness-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0,0,0,0.3);
}
.brightness-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--card);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: all .15s;
}
.brightness-slider::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0,0,0,0.3);
}
.brightness-slider:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.brightness-value {
font-weight: 600;
color: var(--accent);
}
/* ── Modal ── */ /* ── Modal ── */
.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); .modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55);
z-index: 200; align-items: flex-end; justify-content: center; } z-index: 200; align-items: flex-end; justify-content: center; }
@@ -196,8 +252,9 @@
<div id="page-devices" class="page active"> <div id="page-devices" class="page active">
<div class="toolbar"> <div class="toolbar">
<h2>Devices</h2> <h2>Devices</h2>
<div style="flex:1"></div>
<button class="btn btn-ghost btn-sm" onclick="openAddDeviceModal()"> Add IP</button>
<button class="btn btn-primary" id="btn-discover" onclick="discoverDevices()">⟳ Scan</button> <button class="btn btn-primary" id="btn-discover" onclick="discoverDevices()">⟳ Scan</button>
<button class="btn btn-secondary btn-sm" id="btn-manual-add" onclick="openManualModal()" title="Add device manually" style="width:auto;padding:9px 12px;"></button>
</div> </div>
<div id="page-header-devices" class="page-header"> <div id="page-header-devices" class="page-header">
<span class="ws-dot" id="ws-dot-d"></span> <span class="ws-dot" id="ws-dot-d"></span>
@@ -258,7 +315,10 @@
<table style="font-size:12px;color:var(--text2);width:100%;border-collapse:collapse;line-height:1.8;"> <table style="font-size:12px;color:var(--text2);width:100%;border-collapse:collapse;line-height:1.8;">
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices</td><td>List devices</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices</td><td>List devices</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/discover</td><td>Scan network</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/discover</td><td>Scan network</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/add</td><td>Add device manually <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"host":"192.168.1.100","port":49153}</code></td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/:ip/:port/state</td><td>Toggle power <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"on":true}</code></td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/:ip/:port/state</td><td>Toggle power <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"on":true}</code></td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices/:ip/:port/brightness</td><td>Get brightness (dimmer only)</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/:ip/:port/brightness</td><td>Set brightness <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"brightness":50}</code></td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/dwm-rules</td><td>List DWM rules</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/dwm-rules</td><td>List DWM rules</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/dwm-rules</td><td>Create rule</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/dwm-rules</td><td>Create rule</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">PUT /api/dwm-rules/:id</td><td>Update rule</td></tr> <tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">PUT /api/dwm-rules/:id</td><td>Update rule</td></tr>
@@ -437,25 +497,30 @@
</div> </div>
</div> </div>
<!-- ── Manual Add Modal ── --> <!-- ── Add Device Modal ── -->
<div class="modal-backdrop" id="modal-manual" onclick="closeManualModal(event)"> <div class="modal-backdrop" id="modal-add-device" onclick="closeAddDeviceModal(event)">
<div class="modal" onclick="event.stopPropagation()"> <div class="modal" onclick="event.stopPropagation()">
<div class="modal-header"> <div class="modal-header">
<span class="modal-title">Add Device Manually</span> <span class="modal-title">Add Wemo Device Manually</span>
<button class="modal-close" onclick="closeManualModal()"></button> <button class="modal-close" onclick="closeAddDeviceModal()">×</button>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">IP Address</label> <label class="form-label">IP Address</label>
<input class="form-input" id="f-manual-host" type="text" placeholder="192.168.1.100" autofocus /> <input class="form-input" id="add-device-host" type="text" placeholder="192.168.1.100" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Port</label> <label class="form-label">Port (optional)</label>
<input class="form-input" id="f-manual-port" type="number" placeholder="49153" value="49153" /> <input class="form-input" id="add-device-port" type="number" placeholder="49153" min="1" max="65535" />
<div style="font-size:12px;color:var(--text2);margin-top:4px;">Default: 49153 (try 49152-49156 if needed)</div>
</div> </div>
<div id="modal-manual-error" style="display:none;color:var(--danger);font-size:13px;margin-bottom:10px;"></div>
<div id="add-device-error" style="display:none;color:var(--danger);font-size:13px;margin-bottom:10px;"></div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-ghost" onclick="closeManualModal()">Cancel</button> <button class="btn btn-ghost" onclick="closeAddDeviceModal()">Cancel</button>
<button class="btn btn-primary" id="btn-add-manual" onclick="addManualDevice()">Add</button> <button class="btn btn-primary" id="btn-add-device" onclick="addDeviceManually()">Add Device</button>
</div> </div>
</div> </div>
</div> </div>
@@ -585,14 +650,29 @@ function renderDevices() {
} }
el.innerHTML = devices.map((d, i) => { el.innerHTML = devices.map((d, i) => {
const name = d.friendlyName || d.name || d.host; const name = d.friendlyName || d.name || d.host;
const isDimmer = d.isDimmer || false;
const icon = isDimmer ? '🔆' : '💡';
return ` return `
<div class="card" id="dev-${i}"> <div class="card" id="dev-${i}">
<div style="font-size:24px;flex-shrink:0">💡</div> <div style="font-size:24px;flex-shrink:0">${icon}</div>
<div class="card-body"> <div class="card-body">
<div class="card-name">${esc(name)}</div> <div class="card-name">${esc(name)}</div>
<div class="card-meta">${esc(d.host)}:${d.port} <div class="card-meta">${esc(d.host)}:${d.port}
${d.productModel ? ' · ' + esc(d.productModel) : ''} ${d.productModel ? ' · ' + esc(d.productModel) : ''}
${isDimmer ? ' · Dimmer' : ''}
</div> </div>
${isDimmer ? `
<div class="brightness-control" style="margin-top:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:var(--text2);">🔅</span>
<input type="range" class="brightness-slider" id="bright-${i}"
min="0" max="100" value="50"
oninput="updateBrightnessPreview(${i}, this.value)"
onchange="setBrightness(${i}, this.value)">
<span class="brightness-value" id="bright-val-${i}" style="font-size:12px;color:var(--text2);min-width:30px;">50%</span>
</div>
</div>
` : ''}
</div> </div>
<label class="toggle" id="dtog-${i}" onclick="toggleDevice(${i},event)"> <label class="toggle" id="dtog-${i}" onclick="toggleDevice(${i},event)">
<input type="checkbox" id="dchk-${i}"> <input type="checkbox" id="dchk-${i}">
@@ -601,11 +681,29 @@ function renderDevices() {
</label> </label>
</div>`; </div>`;
}).join(''); }).join('');
// fetch current state for each
// fetch current state for each device
devices.forEach((d, i) => { devices.forEach((d, i) => {
api('GET', `/api/devices/${d.host}/${d.port}/state`) api('GET', `/api/devices/${d.host}/${d.port}/state`)
.then((on) => { const c = document.getElementById('dchk-'+i); if (c) c.checked = !!on; }) .then((on) => {
const c = document.getElementById('dchk-'+i);
if (c) c.checked = !!on;
})
.catch(() => {}); .catch(() => {});
// fetch brightness for dimmer devices
if (d.isDimmer) {
api('GET', `/api/devices/${d.host}/${d.port}/brightness`)
.then((data) => {
const slider = document.getElementById('bright-'+i);
const value = document.getElementById('bright-val-'+i);
if (slider && value && data.brightness !== undefined) {
slider.value = data.brightness;
value.textContent = Math.round(data.brightness) + '%';
}
})
.catch(() => {});
}
}); });
} }
@@ -627,64 +725,34 @@ async function toggleDevice(i, e) {
} }
} }
// ── Manual Add ───────────────────────────────────────────────────────────── // ── Brightness control ───────────────────────────────────────────────────────
function openManualModal() { function updateBrightnessPreview(i, value) {
document.getElementById('f-manual-host').value = ''; const valueEl = document.getElementById('bright-val-' + i);
document.getElementById('f-manual-port').value = '49153'; if (valueEl) {
document.getElementById('modal-manual-error').style.display = 'none'; valueEl.textContent = Math.round(value) + '%';
document.getElementById('modal-manual').classList.add('open');
setTimeout(() => document.getElementById('f-manual-host').focus(), 100);
}
function closeManualModal(e) {
if (e && e.target !== document.getElementById('modal-manual')) return;
document.getElementById('modal-manual').classList.remove('open');
}
async function addManualDevice() {
const errEl = document.getElementById('modal-manual-error');
errEl.style.display = 'none';
const host = document.getElementById('f-manual-host').value.trim();
const port = parseInt(document.getElementById('f-manual-port').value, 10) || 49153;
if (!host) {
errEl.textContent = '⚠ Enter an IP address';
errEl.style.display = 'block';
return;
} }
}
const btn = document.getElementById('btn-add-manual'); async function setBrightness(i, value) {
btn.disabled = true; btn.textContent = 'Adding…'; const dev = devices[i];
const slider = document.getElementById('bright-' + i);
if (!dev || !slider) return;
slider.disabled = true;
try { try {
const result = await api('POST', '/api/devices/discover', { await api('POST', `/api/devices/${dev.host}/${dev.port}/brightness`, { brightness: Number(value) });
manualEntries: [{ host, port }]
});
if (!result || result.length === 0) { // Update power toggle state based on brightness
errEl.textContent = '⚠ No device found at that address'; const chk = document.getElementById('dchk-' + i);
errEl.style.display = 'block'; if (chk) {
btn.disabled = false; btn.textContent = 'Add'; chk.checked = Number(value) > 0;
return;
} }
const device = result.find((d) => d.host === host); toast(`Brightness set to ${Math.round(value)}%`, 'success');
if (device) {
const friendlyName = device.friendlyName || device.name || host;
toast(`Added ${friendlyName}`, 'success');
devices = result;
renderDevices();
closeManualModal();
} else {
errEl.textContent = '⚠ Device added but not found in results';
errEl.style.display = 'block';
}
} catch (err) { } catch (err) {
errEl.textContent = `${err.message}`; toast(`Failed to set brightness: ${err.message}`, 'error');
errEl.style.display = 'block';
} finally { } finally {
btn.disabled = false; btn.textContent = 'Add'; slider.disabled = false;
} }
} }
@@ -927,6 +995,67 @@ function closeRuleModal(e) {
document.getElementById('modal-rule').classList.remove('open'); document.getElementById('modal-rule').classList.remove('open');
} }
// ── Add Device Modal ───────────────────────────────────────────────────────
function openAddDeviceModal() {
document.getElementById('modal-add-device').classList.add('open');
document.getElementById('add-device-error').style.display = 'none';
document.getElementById('add-device-host').value = '';
document.getElementById('add-device-port').value = '';
setTimeout(() => document.getElementById('add-device-host').focus(), 100);
}
function closeAddDeviceModal(e) {
if (e && e.target !== document.getElementById('modal-add-device')) return;
document.getElementById('modal-add-device').classList.remove('open');
}
async function addDeviceManually() {
const errEl = document.getElementById('add-device-error');
const btn = document.getElementById('btn-add-device');
errEl.style.display = 'none';
const host = document.getElementById('add-device-host').value.trim();
const port = document.getElementById('add-device-port').value.trim();
if (!host) {
errEl.textContent = 'IP address is required';
errEl.style.display = 'block';
return;
}
// Basic IP validation
const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!ipPattern.test(host)) {
errEl.textContent = 'Please enter a valid IP address';
errEl.style.display = 'block';
return;
}
const portNum = port ? parseInt(port, 10) : 49153;
if (portNum < 1 || portNum > 65535) {
errEl.textContent = 'Port must be between 1 and 65535';
errEl.style.display = 'block';
return;
}
btn.disabled = true;
btn.textContent = 'Adding…';
try {
const device = await api('POST', '/api/devices/add', { host, port: portNum });
toast(`Successfully added ${device.friendlyName || device.host}`, 'success');
closeAddDeviceModal();
await loadDevices(); // Refresh device list
} catch (err) {
errEl.textContent = err.message || 'Failed to add device';
errEl.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Add Device';
}
}
async function saveRule() { async function saveRule() {
const errEl = document.getElementById('modal-rule-error'); const errEl = document.getElementById('modal-rule-error');
errEl.style.display = 'none'; errEl.style.display = 'none';
+29
View File
@@ -1,3 +1,5 @@
version: '3.8'
services: services:
dibbly-web: dibbly-web:
image: reg.dev.nervesocket.com/dibbly:latest image: reg.dev.nervesocket.com/dibbly:latest
@@ -12,6 +14,11 @@ services:
- DATA_DIR=/data - DATA_DIR=/data
- PORT=3456 - PORT=3456
- NODE_ENV=production - NODE_ENV=production
networks:
- dibbly-network
# Use host networking on Linux for Wemo SSDP discovery
# Uncomment the line below if running on Linux
# network_mode: host
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3456/"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3456/"]
interval: 30s interval: 30s
@@ -30,8 +37,30 @@ services:
reservations: reservations:
memory: 128M memory: 128M
# Optional: Reverse proxy for SSL termination
nginx:
image: nginx:alpine
container_name: dibbly-nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
networks:
- dibbly-network
depends_on:
- dibbly-web
profiles:
- with-nginx
volumes: volumes:
dibbly-data: dibbly-data:
driver: local driver: local
dibbly-logs: dibbly-logs:
driver: local driver: local
networks:
dibbly-network:
driver: bridge
+51 -9
View File
@@ -88,19 +88,46 @@ async function handleRequest(req, res) {
// ── Devices ──────────────────────────────────────────────────────────── // ── Devices ────────────────────────────────────────────────────────────
if (url === '/api/devices' && method === 'GET') { if (url === '/api/devices' && method === 'GET') {
return json(res, store.getDevices()); const devices = store.getDevices();
// Add dimmer detection to each device
const devicesWithDimmerInfo = devices.map(device => ({
...device,
isDimmer: wemo.isDimmerDevice ? wemo.isDimmerDevice(device) : false
}));
return json(res, devicesWithDimmerInfo);
} }
if (url === '/api/devices/discover' && method === 'POST') { if (url === '/api/devices/discover' && method === 'POST') {
const saved = store.getDevices(); const saved = store.getDevices();
const manual = saved.map((d) => ({ host: d.host, port: d.port })); const manual = saved.map((d) => ({ host: d.host, port: d.port }));
// Add any manual entries from the request body const devices = await wemo.discoverDevices(8000, manual);
if (body.manualEntries && Array.isArray(body.manualEntries)) { store.saveDevices(devices);
manual.push(...body.manualEntries); return json(res, devices);
}
if (url === '/api/devices/add' && method === 'POST') {
const { host, port } = body;
if (!host) {
return jsonErr(res, 'Host is required', 400);
}
const devicePort = port ? parseInt(port, 10) : 49153;
const manualEntry = { host, port: devicePort };
try {
// Try to discover this specific device
const devices = await wemo.discoverDevices(5000, [manualEntry]);
if (devices.length > 0) {
// Add to existing devices
const allDevices = [...store.getDevices(), ...devices];
store.saveDevices(allDevices);
return json(res, devices[0], 201);
} else {
return jsonErr(res, 'No Wemo device found at this address', 404);
}
} catch (err) {
return jsonErr(res, `Failed to connect: ${err.message}`, 500);
} }
const devs = await wemo.discoverDevices(8000, manual);
store.saveDevices(devs);
return json(res, devs);
} }
const stateMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/state$/); const stateMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/state$/);
@@ -116,6 +143,21 @@ async function handleRequest(req, res) {
} }
} }
// ── Brightness control ───────────────────────────────────────────────────
const brightnessMatch = url.match(/^\/api\/devices\/([^/]+)\/(\d+)\/brightness$/);
if (brightnessMatch) {
const [, host, port] = brightnessMatch;
if (method === 'GET') {
const brightness = await wemo.getBrightness(host, Number(port));
return json(res, { brightness });
}
if (method === 'POST') {
await wemo.setBrightness(host, Number(port), body.brightness);
return json(res, { ok: true });
}
}
// ── DWM Rules ────────────────────────────────────────────────────────── // ── DWM Rules ──────────────────────────────────────────────────────────
if (url === '/api/dwm-rules') { if (url === '/api/dwm-rules') {
@@ -122,6 +122,70 @@ async function setBinaryState(host, port, on) {
await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', { BinaryState: on ? '1' : '0' }); await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', { BinaryState: on ? '1' : '0' });
} }
// ---------------------------------------------------------------------------
// Dimmer control
// ---------------------------------------------------------------------------
async function getBrightness(host, port) {
try {
const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetBinaryState');
const raw = String(res['BinaryState'] ?? '0');
// For dimmers, BinaryState contains brightness info in format: "1|brightness|..."
// Example: "1|50|0" where 50 is the brightness level (0-100)
if (raw.includes('|')) {
const parts = raw.split('|');
if (parts.length >= 2) {
const brightness = parseInt(parts[1], 10);
return !isNaN(brightness) ? brightness : null;
}
}
// If device is off, return 0
return raw === '1' || raw === '8' ? 100 : 0;
} catch (err) {
// Fallback for non-dimmer devices
const isOn = await getBinaryState(host, port);
return isOn ? 100 : 0;
}
}
async function setBrightness(host, port, brightness) {
// Brightness should be 0-100
const level = Math.max(0, Math.min(100, Math.round(brightness)));
if (level === 0) {
// Turn off the device
await setBinaryState(host, port, false);
} else {
// For dimmers, use the brightness format: "brightness|0"
// For non-dimmers, just turn on
try {
await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', {
BinaryState: `${level}|0`
});
} catch (err) {
// Fallback for non-dimmer devices - just turn on
await setBinaryState(host, port, true);
}
}
}
function isDimmerDevice(deviceInfo) {
if (!deviceInfo) return false;
const { productModel, modelDescription, udn } = deviceInfo;
// Check various indicators that this is a dimmer
return (
(productModel && productModel.toLowerCase().includes('dimmer')) ||
(modelDescription && modelDescription.toLowerCase().includes('dimmer')) ||
(udn && udn.toLowerCase().includes('dimmer')) ||
(productModel && productModel.includes('WDS060')) ||
(modelDescription && modelDescription.includes('Dimmer'))
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Device info // Device info
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -478,6 +542,9 @@ function _insertNewRule(db, ruleId, ruleData) {
module.exports = { module.exports = {
getBinaryState, getBinaryState,
setBinaryState, setBinaryState,
getBrightness,
setBrightness,
isDimmerDevice,
getDeviceInfo, getDeviceInfo,
discoverDevices, discoverDevices,
fetchRules, fetchRules,