fix: reduce status flapping, add IPv6 ping, color manual web services

Addresses three reports from issue #196:

- Ping now sends 2 probes with a ~2s timeout (was 1 probe / 1s) so a
  single dropped packet or a slow IoT/ESPHome device no longer flaps a
  node offline (#196.1, #196.2).
- IPv6-only devices (e.g. Alexa) are now pinged over IPv6: ping6 on
  macOS, -6 flag on Linux/Windows, detected via inet_pton (#196.3).
- Manually-added services carry no category and so always rendered grey
  even when they were reachable HTTP/HTTPS. A resolvable web URL now
  falls back to the web colour (#196.9).

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-09 21:20:05 +02:00
parent a39d4d4406
commit aa0a97c245
4 changed files with 124 additions and 11 deletions
+25 -7
View File
@@ -64,17 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
return {"status": "offline", "response_time_ms": None}
def _is_ipv6(host: str) -> bool:
"""True if host is a literal IPv6 address (bracketed or bare)."""
try:
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
return True
except OSError:
return False
async def _ping(host: str) -> bool:
# ping(8) -W flag units differ by OS:
# Linux: seconds (-W 1 = 1s)
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
# Windows: -w in ms (-w 1000 = 1s)
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
#
# -W flag units differ by OS:
# Linux: seconds (-W 2 = 2s)
# macOS: milliseconds (-W 2000 = 2s)
# Windows: -w in ms (-w 2000 = 2s)
#
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
ipv6 = _is_ipv6(host)
if sys.platform == "win32":
args = ["ping", "-n", "1", "-w", "1000", host]
family = ["-6"] if ipv6 else ["-4"]
args = ["ping", *family, "-n", "2", "-w", "2000", host]
elif sys.platform == "darwin":
args = ["ping", "-c", "1", "-W", "1000", host]
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
else:
args = ["ping", "-c", "1", "-W", "1", host]
family = ["-6"] if ipv6 else []
args = ["ping", *family, "-c", "2", "-W", "2", host]
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
+77 -3
View File
@@ -169,9 +169,14 @@ async def test_ping_uses_unix_args_on_non_windows():
assert "-c" in captured["args"]
assert "-W" in captured["args"]
assert "-n" not in captured["args"]
# Linux: -W is in seconds; 1s is the intended timeout
# 2 probes so a single dropped packet doesn't flap the node offline
c_idx = captured["args"].index("-c")
assert captured["args"][c_idx + 1] == "2"
# Linux: -W is in seconds; 2s is the intended timeout
w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1"
assert captured["args"][w_idx + 1] == "2"
# IPv4 target → no -6 flag
assert "-6" not in captured["args"]
@pytest.mark.asyncio
@@ -193,7 +198,7 @@ async def test_ping_uses_macos_millisecond_timeout():
assert "-c" in captured["args"]
assert "-W" in captured["args"]
w_idx = captured["args"].index("-W")
assert captured["args"][w_idx + 1] == "1000"
assert captured["args"][w_idx + 1] == "2000"
@pytest.mark.asyncio
@@ -216,6 +221,75 @@ async def test_ping_uses_windows_args_on_win32():
assert "-c" not in captured["args"]
# --- _ping IPv6 support ---
@pytest.mark.asyncio
async def test_ping_ipv6_linux_uses_dash6():
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "linux"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("fe80::1")
assert "-6" in captured["args"]
assert captured["args"][-1] == "fe80::1"
@pytest.mark.asyncio
async def test_ping_ipv6_macos_uses_ping6():
"""macOS ships a separate ping6 binary for IPv6 targets."""
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "darwin"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("2001:db8::1")
assert captured["args"][0] == "ping6"
@pytest.mark.asyncio
async def test_ping_ipv6_windows_uses_dash6():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "win32"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("2001:db8::1")
assert "-6" in captured["args"]
def test_is_ipv6_detection():
from app.services.status_checker import _is_ipv6
assert _is_ipv6("fe80::1") is True
assert _is_ipv6("2001:db8::1") is True
assert _is_ipv6("[2001:db8::1]") is True
assert _is_ipv6("192.168.1.1") is False
assert _is_ipv6("example.local") is False
# --- check_node target validation ---
@pytest.mark.asyncio