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
8 changes: 4 additions & 4 deletions .github/workflows/build-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,10 @@ jobs:
fail-fast: false
matrix:
include:
- runner: git01
- runner: [self-hosted, Linux, X64]
platform: linux/amd64
platform_tag: amd64
- runner: gitpi01
- runner: [self-hosted, Linux, ARM64]
platform: linux/arm64
platform_tag: arm64
steps:
Expand All @@ -166,7 +166,7 @@ jobs:

- name: Cache Docker layers
# Skip GitHub Actions cache for self-hosted runners (files persist locally)
if: ${{ runner.name != 'git01' && runner.name != 'gitpi01' }}
if: ${{ !contains(runner.labels, 'self-hosted') }}
uses: actions/cache@v4
with:
# CHANGE: Use a path in the home directory, not /tmp
Expand Down Expand Up @@ -197,7 +197,7 @@ jobs:
merge:
name: Merge Multi-Arch Image
needs: [prepare, build]
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux]
steps:
- name: Login to Docker Hub
uses: docker/login-action@v3
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/snd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
fail-fast: false
matrix:
include:
- runner: git01
- runner: [self-hosted, Linux, X64]
platform: linux/amd64
platform_tag: amd64
- runner: gitpi01
- runner: [self-hosted, Linux, ARM64]
platform: linux/arm64
platform_tag: arm64
steps:
Expand All @@ -44,7 +44,7 @@ jobs:

- name: Cache Docker layers
# Skip GitHub Actions cache for self-hosted runners (files persist locally)
if: ${{ runner.name != 'git01' && runner.name != 'gitpi01' }}
if: ${{ !contains(runner.labels, 'self-hosted') }}
uses: actions/cache@v4
with:
# CHANGE: Use a path in the home directory, not /tmp
Expand Down Expand Up @@ -75,7 +75,7 @@ jobs:
merge:
name: Merge Multi-Arch Image
needs: build
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64]
steps:
- name: Login to Docker Hub
uses: docker/login-action@v3
Expand Down
32 changes: 31 additions & 1 deletion alpaca/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
import io
import json
from collections import deque
from datetime import datetime
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, Tuple
import paho.mqtt.client as mqtt
from PIL import Image

# Watchdog constants
CLIENT_TIMEOUT_SECONDS = 300 # 5 Minutes

# Import from sibling modules
from .config import AlpacaConfig, get_current_time
# Assuming detect.py is in the root path or installed as a package
Expand Down Expand Up @@ -158,6 +161,31 @@ def _update_cached_safety(self, detection: Dict[str, Any]):
f"(class={class_name}, confidence={confidence:.1f}%, "
f"threshold={threshold:.1f}%, debounce={elapsed_time:.1f}s)")

def _prune_stale_clients(self):
"""Remove clients that haven't been seen for CLIENT_TIMEOUT_SECONDS (assumes lock is held)"""
now = get_current_time(self.alpaca_config.timezone)
cutoff_time = now - timedelta(seconds=CLIENT_TIMEOUT_SECONDS)

stale_clients = []
for key, last_seen in list(self.connected_clients.items()):
if last_seen < cutoff_time:
stale_clients.append(key)

for key in stale_clients:
client_ip, client_id = key
conn_time = self.connected_clients[key]
self.disconnected_clients[key] = (conn_time, now)
del self.connected_clients[key]
logger.warning(f"Watchdog: Pruned stale client {client_ip} (ID: {client_id}) - "
f"inactive for {(now - conn_time).total_seconds():.0f}s")

def register_heartbeat(self, client_ip: str, client_id: int):
"""Update the last seen timestamp for a connected client"""
with self.connection_lock:
key = (client_ip, client_id)
if key in self.connected_clients:
self.connected_clients[key] = get_current_time(self.alpaca_config.timezone)

def _setup_mqtt(self):
"""Setup and return MQTT client based on detect_config"""
if not self.detect_config.broker:
Expand Down Expand Up @@ -299,6 +327,7 @@ def _get_arg(self, key: str, default: Any = None) -> str:
def is_connected(self) -> bool:
"""Check if any clients are connected"""
with self.connection_lock:
self._prune_stale_clients()
return len(self.connected_clients) > 0

def connect(self, client_ip: str, client_id: int):
Expand All @@ -321,6 +350,7 @@ def connect(self, client_ip: str, client_id: int):
def disconnect(self, client_ip: str = None, client_id: int = None):
"""Disconnect a client from the device"""
with self.connection_lock:
self._prune_stale_clients()
if client_ip is None or client_id is None:
# Disconnect all
for key in list(self.connected_clients.keys()):
Expand Down
11 changes: 10 additions & 1 deletion alpaca/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ def validate_device_number(device_number: int):
error_message=f"Invalid device number: {device_number}",
client_transaction_id=client_tx_id
)), 400

# Register heartbeat for watchdog (every API request keeps session alive)
try:
client_id, _ = monitor.get_client_params()
client_ip = request.remote_addr
monitor.register_heartbeat(client_ip, client_id)
except Exception as e:
# Don't fail the request if heartbeat fails
logger.debug(f"Heartbeat registration failed: {e}")

return None

def create_simple_get_endpoint(attribute_getter):
Expand Down Expand Up @@ -222,4 +232,3 @@ def not_implemented(device_number: int):
error_message="Command not implemented",
client_transaction_id=client_tx_id
)), 400

32 changes: 24 additions & 8 deletions alpaca/routes/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,21 +112,29 @@ def setup_device(device_number: int):
if ip not in unique_clients or conn_time > unique_clients[ip]['conn_time']:
duration = (get_current_time(monitor.alpaca_config.timezone) - conn_time).total_seconds()

# Format duration as dd-hh-mm-ss fixed width
dur_int = int(duration)
days = dur_int // 86400
hours = (dur_int % 86400) // 3600
minutes = (dur_int % 3600) // 60
seconds = dur_int % 60
duration_str = f"{days:02d}d {hours:02d}h {minutes:02d}m {seconds:02d}s"

try:
# Convert timestamp to current timezone
tz = ZoneInfo(monitor.alpaca_config.timezone)
local_conn_time = conn_time.astimezone(tz)
conn_time_str = local_conn_time.strftime("%H:%M:%S")
conn_time_str = local_conn_time.strftime("%Y-%m-%d %H:%M:%S")
conn_ts = local_conn_time.timestamp()
except Exception:
# Fallback if timezone conversion fails
conn_time_str = conn_time.strftime("%H:%M:%S")
conn_time_str = conn_time.strftime("%Y-%m-%d %H:%M:%S")
conn_ts = conn_time.timestamp()

unique_clients[ip] = {
'ip': ip,
'status': 'connected',
'duration': f"{int(duration)}s",
'duration': duration_str,
'duration_seconds': duration,
'connected_time': conn_time_str,
'connected_ts': conn_ts,
Expand All @@ -145,26 +153,34 @@ def setup_device(device_number: int):
if ip not in unique_clients or disc_time > unique_clients[ip].get('disc_time', datetime.min.replace(tzinfo=conn_time.tzinfo)):
duration = (disc_time - conn_time).total_seconds()

# Format duration as dd-hh-mm-ss fixed width
dur_int = int(duration)
days = dur_int // 86400
hours = (dur_int % 86400) // 3600
minutes = (dur_int % 3600) // 60
seconds = dur_int % 60
duration_str = f"{days:02d}d {hours:02d}h {minutes:02d}m {seconds:02d}s"

try:
# Convert timestamps to current timezone
tz = ZoneInfo(monitor.alpaca_config.timezone)
local_conn_time = conn_time.astimezone(tz)
local_disc_time = disc_time.astimezone(tz)
conn_time_str = local_conn_time.strftime("%H:%M:%S")
disc_time_str = local_disc_time.strftime("%H:%M:%S")
conn_time_str = local_conn_time.strftime("%Y-%m-%d %H:%M:%S")
disc_time_str = local_disc_time.strftime("%Y-%m-%d %H:%M:%S")
conn_ts = local_conn_time.timestamp()
disc_ts = local_disc_time.timestamp()
except Exception:
# Fallback if timezone conversion fails
conn_time_str = conn_time.strftime("%H:%M:%S")
disc_time_str = disc_time.strftime("%H:%M:%S")
conn_time_str = conn_time.strftime("%Y-%m-%d %H:%M:%S")
disc_time_str = disc_time.strftime("%Y-%m-%d %H:%M:%S")
conn_ts = conn_time.timestamp()
disc_ts = disc_time.timestamp()

unique_clients[ip] = {
'ip': ip,
'status': 'disconnected',
'duration': f"{int(duration)}s",
'duration': duration_str,
'duration_seconds': duration,
'connected_time': conn_time_str,
'connected_ts': conn_ts,
Expand Down
8 changes: 4 additions & 4 deletions templates/setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -843,8 +843,8 @@ <h1>☁️ Simple<span class="highlight">Cloud</span>Detect</h1>
<th style="padding: 8px; width: 50px; text-align: center; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(0)">Status ↕</th>
<th style="padding: 8px; text-align: left; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(1)">IP ↕</th>
<th style="padding: 8px; text-align: right; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(2)">Duration ↕</th>
<th style="padding: 8px; text-align: left; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(3)">Connected ↕</th>
<th style="padding: 8px; text-align: left; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(4)">Disconnected ↕</th>
<th style="padding: 8px; text-align: right; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(3)">Connected ↕</th>
<th style="padding: 8px; text-align: right; color: rgb(148, 163, 184); cursor: pointer;" onclick="sortClientTable(4)">Disconnected ↕</th>
</tr>
</thead>
<tbody>
Expand All @@ -855,8 +855,8 @@ <h1>☁️ Simple<span class="highlight">Cloud</span>Detect</h1>
</td>
<td style="padding: 8px; color: rgb(226, 232, 240);">{{ client.ip }}</td>
<td style="padding: 8px; text-align: right; color: rgb(148, 163, 184);" data-sort="{{ client.duration_seconds }}">{{ client.duration }}</td>
<td style="padding: 8px; color: rgb(148, 163, 184);" data-sort="{{ client.connected_ts }}">{{ client.connected_time }}</td>
<td style="padding: 8px; color: rgb(148, 163, 184);" data-sort="{{ client.disconnected_ts }}">{{ client.disconnected_time }}</td>
<td style="padding: 8px; text-align: right; color: rgb(148, 163, 184);" data-sort="{{ client.connected_ts }}">{{ client.connected_time }}</td>
<td style="padding: 8px; text-align: right; color: rgb(148, 163, 184);" data-sort="{{ client.disconnected_ts }}">{{ client.disconnected_time }}</td>
</tr>
{% endfor %}
</tbody>
Expand Down
Loading
Loading