Skip to content

Commit 87c5cb8

Browse files
authored
Merge pull request #8 from DoktorShift/websockets_extension
own websockets - own connection tracking
2 parents 850787e + 6de993d commit 87c5cb8

File tree

5 files changed

+174
-29
lines changed

5 files changed

+174
-29
lines changed

__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .views import devicetimer_generic_router
99
from .views_api import devicetimer_api_router
1010
from .lnurl import devicetimer_lnurl_router
11+
from .websocket import devicetimer_websocket_router
1112

1213
scheduled_tasks: list[asyncio.Task] = []
1314

@@ -22,6 +23,7 @@
2223
devicetimer_ext.include_router(devicetimer_generic_router)
2324
devicetimer_ext.include_router(devicetimer_api_router)
2425
devicetimer_ext.include_router(devicetimer_lnurl_router)
26+
devicetimer_ext.include_router(devicetimer_websocket_router)
2527

2628

2729
def devicetimer_stop():

static/js/index.js

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,18 @@ window.app = Vue.createApp({
1818
lnurlValue: '',
1919
qrcodeUrl: '',
2020
websocketMessage: '',
21-
activeWebsocketDeviceId: null,
2221
activeWebsocket: null,
22+
activeWebsocketDeviceId: null,
23+
connectedDevices: [],
24+
statusPollInterval: null,
2325
protocol: window.location.protocol,
2426
wsLocation: '',
2527

2628
stats: {
2729
totalDevices: 0,
2830
totalSwitches: 0,
29-
activeSwitches: 0,
30-
inactiveSwitches: 0
31+
connectedDevices: 0,
32+
offlineDevices: 0
3133
},
3234

3335
deviceColumns: [
@@ -123,11 +125,9 @@ window.app = Vue.createApp({
123125
},
124126
filteredDevices() {
125127
let devices = this.devices
126-
// Filter by wallet if not "all"
127128
if (this.selectedWallet && this.selectedWallet !== 'all') {
128129
devices = devices.filter(d => d.wallet === this.selectedWallet)
129130
}
130-
// Filter by search term
131131
if (this.filter) {
132132
const search = this.filter.toLowerCase()
133133
devices = devices.filter(d =>
@@ -151,24 +151,53 @@ window.app = Vue.createApp({
151151

152152
methods: {
153153
onWalletChange() {
154-
// Filtering is handled by filteredDevices computed property
155154
this.calculateStats()
156155
},
157156

158157
calculateStats() {
159-
// Use filtered devices for stats when wallet is selected
160158
let devices = this.devices
161159
if (this.selectedWallet && this.selectedWallet !== 'all') {
162160
devices = this.devices.filter(d => d.wallet === this.selectedWallet)
163161
}
164162
this.stats.totalDevices = devices.length
165163
this.stats.totalSwitches = devices.reduce((sum, d) => sum + (d.switches?.length || 0), 0)
166164

167-
// Count active/inactive based on WebSocket connection
168-
const activeDeviceId = this.activeWebsocketDeviceId
169-
const connectedDevice = activeDeviceId ? devices.find(d => d.id === activeDeviceId) : null
170-
this.stats.activeSwitches = connectedDevice ? (connectedDevice.switches?.length || 0) : 0
171-
this.stats.inactiveSwitches = this.stats.totalSwitches - this.stats.activeSwitches
165+
// Count connected/offline based on server-side WebSocket tracking
166+
const connectedIds = this.connectedDevices
167+
const filteredIds = devices.map(d => d.id)
168+
this.stats.connectedDevices = connectedIds.filter(id => filteredIds.includes(id)).length
169+
this.stats.offlineDevices = this.stats.totalDevices - this.stats.connectedDevices
170+
},
171+
172+
async fetchConnectionStatus() {
173+
try {
174+
const response = await LNbits.api.request(
175+
'GET',
176+
'/devicetimer/api/v1/ws/status',
177+
this.g.user.wallets[0].inkey
178+
)
179+
if (response.data && response.data.connected) {
180+
this.connectedDevices = response.data.connected
181+
this.calculateStats()
182+
}
183+
} catch (err) {
184+
console.warn('Failed to fetch connection status')
185+
}
186+
},
187+
188+
startStatusPolling() {
189+
// Poll every 60 seconds
190+
this.fetchConnectionStatus()
191+
this.statusPollInterval = setInterval(() => {
192+
this.fetchConnectionStatus()
193+
}, 60000)
194+
},
195+
196+
stopStatusPolling() {
197+
if (this.statusPollInterval) {
198+
clearInterval(this.statusPollInterval)
199+
this.statusPollInterval = null
200+
}
172201
},
173202

174203
formatHours(device) {
@@ -178,7 +207,6 @@ window.app = Vue.createApp({
178207
async getDevices() {
179208
this.loading = true
180209
try {
181-
// Always fetch all devices using the first wallet's adminkey
182210
const response = await LNbits.api.request(
183211
'GET',
184212
'/devicetimer/api/v1/device',
@@ -382,7 +410,8 @@ window.app = Vue.createApp({
382410
return
383411
}
384412

385-
const websocketUrl = this.wsLocation + '/api/v1/ws/' + deviceId
413+
// Use extension WebSocket endpoint
414+
const websocketUrl = this.wsLocation + '/devicetimer/api/v1/ws/' + deviceId
386415
this.websocketMessage = 'Connecting...'
387416
this.activeWebsocketDeviceId = deviceId
388417

@@ -391,12 +420,14 @@ window.app = Vue.createApp({
391420
this.activeWebsocket = ws
392421

393422
ws.onopen = () => {
394-
this.websocketMessage = 'connected'
395-
this.calculateStats()
423+
this.websocketMessage = 'Connected'
424+
this.fetchConnectionStatus()
396425
}
397426

398-
ws.onmessage = () => {
427+
ws.onmessage = (event) => {
399428
this.websocketMessage = 'Payment received!'
429+
// Refresh status after payment
430+
setTimeout(() => this.fetchConnectionStatus(), 1000)
400431
}
401432

402433
ws.onclose = () => {
@@ -426,7 +457,6 @@ window.app = Vue.createApp({
426457
this.activeWebsocket = null
427458
this.activeWebsocketDeviceId = null
428459
this.websocketMessage = ''
429-
this.calculateStats()
430460
}
431461
},
432462

@@ -442,7 +472,8 @@ window.app = Vue.createApp({
442472
},
443473

444474
openWebsocketDialog(device) {
445-
this.websocketDialog.url = this.wsLocation + '/api/v1/ws/' + device.id
475+
// Show extension WebSocket URL for hardware configuration
476+
this.websocketDialog.url = this.wsLocation + '/devicetimer/api/v1/ws/' + device.id
446477
this.websocketDialog.deviceTitle = device.title
447478
this.websocketDialog.show = true
448479
},
@@ -498,8 +529,8 @@ window.app = Vue.createApp({
498529
return amount.toString()
499530
},
500531

501-
isDeviceLive(deviceId) {
502-
return this.activeWebsocketDeviceId === deviceId && this.activeWebsocket !== null
532+
isDeviceConnected(deviceId) {
533+
return this.connectedDevices.includes(deviceId)
503534
}
504535
},
505536

@@ -508,12 +539,18 @@ window.app = Vue.createApp({
508539
this.wsLocation = (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host
509540

510541
await this.getDevices()
542+
this.startStatusPolling()
511543

512544
try {
513545
const response = await LNbits.api.request('GET', '/devicetimer/api/v1/timezones')
514546
this.timezones = response.data
515547
} catch (err) {
516548
console.warn('Failed to load timezones')
517549
}
550+
},
551+
552+
beforeUnmount() {
553+
this.stopStatusPolling()
554+
this.disconnectWebsocket()
518555
}
519556
})

tasks.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import asyncio
22

3+
from loguru import logger
34
from lnbits.core.models import Payment
4-
from lnbits.core.services import websocket_updater
55
from lnbits.tasks import register_invoice_listener
66

77
from .crud import get_payment, update_payment, get_device
8+
from .websocket import send_to_device
89

910

1011
async def wait_for_paid_invoices() -> None:
@@ -42,6 +43,11 @@ async def on_invoice_paid(payment: Payment) -> None:
4243
if not switch:
4344
return
4445

45-
await websocket_updater(
46-
device_payment.deviceid, f"{switch.gpio_pin}-{switch.gpio_duration}"
47-
)
46+
# Send trigger command to hardware via our WebSocket
47+
message = f"{switch.gpio_pin}-{switch.gpio_duration}"
48+
sent = await send_to_device(device_payment.deviceid, message)
49+
50+
if sent:
51+
logger.info(f"Payment notification sent to device {device_payment.deviceid}: {message}")
52+
else:
53+
logger.warning(f"No active connection for device {device_payment.deviceid}")

templates/devicetimer/index.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,17 +377,17 @@
377377
<div class="row items-center">
378378
<div class="col">
379379
<div class="stat-label">Connected</div>
380-
<div class="stat-value text-positive">{% raw %}{{ stats.activeSwitches }}{% endraw %}</div>
380+
<div class="stat-value text-positive">{% raw %}{{ stats.connectedDevices }}{% endraw %}</div>
381381
</div>
382382
<q-separator vertical class="q-mx-sm" style="height: 32px; opacity: 0.2;"></q-separator>
383383
<div class="col">
384384
<div class="stat-label">Offline</div>
385-
<div class="stat-value text-grey-6">{% raw %}{{ stats.inactiveSwitches }}{% endraw %}</div>
385+
<div class="stat-value text-grey-6">{% raw %}{{ stats.offlineDevices }}{% endraw %}</div>
386386
</div>
387387
</div>
388388
</div>
389389
<div class="col-auto">
390-
<q-avatar size="36px" square :class="stats.activeSwitches > 0 ? 'bg-positive' : 'bg-grey-5'" class="avatar_style text-white">
390+
<q-avatar size="36px" square :class="stats.connectedDevices > 0 ? 'bg-positive' : 'bg-grey-5'" class="avatar_style text-white">
391391
<q-icon size="18px">
392392
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
393393
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
@@ -531,7 +531,7 @@
531531
<div class="row items-center no-wrap">
532532
<span>{% raw %}{{ props.row.title }}{% endraw %}</span>
533533
<q-badge
534-
v-if="isDeviceLive(props.row.id)"
534+
v-if="isDeviceConnected(props.row.id)"
535535
color="positive"
536536
class="q-ml-sm"
537537
rounded

websocket.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""
2+
WebSocket management for DeviceTimer extension.
3+
4+
Handles device connections and message broadcasting.
5+
Tracks connected devices to show real-time status in UI.
6+
"""
7+
8+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
9+
from loguru import logger
10+
from typing import Dict, Set
11+
12+
devicetimer_websocket_router = APIRouter()
13+
14+
# Track connected devices: device_id -> set of WebSocket connections
15+
# Multiple connections per device are supported (e.g., multiple browser tabs)
16+
_connected_clients: Dict[str, Set[WebSocket]] = {}
17+
18+
19+
def get_connected_device_ids() -> list[str]:
20+
"""Return list of device IDs with active WebSocket connections."""
21+
return list(_connected_clients.keys())
22+
23+
24+
def is_device_connected(device_id: str) -> bool:
25+
"""Check if a device has any active WebSocket connections."""
26+
return device_id in _connected_clients and len(_connected_clients[device_id]) > 0
27+
28+
29+
async def send_to_device(device_id: str, message: str) -> bool:
30+
"""
31+
Send a message to all WebSocket connections for a device.
32+
Returns True if message was sent to at least one client.
33+
"""
34+
if device_id not in _connected_clients:
35+
logger.warning(f"No WebSocket connections for device {device_id}")
36+
return False
37+
38+
sent = False
39+
dead_connections: Set[WebSocket] = set()
40+
41+
for websocket in _connected_clients[device_id]:
42+
try:
43+
await websocket.send_text(message)
44+
sent = True
45+
except Exception as e:
46+
logger.debug(f"Failed to send to WebSocket: {e}")
47+
dead_connections.add(websocket)
48+
49+
# Clean up dead connections
50+
for ws in dead_connections:
51+
_connected_clients[device_id].discard(ws)
52+
53+
# Remove device entry if no connections left
54+
if device_id in _connected_clients and not _connected_clients[device_id]:
55+
del _connected_clients[device_id]
56+
57+
return sent
58+
59+
60+
@devicetimer_websocket_router.websocket("/api/v1/ws/{device_id}")
61+
async def websocket_endpoint(websocket: WebSocket, device_id: str):
62+
"""
63+
WebSocket endpoint for device connections.
64+
Hardware devices connect here to receive payment notifications.
65+
"""
66+
await websocket.accept()
67+
68+
# Add to tracking
69+
if device_id not in _connected_clients:
70+
_connected_clients[device_id] = set()
71+
_connected_clients[device_id].add(websocket)
72+
73+
logger.info(f"Device {device_id} connected. Total connections: {len(_connected_clients[device_id])}")
74+
75+
try:
76+
while True:
77+
# Keep connection alive, wait for messages (ping/pong handled automatically)
78+
data = await websocket.receive_text()
79+
# Hardware might send status updates, we just acknowledge
80+
logger.debug(f"Received from {device_id}: {data}")
81+
except WebSocketDisconnect:
82+
logger.info(f"Device {device_id} disconnected")
83+
except Exception as e:
84+
logger.debug(f"WebSocket error for {device_id}: {e}")
85+
finally:
86+
# Remove from tracking
87+
if device_id in _connected_clients:
88+
_connected_clients[device_id].discard(websocket)
89+
if not _connected_clients[device_id]:
90+
del _connected_clients[device_id]
91+
logger.info(f"Device {device_id} cleaned up. Connected devices: {list(_connected_clients.keys())}")
92+
93+
94+
@devicetimer_websocket_router.get("/api/v1/ws/status")
95+
async def get_websocket_status():
96+
"""
97+
Return list of connected device IDs.
98+
Used by frontend to show real-time connection status.
99+
"""
100+
return {"connected": get_connected_device_ids()}

0 commit comments

Comments
 (0)