py-bragerone 0.2.4__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,96 @@
1
+ name: CI / Publish (TestPyPI & PyPI)
2
+
3
+ on:
4
+ push:
5
+ branches: [ "main" ]
6
+ tags:
7
+ - "v*.*.*"
8
+ workflow_dispatch: {}
9
+
10
+ jobs:
11
+ build-and-test:
12
+ name: Build & Test
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - name: Checkout (with tags)
16
+ uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0 # potrzebne dla setuptools-scm
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.11"
24
+
25
+ - name: Cache pip
26
+ uses: actions/cache@v4
27
+ with:
28
+ path: ~/.cache/pip
29
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
30
+
31
+ - name: Install build & test deps
32
+ run: |
33
+ python -m pip install --upgrade pip
34
+ pip install -e .[test] build
35
+ # Jeśli nie masz extra "test", zamień na: pip install -r requirements.txt -r requirements-dev.txt
36
+
37
+ - name: Run tests
38
+ run: pytest -q
39
+
40
+ - name: Build sdist + wheel
41
+ run: python -m build
42
+
43
+ - name: Upload dist artifacts
44
+ uses: actions/upload-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/*
48
+
49
+ publish-testpypi:
50
+ name: Publish dev to TestPyPI (on push to main)
51
+ needs: build-and-test
52
+ if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main') }}
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - uses: actions/download-artifact@v4
56
+ with:
57
+ name: dist
58
+ path: dist
59
+
60
+ - name: Publish to TestPyPI
61
+ uses: pypa/gh-action-pypi-publish@release/v1
62
+ with:
63
+ repository-url: https://test.pypi.org/legacy/
64
+ password: ${{ secrets.TEST_PYPI_API_TOKEN }}
65
+ skip-existing: true # nie wywali się, jeśli ta wersja już jest
66
+
67
+ - name: Smoke test install from TestPyPI
68
+ run: |
69
+ python -m venv .venv
70
+ . .venv/bin/activate
71
+ python -m pip install --upgrade pip
72
+ pip install --index-url https://test.pypi.org/simple/ \
73
+ --extra-index-url https://pypi.org/simple \
74
+ py-bragerone
75
+ python - <<'PY'
76
+ import bragerone as m
77
+ print("Imported OK:", getattr(m, "__version__", "no __version__"))
78
+ PY
79
+
80
+ publish-pypi:
81
+ name: Publish release to PyPI (on tag vX.Y.Z)
82
+ needs: build-and-test
83
+ if: ${{ startsWith(github.ref, 'refs/tags/v') }}
84
+ runs-on: ubuntu-latest
85
+ steps:
86
+ - uses: actions/download-artifact@v4
87
+ with:
88
+ name: dist
89
+ path: dist
90
+
91
+ - name: Publish to PyPI
92
+ uses: pypa/gh-action-pypi-publish@release/v1
93
+ with:
94
+ password: ${{ secrets.PYPI_API_TOKEN }}
95
+ skip-existing: false
96
+
@@ -0,0 +1,85 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ .Python
8
+ build/
9
+ develop-eggs/
10
+ dist/
11
+ downloads/
12
+ eggs/
13
+ .eggs/
14
+ lib/
15
+ lib64/
16
+ parts/
17
+ sdist/
18
+ var/
19
+ wheels/
20
+ share/python-wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+ MANIFEST
25
+ *.pyc
26
+ *.pyo
27
+
28
+ # PyInstaller
29
+ *.manifest
30
+ *.spec
31
+
32
+ # Installer logs
33
+ pip-log.txt
34
+ pip-delete-this-directory.txt
35
+
36
+ # Unit test / coverage reports
37
+ htmlcov/
38
+ .tox/
39
+ .nox/
40
+ .coverage
41
+ .coverage.*
42
+ .cache
43
+ nosetests.xml
44
+ coverage.xml
45
+ *.cover
46
+ *.py,cover
47
+ .hypothesis/
48
+ .pytest_cache/
49
+ cover/
50
+
51
+ # Jupyter Notebook
52
+ .ipynb_checkpoints
53
+
54
+ # PyCharm / VS Code
55
+ .idea/
56
+ .vscode/
57
+
58
+ # mypy / type checker
59
+ .mypy_cache/
60
+ .dmypy.json
61
+ dmypy.json
62
+ .pyre/
63
+ .pytype/
64
+
65
+ # Cython debug symbols
66
+ cython_debug/
67
+
68
+ # Virtual environments
69
+ .env
70
+ .venv
71
+ env/
72
+ venv/
73
+ ENV/
74
+ env.bak/
75
+ venv.bak/
76
+
77
+ # Mac / Windows system files
78
+ .DS_Store
79
+ Thumbs.db
80
+
81
+ # local
82
+ labels_debug.log
83
+ .assets/
84
+ *.save
85
+ py-bragerone.*
@@ -0,0 +1,25 @@
1
+ ---
2
+
3
+ ## `CHANGELOG.md`
4
+
5
+ ```markdown
6
+ # Changelog
7
+
8
+ ## [0.2.0] - 2025-09-08
9
+ ### Added
10
+ - Split into modules: `api.py`, `ws.py`, `gateway.py`, `labels.py`, `const.py`.
11
+ - CLI entrypoint `bragerone` with `--email/--password/--object-id/--lang/--log-level`.
12
+ - Initial snapshot fetch and WS subscription (parameters & activity).
13
+ - Human-readable change logs with previous → new value.
14
+
15
+ ### Changed
16
+ - Refactor: Gateway orchestrates REST + WS; API handles REST; WS handles socket wiring; labels kept standalone.
17
+ - Logging cleanup and levels clarified (INFO/DEBUG).
18
+
19
+ ### Fixed
20
+ - Stable WS connect (namespace `/ws`, correct socket.io path, auth header).
21
+ - Robust modules listing and device selection.
22
+
23
+ ## [0.1.0] - 2025-09-01
24
+ ### Added
25
+ - Initial working version (REST + WS combined).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [year] [fullname]
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,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-bragerone
3
+ Version: 0.2.4
4
+ Summary: Brager One client: REST + WebSocket + label resolver for Home Assistant & tools
5
+ Author: ChatGPT5
6
+ Author-email: MarPi82 <marpi82@users.noreply.github.com>
7
+ Maintainer-email: MarPi82 <marpi82@users.noreply.github.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) [year] [fullname]
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ Project-URL: Homepage, https://github.com/marpi82/py-bragerone
31
+ Project-URL: Issues, https://github.com/marpi82/py-bragerone/issues
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.10
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Topic :: Home Automation
39
+ Requires-Python: >=3.10
40
+ Description-Content-Type: text/markdown
41
+ License-File: LICENSE
42
+ Requires-Dist: aiohttp>=3.9
43
+ Requires-Dist: python-socketio[asyncio_client]>=5.11
44
+ Provides-Extra: test
45
+ Requires-Dist: pytest>=8; extra == "test"
46
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
47
+ Requires-Dist: packaging>=24; extra == "test"
48
+ Dynamic: license-file
49
+
50
+ # py-bragerone
51
+
52
+ Python client library for [one.brager.pl](https://one.brager.pl).
53
+
54
+ Features:
55
+ - **REST API**: login, list modules, parameters snapshot
56
+ - **WebSocket (Socket.IO)**: real-time parameter changes
57
+ - **Labels**: human-readable names & units (safe fallbacks, parser WIP)
58
+ - **Gateway**: thin facade for HA/integrations or console usage
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ pip install py-bragerone
64
+ ```
65
+ ## Quick start
66
+ ```python
67
+ import asyncio
68
+ from bragerone.gateway import Gateway
69
+
70
+ async def main():
71
+ g = Gateway(email="you@example.com", password="secret", object_id=439, lang="en")
72
+ await g.login()
73
+ await g.pick_modules()
74
+ await g.bootstrap_labels()
75
+ await g.initial_snapshot()
76
+ await g.start_ws() # keeps listening
77
+
78
+ asyncio.run(main())
79
+ ```
80
+
81
+ ## CLI
82
+ ```bash
83
+ python -m bragerone --email you@example.com --password secret --object-id 439 --lang en --log-level DEBUG
84
+ ```
85
+
86
+ ## DEV
87
+ Code under src/bragerone/
88
+ Tests in tests/
89
+ Run tests: pytest -q
90
+
91
+ ### License
92
+ [MIT](LICENSE.md) © MarPi82
93
+
@@ -0,0 +1,44 @@
1
+ # py-bragerone
2
+
3
+ Python client library for [one.brager.pl](https://one.brager.pl).
4
+
5
+ Features:
6
+ - **REST API**: login, list modules, parameters snapshot
7
+ - **WebSocket (Socket.IO)**: real-time parameter changes
8
+ - **Labels**: human-readable names & units (safe fallbacks, parser WIP)
9
+ - **Gateway**: thin facade for HA/integrations or console usage
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install py-bragerone
15
+ ```
16
+ ## Quick start
17
+ ```python
18
+ import asyncio
19
+ from bragerone.gateway import Gateway
20
+
21
+ async def main():
22
+ g = Gateway(email="you@example.com", password="secret", object_id=439, lang="en")
23
+ await g.login()
24
+ await g.pick_modules()
25
+ await g.bootstrap_labels()
26
+ await g.initial_snapshot()
27
+ await g.start_ws() # keeps listening
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ ## CLI
33
+ ```bash
34
+ python -m bragerone --email you@example.com --password secret --object-id 439 --lang en --log-level DEBUG
35
+ ```
36
+
37
+ ## DEV
38
+ Code under src/bragerone/
39
+ Tests in tests/
40
+ Run tests: pytest -q
41
+
42
+ ### License
43
+ [MIT](LICENSE.md) © MarPi82
44
+
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "setuptools-scm>=8", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "py-bragerone"
7
+ dynamic = ["version"] # wersja zaciągana z tagów git (setuptools-scm)
8
+ description = "Brager One client: REST + WebSocket + label resolver for Home Assistant & tools"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [
12
+ { name = "MarPi82", email = "marpi82@users.noreply.github.com" },
13
+ { name = "ChatGPT5" }
14
+ ]
15
+ maintainers = [
16
+ { name = "MarPi82", email = "marpi82@users.noreply.github.com" }
17
+ ]
18
+ requires-python = ">=3.10"
19
+ dependencies = [
20
+ "aiohttp>=3.9",
21
+ "python-socketio[asyncio_client]>=5.11",
22
+ ]
23
+
24
+ classifiers = [
25
+ "License :: OSI Approved :: MIT License",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.10",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Operating System :: OS Independent",
31
+ "Topic :: Home Automation",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/marpi82/py-bragerone"
36
+ Issues = "https://github.com/marpi82/py-bragerone/issues"
37
+
38
+ [project.scripts]
39
+ bragerone-cli = "bragerone.__main__:main"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ # --- setuptools-scm konfiguracja ---
45
+ [tool.setuptools_scm]
46
+ # Domyślnie akceptuje tagi w stylu v1.2.3 lub 1.2.3; nie musisz nic zmieniać.
47
+ # Jeśli budujesz z commita bez taga, dostaniesz wersję dev/post:
48
+ version_scheme = "guess-next-dev"
49
+ local_scheme = "no-local-version"
50
+ fallback_version = "0.0.0"
51
+
52
+ [project.optional-dependencies]
53
+ test = [
54
+ "pytest>=8",
55
+ "pytest-asyncio>=0.23",
56
+ "packaging>=24",
57
+ ]
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ addopts = -q
3
+ filterwarnings =
4
+ ignore::DeprecationWarning
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ # src/bragerone/__init__.py
2
+ from importlib.metadata import version, PackageNotFoundError
3
+
4
+ try:
5
+ __version__ = version("py-bragerone")
6
+ except PackageNotFoundError:
7
+ __version__ = "0.0.0"
8
+
9
+ from .gateway import Gateway
10
+
11
+ __all__ = ["Gateway", "__version__"]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import asyncio
4
+ import logging
5
+ from .gateway import Gateway
6
+
7
+ def setup_logging(level: str):
8
+ lvl = getattr(logging, level.upper(), logging.INFO)
9
+ logging.basicConfig(
10
+ level=lvl,
11
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
12
+ )
13
+
14
+ async def main():
15
+ p = argparse.ArgumentParser()
16
+ p.add_argument("--email", required=True)
17
+ p.add_argument("--password", required=True)
18
+ p.add_argument("--object-id", type=int, required=True)
19
+ p.add_argument("--lang", default="en")
20
+ p.add_argument("--log-level", default="INFO")
21
+ args = p.parse_args()
22
+
23
+ setup_logging(args.log_level)
24
+ log = logging.getLogger("bragerone")
25
+
26
+ g = Gateway(args.email, args.password, object_id=args.object_id, lang=args.lang)
27
+ try:
28
+ await g.login()
29
+ await g.pick_modules()
30
+ await g.bootstrap_labels()
31
+ await g.initial_snapshot()
32
+ await g.start_ws()
33
+ while True:
34
+ await asyncio.sleep(3600)
35
+ finally:
36
+ await g.close()
37
+
38
+ if __name__ == "__main__":
39
+ asyncio.run(main())
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import aiohttp
4
+ from typing import Any, Optional
5
+
6
+ from .const import API_BASE, AUTH_URL, ORIGIN, REFERER
7
+
8
+ DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=25)
9
+
10
+ class Api:
11
+ def __init__(self, session: Optional[aiohttp.ClientSession] = None):
12
+ self.http = session
13
+ self._own = False
14
+ self.jwt: Optional[str] = None
15
+
16
+ async def ensure(self):
17
+ if self.http is None:
18
+ self.http = aiohttp.ClientSession()
19
+ self._own = True
20
+
21
+ async def close(self):
22
+ if self._own and self.http:
23
+ await self.http.close()
24
+
25
+ async def _req(self, method: str, url: str, *, headers: dict | None = None, **kw) -> Any:
26
+ await self.ensure()
27
+ headers = headers or {}
28
+ headers.setdefault("Accept", "application/json, text/plain, */*")
29
+ headers.setdefault("Origin", ORIGIN)
30
+ headers.setdefault("Referer", REFERER)
31
+ if self.jwt:
32
+ headers["Authorization"] = f"Bearer {self.jwt}"
33
+ kw.setdefault("timeout", DEFAULT_TIMEOUT)
34
+ async with self.http.request(method, url, headers=headers, **kw) as r:
35
+ txt = await r.text()
36
+ ct = r.headers.get("content-type", "")
37
+ if r.status >= 400:
38
+ raise RuntimeError(f"{method} {url} -> {r.status}: {txt[:400]}")
39
+ if "application/json" in ct or txt.startswith(("{","[")):
40
+ try:
41
+ return json.loads(txt)
42
+ except json.JSONDecodeError:
43
+ return txt
44
+ return txt
45
+
46
+ # ---------- high-level ----------
47
+ async def login(self, email: str, password: str) -> dict:
48
+ data = await self._req("POST", AUTH_URL, json={"email": email, "password": password})
49
+ tok = data.get("accessToken") if isinstance(data, dict) else None
50
+ if not tok:
51
+ raise RuntimeError("Brak accessToken w odpowiedzi logowania")
52
+ self.jwt = tok
53
+ return data
54
+
55
+ async def list_objects(self) -> list[dict]:
56
+ objs: list[dict] = []
57
+ try:
58
+ d = await self._req("GET", f"{API_BASE}/objects")
59
+ items = d.get("data") or d.get("items") or d.get("objects") or d
60
+ if isinstance(items, list):
61
+ for it in items:
62
+ oid = it.get("id") or it.get("group_id") or it.get("object_id")
63
+ name = it.get("name") or it.get("title") or it.get("label") or f"Object {oid}"
64
+ if oid is not None:
65
+ objs.append({"id": int(oid), "name": name})
66
+ except Exception:
67
+ pass
68
+ if not objs:
69
+ try:
70
+ u = await self._req("GET", f"{API_BASE}/user")
71
+ cand = u.get("objects") or u.get("groups") or u.get("data", {}).get("groups") or []
72
+ for it in cand:
73
+ oid = it.get("id") or it.get("group_id")
74
+ name = it.get("name") or it.get("title") or it.get("label") or f"Object {oid}"
75
+ if oid is not None:
76
+ objs.append({"id": int(oid), "name": name})
77
+ except Exception:
78
+ pass
79
+ return list({o["id"]: o for o in objs}.values())
80
+
81
+ async def list_modules(self, object_id: int) -> list[dict]:
82
+ d = await self._req("GET", f"{API_BASE}/modules?page=1&limit=999&group_id={object_id}")
83
+ items = d.get("data") or d.get("items") or d.get("modules") or d
84
+ return items if isinstance(items, list) else []
85
+
86
+ async def snapshot_parameters(self, devs: list[str]) -> dict:
87
+ res = await self._req("POST", f"{API_BASE}/modules/parameters", json={"modules": devs})
88
+ return res if isinstance(res, dict) else {}
89
+
90
+ async def activity_quantity(self, devs: list[str]) -> dict:
91
+ res = await self._req("POST", f"{API_BASE}/modules/activity/quantity", json={"modules": devs})
92
+ return res if isinstance(res, dict) else {}
93
+
94
+ async def modules_connect(self, wsid: str, devs: list[str], object_id: int | None = None) -> bool:
95
+ headers = {
96
+ "X-Requested-With": "XMLHttpRequest",
97
+ "Content-Type": "application/json;charset=UTF-8",
98
+ }
99
+ payloads = [
100
+ {"wsid": wsid, "modules": devs},
101
+ {"sid": wsid, "modules": devs},
102
+ {"wsid": wsid, "group_id": object_id, "modules": devs} if object_id else None,
103
+ ]
104
+ for pl in payloads:
105
+ if not pl: continue
106
+ try:
107
+ res = await self._req("POST", f"{API_BASE}/modules/connect", json=pl, headers=headers)
108
+ if isinstance(res, dict):
109
+ return True
110
+ except Exception:
111
+ continue
112
+ return False
113
+
@@ -0,0 +1,14 @@
1
+ IO_BASE = "https://io.brager.pl" # API + login + WS
2
+ ONE_BASE = "https://one.brager.pl" # assety frontendu
3
+ API_BASE = f"{IO_BASE}/v1"
4
+
5
+ # HTTP dekoracja (dla assetów/frontu)
6
+ ORIGIN = ONE_BASE
7
+ REFERER = f"{ONE_BASE}/"
8
+
9
+ # WS / Socket.IO
10
+ WS_NAMESPACE = "/ws"
11
+ SOCK_PATH = "/socket.io"
12
+
13
+ # Endpoints
14
+ AUTH_URL = f"{API_BASE}/auth/user"
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from typing import Any, Dict, Optional
7
+
8
+ from .const import IO_BASE, ONE_BASE, API_BASE, WS_NAMESPACE
9
+ from .api import Api
10
+ from .labels import LabelFetcher
11
+ from .ws import WsClient
12
+
13
+
14
+ class Gateway:
15
+ """
16
+ Spina Api (REST), LabelFetcher (etykiety z frontendowych assetów) i WsClient (socket.io).
17
+ Utrzymuje prosty stan ostatnich wartości i robi czytelne logi zmian.
18
+ """
19
+
20
+ def __init__(self, email: str, password: str, object_id: int, lang: str = "en"):
21
+ self.email = email
22
+ self.password = password
23
+ self.object_id = object_id
24
+ self.lang = lang
25
+
26
+ self.log = logging.getLogger("BragerOne")
27
+ self.api = Api()
28
+ self.labels = LabelFetcher(base_url=ONE_BASE, http_get=self._http_get)
29
+ self.ws = WsClient(self.api, logger=self.log.getChild("ws"))
30
+
31
+ self.jwt: Optional[str] = None
32
+ self.devids: list[str] = []
33
+ self._state: Dict[str, Any] = {} # np. {"P6.v0": 65}
34
+
35
+ # CB z WS
36
+ self.ws.add_event_cb(self._on_ws_event)
37
+ self.ws.add_change_cb(self._on_ws_change)
38
+
39
+ # -------- helpers --------
40
+
41
+ async def _http_get(self, url: str) -> str:
42
+ # delegat dla LabelFetcher-a
43
+ return await self.api._req("GET", url)
44
+
45
+ def _pretty_name(self, pool: str, var: str) -> str:
46
+ # stosujemy aliasy z LabelFetcher, jeśli są; inaczej proste pool.var
47
+ label = self.labels.param_label(pool, var, self.lang)
48
+ if label:
49
+ return f"{pool}.{var} [{label}]"
50
+ # druga próba: label po numerze (vNN -> NN)
51
+ try:
52
+ if var and var[0].isalpha():
53
+ num = int(var[1:])
54
+ else:
55
+ num = None
56
+ except Exception:
57
+ num = None
58
+ if num is not None:
59
+ lbl2 = self.labels.param_label(pool, num, self.lang)
60
+ if lbl2:
61
+ return f"{pool}.{var} [{lbl2}]"
62
+ return f"{pool}.{var}"
63
+
64
+ # -------- public flow --------
65
+
66
+ async def login(self) -> None:
67
+ await self.api.ensure()
68
+ await self.api.login(self.email, self.password)
69
+ self.jwt = self.api.jwt
70
+ self.log.info("Login OK")
71
+
72
+ async def pick_modules(self) -> None:
73
+ mods = await self.api.list_modules(self.object_id)
74
+ devids = [m.get("devid") or m.get("device_id") or m.get("id") for m in mods if m]
75
+ self.devids = [d for d in devids if d]
76
+ if not self.devids:
77
+ raise RuntimeError("No modules for that object_id")
78
+ self.log.info("Modules: %s", self.devids)
79
+
80
+ async def bootstrap_labels(self) -> None:
81
+ try:
82
+ await self.labels.bootstrap(lang=self.lang)
83
+ self.log.debug("[labels] bootstrap ok")
84
+ except Exception as e:
85
+ self.log.warning("[labels] bootstrap failed: %s", e)
86
+
87
+ async def initial_snapshot(self) -> None:
88
+ snap = await self.api.snapshot_parameters(self.devids)
89
+ # flatten + log
90
+ cnt = 0
91
+ for _dev, pools in (snap or {}).items():
92
+ if not isinstance(pools, dict):
93
+ continue
94
+ for pool, vars_ in (pools or {}).items():
95
+ if not isinstance(vars_, dict):
96
+ continue
97
+ for var, meta in (vars_ or {}).items():
98
+ if not isinstance(meta, dict):
99
+ continue
100
+ val = meta.get("value")
101
+ key = f"{pool}.{var}"
102
+ self._state[key] = val
103
+ self.log.info("[init] %s = %s", self._pretty_name(pool, var), val)
104
+ cnt += 1
105
+ self.log.debug("[init] snapshot items: %d", cnt)
106
+
107
+ async def start_ws(self) -> None:
108
+ """
109
+ Proxy dla kompatybilności z __main__.py.
110
+ Łączy WS, wiąże sesję z modułem i subskrybuje zmiany parametrów.
111
+ """
112
+ if not self.jwt:
113
+ raise RuntimeError("JWT is empty – call login() first")
114
+
115
+ # 1) uruchom websocket (autoryzacja tokenem)
116
+ await self.ws.start_ws(self.jwt, namespace=WS_NAMESPACE)
117
+ self.log.info("WS connected %s", WS_NAMESPACE)
118
+
119
+ # 2) powiązanie sesji WS z modułami przez REST
120
+ sid = self.ws.get_sid()
121
+ ok = await self.api.modules_connect(sid, self.devids, object_id=self.object_id)
122
+ self.log.info("modules.connect: %s", ok)
123
+
124
+ # 3) subskrypcje (to co wcześniej działało)
125
+ await self.ws.subscribe(self.devids, namespace=WS_NAMESPACE)
126
+
127
+ async def connect_ws(self) -> None:
128
+ if not self.jwt:
129
+ raise RuntimeError("JWT missing; call login() first")
130
+
131
+ await self.ws.start_ws(self.jwt, namespace=WS_NAMESPACE)
132
+
133
+ # powiązanie sesji WS z modułem przez REST (to jest endpoint HTTP)
134
+ try:
135
+ ok = await self.api.modules_connect(self.ws.sio.sid, self.devids, object_id=self.object_id)
136
+ self.log.info("modules.connect: %s", ok)
137
+ except Exception as e:
138
+ self.log.warning("modules.connect failed: %s", e)
139
+
140
+ # subskrypcje
141
+ await self.ws.subscribe(self.devids, namespace=WS_NAMESPACE)
142
+
143
+ async def run_full_flow(self) -> None:
144
+ """
145
+ Używane przez CLI: login -> modules -> labels -> snapshot -> WS -> wait.
146
+ """
147
+ await self.login()
148
+ await self.pick_modules()
149
+ await self.bootstrap_labels()
150
+ await self.initial_snapshot()
151
+ await self.connect_ws()
152
+ await self.ws.wait_forever()
153
+
154
+ async def close(self) -> None:
155
+ await self.ws.close()
156
+ await self.api.close()
157
+
158
+ # -------- WS callbacks --------
159
+
160
+ def _on_ws_event(self, name: str, data: Any) -> None:
161
+ # lekki log przydatny w debug
162
+ try:
163
+ if isinstance(data, (dict, list)):
164
+ self.log.debug("[ws %s] %s", name, json.dumps(data, ensure_ascii=False))
165
+ else:
166
+ self.log.debug("[ws %s] %r", name, data)
167
+ except Exception:
168
+ self.log.debug("[ws %s] %r", name, data)
169
+
170
+ def _on_ws_change(self, payload: dict) -> None:
171
+ # payload: {"<devid>":{"P6":{"v0":{"value":65}}}}
172
+ try:
173
+ for _devid, pools in (payload or {}).items():
174
+ if not isinstance(pools, dict):
175
+ continue
176
+ for pool, vars_ in pools.items():
177
+ if not isinstance(vars_, dict):
178
+ continue
179
+ for var, meta in vars_.items():
180
+ new_val = meta.get("value") if isinstance(meta, dict) else meta
181
+ key = f"{pool}.{var}"
182
+ old_val = self._state.get(key)
183
+ if new_val != old_val:
184
+ self._state[key] = new_val
185
+ self.log.info("[change] %s: %s -> %s",
186
+ self._pretty_name(pool, var), old_val, new_val)
187
+ except Exception as e:
188
+ self.log.debug("on_change parse error: %s | raw=%s", e, payload)
189
+
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ from typing import Callable, Dict, Optional
4
+
5
+ from .const import ONE_BASE
6
+
7
+ HttpGet = Callable[[str], "str | bytes"]
8
+
9
+ # NOTE: simple safe fallback resolver; parser WIP
10
+ POOL_NAMES_PL = {
11
+ "P4": "Sensors",
12
+ "P5": "Statuses",
13
+ "P6": "Boiler settings",
14
+ "P7": "P7",
15
+ "P8": "Login/Passwords",
16
+ "P10": "Burner settings",
17
+ "P11": "Hardware/Software",
18
+ "P12": "Thermostats",
19
+ "P17": "P17",
20
+ }
21
+
22
+ class LabelFetcher:
23
+ def __init__(self, base_url: str = ONE_BASE, http_get: Optional[HttpGet] = None, logger: Optional[logging.Logger] = None):
24
+ self.base_url = base_url.rstrip("/")
25
+ self.http_get = http_get
26
+ self.log = logger or logging.getLogger("bragerone.labels")
27
+
28
+ # alias maps: "parameters.PARAM_7" -> {"pl":"Temperatura załączenia pomp", ...}
29
+ self._alias_lang_map: Dict[str, Dict[str, str]] = {}
30
+ # reverse map: ("P6", 7) -> "parameters.PARAM_7"
31
+ self._param_alias: Dict[tuple[str, int], str] = {}
32
+
33
+ # --- public API ---
34
+ async def bootstrap(self, lang: str = "en"):
35
+ """
36
+ In the future: fetch index bundle(s), parse `parameters-*.js` assets, etc.
37
+ For now: keep it no-op but safe, so logging works and nothing crashes.
38
+ """
39
+ # No-op parser (WIP). Keep your earlier dumps for the next step.
40
+ return
41
+
42
+ def count_vars(self) -> int:
43
+ return len(self._param_alias)
44
+
45
+ def count_langs(self) -> int:
46
+ # count unique languages across all aliases
47
+ langs = set()
48
+ for d in self._alias_lang_map.values():
49
+ langs.update(d.keys())
50
+ return len(langs)
51
+
52
+ def param_label(self, pool: str, num: int, lang: str = "en") -> Optional[str]:
53
+ """
54
+ Return human-readable label for (pool, num); if not known, fall back to None.
55
+ """
56
+ alias = self._param_alias.get((pool, num))
57
+ if alias:
58
+ tr = self._alias_lang_map.get(alias, {})
59
+ # try requested lang
60
+ if lang in tr and tr[lang]:
61
+ return tr[lang]
62
+ # try English
63
+ if "en" in tr and tr["en"]:
64
+ return tr["en"]
65
+ # try Polish
66
+ if "pl" in tr and tr["pl"]:
67
+ return tr["pl"]
68
+ return None
69
+
70
+ def pool_human(self, pool: str, lang: str = "en") -> str:
71
+ # simple fallback pool names
72
+ return POOL_NAMES_PL.get(pool, pool)
73
+
74
+ # --- helpers for Gateway ---
75
+ def pretty(self, pool: str, var: str, lang: str = "en") -> str:
76
+ num = None
77
+ if var and var[:1] in ("v", "u", "s", "n", "x"):
78
+ try:
79
+ num = int(var[1:])
80
+ except Exception:
81
+ pass
82
+ if num is not None:
83
+ label = self.param_label(pool, num, lang)
84
+ if label:
85
+ return f"[{self.pool_human(pool, lang)}] {pool}.{var} – {label}"
86
+ return f"[{self.pool_human(pool, lang)}] {pool}.{var}"
87
+ return f"{pool}.{var}"
88
+
@@ -0,0 +1,120 @@
1
+ # ws.py
2
+ from __future__ import annotations
3
+ import socketio
4
+ from typing import Any, Callable, Optional
5
+
6
+ from .const import IO_BASE, ONE_BASE, WS_NAMESPACE
7
+
8
+ SOCK_PATH = "/socket.io"
9
+
10
+ class WsClient:
11
+ def __init__(self, api, logger=None):
12
+ self.api = api
13
+ self.log = logger
14
+ self.sio: Optional[socketio.AsyncClient] = None
15
+ self._ws_sid: Optional[str] = None
16
+ self._ns: str = WS_NAMESPACE
17
+ self._on_event: list[Callable[[str, Any], None]] = []
18
+ self._on_change: list[Callable[[dict], None]] = []
19
+
20
+ # --- kompatybilność: używa tego Gateway ---
21
+ @property
22
+ def wsid(self) -> Optional[str]:
23
+ return self.get_sid()
24
+
25
+ def get_sid(self) -> Optional[str]:
26
+ if self._ws_sid:
27
+ return self._ws_sid
28
+ if self.sio:
29
+ try:
30
+ sid_ns = self.sio.get_sid(self._ns)
31
+ except Exception:
32
+ sid_ns = None
33
+ return sid_ns or getattr(self.sio, "sid", None)
34
+ return None
35
+
36
+ # --- callbacki opcjonalne (jeśli ich używasz gdzie indziej) ---
37
+ def add_event_cb(self, cb): self._on_event.append(cb)
38
+ def add_change_cb(self, cb): self._on_change.append(cb)
39
+
40
+ def _emit_event(self, name: str, data: Any):
41
+ for cb in list(self._on_event):
42
+ try: cb(name, data)
43
+ except Exception: pass
44
+
45
+ def _emit_change(self, payload: dict):
46
+ for cb in list(self._on_change):
47
+ try: cb(payload)
48
+ except Exception: pass
49
+
50
+ def _wire_handlers(self, namespace: str):
51
+ sio = self.sio
52
+
53
+ @sio.on("connect", namespace=namespace)
54
+ async def _on_conn():
55
+ if self.log:
56
+ self.log.info("WS connected %s", namespace)
57
+ self._emit_event("socket.connect", {"ns": namespace})
58
+
59
+ @sio.on("disconnect", namespace=namespace)
60
+ async def _on_disc():
61
+ if self.log:
62
+ self.log.info("WS disconnected %s", namespace)
63
+ self._emit_event("socket.disconnect", {"ns": namespace})
64
+
65
+ @sio.event
66
+ async def connect_error(err):
67
+ if self.log:
68
+ self.log.warning("WS connect_error: %s", err)
69
+ self._emit_event("socket.connect_error", err)
70
+
71
+ @sio.on("app:modules:parameters:change", namespace=namespace)
72
+ async def _on_params_change(payload):
73
+ self._emit_change(payload)
74
+
75
+ @sio.on("app:modules:activity:quantity", namespace=namespace)
76
+ async def _on_act_qty(payload):
77
+ self._emit_event("app:modules:activity:quantity", payload)
78
+
79
+ async def start_ws(self, jwt: str, namespace: str | None = None) -> None:
80
+ ns = namespace or self._ns
81
+ self._ns = ns
82
+ if self.sio is None:
83
+ self.sio = socketio.AsyncClient(reconnection=True)
84
+ self._wire_handlers(ns)
85
+
86
+ headers = {
87
+ "Authorization": f"Bearer {jwt}",
88
+ "Origin": ONE_BASE,
89
+ "Referer": f"{ONE_BASE}/",
90
+ }
91
+ await self.sio.connect(
92
+ IO_BASE, # python-socketio sam zrobi upgrade do WS
93
+ namespaces=[ns],
94
+ transports=["websocket"],
95
+ socketio_path=SOCK_PATH,
96
+ headers=headers,
97
+ )
98
+ # zapamiętaj SID (zarówno namespacowy jak i engine.io fallback)
99
+ try:
100
+ sid_ns = self.sio.get_sid(ns)
101
+ except Exception:
102
+ sid_ns = None
103
+ self._ws_sid = sid_ns or getattr(self.sio, "sid", None)
104
+
105
+ async def subscribe(self, devs: list[str], namespace: str | None = None) -> None:
106
+ if not self.sio:
107
+ return
108
+ ns = namespace or self._ns
109
+ await self.sio.emit("app:modules:parameters:listen", {"modules": devs}, namespace=ns)
110
+ await self.sio.emit("app:modules:activity:quantity:listen", {"modules": devs}, namespace=ns)
111
+
112
+ async def wait(self) -> None:
113
+ if self.sio:
114
+ await self.sio.wait()
115
+
116
+ async def disconnect(self) -> None:
117
+ if self.sio:
118
+ await self.sio.disconnect()
119
+ self._ws_sid = None
120
+
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-bragerone
3
+ Version: 0.2.4
4
+ Summary: Brager One client: REST + WebSocket + label resolver for Home Assistant & tools
5
+ Author: ChatGPT5
6
+ Author-email: MarPi82 <marpi82@users.noreply.github.com>
7
+ Maintainer-email: MarPi82 <marpi82@users.noreply.github.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) [year] [fullname]
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+
30
+ Project-URL: Homepage, https://github.com/marpi82/py-bragerone
31
+ Project-URL: Issues, https://github.com/marpi82/py-bragerone/issues
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.10
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Topic :: Home Automation
39
+ Requires-Python: >=3.10
40
+ Description-Content-Type: text/markdown
41
+ License-File: LICENSE
42
+ Requires-Dist: aiohttp>=3.9
43
+ Requires-Dist: python-socketio[asyncio_client]>=5.11
44
+ Provides-Extra: test
45
+ Requires-Dist: pytest>=8; extra == "test"
46
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
47
+ Requires-Dist: packaging>=24; extra == "test"
48
+ Dynamic: license-file
49
+
50
+ # py-bragerone
51
+
52
+ Python client library for [one.brager.pl](https://one.brager.pl).
53
+
54
+ Features:
55
+ - **REST API**: login, list modules, parameters snapshot
56
+ - **WebSocket (Socket.IO)**: real-time parameter changes
57
+ - **Labels**: human-readable names & units (safe fallbacks, parser WIP)
58
+ - **Gateway**: thin facade for HA/integrations or console usage
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ pip install py-bragerone
64
+ ```
65
+ ## Quick start
66
+ ```python
67
+ import asyncio
68
+ from bragerone.gateway import Gateway
69
+
70
+ async def main():
71
+ g = Gateway(email="you@example.com", password="secret", object_id=439, lang="en")
72
+ await g.login()
73
+ await g.pick_modules()
74
+ await g.bootstrap_labels()
75
+ await g.initial_snapshot()
76
+ await g.start_ws() # keeps listening
77
+
78
+ asyncio.run(main())
79
+ ```
80
+
81
+ ## CLI
82
+ ```bash
83
+ python -m bragerone --email you@example.com --password secret --object-id 439 --lang en --log-level DEBUG
84
+ ```
85
+
86
+ ## DEV
87
+ Code under src/bragerone/
88
+ Tests in tests/
89
+ Run tests: pytest -q
90
+
91
+ ### License
92
+ [MIT](LICENSE.md) © MarPi82
93
+
@@ -0,0 +1,24 @@
1
+ .gitignore
2
+ CHANGELOG.md
3
+ LICENSE
4
+ README.md
5
+ pyproject.toml
6
+ pytest.ini
7
+ .github/workflows/publish.yml
8
+ src/bragerone/__init__.py
9
+ src/bragerone/__main__.py
10
+ src/bragerone/api.py
11
+ src/bragerone/const.py
12
+ src/bragerone/gateway.py
13
+ src/bragerone/labels.py
14
+ src/bragerone/ws.py
15
+ src/py_bragerone.egg-info/PKG-INFO
16
+ src/py_bragerone.egg-info/SOURCES.txt
17
+ src/py_bragerone.egg-info/dependency_links.txt
18
+ src/py_bragerone.egg-info/entry_points.txt
19
+ src/py_bragerone.egg-info/requires.txt
20
+ src/py_bragerone.egg-info/top_level.txt
21
+ tests/test_api.py
22
+ tests/test_labels.py
23
+ tests/test_version.py
24
+ tests/test_ws.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bragerone-cli = bragerone.__main__:main
@@ -0,0 +1,7 @@
1
+ aiohttp>=3.9
2
+ python-socketio[asyncio_client]>=5.11
3
+
4
+ [test]
5
+ pytest>=8
6
+ pytest-asyncio>=0.23
7
+ packaging>=24
@@ -0,0 +1,25 @@
1
+ import pytest
2
+ from bragerone.api import Api
3
+
4
+ def test_gateway_is_exposed_on_top_level():
5
+ import bragerone
6
+ assert hasattr(bragerone, "Gateway"), "Gateway nie jest eksportowany w __all__"
7
+ # opcjonalnie: sprawdź, że to klasa
8
+ assert isinstance(bragerone.Gateway, type)
9
+
10
+ @pytest.mark.asyncio
11
+ async def test_api_construct():
12
+ a = Api()
13
+ assert a.jwt is None
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_api_login_monkeypatch(monkeypatch):
17
+ api = Api()
18
+
19
+ async def fake_req(method, url, **kw):
20
+ return {"accessToken": "abc"}
21
+ monkeypatch.setattr(api, "_req", fake_req)
22
+
23
+ data = await api.login("x","y")
24
+ assert api.jwt == "abc"
25
+ assert "accessToken" in data
@@ -0,0 +1,38 @@
1
+ import asyncio
2
+ import pytest
3
+ from bragerone.labels import LabelFetcher
4
+
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_labels_bootstrap_no_crash():
8
+ """Bootstrap shouldn’t crash and counters should be integers."""
9
+ lf = LabelFetcher()
10
+ # current implementation is a no-op; must not raise
11
+ await lf.bootstrap(lang="pl")
12
+ assert isinstance(lf.count_vars(), int)
13
+ assert isinstance(lf.count_langs(), int)
14
+
15
+
16
+ def test_param_label_unknown_returns_none():
17
+ """
18
+ With the current minimal label store, unknown params have no label.
19
+ That’s fine — higher layers can fall back to raw names.
20
+ """
21
+ lf = LabelFetcher()
22
+ assert lf.param_label("P6", 7, "pl") is None
23
+
24
+
25
+ def test_idempotent_counters():
26
+ """
27
+ Counters should remain stable across no-op bootstrap calls.
28
+ (Regression guard for future changes.)
29
+ """
30
+ lf = LabelFetcher()
31
+ c1_vars = lf.count_vars()
32
+ c1_langs = lf.count_langs()
33
+ # pretend we call bootstrap again (still a no-op today)
34
+ asyncio.get_event_loop().run_until_complete(lf.bootstrap(lang="pl"))
35
+ c2_vars = lf.count_vars()
36
+ c2_langs = lf.count_langs()
37
+ assert c1_vars == c2_vars
38
+ assert c1_langs == c2_langs
@@ -0,0 +1,14 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+ from packaging.version import Version
3
+
4
+ PKG = "py-bragerone"
5
+
6
+ def test_version_string_is_parseable():
7
+ try:
8
+ v = version(PKG)
9
+ except PackageNotFoundError:
10
+ # fallback: import bez instalacji — wersja z __init__.py
11
+ import bragerone as m
12
+ v = getattr(m, "__version__", "0.0.0")
13
+ # parsowalne wg PEP 440 (np. 0.3.0.dev1+gabcdef)
14
+ Version(v) # nie rzuci wyjątku, jeśli OK
@@ -0,0 +1,15 @@
1
+ from bragerone.ws import WsClient
2
+ from bragerone.api import Api
3
+
4
+ def test_ws_construct():
5
+ w = WsClient(Api())
6
+ assert w.api is not None
7
+
8
+ def test_ws_callbacks_collect():
9
+ ws = WsClient(lambda: "tok")
10
+ seen = {}
11
+ ws.add_event_cb(lambda n,d: seen.setdefault("e", 0) or seen.__setitem__("e", 1))
12
+ ws.add_change_cb(lambda p: seen.setdefault("c", 0) or seen.__setitem__("c", 1))
13
+ ws._emit_event("x", {})
14
+ ws._emit_change({})
15
+ assert seen["e"] == 1 and seen["c"] == 1