py-bragerone 0.2.4__py3-none-any.whl

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.
bragerone/__init__.py ADDED
@@ -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__"]
bragerone/__main__.py ADDED
@@ -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())
bragerone/api.py ADDED
@@ -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
+
bragerone/const.py ADDED
@@ -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"
bragerone/gateway.py ADDED
@@ -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
+
bragerone/labels.py ADDED
@@ -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
+
bragerone/ws.py ADDED
@@ -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,13 @@
1
+ bragerone/__init__.py,sha256=pOiHysImCgZcDcj0Ij-HrUJ1bSf1G8e6W-KkdhqRgco,260
2
+ bragerone/__main__.py,sha256=WYAQHZjV5MtWY-wLuR3lFt73TpTLtyBKgCrVyn4Bjac,1109
3
+ bragerone/api.py,sha256=OWoyGV0hQIu3XTsrJgq4auKq6VkNbHMrrHlQD344_aU,4744
4
+ bragerone/const.py,sha256=hQbbHKbMCWGatJzDtyAKWpW9t2B7dTrvuTxMEXg_dNQ,348
5
+ bragerone/gateway.py,sha256=vNb_EZQDOvUz2KcsRnxr0ktkY-Sd4Yjc6t97WkHV3RU,7053
6
+ bragerone/labels.py,sha256=OM3rkk14cJozGbkOwBMnJRxBdw8zPxoo5y7N_-eGOqY,3013
7
+ bragerone/ws.py,sha256=izfEvy5xScJTgUIsAd4mUNkKWGbhx0r6AGAbpL5uyQQ,4085
8
+ py_bragerone-0.2.4.dist-info/licenses/LICENSE,sha256=ACwmltkrXIz5VsEQcrqljq-fat6ZXAMepjXGoe40KtE,1069
9
+ py_bragerone-0.2.4.dist-info/METADATA,sha256=zE8D6ryd8hI-bwQav8UdMPrerO76shxJztct-P0sNqI,3338
10
+ py_bragerone-0.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ py_bragerone-0.2.4.dist-info/entry_points.txt,sha256=Sk_lJskMh5W9PbX2UGaKXdsjiKT60nVPnX7CfdxPz-E,58
12
+ py_bragerone-0.2.4.dist-info/top_level.txt,sha256=X-2TUaMxKl4AEnygGgc85MGRLTIehxYZHZ6delIWskc,10
13
+ py_bragerone-0.2.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bragerone-cli = bragerone.__main__:main
@@ -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 @@
1
+ bragerone