Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

Expand All @@ -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():
Expand Down
79 changes: 58 additions & 21 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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 =>
Expand All @@ -151,24 +151,53 @@ 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)
}
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) {
Expand All @@ -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',
Expand Down Expand Up @@ -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

Expand All @@ -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 = () => {
Expand Down Expand Up @@ -426,7 +457,6 @@ window.app = Vue.createApp({
this.activeWebsocket = null
this.activeWebsocketDeviceId = null
this.websocketMessage = ''
this.calculateStats()
}
},

Expand All @@ -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
},
Expand Down Expand Up @@ -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)
}
},

Expand All @@ -508,12 +539,18 @@ 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')
this.timezones = response.data
} catch (err) {
console.warn('Failed to load timezones')
}
},

beforeUnmount() {
this.stopStatusPolling()
this.disconnectWebsocket()
}
})
14 changes: 10 additions & 4 deletions tasks.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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}")
8 changes: 4 additions & 4 deletions templates/devicetimer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -377,17 +377,17 @@
<div class="row items-center">
<div class="col">
<div class="stat-label">Connected</div>
<div class="stat-value text-positive">{% raw %}{{ stats.activeSwitches }}{% endraw %}</div>
<div class="stat-value text-positive">{% raw %}{{ stats.connectedDevices }}{% endraw %}</div>
</div>
<q-separator vertical class="q-mx-sm" style="height: 32px; opacity: 0.2;"></q-separator>
<div class="col">
<div class="stat-label">Offline</div>
<div class="stat-value text-grey-6">{% raw %}{{ stats.inactiveSwitches }}{% endraw %}</div>
<div class="stat-value text-grey-6">{% raw %}{{ stats.offlineDevices }}{% endraw %}</div>
</div>
</div>
</div>
<div class="col-auto">
<q-avatar size="36px" square :class="stats.activeSwitches > 0 ? 'bg-positive' : 'bg-grey-5'" class="avatar_style text-white">
<q-avatar size="36px" square :class="stats.connectedDevices > 0 ? 'bg-positive' : 'bg-grey-5'" class="avatar_style text-white">
<q-icon size="18px">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
Expand Down Expand Up @@ -531,7 +531,7 @@
<div class="row items-center no-wrap">
<span>{% raw %}{{ props.row.title }}{% endraw %}</span>
<q-badge
v-if="isDeviceLive(props.row.id)"
v-if="isDeviceConnected(props.row.id)"
color="positive"
class="q-ml-sm"
rounded
Expand Down
100 changes: 100 additions & 0 deletions websocket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""
WebSocket management for DeviceTimer extension.

Handles device connections and message broadcasting.
Tracks connected devices to show real-time status in UI.
"""

from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from loguru import logger
from typing import Dict, Set

devicetimer_websocket_router = APIRouter()

# Track connected devices: device_id -> 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()}