diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py
index f943ceb..69703e5 100644
--- a/server/src/wled_controller/api/routes/devices.py
+++ b/server/src/wled_controller/api/routes/devices.py
@@ -406,6 +406,26 @@ async def get_device_state(
raise HTTPException(status_code=404, detail=str(e))
+@router.post("/api/v1/devices/{device_id}/ping", response_model=DeviceStateResponse, tags=["Devices"])
+async def ping_device(
+ device_id: str,
+ _auth: AuthRequired,
+ store: DeviceStore = Depends(get_device_store),
+ manager: ProcessorManager = Depends(get_processor_manager),
+):
+ """Force an immediate health check on a device."""
+ device = store.get_device(device_id)
+ if not device:
+ raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
+
+ try:
+ state = await manager.force_device_health_check(device_id)
+ state["device_type"] = device.device_type
+ return DeviceStateResponse(**state)
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+
+
# ===== WLED BRIGHTNESS ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py
index 56ee49c..398c5b2 100644
--- a/server/src/wled_controller/core/processing/processor_manager.py
+++ b/server/src/wled_controller/core/processing/processor_manager.py
@@ -1080,6 +1080,13 @@ class ProcessorManager:
except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {e}")
+ async def force_device_health_check(self, device_id: str) -> dict:
+ """Run an immediate health check for a device and return the result."""
+ if device_id not in self._devices:
+ raise ValueError(f"Device {device_id} not found")
+ await self._check_device_health(device_id)
+ return self.get_device_health_dict(device_id)
+
# ===== HELPERS =====
def has_device(self, device_id: str) -> bool:
diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css
index e53a7e0..a3223b1 100644
--- a/server/src/wled_controller/static/css/layout.css
+++ b/server/src/wled_controller/static/css/layout.css
@@ -147,6 +147,11 @@ h2 {
to { transform: rotate(360deg); }
}
+/* Device card ping button spinning animation */
+.card-ping-btn.spinning svg {
+ animation: conn-spin 0.8s linear infinite;
+}
+
/* WLED device health indicator */
.health-dot {
display: inline-block;
diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js
index 65f03ae..38e6b0d 100644
--- a/server/src/wled_controller/static/js/app.js
+++ b/server/src/wled_controller/static/js/app.js
@@ -31,7 +31,7 @@ import {
import {
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
- turnOffDevice, removeDevice, loadDevices,
+ turnOffDevice, pingDevice, removeDevice, loadDevices,
updateSettingsBaudFpsHint, copyWsUrl,
} from './features/devices.js';
import {
@@ -211,6 +211,7 @@ Object.assign(window, {
updateBrightnessLabel,
saveCardBrightness,
turnOffDevice,
+ pingDevice,
removeDevice,
loadDevices,
updateSettingsBaudFpsHint,
diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js
index f9bd6d8..103d37c 100644
--- a/server/src/wled_controller/static/js/features/devices.js
+++ b/server/src/wled_controller/static/js/features/devices.js
@@ -11,7 +11,7 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode }
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
-import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js';
+import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
@@ -133,6 +133,9 @@ export function createDeviceCard(device) {
` : ''}
${renderTagChips(device.tags)}`,
actions: `
+
`,
@@ -157,6 +160,32 @@ export async function turnOffDevice(deviceId) {
}
}
+export async function pingDevice(deviceId) {
+ const btn = document.querySelector(`[data-device-id="${deviceId}"] .card-ping-btn`);
+ if (btn) btn.classList.add('spinning');
+ try {
+ const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
+ if (resp.ok) {
+ const data = await resp.json();
+ const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
+ showToast(data.device_online
+ ? t('device.ping.online', { ms })
+ : t('device.ping.offline'), data.device_online ? 'success' : 'error');
+ // Refresh device cards to update health dot
+ devicesCache.invalidate();
+ await window.loadDevices();
+ } else {
+ const err = await resp.json();
+ showToast(err.detail || 'Ping failed', 'error');
+ }
+ } catch (error) {
+ if (error.isAuth) return;
+ showToast(t('device.ping.error'), 'error');
+ } finally {
+ if (btn) btn.classList.remove('spinning');
+ }
+}
+
export function attachDeviceListeners(deviceId) {
// Add any specific event listeners here if needed
}
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index 56b112d..d0088ba 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -186,6 +186,10 @@
"device.button.remove": "Remove",
"device.button.webui": "Open Device Web UI",
"device.button.power_off": "Turn Off",
+ "device.button.ping": "Ping Device",
+ "device.ping.online": "Online ({ms}ms)",
+ "device.ping.offline": "Device offline",
+ "device.ping.error": "Ping failed",
"device.power.off_success": "Device turned off",
"device.status.connected": "Connected",
"device.status.disconnected": "Disconnected",
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index b6ae97a..7326732 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -186,6 +186,10 @@
"device.button.remove": "Удалить",
"device.button.webui": "Открыть веб-интерфейс устройства",
"device.button.power_off": "Выключить",
+ "device.button.ping": "Пинг устройства",
+ "device.ping.online": "Онлайн ({ms}мс)",
+ "device.ping.offline": "Устройство недоступно",
+ "device.ping.error": "Ошибка пинга",
"device.power.off_success": "Устройство выключено",
"device.status.connected": "Подключено",
"device.status.disconnected": "Отключено",
diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json
index ce86db3..1066fac 100644
--- a/server/src/wled_controller/static/locales/zh.json
+++ b/server/src/wled_controller/static/locales/zh.json
@@ -186,6 +186,10 @@
"device.button.remove": "移除",
"device.button.webui": "打开设备 Web UI",
"device.button.power_off": "关闭",
+ "device.button.ping": "Ping 设备",
+ "device.ping.online": "在线 ({ms}ms)",
+ "device.ping.offline": "设备离线",
+ "device.ping.error": "Ping 失败",
"device.power.off_success": "设备已关闭",
"device.status.connected": "已连接",
"device.status.disconnected": "已断开",