Skip to content

Commit c7417da

Browse files
authored
Merge pull request #94 from chvvkumar/dev
Implement client heartbeats, improve display formatting, and fix UI
2 parents eefeeef + cca3cff commit c7417da

File tree

7 files changed

+217
-318
lines changed

7 files changed

+217
-318
lines changed

.github/workflows/build-and-release.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,10 @@ jobs:
138138
fail-fast: false
139139
matrix:
140140
include:
141-
- runner: git01
141+
- runner: [self-hosted, Linux, X64]
142142
platform: linux/amd64
143143
platform_tag: amd64
144-
- runner: gitpi01
144+
- runner: [self-hosted, Linux, ARM64]
145145
platform: linux/arm64
146146
platform_tag: arm64
147147
steps:
@@ -166,7 +166,7 @@ jobs:
166166
167167
- name: Cache Docker layers
168168
# Skip GitHub Actions cache for self-hosted runners (files persist locally)
169-
if: ${{ runner.name != 'git01' && runner.name != 'gitpi01' }}
169+
if: ${{ !contains(runner.labels, 'self-hosted') }}
170170
uses: actions/cache@v4
171171
with:
172172
# CHANGE: Use a path in the home directory, not /tmp
@@ -197,7 +197,7 @@ jobs:
197197
merge:
198198
name: Merge Multi-Arch Image
199199
needs: [prepare, build]
200-
runs-on: ubuntu-latest
200+
runs-on: [self-hosted, Linux]
201201
steps:
202202
- name: Login to Docker Hub
203203
uses: docker/login-action@v3

.github/workflows/snd.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ jobs:
1616
fail-fast: false
1717
matrix:
1818
include:
19-
- runner: git01
19+
- runner: [self-hosted, Linux, X64]
2020
platform: linux/amd64
2121
platform_tag: amd64
22-
- runner: gitpi01
22+
- runner: [self-hosted, Linux, ARM64]
2323
platform: linux/arm64
2424
platform_tag: arm64
2525
steps:
@@ -44,7 +44,7 @@ jobs:
4444
4545
- name: Cache Docker layers
4646
# Skip GitHub Actions cache for self-hosted runners (files persist locally)
47-
if: ${{ runner.name != 'git01' && runner.name != 'gitpi01' }}
47+
if: ${{ !contains(runner.labels, 'self-hosted') }}
4848
uses: actions/cache@v4
4949
with:
5050
# CHANGE: Use a path in the home directory, not /tmp
@@ -75,7 +75,7 @@ jobs:
7575
merge:
7676
name: Merge Multi-Arch Image
7777
needs: build
78-
runs-on: ubuntu-latest
78+
runs-on: [self-hosted, Linux, X64]
7979
steps:
8080
- name: Login to Docker Hub
8181
uses: docker/login-action@v3

alpaca/device.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
import io
77
import json
88
from collections import deque
9-
from datetime import datetime
9+
from datetime import datetime, timedelta
1010
from typing import Optional, Dict, Any, Tuple
1111
import paho.mqtt.client as mqtt
1212
from PIL import Image
1313

14+
# Watchdog constants
15+
CLIENT_TIMEOUT_SECONDS = 300 # 5 Minutes
16+
1417
# Import from sibling modules
1518
from .config import AlpacaConfig, get_current_time
1619
# Assuming detect.py is in the root path or installed as a package
@@ -158,6 +161,31 @@ def _update_cached_safety(self, detection: Dict[str, Any]):
158161
f"(class={class_name}, confidence={confidence:.1f}%, "
159162
f"threshold={threshold:.1f}%, debounce={elapsed_time:.1f}s)")
160163

164+
def _prune_stale_clients(self):
165+
"""Remove clients that haven't been seen for CLIENT_TIMEOUT_SECONDS (assumes lock is held)"""
166+
now = get_current_time(self.alpaca_config.timezone)
167+
cutoff_time = now - timedelta(seconds=CLIENT_TIMEOUT_SECONDS)
168+
169+
stale_clients = []
170+
for key, last_seen in list(self.connected_clients.items()):
171+
if last_seen < cutoff_time:
172+
stale_clients.append(key)
173+
174+
for key in stale_clients:
175+
client_ip, client_id = key
176+
conn_time = self.connected_clients[key]
177+
self.disconnected_clients[key] = (conn_time, now)
178+
del self.connected_clients[key]
179+
logger.warning(f"Watchdog: Pruned stale client {client_ip} (ID: {client_id}) - "
180+
f"inactive for {(now - conn_time).total_seconds():.0f}s")
181+
182+
def register_heartbeat(self, client_ip: str, client_id: int):
183+
"""Update the last seen timestamp for a connected client"""
184+
with self.connection_lock:
185+
key = (client_ip, client_id)
186+
if key in self.connected_clients:
187+
self.connected_clients[key] = get_current_time(self.alpaca_config.timezone)
188+
161189
def _setup_mqtt(self):
162190
"""Setup and return MQTT client based on detect_config"""
163191
if not self.detect_config.broker:
@@ -299,6 +327,7 @@ def _get_arg(self, key: str, default: Any = None) -> str:
299327
def is_connected(self) -> bool:
300328
"""Check if any clients are connected"""
301329
with self.connection_lock:
330+
self._prune_stale_clients()
302331
return len(self.connected_clients) > 0
303332

304333
def connect(self, client_ip: str, client_id: int):
@@ -321,6 +350,7 @@ def connect(self, client_ip: str, client_id: int):
321350
def disconnect(self, client_ip: str = None, client_id: int = None):
322351
"""Disconnect a client from the device"""
323352
with self.connection_lock:
353+
self._prune_stale_clients()
324354
if client_ip is None or client_id is None:
325355
# Disconnect all
326356
for key in list(self.connected_clients.keys()):

alpaca/routes/api.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ def validate_device_number(device_number: int):
2828
error_message=f"Invalid device number: {device_number}",
2929
client_transaction_id=client_tx_id
3030
)), 400
31+
32+
# Register heartbeat for watchdog (every API request keeps session alive)
33+
try:
34+
client_id, _ = monitor.get_client_params()
35+
client_ip = request.remote_addr
36+
monitor.register_heartbeat(client_ip, client_id)
37+
except Exception as e:
38+
# Don't fail the request if heartbeat fails
39+
logger.debug(f"Heartbeat registration failed: {e}")
40+
3141
return None
3242

3343
def create_simple_get_endpoint(attribute_getter):
@@ -222,4 +232,3 @@ def not_implemented(device_number: int):
222232
error_message="Command not implemented",
223233
client_transaction_id=client_tx_id
224234
)), 400
225-

alpaca/routes/management.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,21 +112,29 @@ def setup_device(device_number: int):
112112
if ip not in unique_clients or conn_time > unique_clients[ip]['conn_time']:
113113
duration = (get_current_time(monitor.alpaca_config.timezone) - conn_time).total_seconds()
114114

115+
# Format duration as dd-hh-mm-ss fixed width
116+
dur_int = int(duration)
117+
days = dur_int // 86400
118+
hours = (dur_int % 86400) // 3600
119+
minutes = (dur_int % 3600) // 60
120+
seconds = dur_int % 60
121+
duration_str = f"{days:02d}d {hours:02d}h {minutes:02d}m {seconds:02d}s"
122+
115123
try:
116124
# Convert timestamp to current timezone
117125
tz = ZoneInfo(monitor.alpaca_config.timezone)
118126
local_conn_time = conn_time.astimezone(tz)
119-
conn_time_str = local_conn_time.strftime("%H:%M:%S")
127+
conn_time_str = local_conn_time.strftime("%Y-%m-%d %H:%M:%S")
120128
conn_ts = local_conn_time.timestamp()
121129
except Exception:
122130
# Fallback if timezone conversion fails
123-
conn_time_str = conn_time.strftime("%H:%M:%S")
131+
conn_time_str = conn_time.strftime("%Y-%m-%d %H:%M:%S")
124132
conn_ts = conn_time.timestamp()
125133

126134
unique_clients[ip] = {
127135
'ip': ip,
128136
'status': 'connected',
129-
'duration': f"{int(duration)}s",
137+
'duration': duration_str,
130138
'duration_seconds': duration,
131139
'connected_time': conn_time_str,
132140
'connected_ts': conn_ts,
@@ -145,26 +153,34 @@ def setup_device(device_number: int):
145153
if ip not in unique_clients or disc_time > unique_clients[ip].get('disc_time', datetime.min.replace(tzinfo=conn_time.tzinfo)):
146154
duration = (disc_time - conn_time).total_seconds()
147155

156+
# Format duration as dd-hh-mm-ss fixed width
157+
dur_int = int(duration)
158+
days = dur_int // 86400
159+
hours = (dur_int % 86400) // 3600
160+
minutes = (dur_int % 3600) // 60
161+
seconds = dur_int % 60
162+
duration_str = f"{days:02d}d {hours:02d}h {minutes:02d}m {seconds:02d}s"
163+
148164
try:
149165
# Convert timestamps to current timezone
150166
tz = ZoneInfo(monitor.alpaca_config.timezone)
151167
local_conn_time = conn_time.astimezone(tz)
152168
local_disc_time = disc_time.astimezone(tz)
153-
conn_time_str = local_conn_time.strftime("%H:%M:%S")
154-
disc_time_str = local_disc_time.strftime("%H:%M:%S")
169+
conn_time_str = local_conn_time.strftime("%Y-%m-%d %H:%M:%S")
170+
disc_time_str = local_disc_time.strftime("%Y-%m-%d %H:%M:%S")
155171
conn_ts = local_conn_time.timestamp()
156172
disc_ts = local_disc_time.timestamp()
157173
except Exception:
158174
# Fallback if timezone conversion fails
159-
conn_time_str = conn_time.strftime("%H:%M:%S")
160-
disc_time_str = disc_time.strftime("%H:%M:%S")
175+
conn_time_str = conn_time.strftime("%Y-%m-%d %H:%M:%S")
176+
disc_time_str = disc_time.strftime("%Y-%m-%d %H:%M:%S")
161177
conn_ts = conn_time.timestamp()
162178
disc_ts = disc_time.timestamp()
163179

164180
unique_clients[ip] = {
165181
'ip': ip,
166182
'status': 'disconnected',
167-
'duration': f"{int(duration)}s",
183+
'duration': duration_str,
168184
'duration_seconds': duration,
169185
'connected_time': conn_time_str,
170186
'connected_ts': conn_ts,

templates/setup.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -843,8 +843,8 @@ <h1>☁️ Simple<span class="highlight">Cloud</span>Detect</h1>
843843
<th style="padding: 8px; width: 50px; text-align: center; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(0)">Status ↕</th>
844844
<th style="padding: 8px; text-align: left; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(1)">IP ↕</th>
845845
<th style="padding: 8px; text-align: right; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(2)">Duration ↕</th>
846-
<th style="padding: 8px; text-align: left; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(3)">Connected ↕</th>
847-
<th style="padding: 8px; text-align: left; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(4)">Disconnected ↕</th>
846+
<th style="padding: 8px; text-align: right; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(3)">Connected ↕</th>
847+
<th style="padding: 8px; text-align: right; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(4)">Disconnected ↕</th>
848848
</tr>
849849
</thead>
850850
<tbody>
@@ -855,8 +855,8 @@ <h1>☁️ Simple<span class="highlight">Cloud</span>Detect</h1>
855855
</td>
856856
<td style="padding: 8px; color: rgb(226, 232, 240);">{{ client.ip }}</td>
857857
<td style="padding: 8px; text-align: right; color: rgb(148, 163, 184);" data-sort="{{ client.duration_seconds }}">{{ client.duration }}</td>
858-
<td style="padding: 8px; color: rgb(148, 163, 184);" data-sort="{{ client.connected_ts }}">{{ client.connected_time }}</td>
859-
<td style="padding: 8px; color: rgb(148, 163, 184);" data-sort="{{ client.disconnected_ts }}">{{ client.disconnected_time }}</td>
858+
<td style="padding: 8px; text-align: right; color: rgb(148, 163, 184);" data-sort="{{ client.connected_ts }}">{{ client.connected_time }}</td>
859+
<td style="padding: 8px; text-align: right; color: rgb(148, 163, 184);" data-sort="{{ client.disconnected_ts }}">{{ client.disconnected_time }}</td>
860860
</tr>
861861
{% endfor %}
862862
</tbody>

0 commit comments

Comments
 (0)