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.
- bosch_shc_camera_client-0.1.0/.gitignore +9 -0
- bosch_shc_camera_client-0.1.0/CHANGELOG.md +7 -0
- bosch_shc_camera_client-0.1.0/LICENSE +21 -0
- bosch_shc_camera_client-0.1.0/PKG-INFO +66 -0
- bosch_shc_camera_client-0.1.0/README.md +33 -0
- bosch_shc_camera_client-0.1.0/pyproject.toml +39 -0
- bosch_shc_camera_client-0.1.0/src/bosch_shc_camera_client/__init__.py +5 -0
- bosch_shc_camera_client-0.1.0/src/bosch_shc_camera_client/local_rcp.py +140 -0
- bosch_shc_camera_client-0.1.0/src/bosch_shc_camera_client/py.typed +0 -0
- bosch_shc_camera_client-0.1.0/tests/test_import.py +7 -0
|
@@ -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,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.
|
|
File without changes
|