titon 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.
- titon-0.1.0/.gitignore +8 -0
- titon-0.1.0/LICENSE +21 -0
- titon-0.1.0/PKG-INFO +102 -0
- titon-0.1.0/README.md +79 -0
- titon-0.1.0/docs/protocol.md +85 -0
- titon-0.1.0/pyproject.toml +38 -0
- titon-0.1.0/src/titon/TitonClient.py +147 -0
- titon-0.1.0/src/titon/TitonFanSpeed.py +80 -0
- titon-0.1.0/src/titon/TitonGeneralInfo.py +55 -0
- titon-0.1.0/src/titon/TitonHandshake.py +21 -0
- titon-0.1.0/src/titon/TitonKitchenTimer.py +57 -0
- titon-0.1.0/src/titon/__init__.py +18 -0
- titon-0.1.0/src/titon/cli.py +95 -0
- titon-0.1.0/src/titon/helpers.py +33 -0
titon-0.1.0/.gitignore
ADDED
titon-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ugis Lazdins
|
|
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.
|
titon-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: titon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client for Titon Aura-T heat recovery ventilation units
|
|
5
|
+
Project-URL: Homepage, https://github.com/ULazdins/titon
|
|
6
|
+
Project-URL: Issues, https://github.com/ULazdins/titon/issues
|
|
7
|
+
Author: Ugis Lazdins
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: aura-t,home-assistant,hrv,titon,ventilation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Home Automation
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Provides-Extra: cli
|
|
21
|
+
Requires-Dist: aioconsole>=0.7; extra == 'cli'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# titon
|
|
25
|
+
|
|
26
|
+
Async Python client for [Titon Aura-T](https://www.titon.com/) heat recovery
|
|
27
|
+
ventilation (HRV) units.
|
|
28
|
+
|
|
29
|
+
The unit does not expose a local API. It keeps an outbound connection to
|
|
30
|
+
Titon's relay at `app.manageiaq.com:6275`, and clients address a specific unit
|
|
31
|
+
by its MAC address over that relay. This library speaks that protocol.
|
|
32
|
+
|
|
33
|
+
> Reverse engineered from the vendor app's traffic. Unofficial and unaffiliated
|
|
34
|
+
> with Titon. The protocol may change without notice.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install titon
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The interactive console needs one extra dependency:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install "titon[cli]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import asyncio
|
|
52
|
+
|
|
53
|
+
from titon import TitonClient, TitonFanSpeed
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def main():
|
|
57
|
+
client = TitonClient("AA-BB-CC-11-22-33") # your unit's MAC
|
|
58
|
+
await client.connect()
|
|
59
|
+
|
|
60
|
+
fan = TitonFanSpeed(client)
|
|
61
|
+
print("current fan speed:", await fan.perform())
|
|
62
|
+
|
|
63
|
+
await fan.set_to(3)
|
|
64
|
+
|
|
65
|
+
client.disconnect()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Each capability is a small request object wrapping the client:
|
|
72
|
+
|
|
73
|
+
| Class | Reads | Writes |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `TitonGeneralInfo` | temperatures, humidity, status flags | — |
|
|
76
|
+
| `TitonFanSpeed` | current fan speed | `set_to(speed)` |
|
|
77
|
+
| `TitonKitchenTimer` | kitchen boost timer | `set_to(minutes)` |
|
|
78
|
+
| `TitonHandshake` | performed automatically by `connect()` | — |
|
|
79
|
+
|
|
80
|
+
## Console
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
python -m titon.cli AA-BB-CC-11-22-33
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or set `TITON_HRV_MAC` and omit the argument. Commands: `info`, `fan`,
|
|
87
|
+
`set fan`, `kitchen`, `set kitchen`, `quit`. Anything else is sent as a raw
|
|
88
|
+
`DAT` message.
|
|
89
|
+
|
|
90
|
+
## Protocol notes
|
|
91
|
+
|
|
92
|
+
See [`docs/protocol.md`](docs/protocol.md) for the message framing, checksum,
|
|
93
|
+
and the register notes gathered so far.
|
|
94
|
+
|
|
95
|
+
## Home Assistant
|
|
96
|
+
|
|
97
|
+
The Home Assistant integration built on this library lives at
|
|
98
|
+
[ULazdins/titon-has](https://github.com/ULazdins/titon-has).
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
titon-0.1.0/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# titon
|
|
2
|
+
|
|
3
|
+
Async Python client for [Titon Aura-T](https://www.titon.com/) heat recovery
|
|
4
|
+
ventilation (HRV) units.
|
|
5
|
+
|
|
6
|
+
The unit does not expose a local API. It keeps an outbound connection to
|
|
7
|
+
Titon's relay at `app.manageiaq.com:6275`, and clients address a specific unit
|
|
8
|
+
by its MAC address over that relay. This library speaks that protocol.
|
|
9
|
+
|
|
10
|
+
> Reverse engineered from the vendor app's traffic. Unofficial and unaffiliated
|
|
11
|
+
> with Titon. The protocol may change without notice.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install titon
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The interactive console needs one extra dependency:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install "titon[cli]"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
|
|
30
|
+
from titon import TitonClient, TitonFanSpeed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
client = TitonClient("AA-BB-CC-11-22-33") # your unit's MAC
|
|
35
|
+
await client.connect()
|
|
36
|
+
|
|
37
|
+
fan = TitonFanSpeed(client)
|
|
38
|
+
print("current fan speed:", await fan.perform())
|
|
39
|
+
|
|
40
|
+
await fan.set_to(3)
|
|
41
|
+
|
|
42
|
+
client.disconnect()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
asyncio.run(main())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Each capability is a small request object wrapping the client:
|
|
49
|
+
|
|
50
|
+
| Class | Reads | Writes |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `TitonGeneralInfo` | temperatures, humidity, status flags | — |
|
|
53
|
+
| `TitonFanSpeed` | current fan speed | `set_to(speed)` |
|
|
54
|
+
| `TitonKitchenTimer` | kitchen boost timer | `set_to(minutes)` |
|
|
55
|
+
| `TitonHandshake` | performed automatically by `connect()` | — |
|
|
56
|
+
|
|
57
|
+
## Console
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
python -m titon.cli AA-BB-CC-11-22-33
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or set `TITON_HRV_MAC` and omit the argument. Commands: `info`, `fan`,
|
|
64
|
+
`set fan`, `kitchen`, `set kitchen`, `quit`. Anything else is sent as a raw
|
|
65
|
+
`DAT` message.
|
|
66
|
+
|
|
67
|
+
## Protocol notes
|
|
68
|
+
|
|
69
|
+
See [`docs/protocol.md`](docs/protocol.md) for the message framing, checksum,
|
|
70
|
+
and the register notes gathered so far.
|
|
71
|
+
|
|
72
|
+
## Home Assistant
|
|
73
|
+
|
|
74
|
+
The Home Assistant integration built on this library lives at
|
|
75
|
+
[ULazdins/titon-has](https://github.com/ULazdins/titon-has).
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
FilterChangeActivity
|
|
2
|
+
SF1 ?
|
|
3
|
+
SF01%03d ?
|
|
4
|
+
|
|
5
|
+
FunMainActivity
|
|
6
|
+
L
|
|
7
|
+
Z - ja saņem <zck>
|
|
8
|
+
|
|
9
|
+
SD01 - SD07 - dabūt datumus?
|
|
10
|
+
F1-F4 - uzstādīt ātrumus
|
|
11
|
+
|
|
12
|
+
ST0xxxx - uzstādīt taimeri? - "Please confirm that you want to update the aura-t display time and day?"
|
|
13
|
+
|
|
14
|
+
OtherSettingActivity
|
|
15
|
+
SK10 - virtuves taimeris
|
|
16
|
+
SW10 - vannas taimeris
|
|
17
|
+
|
|
18
|
+
SW0xxx, SK0xxx - uzstādīt taimerus uz xxx (0-100)
|
|
19
|
+
|
|
20
|
+
PHSettingActivity
|
|
21
|
+
SH1000 - nolasa mitruma līmeni
|
|
22
|
+
atbild ar SH065040 - 65%
|
|
23
|
+
|
|
24
|
+
SH0xxx - uzstāda mitruma līmeni
|
|
25
|
+
|
|
26
|
+
PwmSettingActivity
|
|
27
|
+
Gaisa intake/outtake
|
|
28
|
+
C010 - C710
|
|
29
|
+
|
|
30
|
+
CS - uzsāk transaction?
|
|
31
|
+
C00xxxx - uzstāda gaisa intake, outtake xxxx (0-100)
|
|
32
|
+
CE - beidz transaction? Vai arī nobloķē edit, atbloķē edit?
|
|
33
|
+
|
|
34
|
+
SummerSettingActivity
|
|
35
|
+
|
|
36
|
+
SS1000 - initial message?, atgriež SS 190 056, kur no 190 - 19.0 grādi
|
|
37
|
+
|
|
38
|
+
SE1000 - atgriež SE 250 033, kur 250 - 25.0 grādi
|
|
39
|
+
|
|
40
|
+
SB10 - atgriež SB 0 033, kur 0 ir "Summer boost disable" izslēgts
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
SBx - ieslēgt/izslēgt summer bypass
|
|
44
|
+
|
|
45
|
+
SE0xxx - uzstādīt Summer Extract
|
|
46
|
+
SS0xxx - uzstādīt Summer Supply
|
|
47
|
+
|
|
48
|
+
SwitchSelectActivity
|
|
49
|
+
X010 - X410 - četri slēdžu uzstādījumi - atbild ar X 2 05 111, kur 2 - slēdža kods (no pieprasījuma, 05 - istabas kods)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
1 - getString(R.string.wetRoomBoost),
|
|
53
|
+
2- getString(R.string.kitchenBoost),
|
|
54
|
+
getString(R.string.setBack), ??
|
|
55
|
+
getString(R.string.summerBoostDisable),
|
|
56
|
+
getString(R.string.fanSpeed4),
|
|
57
|
+
getString(R.string.fansOff_o), "Fans off (N/O)"
|
|
58
|
+
getString(R.string.fansOff_c), "Fans off (N/C)"
|
|
59
|
+
8 - getString(R.string.manualSummerBypass)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
X00xx - saglabā slēdža uzstādījumus, xx - istabas kods
|
|
63
|
+
|
|
64
|
+
TimerSetActivity
|
|
65
|
+
TM1 - nolasa taimera statusu, saņem TM1 0 40, kur 0 - nav ieslēgts
|
|
66
|
+
TM0x - uzstāda taimer uz x
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
message_get_fan_1_speed_in = get_full_message("C010")
|
|
75
|
+
message_get_fan_1_speed_out = get_full_message("C110")
|
|
76
|
+
message_get_fan_2_speed_in = get_full_message("C210")
|
|
77
|
+
message_get_fan_2_speed_out = get_full_message("C310")
|
|
78
|
+
message_get_fan_3_speed_in = get_full_message("C410")
|
|
79
|
+
message_get_fan_3_speed_out = get_full_message("C510")
|
|
80
|
+
message_get_fan_4_speed_in = get_full_message("C610")
|
|
81
|
+
message_get_fan_4_speed_out = get_full_message("C710")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
message_get_fan_speed = get_full_message("L")
|
|
85
|
+
message_get_fan_speed_ack = f":DAT|{hrv_mac}|{my_mac}|PS"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "titon"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async client for Titon Aura-T heat recovery ventilation units"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [{ name = "Ugis Lazdins" }]
|
|
14
|
+
keywords = ["titon", "aura-t", "hrv", "ventilation", "home-assistant"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Home Automation",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
cli = ["aioconsole>=0.7"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/ULazdins/titon"
|
|
32
|
+
Issues = "https://github.com/ULazdins/titon/issues"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/titon"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["src/titon", "docs", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from .helpers import get_symbol_hash
|
|
5
|
+
from .TitonHandshake import TitonHandshake
|
|
6
|
+
|
|
7
|
+
_LOGGER = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TitonClient:
|
|
11
|
+
messages = []
|
|
12
|
+
callbacks = []
|
|
13
|
+
my_mac = "12-34-56-78-12-34"
|
|
14
|
+
is_connected = False
|
|
15
|
+
is_connecting = False
|
|
16
|
+
connecting_callbacks = []
|
|
17
|
+
|
|
18
|
+
def __init__(self, hrv_mac):
|
|
19
|
+
self.hrv_mac = hrv_mac
|
|
20
|
+
|
|
21
|
+
async def connect(self):
|
|
22
|
+
_LOGGER.debug("-- connecting")
|
|
23
|
+
|
|
24
|
+
if self.is_connected:
|
|
25
|
+
_LOGGER.debug("-- connected\n")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
if self.is_connecting:
|
|
29
|
+
_LOGGER.debug("-- already connecting; reusing existing process\n")
|
|
30
|
+
future = asyncio.Future()
|
|
31
|
+
self.connecting_callbacks.append(future)
|
|
32
|
+
return future
|
|
33
|
+
|
|
34
|
+
self.is_connecting = True
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
self.reader, self.writer = await asyncio.open_connection(
|
|
38
|
+
"app.manageiaq.com", 6275
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
reader_task = asyncio.ensure_future(self.receive_messages_loop(self.reader))
|
|
42
|
+
asyncio.ensure_future(self.send_messages_loop(self.writer))
|
|
43
|
+
reader_task.add_done_callback(self.handle_future_exception)
|
|
44
|
+
|
|
45
|
+
handhske = TitonHandshake(self)
|
|
46
|
+
await handhske.perform()
|
|
47
|
+
_LOGGER.debug("-- connected\n")
|
|
48
|
+
|
|
49
|
+
# A success
|
|
50
|
+
self.is_connecting = False
|
|
51
|
+
self.is_connected = True
|
|
52
|
+
|
|
53
|
+
for x in self.connecting_callbacks:
|
|
54
|
+
x.set_result(True)
|
|
55
|
+
self.connecting_callbacks = []
|
|
56
|
+
|
|
57
|
+
except (BaseException, ValueError) as e:
|
|
58
|
+
_LOGGER.debug("-- connection failed\n")
|
|
59
|
+
|
|
60
|
+
# A failure
|
|
61
|
+
self.is_connecting = False
|
|
62
|
+
self.is_connected = False
|
|
63
|
+
|
|
64
|
+
for x in self.connecting_callbacks:
|
|
65
|
+
x.set_exception(e)
|
|
66
|
+
self.connecting_callbacks = []
|
|
67
|
+
|
|
68
|
+
def handle_future_exception(self, future):
|
|
69
|
+
exception = future.exception()
|
|
70
|
+
if exception:
|
|
71
|
+
_LOGGER.debug(f"An exception occurred: {exception}")
|
|
72
|
+
|
|
73
|
+
self.disconnect()
|
|
74
|
+
|
|
75
|
+
def disconnect(self):
|
|
76
|
+
self.writer.close()
|
|
77
|
+
|
|
78
|
+
self.is_connected = False
|
|
79
|
+
_LOGGER.debug("-- disconnected")
|
|
80
|
+
|
|
81
|
+
async def receive_messages_loop(self, reader):
|
|
82
|
+
while True:
|
|
83
|
+
data = await reader.readuntil(b";")
|
|
84
|
+
if not data:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
string = data.decode()
|
|
88
|
+
_LOGGER.debug(f"<<< {string}")
|
|
89
|
+
|
|
90
|
+
for callback in self.callbacks:
|
|
91
|
+
callback(string)
|
|
92
|
+
|
|
93
|
+
async def send_messages_loop(self, writer):
|
|
94
|
+
while True:
|
|
95
|
+
try:
|
|
96
|
+
message = self.messages.pop()
|
|
97
|
+
message = message
|
|
98
|
+
_LOGGER.debug(f">>> {message}")
|
|
99
|
+
writer.write(message.encode())
|
|
100
|
+
await writer.drain()
|
|
101
|
+
except IndexError:
|
|
102
|
+
pass
|
|
103
|
+
except Exception as e:
|
|
104
|
+
_LOGGER.error(f"Write failed with {e}")
|
|
105
|
+
|
|
106
|
+
# Let the loop breathe
|
|
107
|
+
await asyncio.sleep(0.1)
|
|
108
|
+
|
|
109
|
+
def get_full_message(self, msg):
|
|
110
|
+
payload = f"{msg}{ '%03d' % get_symbol_hash(msg) }"
|
|
111
|
+
return f":DAT|{self.hrv_mac}|{self.my_mac}|<stx>{payload}<etx>;"
|
|
112
|
+
|
|
113
|
+
async def send_request_response(self, request, check_if_wants_to_handle_response):
|
|
114
|
+
future = asyncio.Future()
|
|
115
|
+
|
|
116
|
+
# Send message
|
|
117
|
+
self.messages.append(request)
|
|
118
|
+
|
|
119
|
+
# Await response
|
|
120
|
+
def callback(message):
|
|
121
|
+
if check_if_wants_to_handle_response(message):
|
|
122
|
+
future.set_result(message)
|
|
123
|
+
self.callbacks.remove(callback)
|
|
124
|
+
|
|
125
|
+
self.callbacks.append(callback)
|
|
126
|
+
|
|
127
|
+
# Timeout handling
|
|
128
|
+
async def schedule_timeout_job():
|
|
129
|
+
await asyncio.sleep(5)
|
|
130
|
+
|
|
131
|
+
if not future.done():
|
|
132
|
+
_LOGGER.debug("- handling timeout")
|
|
133
|
+
self.callbacks.remove(callback)
|
|
134
|
+
|
|
135
|
+
self.is_connected = False
|
|
136
|
+
|
|
137
|
+
future.set_exception(ValueError("Timeout"))
|
|
138
|
+
|
|
139
|
+
loop = asyncio.get_event_loop()
|
|
140
|
+
loop.create_task(schedule_timeout_job())
|
|
141
|
+
|
|
142
|
+
# Return future
|
|
143
|
+
return await future
|
|
144
|
+
|
|
145
|
+
async def send_dat_message(self, message):
|
|
146
|
+
payload = self.get_full_message(message)
|
|
147
|
+
self.messages.append(payload)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from .helpers import get_symbol_hash, get_after_last_pipe, split_into_bits
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_LOGGER = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TitonFanSpeed:
|
|
10
|
+
update_callbacks = []
|
|
11
|
+
|
|
12
|
+
def __init__(self, client):
|
|
13
|
+
self.client = client
|
|
14
|
+
self.value = None
|
|
15
|
+
|
|
16
|
+
self.message = f":DAT|{self.client.hrv_mac}|{self.client.my_mac}|<stx>L{ '%03d' % get_symbol_hash('L') }<etx>;"
|
|
17
|
+
self.response_prefix = (
|
|
18
|
+
f":DAT|{self.client.hrv_mac}|{self.client.my_mac}|PS|<stx>L"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def perform(self):
|
|
22
|
+
response = await self.client.send_request_response(
|
|
23
|
+
self.message,
|
|
24
|
+
lambda x: x.startswith(self.response_prefix),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
payload = get_after_last_pipe(response)
|
|
28
|
+
payload = payload.removeprefix("<stx>").removesuffix("<etx>;")
|
|
29
|
+
|
|
30
|
+
byte1 = payload[4:6]
|
|
31
|
+
|
|
32
|
+
bits1 = split_into_bits(int(byte1, 16))
|
|
33
|
+
|
|
34
|
+
value = 0
|
|
35
|
+
for x in range(0, 4):
|
|
36
|
+
if bits1[x] != 0:
|
|
37
|
+
value = x + 1
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
self.set_value(value)
|
|
41
|
+
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
async def set_to(self, value):
|
|
45
|
+
try:
|
|
46
|
+
if value < 0 or value > 4:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
message = self.client.get_full_message(f"F{ '%d' % value }")
|
|
50
|
+
response_prefix = (
|
|
51
|
+
f":DAT|{self.client.hrv_mac}|{self.client.my_mac}|PS|<stx>F"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not self.client.is_connected:
|
|
55
|
+
await self.client.connect()
|
|
56
|
+
|
|
57
|
+
response = await self.client.send_request_response(
|
|
58
|
+
message,
|
|
59
|
+
lambda x: x.startswith(response_prefix),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
payload = get_after_last_pipe(response)
|
|
63
|
+
payload = payload.removeprefix("<stx>").removesuffix("<etx>;")
|
|
64
|
+
|
|
65
|
+
success = payload == "F<ack>"
|
|
66
|
+
|
|
67
|
+
if success:
|
|
68
|
+
self.set_value(value)
|
|
69
|
+
|
|
70
|
+
return success
|
|
71
|
+
except Exception as e:
|
|
72
|
+
_LOGGER.exception(f"Failed to set fan speed ${e}")
|
|
73
|
+
|
|
74
|
+
def set_value(self, value):
|
|
75
|
+
notify_observers = self.value != value
|
|
76
|
+
self.value = value
|
|
77
|
+
|
|
78
|
+
if notify_observers:
|
|
79
|
+
for callback in self.update_callbacks:
|
|
80
|
+
callback()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from .helpers import get_symbol_hash, get_after_last_pipe, split_into_bits
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_LOGGER = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TitonGeneralInfo:
|
|
10
|
+
def __init__(self, client):
|
|
11
|
+
self.client = client
|
|
12
|
+
|
|
13
|
+
self.message = f":DAT|{self.client.hrv_mac}|{self.client.my_mac}|<stx>L{ '%03d' % get_symbol_hash('L') }<etx>;"
|
|
14
|
+
self.response_prefix = (
|
|
15
|
+
f":DAT|{self.client.hrv_mac}|{self.client.my_mac}|PS|<stx>L"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
async def perform(self):
|
|
19
|
+
response = await self.client.send_request_response(
|
|
20
|
+
self.message,
|
|
21
|
+
lambda x: x.startswith(self.response_prefix),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
payload = get_after_last_pipe(response)
|
|
25
|
+
payload = payload.removeprefix("<stx>").removesuffix("<etx>;")
|
|
26
|
+
|
|
27
|
+
byte1 = payload[4:6]
|
|
28
|
+
byte2 = payload[10:12]
|
|
29
|
+
byte3 = payload[16:18]
|
|
30
|
+
|
|
31
|
+
bits1 = split_into_bits(int(byte1, 16))
|
|
32
|
+
bits2 = split_into_bits(int(byte2, 16))
|
|
33
|
+
bits3 = split_into_bits(int(byte3, 16))
|
|
34
|
+
|
|
35
|
+
# FunModel
|
|
36
|
+
_LOGGER.debug(f"Byte1 {bits1}")
|
|
37
|
+
_LOGGER.debug(f"Speed1 {bits1[0]}")
|
|
38
|
+
_LOGGER.debug(f"Speed2 {bits1[1]}")
|
|
39
|
+
_LOGGER.debug(f"Speed3 {bits1[2]}")
|
|
40
|
+
_LOGGER.debug(f"Speed4 {bits1[3]}")
|
|
41
|
+
_LOGGER.debug(f"Timer {bits1[4]}")
|
|
42
|
+
_LOGGER.debug(f"Switch {bits1[5]}")
|
|
43
|
+
|
|
44
|
+
_LOGGER.debug(f"\nByte2 {bits2}")
|
|
45
|
+
_LOGGER.debug(f"Sensor {bits2[0]}")
|
|
46
|
+
_LOGGER.debug(f"Filter {bits2[1]}")
|
|
47
|
+
_LOGGER.debug(f"Inhibit {bits2[2]}")
|
|
48
|
+
_LOGGER.debug(f"Boost {bits2[3]}")
|
|
49
|
+
_LOGGER.debug(f"Frost {bits2[4]}")
|
|
50
|
+
_LOGGER.debug(f"Internal {bits2[5]}")
|
|
51
|
+
|
|
52
|
+
_LOGGER.debug(f"\nByte3 {bits3}")
|
|
53
|
+
_LOGGER.debug(f"Summer {bits3[0]}")
|
|
54
|
+
_LOGGER.debug(f"Attention {bits3[1]}")
|
|
55
|
+
_LOGGER.debug(f"Duct heater {bits3[2]}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class TitonHandshake:
|
|
2
|
+
def __init__(self, client):
|
|
3
|
+
self.client = client
|
|
4
|
+
|
|
5
|
+
self.message_handshake = f":DLF||{client.my_mac};"
|
|
6
|
+
self.message_handshake_ack = f":DLF||{client.my_mac}|PS;"
|
|
7
|
+
self.message_register_hrv = f":_CX|{client.hrv_mac}|0;"
|
|
8
|
+
self.message_register_hrv_ack = (
|
|
9
|
+
f":_CX|{client.hrv_mac}|0|PS;" # FA - response if HRV not found
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
async def perform(self):
|
|
13
|
+
await self.client.send_request_response(
|
|
14
|
+
self.message_handshake,
|
|
15
|
+
lambda x: x == self.message_handshake_ack,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
await self.client.send_request_response(
|
|
19
|
+
self.message_register_hrv,
|
|
20
|
+
lambda x: x == self.message_register_hrv_ack,
|
|
21
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from .helpers import get_symbol_hash, get_after_last_pipe
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TitonKitchenTimer:
|
|
5
|
+
update_callbacks = []
|
|
6
|
+
value = None
|
|
7
|
+
|
|
8
|
+
def __init__(self, client):
|
|
9
|
+
self.client = client
|
|
10
|
+
|
|
11
|
+
async def perform(self):
|
|
12
|
+
message = f":DAT|{self.client.hrv_mac}|{self.client.my_mac}|<stx>SK10{ '%03d' % get_symbol_hash('SK10') }<etx>;"
|
|
13
|
+
response_prefix = f":DAT|{self.client.hrv_mac}|{self.client.my_mac}|PS|<stx>SK"
|
|
14
|
+
|
|
15
|
+
response = await self.client.send_request_response(
|
|
16
|
+
message,
|
|
17
|
+
lambda x: x.startswith(response_prefix),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
payload = get_after_last_pipe(response)
|
|
21
|
+
payload = payload.removeprefix("<stx>").removesuffix("<etx>;")
|
|
22
|
+
|
|
23
|
+
byte1 = payload[2:5]
|
|
24
|
+
|
|
25
|
+
self.set_value(int(byte1, 10))
|
|
26
|
+
|
|
27
|
+
return self.value
|
|
28
|
+
|
|
29
|
+
async def set_to(self, value):
|
|
30
|
+
if value < 0 or value > 100:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
message = self.client.get_full_message(f"SK0{ '%03d' % value }")
|
|
34
|
+
response_prefix = f":DAT|{self.client.hrv_mac}|{self.client.my_mac}|PS|<stx>S"
|
|
35
|
+
|
|
36
|
+
response = await self.client.send_request_response(
|
|
37
|
+
message,
|
|
38
|
+
lambda x: x.startswith(response_prefix),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
payload = get_after_last_pipe(response)
|
|
42
|
+
payload = payload.removeprefix("<stx>").removesuffix("<etx>;")
|
|
43
|
+
|
|
44
|
+
success = payload == "S<ack>"
|
|
45
|
+
|
|
46
|
+
if success:
|
|
47
|
+
self.set_value(value)
|
|
48
|
+
|
|
49
|
+
return success
|
|
50
|
+
|
|
51
|
+
def set_value(self, value):
|
|
52
|
+
notify_observers = self.value != value
|
|
53
|
+
self.value = value
|
|
54
|
+
|
|
55
|
+
if notify_observers:
|
|
56
|
+
for callback in self.update_callbacks:
|
|
57
|
+
callback()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Async client for Titon Aura-T heat recovery ventilation units."""
|
|
2
|
+
|
|
3
|
+
from .TitonClient import TitonClient
|
|
4
|
+
from .TitonFanSpeed import TitonFanSpeed
|
|
5
|
+
from .TitonGeneralInfo import TitonGeneralInfo
|
|
6
|
+
from .TitonHandshake import TitonHandshake
|
|
7
|
+
from .TitonKitchenTimer import TitonKitchenTimer
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"TitonClient",
|
|
13
|
+
"TitonFanSpeed",
|
|
14
|
+
"TitonGeneralInfo",
|
|
15
|
+
"TitonHandshake",
|
|
16
|
+
"TitonKitchenTimer",
|
|
17
|
+
"__version__",
|
|
18
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Interactive console for poking at a Titon HRV unit.
|
|
2
|
+
|
|
3
|
+
Run with the unit's MAC address:
|
|
4
|
+
|
|
5
|
+
python -m titon.cli AA-BB-CC-11-22-33
|
|
6
|
+
|
|
7
|
+
or set TITON_HRV_MAC in the environment.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import aioconsole
|
|
16
|
+
|
|
17
|
+
from .TitonClient import TitonClient
|
|
18
|
+
from .TitonFanSpeed import TitonFanSpeed
|
|
19
|
+
from .TitonGeneralInfo import TitonGeneralInfo
|
|
20
|
+
from .TitonKitchenTimer import TitonKitchenTimer
|
|
21
|
+
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def main(client):
|
|
26
|
+
kitchen_request = TitonKitchenTimer(client)
|
|
27
|
+
info_request = TitonGeneralInfo(client)
|
|
28
|
+
fan_request = TitonFanSpeed(client)
|
|
29
|
+
|
|
30
|
+
while True:
|
|
31
|
+
print("\n")
|
|
32
|
+
user_input = await aioconsole.ainput("Enter command: ")
|
|
33
|
+
print("\n")
|
|
34
|
+
|
|
35
|
+
if not client.is_connected:
|
|
36
|
+
await client.connect()
|
|
37
|
+
|
|
38
|
+
if user_input.lower() == "quit":
|
|
39
|
+
print("Closing the connection")
|
|
40
|
+
break
|
|
41
|
+
elif user_input == "kitchen":
|
|
42
|
+
response = await kitchen_request.perform()
|
|
43
|
+
|
|
44
|
+
print(f"Kitchen timer is set to {response}")
|
|
45
|
+
elif user_input == "set kitchen":
|
|
46
|
+
value = kitchen_request.value + 1
|
|
47
|
+
|
|
48
|
+
response = await kitchen_request.set_to(value)
|
|
49
|
+
|
|
50
|
+
if response:
|
|
51
|
+
print(f"Kitchen timer is set to {value}")
|
|
52
|
+
else:
|
|
53
|
+
print("Setting timer failed")
|
|
54
|
+
elif user_input == "fan":
|
|
55
|
+
response = await fan_request.perform()
|
|
56
|
+
|
|
57
|
+
print(f"Fan speed is set to {response}")
|
|
58
|
+
elif user_input == "set fan":
|
|
59
|
+
value = await aioconsole.ainput("Enter value: ")
|
|
60
|
+
value = int(value)
|
|
61
|
+
|
|
62
|
+
response = await fan_request.set_to(value)
|
|
63
|
+
|
|
64
|
+
if response:
|
|
65
|
+
print(f"Fan speed is set to {value}")
|
|
66
|
+
else:
|
|
67
|
+
print("Setting fan speed failed")
|
|
68
|
+
elif user_input == "info":
|
|
69
|
+
await info_request.perform()
|
|
70
|
+
else:
|
|
71
|
+
await client.send_dat_message(user_input)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run():
|
|
75
|
+
"""Entry point: resolve the MAC, then run the console."""
|
|
76
|
+
mac = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("TITON_HRV_MAC")
|
|
77
|
+
|
|
78
|
+
if not mac:
|
|
79
|
+
sys.exit(
|
|
80
|
+
"No HRV MAC address given.\n"
|
|
81
|
+
"Usage: python -m titon.cli <MAC> (e.g. AA-BB-CC-11-22-33)\n"
|
|
82
|
+
" or: set TITON_HRV_MAC in the environment."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
logging.basicConfig(
|
|
86
|
+
level=logging.DEBUG,
|
|
87
|
+
format="%(name)s - %(levelname)s - %(message)s",
|
|
88
|
+
handlers=[logging.StreamHandler()],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
asyncio.run(main(TitonClient(mac)))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
run()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from functools import reduce
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_symbol_hash(msg):
|
|
5
|
+
chars = [ord(x) for x in msg]
|
|
6
|
+
|
|
7
|
+
def xor(x, y):
|
|
8
|
+
return x ^ y
|
|
9
|
+
|
|
10
|
+
return reduce(xor, chars)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_after_last_pipe(input_string):
|
|
14
|
+
parts = input_string.rsplit("|", 1)
|
|
15
|
+
|
|
16
|
+
if len(parts) > 1:
|
|
17
|
+
return parts[1]
|
|
18
|
+
else:
|
|
19
|
+
# If no pipe is found, return the original string
|
|
20
|
+
return input_string
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def split_into_bits(number):
|
|
24
|
+
# Use bin() to get the binary representation and remove the '0b' prefix
|
|
25
|
+
binary_representation = bin(number)[2:]
|
|
26
|
+
|
|
27
|
+
# Pad with leading zeros to ensure a consistent length
|
|
28
|
+
padded_binary = binary_representation.zfill(8) # Assuming 8 bits for simplicity
|
|
29
|
+
|
|
30
|
+
# Convert the binary string to a list of integers
|
|
31
|
+
bits = [int(bit) for bit in padded_binary]
|
|
32
|
+
|
|
33
|
+
return bits
|