bosch-shc-camera-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [v0.1.0] - 2026-07-14
4
+
5
+ Initial extraction: `smb.py` and `local_rcp.py`, copied verbatim from the Home Assistant integration
6
+ (zero internal coupling to the integration package). `shc.py` and `rcp.py` follow once decoupled from
7
+ `cloud_ssl.async_get_bosch_cloud_session` (session-injection refactor, tracked separately).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Mosandl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: bosch-shc-camera-client
3
+ Version: 0.1.0
4
+ Summary: Async Python client library for Bosch Smart Home Camera cloud + local (RCP) APIs.
5
+ Project-URL: Homepage, https://github.com/mosandlt/bosch-shc-camera-client
6
+ Project-URL: Repository, https://github.com/mosandlt/bosch-shc-camera-client
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Thomas Mosandl
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Requires-Python: >=3.11
30
+ Requires-Dist: aiohttp
31
+ Requires-Dist: defusedxml>=0.7.1
32
+ Description-Content-Type: text/markdown
33
+
34
+ # bosch-shc-camera-client
35
+
36
+ Async Python client library for Bosch Smart Home Camera cloud + local (RCP) APIs.
37
+
38
+ Extracted from the [Bosch Smart Home Camera Tool](https://github.com/mosandlt/Bosch-Smart-Home-Camera-Tool-HomeAssistant)
39
+ integration family (Home Assistant, ioBroker, Python CLI, MCP, Node-RED, NiceGUI frontend) so every
40
+ consumer shares one maintained implementation instead of duplicating the client logic per platform.
41
+
42
+ Status: early extraction in progress. Not yet feature-complete versus the inline modules it's replacing.
43
+
44
+ ## Scope
45
+
46
+ - Local RCP protocol read access over `/rcp.xml` (Gen2 camera on-device control plane), read-only.
47
+ - Bosch cloud API (`residential.cbs.boschsecurity.com`): auth, video inputs, motion zones, privacy
48
+ masks, rules, friends/sharing, firmware, lighting — **planned**, not yet extracted (`shc.py`/`rcp.py`
49
+ in the source integration are coupled to a `hass`/coordinator-provided `aiohttp.ClientSession` and
50
+ need a session-injection refactor before they can move here cleanly).
51
+
52
+ Out of scope (stays integration-specific, not extracted here): FCM push-notification plumbing tied to
53
+ Home Assistant's recorder/snapshot-store internals, anything HA-entity-shaped, and the SMB/FTP export
54
+ helpers (`smb.py`) — those reach into the HA integration's coordinator object directly
55
+ (`coordinator.hass`, `coordinator.options`, private state) rather than being a standalone API client, so
56
+ they don't belong in a generic library without their own decoupling pass first.
57
+
58
+ ## Install
59
+
60
+ ```
61
+ pip install bosch-shc-camera-client
62
+ ```
63
+
64
+ ## License
65
+
66
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,33 @@
1
+ # bosch-shc-camera-client
2
+
3
+ Async Python client library for Bosch Smart Home Camera cloud + local (RCP) APIs.
4
+
5
+ Extracted from the [Bosch Smart Home Camera Tool](https://github.com/mosandlt/Bosch-Smart-Home-Camera-Tool-HomeAssistant)
6
+ integration family (Home Assistant, ioBroker, Python CLI, MCP, Node-RED, NiceGUI frontend) so every
7
+ consumer shares one maintained implementation instead of duplicating the client logic per platform.
8
+
9
+ Status: early extraction in progress. Not yet feature-complete versus the inline modules it's replacing.
10
+
11
+ ## Scope
12
+
13
+ - Local RCP protocol read access over `/rcp.xml` (Gen2 camera on-device control plane), read-only.
14
+ - Bosch cloud API (`residential.cbs.boschsecurity.com`): auth, video inputs, motion zones, privacy
15
+ masks, rules, friends/sharing, firmware, lighting — **planned**, not yet extracted (`shc.py`/`rcp.py`
16
+ in the source integration are coupled to a `hass`/coordinator-provided `aiohttp.ClientSession` and
17
+ need a session-injection refactor before they can move here cleanly).
18
+
19
+ Out of scope (stays integration-specific, not extracted here): FCM push-notification plumbing tied to
20
+ Home Assistant's recorder/snapshot-store internals, anything HA-entity-shaped, and the SMB/FTP export
21
+ helpers (`smb.py`) — those reach into the HA integration's coordinator object directly
22
+ (`coordinator.hass`, `coordinator.options`, private state) rather than being a standalone API client, so
23
+ they don't belong in a generic library without their own decoupling pass first.
24
+
25
+ ## Install
26
+
27
+ ```
28
+ pip install bosch-shc-camera-client
29
+ ```
30
+
31
+ ## License
32
+
33
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bosch-shc-camera-client"
7
+ version = "0.1.0"
8
+ description = "Async Python client library for Bosch Smart Home Camera cloud + local (RCP) APIs."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "aiohttp",
14
+ "defusedxml>=0.7.1",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/mosandlt/bosch-shc-camera-client"
19
+ Repository = "https://github.com/mosandlt/bosch-shc-camera-client"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/bosch_shc_camera_client"]
23
+
24
+ [tool.hatch.build.targets.sdist]
25
+ only-include = [
26
+ "src",
27
+ "tests",
28
+ "README.md",
29
+ "LICENSE",
30
+ "CHANGELOG.md",
31
+ "pyproject.toml",
32
+ ]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ addopts = "-ra --strict-markers"
37
+
38
+ [tool.mypy]
39
+ strict = true
@@ -0,0 +1,5 @@
1
+ """Async Python client library for Bosch Smart Home Camera cloud + local (RCP) APIs."""
2
+
3
+ from . import local_rcp
4
+
5
+ __all__ = ["local_rcp"]
@@ -0,0 +1,140 @@
1
+ """Local / cloud-proxy RCP+ READ over `/rcp.xml` (XML format, not binary TLV).
2
+
3
+ Read-only — writes need a `service`-account auth level that Bosch keeps internal only.
4
+ This module provides read helpers only; the caller decides when to poll.
5
+
6
+ Two auth modes:
7
+ LOCAL — HTTP Digest with the `cbs-…` user from `PUT /connection LOCAL`. URL: `https://<cam_ip>:443/rcp.xml?…`.
8
+ REMOTE — HTTP Basic (empty:empty) with the cloud-proxy hash URL from `PUT /connection REMOTE`.
9
+ The hash itself is the credential — URL: `https://proxy-XX:42090/{hash}/rcp.xml?…`.
10
+
11
+ XML response format (verified 2026-04-27 against Gen2 Outdoor FW 9.40.25):
12
+ <rcp>
13
+ <command><hex>0xNNNN</hex>…</command>
14
+ <type>P_OCTET|P_STRING|T_WORD|T_DWORD</type>
15
+ <result>
16
+ <dec>N</dec> ← T_WORD/T_DWORD
17
+ <str>HEX BYTES…</str> ← P_OCTET (space-separated hex) or P_STRING (ASCII)
18
+ <err>0xNN</err> ← read failed / wrong auth level
19
+ </result>
20
+ </rcp>
21
+
22
+ Gotchas:
23
+ - On Gen2 Outdoor (FW 9.40.25), `PUT /connection` rotates the Digest credential. A plain
24
+ RCP read does *not* rotate it — verified via stress test (10 reads/10s, stream stayed up).
25
+ - TLS verify=False: Bosch uses a private CA (NXP-BUID) for the camera cert.
26
+ """
27
+
28
+ import logging
29
+ import ssl
30
+ import urllib.error
31
+ import urllib.request
32
+ from typing import Any
33
+
34
+ from defusedxml import ElementTree as ET # XXE-safe drop-in for xml.etree.ElementTree
35
+
36
+ _LOGGER = logging.getLogger(__name__)
37
+
38
+
39
+ def _parse_rcp_xml(text: str, type_: str) -> Any:
40
+ """Parse RCP+ XML response. Returns int / bytes / str, or None on error/parse-fail."""
41
+ try:
42
+ root = ET.fromstring(text)
43
+ except ET.ParseError:
44
+ _LOGGER.debug("rcp parse: not XML (first 100 chars): %r", text[:100])
45
+ return None
46
+ err_el = root.find(".//result/err")
47
+ if err_el is not None:
48
+ _LOGGER.debug("rcp parse: <err>%s</err> for type=%s", err_el.text, type_)
49
+ return None
50
+ if type_ in ("T_WORD", "T_DWORD", "T_BYTE"):
51
+ dec = root.find(".//result/dec")
52
+ if dec is not None and dec.text:
53
+ try:
54
+ return int(dec.text.strip())
55
+ except ValueError:
56
+ return None
57
+ return None
58
+ if type_ == "P_STRING":
59
+ s = root.find(".//result/str")
60
+ return s.text if s is not None else None
61
+ if type_ == "P_OCTET":
62
+ s = root.find(".//result/str")
63
+ if s is None or not s.text:
64
+ return None
65
+ try:
66
+ return bytes.fromhex(s.text.replace(" ", ""))
67
+ except ValueError:
68
+ return None
69
+ return None
70
+
71
+
72
+ def rcp_read_local_sync(
73
+ host: str, user: str, pwd: str, command: str, type_: str, timeout: float = 5.0
74
+ ) -> Any:
75
+ """RCP+ READ via local HTTPS endpoint with HTTP Digest auth (cbs-…-user).
76
+
77
+ `host` is "192.168.x.x:443". Returns parsed value or None on any failure.
78
+ """
79
+ url = f"https://{host}/rcp.xml?command={command}&type={type_}&direction=READ&num=1&payload="
80
+ pwd_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
81
+ pwd_mgr.add_password(None, url, user, pwd)
82
+ handler = urllib.request.HTTPDigestAuthHandler(pwd_mgr)
83
+ ctx = ssl.create_default_context()
84
+ ctx.check_hostname = False
85
+ ctx.verify_mode = ssl.CERT_NONE
86
+ https_handler = urllib.request.HTTPSHandler(context=ctx)
87
+ opener = urllib.request.build_opener(handler, https_handler)
88
+ try:
89
+ with opener.open(url, timeout=timeout) as r:
90
+ if r.status != 200:
91
+ _LOGGER.debug("rcp local: %s %s → HTTP %d", command, type_, r.status)
92
+ return None
93
+ return _parse_rcp_xml(r.read().decode(errors="replace"), type_)
94
+ except (urllib.error.URLError, OSError, ValueError) as err:
95
+ _LOGGER.debug("rcp local: %s %s → error: %s", command, type_, err)
96
+ return None
97
+
98
+
99
+ def rcp_read_remote_sync(
100
+ proxy_url_with_hash: str, command: str, type_: str, timeout: float = 5.0
101
+ ) -> Any:
102
+ """RCP+ READ via Bosch Cloud-Proxy. Hash inside the URL IS the credential.
103
+
104
+ `proxy_url_with_hash` is e.g.
105
+ "proxy-20.live.cbs.boschsecurity.com:42090/abc123def".
106
+ Returns parsed value or None on any failure.
107
+
108
+ Note: per `research/rcp_findings.txt` (2026-03-22 Cloud-Proxy probe), the
109
+ Cloud-Proxy may return *binary TLV* on `/rcp.xml` rather than XML, depending
110
+ on Content-Type negotiation. We always try XML parse first; if that fails
111
+ we return None (no binary path implemented). When a real REMOTE-only test
112
+ surfaces a binary response, extend `_parse_rcp_xml` with a binary fallback.
113
+ """
114
+ url = f"https://{proxy_url_with_hash}/rcp.xml?command={command}&type={type_}&direction=READ&num=1&payload="
115
+ ctx = ssl.create_default_context()
116
+ ctx.check_hostname = False
117
+ ctx.verify_mode = ssl.CERT_NONE
118
+ try:
119
+ with urllib.request.urlopen(url, timeout=timeout, context=ctx) as r: # noqa: S310 # local camera RCP endpoint, fixed https scheme, not user-supplied
120
+ if r.status != 200:
121
+ _LOGGER.debug("rcp remote: %s %s → HTTP %d", command, type_, r.status)
122
+ return None
123
+ return _parse_rcp_xml(r.read().decode(errors="replace"), type_)
124
+ except (urllib.error.URLError, OSError, ValueError) as err:
125
+ _LOGGER.debug("rcp remote: %s %s → error: %s", command, type_, err)
126
+ return None
127
+
128
+
129
+ # ── Field-specific helpers — RETIRED ────────────────────────────────────────
130
+ # Earlier versions exported parse_privacy_state(0x0d00) and
131
+ # parse_led_dimmer_percent(0x0c22) here. Both were removed in v10.4.9 after
132
+ # A/B testing proved the byte mappings did NOT match the user-facing
133
+ # privacy-mode toggle (0x0d00 byte[1] stayed 1 even with the mode OFF).
134
+ # rcp_findings.txt's "PRIVACY MASK" label was taken to mean "privacy mode";
135
+ # it doesn't. Don't add field-specific helpers here again without:
136
+ # 1. Toggling the user-facing setting both ways
137
+ # 2. Re-reading the RCP value after each toggle
138
+ # 3. Confirming the RCP value actually changes
139
+ # The generic rcp_read_local_sync / rcp_read_remote_sync helpers remain
140
+ # correct and are kept for future verified uses.
@@ -0,0 +1,7 @@
1
+ """Smoke test: package imports cleanly."""
2
+
3
+ import bosch_shc_camera_client
4
+
5
+
6
+ def test_import() -> None:
7
+ assert bosch_shc_camera_client.local_rcp