ecohome 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ecohome-0.1.0/LICENSE +19 -0
- ecohome-0.1.0/PKG-INFO +137 -0
- ecohome-0.1.0/README.md +123 -0
- ecohome-0.1.0/pyproject.toml +33 -0
- ecohome-0.1.0/src/ecohome/__init__.py +0 -0
- ecohome-0.1.0/src/ecohome/cli.py +230 -0
- ecohome-0.1.0/src/ecohome/client.py +287 -0
- ecohome-0.1.0/src/ecohome/py.typed +0 -0
ecohome-0.1.0/LICENSE
ADDED
|
@@ -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.
|
ecohome-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
ecohome-0.1.0/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ecohome
|
|
2
|
+
|
|
3
|
+
Open-source Python client and CLI for the [New Energy
|
|
4
|
+
Eco-Home](https://ehome.ne01.com/) heat pump API, which is used by Batavia Heat
|
|
5
|
+
heat pumps in the Netherlands.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Requires Python 3.14+. Older versions probably work fine, YMMV.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install ecohome
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or from source using [uv](https://github.com/astral-sh/uv):
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
uv sync
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## CLI usage
|
|
22
|
+
|
|
23
|
+
Credentials can be passed as flags or via environment variables:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
export ECOHOME_USER=you@example.com
|
|
27
|
+
export ECOHOME_PASSWORD=yourpassword
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
After the first successful login, credentials are saved to `~/.ecohome/credentials.json`
|
|
31
|
+
(mode 0600) and reused automatically on subsequent calls.
|
|
32
|
+
|
|
33
|
+
### Show status
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
pyecohome status
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Example output:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Heating: off 29.2℃ / 27.6℃ → 40.0℃ (Verwarming)
|
|
43
|
+
Hot water: off 61.5℃ → 65.0℃
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Add `--json` for machine-readable output:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pyecohome status --json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"heating": {
|
|
55
|
+
"on": false,
|
|
56
|
+
"current_temp_main": 29.2,
|
|
57
|
+
"current_temp_minor": 27.6,
|
|
58
|
+
"target_temp": 40.0,
|
|
59
|
+
"mode": "Verwarming"
|
|
60
|
+
},
|
|
61
|
+
"hot_water": {
|
|
62
|
+
"on": false,
|
|
63
|
+
"current_temp": 61.5,
|
|
64
|
+
"target_temp": 65.0
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Control hot water
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
pyecohome hot-water on
|
|
73
|
+
pyecohome hot-water off
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use `--dry-run` to print the request that would be sent without actually sending it:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
pyecohome hot-water on --dry-run
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Options
|
|
83
|
+
|
|
84
|
+
| Flag | Description |
|
|
85
|
+
|------|-------------|
|
|
86
|
+
| `--username` | Login username (overrides `ECOHOME_USER`) |
|
|
87
|
+
| `--password` | Login password (overrides `ECOHOME_PASSWORD`) |
|
|
88
|
+
| `--device` | Device code to target (auto-selected when you have only one device) |
|
|
89
|
+
| `--dry-run` | Print the outgoing request instead of sending it |
|
|
90
|
+
|
|
91
|
+
## Python API
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from ecohome.client import EcoHomeClient
|
|
95
|
+
|
|
96
|
+
client = EcoHomeClient.login("you@example.com", "yourpassword")
|
|
97
|
+
|
|
98
|
+
# List devices
|
|
99
|
+
devices = client.list_devices()
|
|
100
|
+
device_code = devices[0]["device_code"]
|
|
101
|
+
|
|
102
|
+
# Current state
|
|
103
|
+
detail = client.get_device_detail(device_code)
|
|
104
|
+
|
|
105
|
+
# Turn hot water on/off
|
|
106
|
+
client.update_switch_state(device_code, address="1020", value=True)
|
|
107
|
+
|
|
108
|
+
# Log out (also removes saved credentials)
|
|
109
|
+
client.logout()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Credentials storage
|
|
113
|
+
|
|
114
|
+
Credentials (username, user ID, token, session cookie) are stored in
|
|
115
|
+
`~/.ecohome/credentials.json` after a successful login. Pass
|
|
116
|
+
`save_credentials=False` to `EcoHomeClient.login()` to opt out.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
uv sync
|
|
122
|
+
uv run ruff check src/
|
|
123
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ecohome"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Open-source Python implementation of the New Energy (Batavia Heat) Eco-Home API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Sjors Gielen", email = "pypi@sjorsgielen.nl" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.28.1",
|
|
12
|
+
]
|
|
13
|
+
license = "MIT"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/sgielen/ecohome"
|
|
18
|
+
Issues = "https://github.com/sgielen/ecohome/issues"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
pyecohome = "ecohome.cli:main"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.11.19,<0.12.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"ruff>=0.15.20",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 120
|
|
File without changes
|
|
@@ -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())
|
|
@@ -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))
|
|
File without changes
|