"The cameras won't connect from my mobile internet" was answered by a timeout
Asked directly by the owner, about his own cameras, from his phone's connection. The answer is physics and the product was not giving it. A camera lives on the shop's LAN behind a router. 192.168.1.121 means "something on the network I am attached to" and nothing more - from mobile data, a hotel or head office it resolves to nobody, or to a completely different device holding that number. There is no route in from the internet and there must not be: an RTSP camera reachable from outside is how a shop's cameras end up being watched by strangers. That is why the product is split the way it is - the shop PC is the only machine on the camera's LAN, and every other surface reaches it outbound, which is what makes Watch live work from anywhere while nothing connects in. What was wrong is the message. "cannot reach 192.168.1.121:554 - Operation timed out" reads as a broken camera and sends somebody to re-type an address and a password that were always correct. _wrong_network_hint names the cause and separates two states that need opposite actions: on that network -> check the camera is powered on and the address is right somewhere else -> the COMPUTER is in the wrong place; no setting fixes it - The local address comes from a connected UDP socket that sends nothing. It only fixes a route so the kernel will name the source address. - The LAN ranges are spelled out, not is_private. That property also covers carrier-grade NAT and the documentation networks, and telling somebody who typed 203.0.113.9 that it is "on the shop's own network" is a confident wrong answer in the place people look first. Found by a test using that address as its example of a PUBLIC one. - A DNS name gets no hint: nothing can be concluded about camera.local from the string, and guessing is the failure mode this message exists to fix. - With no network at all it still names the cause and drops the comparison, rather than claiming to know which network this machine is on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
46
CLAUDE.md
46
CLAUDE.md
@@ -3699,3 +3699,49 @@ python.org / pyenv build OpenSSL 3.5.8 0 CAs -> reproduces it
|
||||
|
||||
Checked for teeth by putting the shipped `except` back: both the corrected stub
|
||||
test and the live one fail, and pass again when it is restored.
|
||||
|
||||
## "The cameras won't connect from my mobile internet" — and why that is not a bug
|
||||
|
||||
Asked directly by the owner, about his own cameras, from his phone's
|
||||
connection. The answer is physics, and the product was not giving it.
|
||||
|
||||
A camera lives on the shop's LAN behind a router. `192.168.1.121` means
|
||||
*"something on the network I am attached to"* and nothing more — from mobile
|
||||
data, a hotel, or head office it resolves to nobody, or to a completely
|
||||
different device that happens to hold that number. There is no route in from
|
||||
the internet and **there must not be**: an RTSP camera reachable from outside
|
||||
is how a shop's cameras end up being watched by strangers.
|
||||
|
||||
This is the reason the product is split the way it is, and it is worth stating
|
||||
plainly next to the split itself: the shop PC is the only machine on the
|
||||
camera's LAN, so it does the connecting, and every other surface reaches it
|
||||
**outbound** — the agent's pull, the arrivals feed, and `LiveHub`'s frame relay,
|
||||
which is what makes **Watch live** work from anywhere while nothing connects in.
|
||||
|
||||
What was wrong is the message. `cannot reach 192.168.1.121:554 - Operation
|
||||
timed out` reads as a broken camera and sends somebody to re-type an address
|
||||
and a password that were always correct. `_wrong_network_hint` now names the
|
||||
cause, and it distinguishes two states that need opposite actions — the same
|
||||
rule as `artifact` against `no_faces`, and `stale` against `not_connecting`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| this computer **is** on that network | check the camera is powered on and that the address is right |
|
||||
| this computer is **somewhere else** | the computer is in the wrong place; no setting here fixes it, recognition has to run on a machine in the shop |
|
||||
|
||||
- **The local address comes from a `connect`ed UDP socket** that sends nothing.
|
||||
It only fixes a route so the kernel will name the source address — no packet
|
||||
leaves, and it needs no dependency in an engine that already ships 200 MB of
|
||||
models.
|
||||
- **The LAN ranges are spelled out, not `is_private`.** That property is
|
||||
broader than "an address on somebody's LAN": it also covers carrier-grade NAT
|
||||
and the documentation networks (192.0.2, 198.51.100, 203.0.113), and telling
|
||||
somebody who typed one of those that it is "on the shop's own network" is a
|
||||
confident wrong answer in the place people look first. Found by a test using
|
||||
`203.0.113.9` as its example of a *public* address, which `is_private` calls
|
||||
private.
|
||||
- **A DNS name gets no hint at all.** Nothing can be concluded about
|
||||
`camera.local` from the string, and guessing is the failure mode this whole
|
||||
message exists to fix.
|
||||
- **With no network at all it still names the cause** and drops the comparison,
|
||||
rather than claiming to know which network this machine is on.
|
||||
|
||||
@@ -68,6 +68,78 @@ os.environ.setdefault(
|
||||
STALL_AFTER_S = 10.0
|
||||
|
||||
|
||||
def _local_ipv4() -> str:
|
||||
"""This machine's address on the interface holding the default route.
|
||||
|
||||
A UDP socket is `connect`ed and nothing is sent - it only fixes a route so
|
||||
the kernel will name the source address. No packet leaves, and it needs no
|
||||
dependency, which matters in an engine that already ships 200 MB of models.
|
||||
"""
|
||||
import socket
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.settimeout(0.5)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _wrong_network_hint(host: str) -> str:
|
||||
"""Why a private camera address is unreachable, when that is the reason.
|
||||
|
||||
A camera lives on the shop's LAN behind a router, and 192.168.x.x means
|
||||
"something on the network I am attached to" - nothing more. From mobile
|
||||
data, a hotel, or head office it either resolves to nobody or to a
|
||||
completely different device that happens to hold that number. There is no
|
||||
route in from the internet and there must not be: an RTSP camera reachable
|
||||
from outside is how a shop's cameras end up being watched by strangers.
|
||||
|
||||
Without this the answer was "cannot reach 192.168.1.121:554 - Operation
|
||||
timed out", which reads as a broken camera and sends somebody to re-type
|
||||
an address and a password that were always correct. Asked directly by the
|
||||
owner, about his own cameras, from his phone's connection.
|
||||
|
||||
Two states, two different actions, so they must not share a sentence: on
|
||||
the same network the camera or its address is the problem; on a different
|
||||
one the COMPUTER is in the wrong place and no setting will fix it.
|
||||
"""
|
||||
import ipaddress
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return "" # a DNS name; nothing can be concluded from the string
|
||||
# The RFC1918 blocks and link-local, spelled out rather than `is_private`.
|
||||
# That property is broader than "an address on somebody's LAN": it also
|
||||
# covers the carrier-grade NAT range and the documentation networks
|
||||
# (192.0.2, 198.51.100, 203.0.113), and telling somebody who typed one of
|
||||
# those that it is "on the shop's own network" would be a confident wrong
|
||||
# answer in the place people look first. Found by a test using 203.0.113.9
|
||||
# as an example of a PUBLIC address, which `is_private` calls private.
|
||||
lan = any(addr in ipaddress.ip_network(n) for n in
|
||||
("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16")
|
||||
if addr.version == 4)
|
||||
if not lan:
|
||||
return ""
|
||||
|
||||
mine = _local_ipv4()
|
||||
if not mine:
|
||||
return (" - that is a private address, reachable only from inside "
|
||||
"the network the camera is on")
|
||||
try:
|
||||
same = ipaddress.ip_network(f"{mine}/24", strict=False).supernet_of(
|
||||
ipaddress.ip_network(f"{host}/24", strict=False))
|
||||
except (ValueError, TypeError):
|
||||
same = False
|
||||
if same:
|
||||
return (f" - this computer is on that network ({mine}), so check the "
|
||||
f"camera is powered on and that {host} is its address")
|
||||
return (f" - this computer is on {mine}, not the camera's network. A "
|
||||
f"private address like {host} is only reachable from inside the "
|
||||
f"shop's own network, never over the internet or mobile data, so "
|
||||
f"recognition has to run on a computer in the shop")
|
||||
|
||||
|
||||
def _tcp_reachable(source: "str | int", timeout: float
|
||||
) -> "tuple[bool, str]":
|
||||
"""Cheap pre-flight for an rtsp:// URL. Non-URL sources pass through."""
|
||||
@@ -85,10 +157,11 @@ def _tcp_reachable(source: "str | int", timeout: float
|
||||
return True, ""
|
||||
except socket.timeout:
|
||||
return False, (f"no response from {parsed.hostname}:{port} within "
|
||||
f"{timeout:.0f}s - check the IP address and that the "
|
||||
f"camera is on the same network")
|
||||
f"{timeout:.0f}s{_wrong_network_hint(parsed.hostname)}")
|
||||
except OSError as exc:
|
||||
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
|
||||
return False, (f"cannot reach {parsed.hostname}:{port} - "
|
||||
f"{exc.strerror or exc}"
|
||||
f"{_wrong_network_hint(parsed.hostname)}")
|
||||
|
||||
|
||||
def _fourcc(cap) -> str:
|
||||
|
||||
81
tests/test_wrong_network.py
Normal file
81
tests/test_wrong_network.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Why a camera "will not connect", when the real answer is that the computer
|
||||
asking is in the wrong building.
|
||||
|
||||
Asked directly by the owner, about his own cameras, from his phone's
|
||||
connection: *"when i connect from my mobile internet the cameras wont connect,
|
||||
why is that"*. The answer was `cannot reach 192.168.1.121:554 - Operation
|
||||
timed out`, which reads as a broken camera and sends somebody to re-type an
|
||||
address and a password that were always correct.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from behavision import capture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def on(monkeypatch):
|
||||
def _set(ip):
|
||||
monkeypatch.setattr(capture, "_local_ipv4", lambda: ip)
|
||||
return _set
|
||||
|
||||
|
||||
def test_a_different_network_says_so_and_says_what_to_do(on):
|
||||
on("10.11.12.13") # a phone's tethered network
|
||||
hint = capture._wrong_network_hint("192.168.1.121")
|
||||
assert "not the camera's network" in hint
|
||||
# The action, not just the diagnosis: no setting on this screen fixes it.
|
||||
assert "computer in the shop" in hint
|
||||
|
||||
|
||||
def test_the_same_network_sends_you_to_the_camera_instead(on):
|
||||
on("192.168.1.120")
|
||||
hint = capture._wrong_network_hint("192.168.1.121")
|
||||
assert "on that network" in hint
|
||||
assert "powered on" in hint
|
||||
# Two states that need opposite actions must not share a sentence.
|
||||
assert "not the camera's network" not in hint
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"8.8.8.8", # plainly routable
|
||||
"203.0.113.9", # TEST-NET-3: `is_private` calls this private, and it is
|
||||
# not a LAN address - which is why the check spells out
|
||||
# the RFC1918 blocks instead of asking is_private
|
||||
"100.64.0.5", # carrier-grade NAT, what a mobile network hands out
|
||||
])
|
||||
def test_an_address_that_is_not_a_lan_address_gets_no_hint(on, host):
|
||||
"""A routable address unreachable from here is an ordinary network fault,
|
||||
and inventing a story about private networks would be wrong."""
|
||||
on("192.168.1.120")
|
||||
assert capture._wrong_network_hint(host) == ""
|
||||
|
||||
|
||||
def test_a_name_gets_no_hint(on):
|
||||
"""Nothing can be concluded about `camera.local` from the string, and a
|
||||
guess here is a confident wrong answer in the place people look first."""
|
||||
on("192.168.1.120")
|
||||
assert capture._wrong_network_hint("camera.local") == ""
|
||||
|
||||
|
||||
def test_loopback_gets_no_hint(on):
|
||||
on("192.168.1.120")
|
||||
assert capture._wrong_network_hint("127.0.0.1") == ""
|
||||
|
||||
|
||||
def test_with_no_network_at_all_it_still_names_the_cause(on):
|
||||
"""A machine with no route cannot say which network it is on, and must not
|
||||
pretend: the private-address fact is still true and still the reason."""
|
||||
on("")
|
||||
hint = capture._wrong_network_hint("192.168.1.121")
|
||||
assert "private address" in hint
|
||||
assert "this computer is on" not in hint
|
||||
|
||||
|
||||
def test_the_hint_reaches_the_message_a_person_reads():
|
||||
"""The whole point is the sentence on the screen, not a helper nobody
|
||||
calls. Port 1 on a private address refuses or times out immediately."""
|
||||
ok, msg = capture._tcp_reachable("rtsp://192.168.1.121:1/ch0", 1.0)
|
||||
assert not ok
|
||||
assert "192.168.1.121" in msg
|
||||
# Whichever branch the OS takes, the explanation travels with it.
|
||||
assert "network" in msg
|
||||
Reference in New Issue
Block a user