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.
- py_bragerone-0.2.4/.github/workflows/publish.yml +96 -0
- py_bragerone-0.2.4/.gitignore +85 -0
- py_bragerone-0.2.4/CHANGELOG.md +25 -0
- py_bragerone-0.2.4/LICENSE +21 -0
- py_bragerone-0.2.4/PKG-INFO +93 -0
- py_bragerone-0.2.4/README.md +44 -0
- py_bragerone-0.2.4/pyproject.toml +57 -0
- py_bragerone-0.2.4/pytest.ini +4 -0
- py_bragerone-0.2.4/setup.cfg +4 -0
- py_bragerone-0.2.4/src/bragerone/__init__.py +11 -0
- py_bragerone-0.2.4/src/bragerone/__main__.py +39 -0
- py_bragerone-0.2.4/src/bragerone/api.py +113 -0
- py_bragerone-0.2.4/src/bragerone/const.py +14 -0
- py_bragerone-0.2.4/src/bragerone/gateway.py +189 -0
- py_bragerone-0.2.4/src/bragerone/labels.py +88 -0
- py_bragerone-0.2.4/src/bragerone/ws.py +120 -0
- py_bragerone-0.2.4/src/py_bragerone.egg-info/PKG-INFO +93 -0
- py_bragerone-0.2.4/src/py_bragerone.egg-info/SOURCES.txt +24 -0
- py_bragerone-0.2.4/src/py_bragerone.egg-info/dependency_links.txt +1 -0
- py_bragerone-0.2.4/src/py_bragerone.egg-info/entry_points.txt +2 -0
- py_bragerone-0.2.4/src/py_bragerone.egg-info/requires.txt +7 -0
- py_bragerone-0.2.4/src/py_bragerone.egg-info/top_level.txt +1 -0
- py_bragerone-0.2.4/tests/test_api.py +25 -0
- py_bragerone-0.2.4/tests/test_labels.py +38 -0
- py_bragerone-0.2.4/tests/test_version.py +14 -0
- py_bragerone-0.2.4/tests/test_ws.py +15 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bragerone
|
|
@@ -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
|