ecohome 0.1.0__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.
ecohome/__init__.py ADDED
File without changes
ecohome/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ import argparse
2
+ import json as json_module
3
+ import os
4
+ import sys
5
+
6
+ from ecohome.client import EcoHomeClient, SessionExpiredError
7
+
8
+
9
+ def _get_client(args: argparse.Namespace, force_relogin: bool = False) -> EcoHomeClient:
10
+ username = args.username or os.environ.get("ECOHOME_USER")
11
+ password = args.password or os.environ.get("ECOHOME_PASSWORD")
12
+ if not username or not password:
13
+ print(
14
+ "Error: provide --username/--password or set ECOHOME_USER/ECOHOME_PASSWORD",
15
+ file=sys.stderr,
16
+ )
17
+ sys.exit(1)
18
+ return EcoHomeClient.login(username, password, force_relogin=force_relogin)
19
+
20
+
21
+ def _auto_device_code(client: EcoHomeClient, args: argparse.Namespace) -> str:
22
+ if args.device:
23
+ return args.device
24
+ devices = client.list_devices()
25
+ if not devices:
26
+ print("Error: no devices found", file=sys.stderr)
27
+ sys.exit(1)
28
+ if len(devices) > 1:
29
+ lines = "\n".join(f" {d['device_code']} {d['device_nick_name']}" for d in devices)
30
+ print(f"Error: multiple devices found, use --device:\n{lines}", file=sys.stderr)
31
+ sys.exit(1)
32
+ return devices[0]["device_code"]
33
+
34
+
35
+ def _find_card(card_list: list, *, has_modes: bool) -> dict | None:
36
+ for card in card_list:
37
+ if (card.get("modeList") is not None) == has_modes:
38
+ return card
39
+ return None
40
+
41
+
42
+ def _flatten_params(param_list: list) -> list[dict]:
43
+ """Flatten paramListV3 result to a list of items. type=1 nests items inside modules."""
44
+ result = []
45
+ for item in param_list:
46
+ if "moduleContent" in item:
47
+ result.extend(item["moduleContent"])
48
+ else:
49
+ result.append(item)
50
+ return result
51
+
52
+
53
+ def _usable_params(items: list[dict]) -> list[dict]:
54
+ """Return items with non-null, non-N/A values, with parsed value and stripped name."""
55
+ result = []
56
+ for item in items:
57
+ raw = item.get("addressValue")
58
+ if raw is None or raw == "N/A":
59
+ continue
60
+ try:
61
+ value: float | str = float(raw)
62
+ except (ValueError, TypeError):
63
+ value = str(raw)
64
+ result.append({
65
+ "address": item["address"],
66
+ "name": (item.get("pointName") or item["address"]).strip(),
67
+ "value": value,
68
+ "unit": item.get("unit") or "",
69
+ })
70
+ return result
71
+
72
+
73
+ def _fmt_param(p: dict) -> str:
74
+ val = p["value"]
75
+ return f"{val:g}{p['unit']}" if isinstance(val, float) else f"{val}{p['unit']}"
76
+
77
+
78
+ def cmd_status(client: EcoHomeClient, args: argparse.Namespace) -> int:
79
+ device_code = _auto_device_code(client, args)
80
+ detail = client.get_device_detail(device_code)
81
+ unit = detail.get("curUnit", "°C")
82
+
83
+ heating = _find_card(detail["cardList"], has_modes=True)
84
+ hot_water = _find_card(detail["cardList"], has_modes=False)
85
+
86
+ sensors = _usable_params(_flatten_params(client.get_param_list(device_code, 0)))
87
+ operational = _usable_params(_flatten_params(client.get_param_list(device_code, 1)))
88
+
89
+ if args.json:
90
+ output: dict = {}
91
+ if heating:
92
+ output["heating"] = {
93
+ "on": heating["curSwitch"],
94
+ "current_temp_main": float(heating["curTempMain"]) if heating.get("curTempMain") else None,
95
+ "current_temp_minor": float(heating["curTempMinor"]) if heating.get("curTempMinor") else None,
96
+ "target_temp": float(heating["settingTemp"]) if heating.get("settingTemp") else None,
97
+ "mode": heating["modeList"][0]["modeMeaning"] if heating.get("modeList") else None,
98
+ }
99
+ if hot_water:
100
+ output["hot_water"] = {
101
+ "on": hot_water["curSwitch"],
102
+ "current_temp": float(hot_water["curTempMain"]) if hot_water.get("curTempMain") else None,
103
+ "target_temp": float(hot_water["settingTemp"]) if hot_water.get("settingTemp") else None,
104
+ }
105
+ if sensors:
106
+ output["sensors"] = {
107
+ p["address"]: {"name": p["name"], "value": p["value"], "unit": p["unit"] or None}
108
+ for p in sensors
109
+ }
110
+ if operational:
111
+ output["operational"] = {
112
+ p["address"]: {"name": p["name"], "value": p["value"], "unit": p["unit"] or None}
113
+ for p in operational
114
+ }
115
+ print(json_module.dumps(output, indent=2))
116
+ else:
117
+ if heating:
118
+ state = "on" if heating["curSwitch"] else "off"
119
+ mode = heating["modeList"][0]["modeMeaning"] if heating.get("modeList") else "unknown"
120
+ t_main = heating.get("curTempMain", "?")
121
+ t_minor = heating.get("curTempMinor")
122
+ t_set = heating.get("settingTemp", "?")
123
+ temps = f"{t_main}{unit} / {t_minor}{unit}" if t_minor else f"{t_main}{unit}"
124
+ print(f"Heating: {state:<3} {temps} → {t_set}{unit} ({mode})")
125
+ if hot_water:
126
+ state = "on" if hot_water["curSwitch"] else "off"
127
+ t_main = hot_water.get("curTempMain", "?")
128
+ t_set = hot_water.get("settingTemp", "?")
129
+ print(f"Hot water: {state:<3} {t_main}{unit} → {t_set}{unit}")
130
+ if sensors:
131
+ print("Sensors:")
132
+ for p in sensors:
133
+ print(f" {p['name']}: {_fmt_param(p)}")
134
+ if operational:
135
+ print("Operational:")
136
+ for p in operational:
137
+ print(f" {p['name']}: {_fmt_param(p)}")
138
+
139
+ return 0
140
+
141
+
142
+ def cmd_heating(client: EcoHomeClient, args: argparse.Namespace) -> int:
143
+ device_code = _auto_device_code(client, args)
144
+ detail = client.get_device_detail(device_code)
145
+ card = _find_card(detail["cardList"], has_modes=True)
146
+ if card is None:
147
+ print("Error: no heating card found on this device", file=sys.stderr)
148
+ sys.exit(1)
149
+ if args.heating_subcommand in ("on", "off"):
150
+ value = args.heating_subcommand == "on"
151
+ client.update_switch_state(device_code, card["switchAddress"], value, dry_run=args.dry_run)
152
+ if not args.dry_run:
153
+ print(f"Heating {'enabled' if value else 'disabled'}.")
154
+ elif args.heating_subcommand == "set-temp":
155
+ client.set_value(device_code, card["settingAddress"], args.value, dry_run=args.dry_run)
156
+ if not args.dry_run:
157
+ print(f"Heating target temperature set to {args.value}.")
158
+ return 0
159
+
160
+
161
+ def cmd_hot_water(client: EcoHomeClient, args: argparse.Namespace) -> int:
162
+ device_code = _auto_device_code(client, args)
163
+ detail = client.get_device_detail(device_code)
164
+ card = _find_card(detail["cardList"], has_modes=False)
165
+ if card is None:
166
+ print("Error: no hot water card found on this device", file=sys.stderr)
167
+ sys.exit(1)
168
+ if args.hw_subcommand in ("on", "off"):
169
+ value = args.hw_subcommand == "on"
170
+ client.update_switch_state(device_code, card["switchAddress"], value, dry_run=args.dry_run)
171
+ if not args.dry_run:
172
+ print(f"Hot water {'enabled' if value else 'disabled'}.")
173
+ elif args.hw_subcommand == "set-temp":
174
+ client.set_value(device_code, card["settingAddress"], args.value, dry_run=args.dry_run)
175
+ if not args.dry_run:
176
+ print(f"Hot water target temperature set to {args.value}.")
177
+ return 0
178
+
179
+
180
+ def main() -> int:
181
+ parser = argparse.ArgumentParser(
182
+ prog="pyecohome",
183
+ description="Control your Eco-Home heat pump from the command line.",
184
+ )
185
+ parser.add_argument("--username", default=None, help="Login username (or set ECOHOME_USER)")
186
+ parser.add_argument("--password", default=None, help="Login password (or set ECOHOME_PASSWORD)")
187
+ parser.add_argument("--device", default=None, help="Device code (auto-selected if only one exists)")
188
+ parser.add_argument("--dry-run", action="store_true", help="Print the request instead of sending it")
189
+
190
+ subparsers = parser.add_subparsers(dest="command", required=True)
191
+
192
+ status_p = subparsers.add_parser("status", help="Show current temperatures and state")
193
+ status_p.add_argument("--json", action="store_true", help="Output as JSON")
194
+
195
+ h_p = subparsers.add_parser("heating", help="Control heating")
196
+ h_sub = h_p.add_subparsers(dest="heating_subcommand", required=True)
197
+ h_sub.add_parser("on", help="Enable heating")
198
+ h_sub.add_parser("off", help="Disable heating")
199
+ h_temp_p = h_sub.add_parser("set-temp", help="Set heating target temperature")
200
+ h_temp_p.add_argument("value", type=int, help="Target temperature")
201
+
202
+ hw_p = subparsers.add_parser("hot-water", help="Control hot water")
203
+ hw_sub = hw_p.add_subparsers(dest="hw_subcommand", required=True)
204
+ hw_sub.add_parser("on", help="Enable hot water")
205
+ hw_sub.add_parser("off", help="Disable hot water")
206
+ hw_temp_p = hw_sub.add_parser("set-temp", help="Set hot water target temperature")
207
+ hw_temp_p.add_argument("value", type=int, help="Target temperature")
208
+
209
+ args = parser.parse_args()
210
+
211
+ for attempt in range(2):
212
+ client = _get_client(args, force_relogin=(attempt > 0))
213
+ try:
214
+ if args.command == "status":
215
+ return cmd_status(client, args)
216
+ if args.command == "heating":
217
+ return cmd_heating(client, args)
218
+ if args.command == "hot-water":
219
+ return cmd_hot_water(client, args)
220
+ return 0
221
+ except SessionExpiredError:
222
+ if attempt == 0:
223
+ continue
224
+ raise
225
+
226
+ return 0
227
+
228
+
229
+ if __name__ == "__main__":
230
+ sys.exit(main())
ecohome/client.py ADDED
@@ -0,0 +1,287 @@
1
+ import asyncio
2
+ import hashlib
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ _CLOUDSERVICE_BASE_URL = "https://ehome.ne01.com/cloudservice/api/app"
11
+ _CRM_BASE_URL = "https://ehome.ne01.com/crmservice/api/app"
12
+ _CREDENTIALS_FILE = Path.home() / ".ecohome" / "credentials.json"
13
+
14
+ _HEADERS = {
15
+ "Content-Type": "application/json;charset=UTF-8",
16
+ "Connection": "keep-alive",
17
+ "Accept": "*/*",
18
+ "app-id-type": "0",
19
+ "User-Agent": (
20
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) "
21
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 "
22
+ "Html5Plus/1.0 (Immersed/20) uni-app"
23
+ ),
24
+ "time-zone": "Europe/Berlin",
25
+ "Accept-Language": "nl-NL,nl;q=0.9",
26
+ }
27
+
28
+
29
+ def _load_credentials() -> dict[str, Any]:
30
+ if not _CREDENTIALS_FILE.exists():
31
+ return {}
32
+ return json.loads(_CREDENTIALS_FILE.read_text())
33
+
34
+
35
+ def _save_credentials(creds: dict[str, Any]) -> None:
36
+ _CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
37
+ _CREDENTIALS_FILE.write_text(json.dumps(creds, indent=2))
38
+ _CREDENTIALS_FILE.chmod(0o600)
39
+
40
+
41
+ class SessionExpiredError(RuntimeError):
42
+ pass
43
+
44
+
45
+ def _raise_on_error(data: dict[str, Any], endpoint: str) -> None:
46
+ if "errorCode" in data: # crmservice: camelCase, int 200 for success
47
+ if data["errorCode"] != 200:
48
+ raise RuntimeError(f"{endpoint} failed: {data['errorCode']} {data.get('errorMsg', 'Unknown error')}")
49
+ elif "error_code" in data: # cloudservice: snake_case, string "0" for success
50
+ if data["error_code"] != "0":
51
+ raise RuntimeError(f"{endpoint} failed: {data['error_code']} {data.get('error_msg', 'Unknown error')}")
52
+ elif "sub_code" in data: # gateway/auth error, e.g. sub_code="-100" means session expired
53
+ if data["sub_code"] == "-100":
54
+ raise SessionExpiredError(f"{endpoint}: session expired")
55
+ raise RuntimeError(f"{endpoint} failed: sub_code={data['sub_code']} {data.get('sub_msg', 'Unknown error')}")
56
+ else:
57
+ raise RuntimeError(f"{endpoint} failed: unrecognized response format: {data}")
58
+
59
+
60
+ class AsyncEcoHomeClient:
61
+ def __init__(
62
+ self,
63
+ *,
64
+ token: str | None = None,
65
+ cookie: dict[str, str] | None = None,
66
+ user_id: str | None = None,
67
+ username: str | None = None,
68
+ ):
69
+ self._token = token
70
+ self._cookie: dict[str, str] = cookie or {}
71
+ self._user_id = user_id
72
+ self._username = username
73
+
74
+ @classmethod
75
+ async def login(
76
+ cls,
77
+ username: str,
78
+ password: str,
79
+ save_credentials: bool = True,
80
+ force_relogin: bool = False,
81
+ ) -> "AsyncEcoHomeClient":
82
+ """Return an authenticated client, reusing stored credentials when available."""
83
+ creds: dict[str, Any] = _load_credentials() if save_credentials else {}
84
+ if not force_relogin and username in creds:
85
+ stored = creds[username]
86
+ client = cls(
87
+ token=stored["x_token"],
88
+ cookie=stored["cookie"],
89
+ user_id=stored["user_id"],
90
+ username=username,
91
+ )
92
+ if await client.is_logged_in():
93
+ return client
94
+
95
+ password_md5 = hashlib.md5(password.encode()).hexdigest()
96
+
97
+ async with httpx.AsyncClient() as http:
98
+ response = await http.post(
99
+ f"{_CLOUDSERVICE_BASE_URL}/user/login.json",
100
+ params={"lang": "nl_NL"},
101
+ headers=_HEADERS,
102
+ json={"user_name": username, "password": password_md5, "type": 2},
103
+ )
104
+ response.raise_for_status()
105
+
106
+ data = response.json()
107
+ _raise_on_error(data, "login")
108
+
109
+ result = data["object_result"]
110
+ user_id = str(result["user_id"])
111
+ x_token = result["x-token"]
112
+ cookie = dict(response.cookies)
113
+
114
+ if save_credentials:
115
+ creds[username] = {
116
+ "user_id": user_id,
117
+ "x_token": x_token,
118
+ "cookie": cookie,
119
+ }
120
+ _save_credentials(creds)
121
+
122
+ return cls(token=x_token, cookie=cookie, user_id=user_id, username=username)
123
+
124
+ async def is_logged_in(self) -> bool:
125
+ """Do an API request to see if the user is logged in."""
126
+ async with self._http() as http:
127
+ response = await http.get(
128
+ f"{_CLOUDSERVICE_BASE_URL}/user/getUserInfo.json",
129
+ params={"lang": "nl_NL"},
130
+ headers=self._auth_headers(),
131
+ )
132
+ try:
133
+ response.raise_for_status()
134
+ _raise_on_error(response.json(), "getUserInfo")
135
+ return True
136
+ except (httpx.HTTPStatusError, RuntimeError):
137
+ return False
138
+
139
+ async def logout(self) -> None:
140
+ """Log out and remove stored credentials for this user."""
141
+ async with self._http() as http:
142
+ response = await http.post(
143
+ f"{_CLOUDSERVICE_BASE_URL}/user/logout.json",
144
+ params={"lang": "nl_NL"},
145
+ headers=self._auth_headers(),
146
+ json={"from_user": self._user_id},
147
+ )
148
+ response.raise_for_status()
149
+
150
+ if self._username:
151
+ creds = _load_credentials()
152
+ creds.pop(self._username, None)
153
+ _save_credentials(creds)
154
+
155
+ self._token = None
156
+ self._cookie = {}
157
+ self._user_id = None
158
+ self._username = None
159
+
160
+ def _auth_headers(self) -> dict[str, str]:
161
+ if self._token is None:
162
+ raise RuntimeError("Not authenticated")
163
+ return {**_HEADERS, "x-token": self._token}
164
+
165
+ def _http(self) -> httpx.AsyncClient:
166
+ return httpx.AsyncClient(cookies=self._cookie)
167
+
168
+ async def list_devices(self, page_size: int = 1000) -> list[dict[str, Any]]:
169
+ async with self._http() as http:
170
+ response = await http.post(
171
+ f"{_CLOUDSERVICE_BASE_URL}/device/deviceList.json",
172
+ params={"lang": "nl_NL"},
173
+ headers=self._auth_headers(),
174
+ json={"page_index": "1", "page_size": str(page_size)},
175
+ )
176
+ response.raise_for_status()
177
+ data = response.json()
178
+ _raise_on_error(data, "deviceList")
179
+ return data["object_result"]
180
+
181
+ async def get_device_base_info(self, device_code: str) -> dict[str, Any]:
182
+ async with self._http() as http:
183
+ response = await http.post(
184
+ f"{_CLOUDSERVICE_BASE_URL}/deviceInfo/getDeviceBaseInfo.json",
185
+ params={"lang": "nl_NL"},
186
+ headers=self._auth_headers(),
187
+ json={"device_code": device_code},
188
+ )
189
+ response.raise_for_status()
190
+ data = response.json()
191
+ _raise_on_error(data, "getDeviceBaseInfo")
192
+ return data["object_result"]
193
+
194
+ async def get_device_detail(self, device_code: str) -> dict[str, Any]:
195
+ async with self._http() as http:
196
+ response = await http.post(
197
+ f"{_CRM_BASE_URL}/deviceInfo/getDeviceDetailV3",
198
+ params={"lang": "nl_NL"},
199
+ headers=self._auth_headers(),
200
+ json={"deviceCode": device_code},
201
+ )
202
+ response.raise_for_status()
203
+ data = response.json()
204
+ _raise_on_error(data, "getDeviceDetailV3")
205
+ return data["objectResult"]
206
+
207
+ async def get_param_list(self, device_code: str, param_type: int) -> list[dict[str, Any]]:
208
+ """Return paramListV3 for the given type: 0=sensors, 1=operational, 2=settings."""
209
+ async with self._http() as http:
210
+ response = await http.post(
211
+ f"{_CRM_BASE_URL}/deviceInfo/paramListV3",
212
+ params={"lang": "nl_NL"},
213
+ headers=self._auth_headers(),
214
+ json={"deviceCode": device_code, "type": param_type, "isAutoRefresh": False},
215
+ )
216
+ response.raise_for_status()
217
+ data = response.json()
218
+ _raise_on_error(data, "paramListV3")
219
+ return data["objectResult"]
220
+
221
+ async def update_switch_state(self, device_code: str, address: str, value: bool, dry_run: bool = False) -> None:
222
+ url = f"{_CLOUDSERVICE_BASE_URL}/deviceInfo/updateSwitchSate.json"
223
+ body = {"device_code": device_code, "address": address, "value": value}
224
+ if dry_run:
225
+ print(f"[dry-run] POST {url}?lang=nl_NL")
226
+ print(json.dumps(body, indent=2))
227
+ return
228
+ async with self._http() as http:
229
+ response = await http.post(url, params={"lang": "nl_NL"}, headers=self._auth_headers(), json=body)
230
+ response.raise_for_status()
231
+ data = response.json()
232
+ _raise_on_error(data, "updateSwitchState")
233
+
234
+ async def set_value(self, device_code: str, address: str, value: int, dry_run: bool = False) -> None:
235
+ url = f"{_CLOUDSERVICE_BASE_URL}/deviceInfo/controlOfValue.json"
236
+ body = {"device_code": device_code, "address": address, "value": value}
237
+ if dry_run:
238
+ print(f"[dry-run] POST {url}?lang=nl_NL")
239
+ print(json.dumps(body, indent=2))
240
+ return
241
+ async with self._http() as http:
242
+ response = await http.post(url, params={"lang": "nl_NL"}, headers=self._auth_headers(), json=body)
243
+ response.raise_for_status()
244
+ data = response.json()
245
+ _raise_on_error(data, "controlOfValue")
246
+
247
+
248
+ class EcoHomeClient:
249
+ """Synchronous wrapper around AsyncEcoHomeClient."""
250
+
251
+ def __init__(self, async_client: AsyncEcoHomeClient):
252
+ self._async = async_client
253
+
254
+ @classmethod
255
+ def login(
256
+ cls,
257
+ username: str,
258
+ password: str,
259
+ save_credentials: bool = True,
260
+ force_relogin: bool = False,
261
+ ) -> "EcoHomeClient":
262
+ """Return an authenticated client, reusing stored credentials when available."""
263
+ return cls(asyncio.run(AsyncEcoHomeClient.login(username, password, save_credentials, force_relogin)))
264
+
265
+ def is_logged_in(self) -> bool:
266
+ return asyncio.run(self._async.is_logged_in())
267
+
268
+ def logout(self) -> None:
269
+ asyncio.run(self._async.logout())
270
+
271
+ def list_devices(self, page_size: int = 1000) -> list[dict[str, Any]]:
272
+ return asyncio.run(self._async.list_devices(page_size))
273
+
274
+ def get_device_base_info(self, device_code: str) -> dict[str, Any]:
275
+ return asyncio.run(self._async.get_device_base_info(device_code))
276
+
277
+ def get_device_detail(self, device_code: str) -> dict[str, Any]:
278
+ return asyncio.run(self._async.get_device_detail(device_code))
279
+
280
+ def get_param_list(self, device_code: str, param_type: int) -> list[dict[str, Any]]:
281
+ return asyncio.run(self._async.get_param_list(device_code, param_type))
282
+
283
+ def update_switch_state(self, device_code: str, address: str, value: bool, dry_run: bool = False) -> None:
284
+ asyncio.run(self._async.update_switch_state(device_code, address, value, dry_run))
285
+
286
+ def set_value(self, device_code: str, address: str, value: int, dry_run: bool = False) -> None:
287
+ asyncio.run(self._async.set_value(device_code, address, value, dry_run))
ecohome/py.typed ADDED
File without changes
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: ecohome
3
+ Version: 0.1.0
4
+ Summary: Open-source Python implementation of the New Energy (Batavia Heat) Eco-Home API
5
+ Author: Sjors Gielen
6
+ Author-email: Sjors Gielen <pypi@sjorsgielen.nl>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Python: >=3.14
11
+ Project-URL: Homepage, https://github.com/sgielen/ecohome
12
+ Project-URL: Issues, https://github.com/sgielen/ecohome/issues
13
+ Description-Content-Type: text/markdown
14
+
15
+ # ecohome
16
+
17
+ Open-source Python client and CLI for the [New Energy
18
+ Eco-Home](https://ehome.ne01.com/) heat pump API, which is used by Batavia Heat
19
+ heat pumps in the Netherlands.
20
+
21
+ ## Installation
22
+
23
+ Requires Python 3.14+. Older versions probably work fine, YMMV.
24
+
25
+ ```
26
+ pip install ecohome
27
+ ```
28
+
29
+ Or from source using [uv](https://github.com/astral-sh/uv):
30
+
31
+ ```
32
+ uv sync
33
+ ```
34
+
35
+ ## CLI usage
36
+
37
+ Credentials can be passed as flags or via environment variables:
38
+
39
+ ```
40
+ export ECOHOME_USER=you@example.com
41
+ export ECOHOME_PASSWORD=yourpassword
42
+ ```
43
+
44
+ After the first successful login, credentials are saved to `~/.ecohome/credentials.json`
45
+ (mode 0600) and reused automatically on subsequent calls.
46
+
47
+ ### Show status
48
+
49
+ ```
50
+ pyecohome status
51
+ ```
52
+
53
+ Example output:
54
+
55
+ ```
56
+ Heating: off 29.2℃ / 27.6℃ → 40.0℃ (Verwarming)
57
+ Hot water: off 61.5℃ → 65.0℃
58
+ ```
59
+
60
+ Add `--json` for machine-readable output:
61
+
62
+ ```
63
+ pyecohome status --json
64
+ ```
65
+
66
+ ```json
67
+ {
68
+ "heating": {
69
+ "on": false,
70
+ "current_temp_main": 29.2,
71
+ "current_temp_minor": 27.6,
72
+ "target_temp": 40.0,
73
+ "mode": "Verwarming"
74
+ },
75
+ "hot_water": {
76
+ "on": false,
77
+ "current_temp": 61.5,
78
+ "target_temp": 65.0
79
+ }
80
+ }
81
+ ```
82
+
83
+ ### Control hot water
84
+
85
+ ```
86
+ pyecohome hot-water on
87
+ pyecohome hot-water off
88
+ ```
89
+
90
+ Use `--dry-run` to print the request that would be sent without actually sending it:
91
+
92
+ ```
93
+ pyecohome hot-water on --dry-run
94
+ ```
95
+
96
+ ### Options
97
+
98
+ | Flag | Description |
99
+ |------|-------------|
100
+ | `--username` | Login username (overrides `ECOHOME_USER`) |
101
+ | `--password` | Login password (overrides `ECOHOME_PASSWORD`) |
102
+ | `--device` | Device code to target (auto-selected when you have only one device) |
103
+ | `--dry-run` | Print the outgoing request instead of sending it |
104
+
105
+ ## Python API
106
+
107
+ ```python
108
+ from ecohome.client import EcoHomeClient
109
+
110
+ client = EcoHomeClient.login("you@example.com", "yourpassword")
111
+
112
+ # List devices
113
+ devices = client.list_devices()
114
+ device_code = devices[0]["device_code"]
115
+
116
+ # Current state
117
+ detail = client.get_device_detail(device_code)
118
+
119
+ # Turn hot water on/off
120
+ client.update_switch_state(device_code, address="1020", value=True)
121
+
122
+ # Log out (also removes saved credentials)
123
+ client.logout()
124
+ ```
125
+
126
+ ## Credentials storage
127
+
128
+ Credentials (username, user ID, token, session cookie) are stored in
129
+ `~/.ecohome/credentials.json` after a successful login. Pass
130
+ `save_credentials=False` to `EcoHomeClient.login()` to opt out.
131
+
132
+ ## Development
133
+
134
+ ```
135
+ uv sync
136
+ uv run ruff check src/
137
+ ```
@@ -0,0 +1,9 @@
1
+ ecohome/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ ecohome/cli.py,sha256=igoG3jrlVg9I3zk9QrfldkOTBm77WEGbRqG1-z4xvcU,9491
3
+ ecohome/client.py,sha256=Pygtwt4-IvIdyZTNq-WqXlBkfzR64ckT7F3hi3QLe1c,11090
4
+ ecohome/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ ecohome-0.1.0.dist-info/licenses/LICENSE,sha256=3BLDWcXAkduO6BegSVNL8q0W9hvY7hYghNMvNmpmjjI,1060
6
+ ecohome-0.1.0.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
7
+ ecohome-0.1.0.dist-info/entry_points.txt,sha256=aag46mP-HvWMAuudWnVZM4wWS31vtpE_m4SZ4mWG4RI,48
8
+ ecohome-0.1.0.dist-info/METADATA,sha256=V_F_sj1PCbSsjxLM059spl9681_P89F9Spce096hkeU,2907
9
+ ecohome-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pyecohome = ecohome.cli:main
3
+
@@ -0,0 +1,19 @@
1
+ Copyright 2026 Sjors Gielen
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the “Software”), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.