mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
* feat(group-sync): synchronized playback per group (server + Android) [stage 1]
Play a group's shared playlist in lockstep across its displays — start/end items
together — by reusing the video-wall sync primitive with the spatial transform
removed and keyed on group_id instead of wall_id.
Server:
- device_groups += sync_enabled + leader_device_id (optional pin).
- buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a
member whose playlist matches the group's shared playlist (playlist-match guard —
a mismatched member is ignored, never synced). Membership via device_group_members.
- Leader auto-election: pinned-if-online -> first online matching member -> stable
fallback; computed (never persisted, so the operator's pin is preserved).
- group:sync / group:sync-request relay among eligible members (mirrors wall:sync,
guarded on membership + playlist match).
- Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes.
- PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes.
Android:
- WallController is now mode-aware. WALL = transform + object-fit:fill + forced
follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false).
GROUP = same leader/follower timing incl. the full video drift controller, but
full-screen (no transform), normal fit, and per-item mute honored (no forced mute).
- WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest.
- MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook.
Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles;
server suite 407/407.
Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker,
offline-sweep leader promotion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): web + Tizen player parity [stage 2]
Mirror the Android group-sync generalization in the two web-based players so a group
with a shared playlist syncs across mixed player types, not just Android.
Web player (server/player/index.html):
- group:sync / group:sync-request handlers (index jump + latency-compensated video
drift, same maths as wall:sync) — no audio policy here, per-item mute honored.
- emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request).
NO CSS transform, NO forced mute (unlike applyWallMode).
- isFollower now also true for a group follower (suppresses self-advance).
- handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall).
Tizen (tizen/js/player.js WallController + app.js):
- WallController is mode-aware: WALL styles the slice + forced follower semantics
(UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and
drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/
onSync/onSyncRequest key the event + id off the mode.
- app.js: group:sync/request socket handlers; onPlaylist enters group sync on a
group_sync block, else exits — content renders through the normal single-zone path.
Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits.
Wall path is provably unchanged in both (additive group mode). Server suite 407/407;
both players' JS parse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3]
On each group's header row:
- "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized
playback; server re-pushes to members so they enter/exit sync mode. A hint notes
it needs a group playlist and that a display on a different playlist is ignored.
- When on, a "Leader: Auto / <display>" picker -> { leader_device_id } (null = auto-
elect, which self-heals; or pin a specific member to always lead when online).
Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the
existing per-group UI keys; the i18n parity test is apitoken-scoped).
Frontend parses (ESM); server suite 407/407.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(group-sync): rework to clock/schedule sync + double-buffer + polish
Replace the leader/follower relay model with clock/schedule sync. Every
same-playlist member derives the identical (index, position) locally from a
server-disciplined clock + the deterministic playlist schedule, so sync:
- needs no server at play-time (offline-native), and
- has no leader role to double-elect (kills the split-brain class the leaked
WallController tick produced).
Server
- heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock
discipline; the client caches the offset (survives an outage).
- POST /groups/:id/resync -> group:resync (manual "Resync now").
- (kept: group_sync payload; leader machinery is now vestigial/ignored.)
Clients (web / Tizen / Android)
- Clock offset disciplined over the heartbeat, cached (localStorage / prefs).
- Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL
slot formula identical across platforms so mixed-platform groups can't drift.
- Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of
gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge.
- Double buffer: warm the next clip a few s before the boundary -> instant
switch, no black hold. Android pre-decodes on a throwaway surface so the swap
doesn't flash one wrong-aspect (landscape-stretched) frame.
- In-place duration edits: duration_sec dropped from the change signature and
applied in place, so a duration edit re-anchors the schedule WITHOUT a restart.
- Live-log shows discrete corrections (jump/align/seek) immediately; only the
steady-state line is throttled.
Android
- Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the
main looper outlived the Activity -> zombie broadcaster / split-brain).
Dashboard
- Group leader picker -> "Resync now" button.
Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
354 lines
17 KiB
JavaScript
354 lines
17 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
|
// Phase 2.2i: workspace-aware access. Same pattern as devices/content/widgets.
|
|
const { accessContext } = require('../lib/tenancy');
|
|
// #public-api: operational fleet commands (reboot/shutdown/...) need the 'full' token
|
|
// scope. No-op for JWT sessions; for tokens a read/write scope is rejected.
|
|
const { requireScope } = require('../middleware/apiToken');
|
|
|
|
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
|
const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown'];
|
|
|
|
// Phase 2.2i: split read/write access checks. Both attach req.group on success.
|
|
function loadGroupAccessCtx(req, res) {
|
|
const group = db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id);
|
|
if (!group) { res.status(404).json({ error: 'group not found' }); return null; }
|
|
if (!group.workspace_id) { res.status(403).json({ error: 'Group not assigned to a workspace' }); return null; }
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(group.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
return { group, ctx };
|
|
}
|
|
|
|
function requireGroupRead(req, res, next) {
|
|
const access = loadGroupAccessCtx(req, res);
|
|
if (!access) return;
|
|
req.group = access.group;
|
|
next();
|
|
}
|
|
|
|
function requireGroupWrite(req, res, next) {
|
|
const access = loadGroupAccessCtx(req, res);
|
|
if (!access) return;
|
|
if (!access.ctx.actingAs && access.ctx.workspaceRole === 'workspace_viewer') {
|
|
return res.status(403).json({ error: 'Read-only access' });
|
|
}
|
|
req.group = access.group;
|
|
next();
|
|
}
|
|
|
|
// List groups in the caller's current workspace.
|
|
router.get('/', (req, res) => {
|
|
if (!req.workspaceId) return res.json([]);
|
|
const groups = db.prepare(`
|
|
SELECT g.*, COUNT(dgm.device_id) as device_count
|
|
FROM device_groups g
|
|
LEFT JOIN device_group_members dgm ON g.id = dgm.group_id
|
|
WHERE g.workspace_id = ?
|
|
GROUP BY g.id
|
|
ORDER BY g.name ASC
|
|
`).all(req.workspaceId);
|
|
res.json(groups);
|
|
});
|
|
|
|
// Create group in the caller's current workspace.
|
|
router.post('/', (req, res) => {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating groups.' });
|
|
const { name, color } = req.body;
|
|
if (!name) return res.status(400).json({ error: 'name required' });
|
|
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
|
const id = uuidv4();
|
|
db.prepare('INSERT INTO device_groups (id, user_id, workspace_id, name, color) VALUES (?, ?, ?, ?, ?)')
|
|
.run(id, req.user.id, req.workspaceId, name, color || '#3B82F6');
|
|
res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id));
|
|
});
|
|
|
|
// Update group
|
|
router.put('/:id', requireGroupWrite, (req, res) => {
|
|
const { name, color, sync_enabled, leader_device_id } = req.body;
|
|
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
|
|
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id);
|
|
if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ?').run(color, req.params.id);
|
|
// #group-sync: enable synchronized playback + optional pinned leader.
|
|
if (sync_enabled !== undefined) {
|
|
db.prepare('UPDATE device_groups SET sync_enabled = ? WHERE id = ?').run(sync_enabled ? 1 : 0, req.params.id);
|
|
}
|
|
if (leader_device_id !== undefined) {
|
|
if (leader_device_id !== null) {
|
|
const isMember = db.prepare('SELECT 1 FROM device_group_members WHERE group_id = ? AND device_id = ?').get(req.params.id, leader_device_id);
|
|
if (!isMember) return res.status(400).json({ error: 'leader_device_id must be a member of this group' });
|
|
}
|
|
db.prepare('UPDATE device_groups SET leader_device_id = ? WHERE id = ?').run(leader_device_id || null, req.params.id);
|
|
}
|
|
// Re-push to every member so they enter/exit sync mode and refresh their is_leader flag.
|
|
if (sync_enabled !== undefined || leader_device_id !== undefined) {
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
|
for (const m of members) pushPlaylistToDevice(req, m.device_id);
|
|
}
|
|
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
|
|
});
|
|
|
|
// #group-sync: manual "Resync now" — nudge every member to re-snap to the shared schedule
|
|
// immediately. Sync is clock/schedule based (no leader), so this just forces an instant re-align
|
|
// (handy after a content change or if an operator wants to eyeball alignment).
|
|
router.post('/:id/resync', requireGroupWrite, (req, res) => {
|
|
const io = req.app.get('io');
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
|
if (io) {
|
|
const deviceNs = io.of('/device');
|
|
for (const m of members) deviceNs.to(m.device_id).emit('group:resync', { group_id: req.params.id });
|
|
}
|
|
res.json({ ok: true, notified: members.length });
|
|
});
|
|
|
|
// Delete group — converts group schedules to per-device schedules first
|
|
router.delete('/:id', requireGroupWrite, (req, res) => {
|
|
const groupId = req.params.id;
|
|
|
|
const convert = db.transaction(() => {
|
|
// Find group schedules that need conversion
|
|
const groupSchedules = db.prepare('SELECT * FROM schedules WHERE group_id = ?').all(groupId);
|
|
|
|
// Find current group members
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(groupId);
|
|
|
|
let converted = 0;
|
|
|
|
if (groupSchedules.length > 0 && members.length > 0) {
|
|
const insert = db.prepare(`
|
|
INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id,
|
|
widget_id, layout_id, playlist_id, title, start_time, end_time, timezone,
|
|
recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
|
|
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
for (const schedule of groupSchedules) {
|
|
for (const member of members) {
|
|
insert.run(
|
|
uuidv4(), schedule.user_id, member.device_id,
|
|
schedule.zone_id, schedule.content_id, schedule.widget_id,
|
|
schedule.layout_id, schedule.playlist_id, schedule.title,
|
|
schedule.start_time, schedule.end_time, schedule.timezone,
|
|
schedule.recurrence, schedule.recurrence_end, schedule.priority,
|
|
schedule.enabled, schedule.color, schedule.created_at, schedule.updated_at
|
|
);
|
|
}
|
|
converted++;
|
|
}
|
|
}
|
|
|
|
// Delete group schedules explicitly (before group delete turns group_id to NULL via ON DELETE SET NULL)
|
|
db.prepare('DELETE FROM schedules WHERE group_id = ?').run(groupId);
|
|
|
|
// Delete the group (cascades to device_group_members)
|
|
db.prepare('DELETE FROM device_groups WHERE id = ?').run(groupId);
|
|
|
|
return { converted, devices: members.length };
|
|
});
|
|
|
|
const result = convert();
|
|
res.json({ success: true, schedules_converted: result.converted, devices: result.devices });
|
|
});
|
|
|
|
// Get devices in a group
|
|
router.get('/:id/devices', requireGroupRead, (req, res) => {
|
|
const devices = db.prepare(`
|
|
SELECT d.* FROM devices d
|
|
JOIN device_group_members dgm ON d.id = dgm.device_id
|
|
WHERE dgm.group_id = ?
|
|
ORDER BY d.name ASC
|
|
`).all(req.params.id);
|
|
res.json(devices);
|
|
});
|
|
|
|
// Add device to group. If the group has a playlist set (via the assign-playlist
|
|
// dropdown on the dashboard), the new device inherits it — both for drag-drop
|
|
// onto the group section and for the Manage modal's checkboxes, which both
|
|
// hit this endpoint. Without this, joining a group never auto-assigned the
|
|
// group's playlist, leaving the new device on whatever it had before.
|
|
//
|
|
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
|
|
// checked device.user_id == caller; a workspace_admin who happened to own a
|
|
// device in another workspace could add it to a group in this workspace.
|
|
// Now: the device must belong to the same workspace as the group.
|
|
router.post('/:id/devices', requireGroupWrite, (req, res) => {
|
|
const { device_id } = req.body;
|
|
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
|
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(device_id);
|
|
if (!device) return res.status(404).json({ error: 'Device not found' });
|
|
if (device.workspace_id !== req.group.workspace_id) {
|
|
return res.status(403).json({ error: 'Device is not in this group\'s workspace' });
|
|
}
|
|
try {
|
|
db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id);
|
|
|
|
// Sync device's playlist to the group's: a defined playlist is inherited,
|
|
// a group with no playlist clears the device's. The user's mental model
|
|
// is "joining a group means using its playlist (or none)" — staying on a
|
|
// stale playlist after joining a no-playlist group was the bug we just hit.
|
|
const group = db.prepare('SELECT playlist_id FROM device_groups WHERE id = ?').get(req.params.id);
|
|
const newPlaylist = group?.playlist_id || null;
|
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, device_id);
|
|
pushPlaylistToDevice(req, device_id);
|
|
res.status(201).json({ success: true, playlist_id: newPlaylist });
|
|
} catch (e) {
|
|
res.status(400).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Remove device from group. Sync the device's playlist to whatever its
|
|
// current group membership implies — symmetric with the join sync above.
|
|
// - No remaining groups → clear playlist (Ungrouped).
|
|
// - Remaining group with a playlist → adopt that playlist.
|
|
// - Remaining group(s) but none have a playlist → clear playlist.
|
|
// Without this, a device dragged out of a group keeps stale playlist state
|
|
// from the group it just left.
|
|
router.delete('/:id/devices/:deviceId', requireGroupWrite, (req, res) => {
|
|
const deviceId = req.params.deviceId;
|
|
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(deviceId, req.params.id);
|
|
|
|
const remaining = db.prepare(`
|
|
SELECT g.playlist_id FROM device_groups g
|
|
JOIN device_group_members dgm ON g.id = dgm.group_id
|
|
WHERE dgm.device_id = ?
|
|
ORDER BY g.playlist_id IS NULL, g.name ASC
|
|
LIMIT 1
|
|
`).get(deviceId);
|
|
const newPlaylist = remaining?.playlist_id || null;
|
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, deviceId);
|
|
pushPlaylistToDevice(req, deviceId);
|
|
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Ensure a device has a playlist; auto-create one if missing.
|
|
// Phase 2.2i: pre-emptive loop-closer for the future playlists.js migration.
|
|
// The auto-created playlist lives in the same workspace as the device, so
|
|
// once playlists.js scopes by workspace_id this helper's rows remain visible.
|
|
function ensureDevicePlaylist(deviceId, userId) {
|
|
const device = db.prepare('SELECT playlist_id, workspace_id, name FROM devices WHERE id = ?').get(deviceId);
|
|
if (device?.playlist_id) return device.playlist_id;
|
|
const playlistId = uuidv4();
|
|
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, is_auto_generated) VALUES (?, ?, ?, ?, 1)')
|
|
.run(playlistId, userId, device?.workspace_id || null, `${device?.name || 'Display'} playlist`);
|
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId);
|
|
return playlistId;
|
|
}
|
|
|
|
// Mark playlist as draft (called after any item mutation)
|
|
function markDraft(playlistId) {
|
|
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
|
|
}
|
|
|
|
// Push playlist update to a device (used by assign-playlist which doesn't modify items)
|
|
function pushPlaylistToDevice(req, deviceId) {
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (!io) return;
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
const commandQueue = require('../lib/command-queue');
|
|
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), deviceId, buildPlaylistPayload);
|
|
} catch (e) { /* silent */ }
|
|
}
|
|
|
|
// Bulk assign content to all devices in a group (adds to each device's playlist).
|
|
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
|
|
// checked content.user_id == caller; the content could live in any workspace
|
|
// the caller had any reach into. Now: content must live in the group's
|
|
// workspace (or be a platform-template content row, workspace_id IS NULL).
|
|
router.post('/:id/assign-content', requireGroupWrite, (req, res) => {
|
|
const { content_id, duration_sec } = req.body;
|
|
if (!content_id) return res.status(400).json({ error: 'content_id required' });
|
|
|
|
// Verify content lives in the same workspace as the group (or is a
|
|
// platform-template row).
|
|
const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(content_id);
|
|
if (!content) return res.status(404).json({ error: 'Content not found' });
|
|
if (content.workspace_id && content.workspace_id !== req.group.workspace_id) {
|
|
return res.status(403).json({ error: 'Content is not in this group\'s workspace' });
|
|
}
|
|
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
|
|
|
const transaction = db.transaction(() => {
|
|
for (const m of members) {
|
|
const playlistId = ensureDevicePlaylist(m.device_id, req.user.id);
|
|
const max = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 as next FROM playlist_items WHERE playlist_id = ?').get(playlistId);
|
|
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)')
|
|
.run(playlistId, content_id, max.next, duration_sec || 10);
|
|
markDraft(playlistId);
|
|
}
|
|
});
|
|
transaction();
|
|
|
|
res.json({ success: true, devices_updated: members.length });
|
|
});
|
|
|
|
// Assign an existing playlist to all devices in a group, and persist the
|
|
// choice on the group itself so future joiners inherit it (see POST /:id/devices).
|
|
//
|
|
// Phase 2.2i: closes a pre-existing cross-tenant leak. Today the gate only
|
|
// checked playlist.user_id == caller; the playlist could live in any
|
|
// workspace the caller could reach. Now: playlist must live in the group's
|
|
// workspace. Playlists don't currently have a NULL/template path - playlists.js
|
|
// migration is deferred, so this check uses the raw workspace_id column that
|
|
// 2.2i's ensureDevicePlaylist loop-closer also writes to.
|
|
router.post('/:id/assign-playlist', requireGroupWrite, (req, res) => {
|
|
const { playlist_id } = req.body;
|
|
if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' });
|
|
|
|
const playlist = db.prepare('SELECT id, workspace_id FROM playlists WHERE id = ?').get(playlist_id);
|
|
if (!playlist) return res.status(404).json({ error: 'Playlist not found' });
|
|
if (playlist.workspace_id && playlist.workspace_id !== req.group.workspace_id) {
|
|
return res.status(403).json({ error: 'Playlist is not in this group\'s workspace' });
|
|
}
|
|
|
|
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
|
|
|
const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?');
|
|
const transaction = db.transaction(() => {
|
|
db.prepare('UPDATE device_groups SET playlist_id = ? WHERE id = ?').run(playlist_id, req.params.id);
|
|
for (const m of members) stmt.run(playlist_id, m.device_id);
|
|
});
|
|
transaction();
|
|
|
|
for (const m of members) pushPlaylistToDevice(req, m.device_id);
|
|
res.json({ success: true, devices_updated: members.length });
|
|
});
|
|
|
|
// Send command to all devices in a group (reboot/shutdown/screen on/off etc.)
|
|
router.post('/:id/command', requireScope('full'), requireGroupWrite, (req, res) => {
|
|
const { type, payload } = req.body;
|
|
if (!type) return res.status(400).json({ error: 'command type required' });
|
|
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });
|
|
|
|
const devices = db.prepare(`
|
|
SELECT d.id, d.name, d.status FROM devices d
|
|
JOIN device_group_members dgm ON d.id = dgm.device_id
|
|
WHERE dgm.group_id = ?
|
|
`).all(req.params.id);
|
|
|
|
const deviceNs = req.app.get('io').of('/device');
|
|
const results = [];
|
|
|
|
for (const device of devices) {
|
|
const room = deviceNs.adapter.rooms.get(device.id);
|
|
if (room && room.size > 0) {
|
|
deviceNs.to(device.id).emit('device:command', { type, payload: payload || {} });
|
|
results.push({ device_id: device.id, name: device.name, status: 'sent' });
|
|
} else {
|
|
results.push({ device_id: device.id, name: device.name, status: 'offline' });
|
|
}
|
|
}
|
|
|
|
const sent = results.filter(r => r.status === 'sent').length;
|
|
const offline = results.filter(r => r.status === 'offline').length;
|
|
console.log(`Group command '${type}' sent to group '${req.group.name}': ${sent} sent, ${offline} offline`);
|
|
res.json({ success: true, sent, offline, total: devices.length, results });
|
|
});
|
|
|
|
module.exports = router;
|