diff --git a/__init__.py b/__init__.py index 33c36c9..16db40f 100644 --- a/__init__.py +++ b/__init__.py @@ -8,6 +8,7 @@ from .views import devicetimer_generic_router from .views_api import devicetimer_api_router from .lnurl import devicetimer_lnurl_router +from .websocket import devicetimer_websocket_router scheduled_tasks: list[asyncio.Task] = [] @@ -22,6 +23,7 @@ devicetimer_ext.include_router(devicetimer_generic_router) devicetimer_ext.include_router(devicetimer_api_router) devicetimer_ext.include_router(devicetimer_lnurl_router) +devicetimer_ext.include_router(devicetimer_websocket_router) def devicetimer_stop(): diff --git a/static/js/index.js b/static/js/index.js index 3ad4d1c..d920aff 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -18,16 +18,18 @@ window.app = Vue.createApp({ lnurlValue: '', qrcodeUrl: '', websocketMessage: '', - activeWebsocketDeviceId: null, activeWebsocket: null, + activeWebsocketDeviceId: null, + connectedDevices: [], + statusPollInterval: null, protocol: window.location.protocol, wsLocation: '', stats: { totalDevices: 0, totalSwitches: 0, - activeSwitches: 0, - inactiveSwitches: 0 + connectedDevices: 0, + offlineDevices: 0 }, deviceColumns: [ @@ -123,11 +125,9 @@ window.app = Vue.createApp({ }, filteredDevices() { let devices = this.devices - // Filter by wallet if not "all" if (this.selectedWallet && this.selectedWallet !== 'all') { devices = devices.filter(d => d.wallet === this.selectedWallet) } - // Filter by search term if (this.filter) { const search = this.filter.toLowerCase() devices = devices.filter(d => @@ -151,12 +151,10 @@ window.app = Vue.createApp({ methods: { onWalletChange() { - // Filtering is handled by filteredDevices computed property this.calculateStats() }, calculateStats() { - // Use filtered devices for stats when wallet is selected let devices = this.devices if (this.selectedWallet && this.selectedWallet !== 'all') { devices = this.devices.filter(d => d.wallet === this.selectedWallet) @@ -164,11 +162,42 @@ window.app = Vue.createApp({ this.stats.totalDevices = devices.length this.stats.totalSwitches = devices.reduce((sum, d) => sum + (d.switches?.length || 0), 0) - // Count active/inactive based on WebSocket connection - const activeDeviceId = this.activeWebsocketDeviceId - const connectedDevice = activeDeviceId ? devices.find(d => d.id === activeDeviceId) : null - this.stats.activeSwitches = connectedDevice ? (connectedDevice.switches?.length || 0) : 0 - this.stats.inactiveSwitches = this.stats.totalSwitches - this.stats.activeSwitches + // Count connected/offline based on server-side WebSocket tracking + const connectedIds = this.connectedDevices + const filteredIds = devices.map(d => d.id) + this.stats.connectedDevices = connectedIds.filter(id => filteredIds.includes(id)).length + this.stats.offlineDevices = this.stats.totalDevices - this.stats.connectedDevices + }, + + async fetchConnectionStatus() { + try { + const response = await LNbits.api.request( + 'GET', + '/devicetimer/api/v1/ws/status', + this.g.user.wallets[0].inkey + ) + if (response.data && response.data.connected) { + this.connectedDevices = response.data.connected + this.calculateStats() + } + } catch (err) { + console.warn('Failed to fetch connection status') + } + }, + + startStatusPolling() { + // Poll every 60 seconds + this.fetchConnectionStatus() + this.statusPollInterval = setInterval(() => { + this.fetchConnectionStatus() + }, 60000) + }, + + stopStatusPolling() { + if (this.statusPollInterval) { + clearInterval(this.statusPollInterval) + this.statusPollInterval = null + } }, formatHours(device) { @@ -178,7 +207,6 @@ window.app = Vue.createApp({ async getDevices() { this.loading = true try { - // Always fetch all devices using the first wallet's adminkey const response = await LNbits.api.request( 'GET', '/devicetimer/api/v1/device', @@ -382,7 +410,8 @@ window.app = Vue.createApp({ return } - const websocketUrl = this.wsLocation + '/api/v1/ws/' + deviceId + // Use extension WebSocket endpoint + const websocketUrl = this.wsLocation + '/devicetimer/api/v1/ws/' + deviceId this.websocketMessage = 'Connecting...' this.activeWebsocketDeviceId = deviceId @@ -391,12 +420,14 @@ window.app = Vue.createApp({ this.activeWebsocket = ws ws.onopen = () => { - this.websocketMessage = 'connected' - this.calculateStats() + this.websocketMessage = 'Connected' + this.fetchConnectionStatus() } - ws.onmessage = () => { + ws.onmessage = (event) => { this.websocketMessage = 'Payment received!' + // Refresh status after payment + setTimeout(() => this.fetchConnectionStatus(), 1000) } ws.onclose = () => { @@ -426,7 +457,6 @@ window.app = Vue.createApp({ this.activeWebsocket = null this.activeWebsocketDeviceId = null this.websocketMessage = '' - this.calculateStats() } }, @@ -442,7 +472,8 @@ window.app = Vue.createApp({ }, openWebsocketDialog(device) { - this.websocketDialog.url = this.wsLocation + '/api/v1/ws/' + device.id + // Show extension WebSocket URL for hardware configuration + this.websocketDialog.url = this.wsLocation + '/devicetimer/api/v1/ws/' + device.id this.websocketDialog.deviceTitle = device.title this.websocketDialog.show = true }, @@ -498,8 +529,8 @@ window.app = Vue.createApp({ return amount.toString() }, - isDeviceLive(deviceId) { - return this.activeWebsocketDeviceId === deviceId && this.activeWebsocket !== null + isDeviceConnected(deviceId) { + return this.connectedDevices.includes(deviceId) } }, @@ -508,6 +539,7 @@ window.app = Vue.createApp({ this.wsLocation = (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host await this.getDevices() + this.startStatusPolling() try { const response = await LNbits.api.request('GET', '/devicetimer/api/v1/timezones') @@ -515,5 +547,10 @@ window.app = Vue.createApp({ } catch (err) { console.warn('Failed to load timezones') } + }, + + beforeUnmount() { + this.stopStatusPolling() + this.disconnectWebsocket() } }) diff --git a/tasks.py b/tasks.py index 366b099..2dadd27 100644 --- a/tasks.py +++ b/tasks.py @@ -1,10 +1,11 @@ import asyncio +from loguru import logger from lnbits.core.models import Payment -from lnbits.core.services import websocket_updater from lnbits.tasks import register_invoice_listener from .crud import get_payment, update_payment, get_device +from .websocket import send_to_device async def wait_for_paid_invoices() -> None: @@ -42,6 +43,11 @@ async def on_invoice_paid(payment: Payment) -> None: if not switch: return - await websocket_updater( - device_payment.deviceid, f"{switch.gpio_pin}-{switch.gpio_duration}" - ) + # Send trigger command to hardware via our WebSocket + message = f"{switch.gpio_pin}-{switch.gpio_duration}" + sent = await send_to_device(device_payment.deviceid, message) + + if sent: + logger.info(f"Payment notification sent to device {device_payment.deviceid}: {message}") + else: + logger.warning(f"No active connection for device {device_payment.deviceid}") diff --git a/templates/devicetimer/index.html b/templates/devicetimer/index.html index b09a1be..77ff167 100644 --- a/templates/devicetimer/index.html +++ b/templates/devicetimer/index.html @@ -377,17 +377,17 @@
Connected
-
{% raw %}{{ stats.activeSwitches }}{% endraw %}
+
{% raw %}{{ stats.connectedDevices }}{% endraw %}
Offline
-
{% raw %}{{ stats.inactiveSwitches }}{% endraw %}
+
{% raw %}{{ stats.offlineDevices }}{% endraw %}
- + @@ -531,7 +531,7 @@
{% raw %}{{ props.row.title }}{% endraw %} set of WebSocket connections +# Multiple connections per device are supported (e.g., multiple browser tabs) +_connected_clients: Dict[str, Set[WebSocket]] = {} + + +def get_connected_device_ids() -> list[str]: + """Return list of device IDs with active WebSocket connections.""" + return list(_connected_clients.keys()) + + +def is_device_connected(device_id: str) -> bool: + """Check if a device has any active WebSocket connections.""" + return device_id in _connected_clients and len(_connected_clients[device_id]) > 0 + + +async def send_to_device(device_id: str, message: str) -> bool: + """ + Send a message to all WebSocket connections for a device. + Returns True if message was sent to at least one client. + """ + if device_id not in _connected_clients: + logger.warning(f"No WebSocket connections for device {device_id}") + return False + + sent = False + dead_connections: Set[WebSocket] = set() + + for websocket in _connected_clients[device_id]: + try: + await websocket.send_text(message) + sent = True + except Exception as e: + logger.debug(f"Failed to send to WebSocket: {e}") + dead_connections.add(websocket) + + # Clean up dead connections + for ws in dead_connections: + _connected_clients[device_id].discard(ws) + + # Remove device entry if no connections left + if device_id in _connected_clients and not _connected_clients[device_id]: + del _connected_clients[device_id] + + return sent + + +@devicetimer_websocket_router.websocket("/api/v1/ws/{device_id}") +async def websocket_endpoint(websocket: WebSocket, device_id: str): + """ + WebSocket endpoint for device connections. + Hardware devices connect here to receive payment notifications. + """ + await websocket.accept() + + # Add to tracking + if device_id not in _connected_clients: + _connected_clients[device_id] = set() + _connected_clients[device_id].add(websocket) + + logger.info(f"Device {device_id} connected. Total connections: {len(_connected_clients[device_id])}") + + try: + while True: + # Keep connection alive, wait for messages (ping/pong handled automatically) + data = await websocket.receive_text() + # Hardware might send status updates, we just acknowledge + logger.debug(f"Received from {device_id}: {data}") + except WebSocketDisconnect: + logger.info(f"Device {device_id} disconnected") + except Exception as e: + logger.debug(f"WebSocket error for {device_id}: {e}") + finally: + # Remove from tracking + if device_id in _connected_clients: + _connected_clients[device_id].discard(websocket) + if not _connected_clients[device_id]: + del _connected_clients[device_id] + logger.info(f"Device {device_id} cleaned up. Connected devices: {list(_connected_clients.keys())}") + + +@devicetimer_websocket_router.get("/api/v1/ws/status") +async def get_websocket_status(): + """ + Return list of connected device IDs. + Used by frontend to show real-time connection status. + """ + return {"connected": get_connected_device_ids()}