diff --git a/CLAUDE.md b/CLAUDE.md index c487a10..38083ce 100644 --- a/CLAUDE.md +++ b/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. diff --git a/behavision/capture.py b/behavision/capture.py index 9d70725..98206fc 100644 --- a/behavision/capture.py +++ b/behavision/capture.py @@ -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: diff --git a/tests/test_wrong_network.py b/tests/test_wrong_network.py new file mode 100644 index 0000000..1da7ea7 --- /dev/null +++ b/tests/test_wrong_network.py @@ -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