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 ADDED
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .DS_Store
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