pyairtouch3 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.
- pyairtouch3-0.1.0/PKG-INFO +29 -0
- pyairtouch3-0.1.0/README.md +8 -0
- pyairtouch3-0.1.0/pyproject.toml +36 -0
- pyairtouch3-0.1.0/setup.cfg +4 -0
- pyairtouch3-0.1.0/src/pyairtouch3/__init__.py +41 -0
- pyairtouch3-0.1.0/src/pyairtouch3/airtouch_aircon.py +116 -0
- pyairtouch3-0.1.0/src/pyairtouch3/airtouch_message.py +154 -0
- pyairtouch3-0.1.0/src/pyairtouch3/airtouch_sensor.py +30 -0
- pyairtouch3-0.1.0/src/pyairtouch3/airtouch_zone.py +98 -0
- pyairtouch3-0.1.0/src/pyairtouch3/client.py +115 -0
- pyairtouch3-0.1.0/src/pyairtouch3/discovery.py +151 -0
- pyairtouch3-0.1.0/src/pyairtouch3/enums.py +20 -0
- pyairtouch3-0.1.0/src/pyairtouch3/message_constants.py +26 -0
- pyairtouch3-0.1.0/src/pyairtouch3/message_response_parser.py +225 -0
- pyairtouch3-0.1.0/src/pyairtouch3.egg-info/PKG-INFO +29 -0
- pyairtouch3-0.1.0/src/pyairtouch3.egg-info/SOURCES.txt +21 -0
- pyairtouch3-0.1.0/src/pyairtouch3.egg-info/dependency_links.txt +1 -0
- pyairtouch3-0.1.0/src/pyairtouch3.egg-info/top_level.txt +1 -0
- pyairtouch3-0.1.0/tests/test_airtouch_message.py +103 -0
- pyairtouch3-0.1.0/tests/test_client.py +157 -0
- pyairtouch3-0.1.0/tests/test_discovery.py +162 -0
- pyairtouch3-0.1.0/tests/test_message_response_parser.py +94 -0
- pyairtouch3-0.1.0/tests/test_models.py +22 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyairtouch3
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for AirTouch 3 controllers
|
|
5
|
+
Author: pyairtouch3 contributors
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/L0rdCha0s/pyairtouch3
|
|
8
|
+
Project-URL: Repository, https://github.com/L0rdCha0s/pyairtouch3
|
|
9
|
+
Project-URL: Issues, https://github.com/L0rdCha0s/pyairtouch3/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Home Automation
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
pyairtouch3
|
|
23
|
+
===========
|
|
24
|
+
|
|
25
|
+
Async Python client for AirTouch 3 controllers.
|
|
26
|
+
|
|
27
|
+
The package contains the AirTouch 3 protocol models, message builders, response
|
|
28
|
+
parser, TCP status/command client, and UDP discovery parser/scanner used by the
|
|
29
|
+
Home Assistant `airtouch3` integration.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pyairtouch3
|
|
2
|
+
===========
|
|
3
|
+
|
|
4
|
+
Async Python client for AirTouch 3 controllers.
|
|
5
|
+
|
|
6
|
+
The package contains the AirTouch 3 protocol models, message builders, response
|
|
7
|
+
parser, TCP status/command client, and UDP discovery parser/scanner used by the
|
|
8
|
+
Home Assistant `airtouch3` integration.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyairtouch3"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python client for AirTouch 3 controllers"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "pyairtouch3 contributors" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Home Automation",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/L0rdCha0s/pyairtouch3"
|
|
29
|
+
Repository = "https://github.com/L0rdCha0s/pyairtouch3"
|
|
30
|
+
Issues = "https://github.com/L0rdCha0s/pyairtouch3/issues"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Python client for AirTouch 3 controllers."""
|
|
2
|
+
|
|
3
|
+
from .airtouch_aircon import Aircon
|
|
4
|
+
from .airtouch_message import AirTouchMessage
|
|
5
|
+
from .airtouch_sensor import Sensor
|
|
6
|
+
from .airtouch_zone import AirtouchZone
|
|
7
|
+
from .client import DEFAULT_PORT, RESPONSE_TIMEOUT, AirTouchClient, AirTouchError
|
|
8
|
+
from .discovery import (
|
|
9
|
+
DISCOVERY_ATTEMPTS,
|
|
10
|
+
DISCOVERY_MESSAGE,
|
|
11
|
+
DISCOVERY_PORT,
|
|
12
|
+
DISCOVERY_SEND_INTERVAL,
|
|
13
|
+
AirTouch3Discovery,
|
|
14
|
+
async_discover_targets,
|
|
15
|
+
parse_discovery_payload,
|
|
16
|
+
)
|
|
17
|
+
from .enums import AcMode, ZoneStatus
|
|
18
|
+
from .message_constants import MessageConstants
|
|
19
|
+
from .message_response_parser import MessageResponseParser
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_PORT",
|
|
23
|
+
"DISCOVERY_ATTEMPTS",
|
|
24
|
+
"DISCOVERY_MESSAGE",
|
|
25
|
+
"DISCOVERY_PORT",
|
|
26
|
+
"DISCOVERY_SEND_INTERVAL",
|
|
27
|
+
"RESPONSE_TIMEOUT",
|
|
28
|
+
"AcMode",
|
|
29
|
+
"AirTouch3Discovery",
|
|
30
|
+
"AirTouchClient",
|
|
31
|
+
"AirTouchError",
|
|
32
|
+
"AirTouchMessage",
|
|
33
|
+
"Aircon",
|
|
34
|
+
"AirtouchZone",
|
|
35
|
+
"MessageConstants",
|
|
36
|
+
"MessageResponseParser",
|
|
37
|
+
"Sensor",
|
|
38
|
+
"ZoneStatus",
|
|
39
|
+
"async_discover_targets",
|
|
40
|
+
"parse_discovery_payload",
|
|
41
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Provides the Aircon class for controlling an AirTouch air conditioner."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .airtouch_sensor import Sensor
|
|
6
|
+
from .airtouch_zone import AirtouchZone
|
|
7
|
+
from .enums import AcMode
|
|
8
|
+
|
|
9
|
+
_LOGGER = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Aircon:
|
|
13
|
+
"""Represents an AirTouch air conditioner object."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, ac_id: int) -> None:
|
|
16
|
+
"""Initialize the Aircon with the given AC identifier."""
|
|
17
|
+
self.ac_id = ac_id
|
|
18
|
+
self._zones: list[AirtouchZone] = []
|
|
19
|
+
self._sensors: list[Sensor] = []
|
|
20
|
+
self._status: bool = False
|
|
21
|
+
self._fan_speed: int = 0
|
|
22
|
+
self._room_temperature: float = 0
|
|
23
|
+
self._desired_temperature: int = 0
|
|
24
|
+
self._mode: AcMode = AcMode.AUTO
|
|
25
|
+
self._brand_id: int = 0
|
|
26
|
+
self._system_id: str = ""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def zones(self) -> list[AirtouchZone]:
|
|
30
|
+
"""Return the list of zones controlled by this AC."""
|
|
31
|
+
return self._zones
|
|
32
|
+
|
|
33
|
+
@zones.setter
|
|
34
|
+
def zones(self, new_zones: list[AirtouchZone]) -> None:
|
|
35
|
+
"""Set the list of zones for this AC."""
|
|
36
|
+
self._zones = new_zones
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def sensors(self) -> list[Sensor]:
|
|
40
|
+
"""Return the list of sensors associated with this AC."""
|
|
41
|
+
return self._sensors
|
|
42
|
+
|
|
43
|
+
@sensors.setter
|
|
44
|
+
def sensors(self, new_sensors: list[Sensor]) -> None:
|
|
45
|
+
"""Set the list of sensors for this AC."""
|
|
46
|
+
self._sensors = new_sensors
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def status(self) -> bool:
|
|
50
|
+
"""Return whether the AC unit is currently on (True) or off (False)."""
|
|
51
|
+
return self._status
|
|
52
|
+
|
|
53
|
+
@status.setter
|
|
54
|
+
def status(self, is_on: bool) -> None:
|
|
55
|
+
"""Set the AC unit status."""
|
|
56
|
+
self._status = is_on
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def fan_speed(self) -> int:
|
|
60
|
+
"""Return the current fan speed setting."""
|
|
61
|
+
return self._fan_speed
|
|
62
|
+
|
|
63
|
+
@fan_speed.setter
|
|
64
|
+
def fan_speed(self, speed: int) -> None:
|
|
65
|
+
"""Set the fan speed of the AC."""
|
|
66
|
+
self._fan_speed = speed
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def room_temperature(self) -> float:
|
|
70
|
+
"""Return the current room temperature as reported by the AC."""
|
|
71
|
+
return self._room_temperature
|
|
72
|
+
|
|
73
|
+
@room_temperature.setter
|
|
74
|
+
def room_temperature(self, temperature: float) -> None:
|
|
75
|
+
"""Set the current room temperature for this AC."""
|
|
76
|
+
self._room_temperature = temperature
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def desired_temperature(self) -> int:
|
|
80
|
+
"""Return the desired (target) temperature set on the AC."""
|
|
81
|
+
return self._desired_temperature
|
|
82
|
+
|
|
83
|
+
@desired_temperature.setter
|
|
84
|
+
def desired_temperature(self, temperature: int) -> None:
|
|
85
|
+
"""Set the desired (target) temperature for this AC."""
|
|
86
|
+
self._desired_temperature = temperature
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def mode(self) -> AcMode:
|
|
90
|
+
"""Return the current AC operating mode."""
|
|
91
|
+
return self._mode
|
|
92
|
+
|
|
93
|
+
@mode.setter
|
|
94
|
+
def mode(self, new_mode: AcMode) -> None:
|
|
95
|
+
"""Set the AC operating mode."""
|
|
96
|
+
self._mode = new_mode
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def brand_id(self) -> int:
|
|
100
|
+
"""Return the brand identifier for this AC."""
|
|
101
|
+
return self._brand_id
|
|
102
|
+
|
|
103
|
+
@brand_id.setter
|
|
104
|
+
def brand_id(self, new_brand_id: int) -> None:
|
|
105
|
+
"""Set the brand identifier for this AC."""
|
|
106
|
+
self._brand_id = new_brand_id
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def system_id(self) -> str:
|
|
110
|
+
"""Return the AirTouch controller system identifier."""
|
|
111
|
+
return self._system_id
|
|
112
|
+
|
|
113
|
+
@system_id.setter
|
|
114
|
+
def system_id(self, new_system_id: str) -> None:
|
|
115
|
+
"""Set the AirTouch controller system identifier."""
|
|
116
|
+
self._system_id = new_system_id
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Defines the AirTouchMessage class for building and modifying messages sent to the AirTouch system."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
_LOGGER = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AirTouchMessage:
|
|
10
|
+
"""Builds various message types for the AirTouch system."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
"""Initialize a new AirTouchMessage instance.
|
|
14
|
+
|
|
15
|
+
The default buffer is 13 bytes long, and `sum_byte` is used for checksum calculations.
|
|
16
|
+
"""
|
|
17
|
+
self.buffer = bytearray(13)
|
|
18
|
+
self.sum_byte = bytearray(13)
|
|
19
|
+
self._is_temp = False
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_temp(self) -> bool:
|
|
23
|
+
"""Indicates whether this message pertains to a temperature-related command."""
|
|
24
|
+
return self._is_temp
|
|
25
|
+
|
|
26
|
+
@is_temp.setter
|
|
27
|
+
def is_temp(self, value: bool) -> None:
|
|
28
|
+
"""Set whether this message is temperature-related.
|
|
29
|
+
|
|
30
|
+
:param value: True if temperature-related, otherwise False.
|
|
31
|
+
"""
|
|
32
|
+
self._is_temp = value
|
|
33
|
+
|
|
34
|
+
def reset_message(self) -> None:
|
|
35
|
+
"""Reset the internal message buffer to default values.
|
|
36
|
+
|
|
37
|
+
Sets the first byte to 85 (0x55) and the third byte to 12 (0x0C).
|
|
38
|
+
"""
|
|
39
|
+
for i in range(13):
|
|
40
|
+
self.buffer[i] = 0
|
|
41
|
+
self.buffer[0] = 85
|
|
42
|
+
self.buffer[2] = 12
|
|
43
|
+
|
|
44
|
+
def print_hex_code(self) -> None:
|
|
45
|
+
"""Log the internal message buffer in hex format for debugging."""
|
|
46
|
+
_LOGGER.debug(",".join(format(x, "02x") for x in self.buffer))
|
|
47
|
+
|
|
48
|
+
def calc_checksum(self) -> int:
|
|
49
|
+
"""Calculate an 8-bit checksum of the first 12 bytes of the buffer."""
|
|
50
|
+
self.sum_byte[:] = self.buffer
|
|
51
|
+
return sum(b & 0xFF for b in self.sum_byte[:-1]) % 256
|
|
52
|
+
|
|
53
|
+
def get_init_msg(self) -> bytearray:
|
|
54
|
+
"""Create an initialization message buffer (13 bytes)."""
|
|
55
|
+
self.reset_message()
|
|
56
|
+
self.buffer[1] = 1
|
|
57
|
+
self.buffer[12] = self.calc_checksum()
|
|
58
|
+
return self.buffer
|
|
59
|
+
|
|
60
|
+
def toggle_zone(self, zone: int) -> bytearray:
|
|
61
|
+
"""Create a command to toggle power on/off for a specific zone.
|
|
62
|
+
|
|
63
|
+
:param zone: The zone index to toggle.
|
|
64
|
+
:return: A 13-byte buffer for the toggle_zone command.
|
|
65
|
+
"""
|
|
66
|
+
self.reset_message()
|
|
67
|
+
self.buffer[1] = 129 # -127 as unsigned
|
|
68
|
+
self.buffer[3] = zone
|
|
69
|
+
self.buffer[4] = 128 # -128 as unsigned
|
|
70
|
+
self.buffer[12] = self.calc_checksum()
|
|
71
|
+
return self.buffer
|
|
72
|
+
|
|
73
|
+
def set_fan(self, room: int, inc_dec: int) -> bytearray:
|
|
74
|
+
"""Create a command to increment or decrement the fan/temperature setting for a given room.
|
|
75
|
+
|
|
76
|
+
:param room: The room or zone index.
|
|
77
|
+
:param inc_dec: Positive to increment temperature; negative to decrement.
|
|
78
|
+
:return: A 13-byte buffer with the fan adjustment command.
|
|
79
|
+
"""
|
|
80
|
+
self.reset_message()
|
|
81
|
+
self.buffer[1] = 129 # -127 as unsigned
|
|
82
|
+
self.buffer[3] = room
|
|
83
|
+
self.buffer[4] = 2 if inc_dec >= 0 else 1
|
|
84
|
+
self.buffer[5] = 1
|
|
85
|
+
self.buffer[12] = self.calc_checksum()
|
|
86
|
+
return self.buffer
|
|
87
|
+
|
|
88
|
+
def toggle_ac_on_off(self, ac_id: int) -> bytearray:
|
|
89
|
+
"""Create a command to toggle the AC on/off.
|
|
90
|
+
|
|
91
|
+
:param ac_id: The AirTouch AC identifier.
|
|
92
|
+
:return: A 13-byte buffer with the toggle command.
|
|
93
|
+
"""
|
|
94
|
+
_LOGGER.debug("Toggling AC for id %d", ac_id)
|
|
95
|
+
self.reset_message()
|
|
96
|
+
self.buffer[1] = 134 # -122 as unsigned
|
|
97
|
+
self.buffer[3] = ac_id
|
|
98
|
+
self.buffer[4] = 128 # -128 as unsigned
|
|
99
|
+
self.buffer[12] = self.calc_checksum()
|
|
100
|
+
return self.buffer
|
|
101
|
+
|
|
102
|
+
def set_mode(self, ac_id: int, brand_id: int, in_mode: Any) -> bytearray:
|
|
103
|
+
"""Create a command to set the AC mode (e.g., cool, heat, auto).
|
|
104
|
+
|
|
105
|
+
:param ac_id: AirTouch AC identifier.
|
|
106
|
+
:param brand_id: AC brand identifier, for special offsets.
|
|
107
|
+
:param in_mode: Mode, castable to int.
|
|
108
|
+
:return: A 13-byte buffer with the mode-setting command.
|
|
109
|
+
"""
|
|
110
|
+
self.reset_message()
|
|
111
|
+
mode = int(in_mode) # Ensure it's an integer
|
|
112
|
+
|
|
113
|
+
# Brand-specific remapping
|
|
114
|
+
if ac_id == 0 and brand_id == 11:
|
|
115
|
+
mode = {0: 0, 1: 2, 2: 3, 3: 4, 4: 1}.get(mode, mode)
|
|
116
|
+
|
|
117
|
+
if ac_id == 0 and brand_id == 15:
|
|
118
|
+
mode = {0: 5, 1: 2, 2: 3, 3: 4, 4: 1}.get(mode, mode)
|
|
119
|
+
|
|
120
|
+
_LOGGER.debug(
|
|
121
|
+
"Air Conditioner brand id at mode select: %d and mode %d", brand_id, mode
|
|
122
|
+
)
|
|
123
|
+
self.buffer[1] = 134 # -122 as unsigned
|
|
124
|
+
self.buffer[3] = ac_id
|
|
125
|
+
self.buffer[4] = 129 # -127 as unsigned
|
|
126
|
+
self.buffer[5] = mode
|
|
127
|
+
|
|
128
|
+
_LOGGER.debug("Checksum is %d", self.calc_checksum())
|
|
129
|
+
self.buffer[12] = self.calc_checksum()
|
|
130
|
+
return self.buffer
|
|
131
|
+
|
|
132
|
+
def set_fan_speed(self, ac_id: int, brand_id: int, in_mode: Any) -> bytearray:
|
|
133
|
+
"""Create a command to set the fan speed for the AC unit.
|
|
134
|
+
|
|
135
|
+
:param ac_id: AirTouch AC identifier.
|
|
136
|
+
:param brand_id: AC brand identifier, used for certain speed mappings.
|
|
137
|
+
:param in_mode: Fan speed, castable to int.
|
|
138
|
+
:return: A 13-byte buffer with the fan speed-setting command.
|
|
139
|
+
"""
|
|
140
|
+
self.reset_message()
|
|
141
|
+
mode = int(in_mode) # Ensure it's an integer
|
|
142
|
+
|
|
143
|
+
if ac_id == 0 and brand_id == 15 and mode == 0:
|
|
144
|
+
mode = 4
|
|
145
|
+
if ac_id == 0 and brand_id == 2:
|
|
146
|
+
mode = {0: 0, 4: 1}.get(mode, mode + 1)
|
|
147
|
+
|
|
148
|
+
_LOGGER.debug("Final mode sending for fan speed: %d", mode)
|
|
149
|
+
self.buffer[1] = 134 # -122 as unsigned
|
|
150
|
+
self.buffer[3] = ac_id
|
|
151
|
+
self.buffer[4] = 130 # -126 as unsigned
|
|
152
|
+
self.buffer[5] = mode
|
|
153
|
+
self.buffer[12] = self.calc_checksum()
|
|
154
|
+
return self.buffer
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Defines the Sensor class for representing a single sensor in an AirTouch system."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Sensor:
|
|
5
|
+
"""Represents an AirTouch sensor with temperature and availability state."""
|
|
6
|
+
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
"""Initialize a new Sensor with default values for temperature and availability."""
|
|
9
|
+
self._current_temperature = 0
|
|
10
|
+
self._is_available = False
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def current_temperature(self) -> int:
|
|
14
|
+
"""Return the current measured temperature."""
|
|
15
|
+
return self._current_temperature
|
|
16
|
+
|
|
17
|
+
@current_temperature.setter
|
|
18
|
+
def current_temperature(self, value: int) -> None:
|
|
19
|
+
"""Set the current measured temperature."""
|
|
20
|
+
self._current_temperature = value
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_available(self) -> bool:
|
|
24
|
+
"""Return True if the sensor is operational, otherwise False."""
|
|
25
|
+
return self._is_available
|
|
26
|
+
|
|
27
|
+
@is_available.setter
|
|
28
|
+
def is_available(self, value: bool) -> None:
|
|
29
|
+
"""Set whether the sensor is operational."""
|
|
30
|
+
self._is_available = value
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Provides the AirtouchZone class for handling a single zone in an AirTouch system."""
|
|
2
|
+
|
|
3
|
+
from .airtouch_sensor import Sensor
|
|
4
|
+
from .enums import ZoneStatus
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AirtouchZone:
|
|
8
|
+
"""Represents a single AirTouch zone with temperature and status attributes."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, touch_pad_temperature: int) -> None:
|
|
11
|
+
"""Initialize the AirtouchZone.
|
|
12
|
+
|
|
13
|
+
:param touch_pad_temperature: Initial temperature displayed on the touch pad.
|
|
14
|
+
"""
|
|
15
|
+
self._touch_pad_temperature = touch_pad_temperature
|
|
16
|
+
self._sensor = None # type: Sensor | None
|
|
17
|
+
self._desired_temperature = 0
|
|
18
|
+
self._name = ""
|
|
19
|
+
self._status = ZoneStatus.ZONE_OFF
|
|
20
|
+
self._id = 0
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def touch_pad_temperature(self) -> int:
|
|
24
|
+
"""Return the current touch pad temperature for this zone."""
|
|
25
|
+
return self._touch_pad_temperature
|
|
26
|
+
|
|
27
|
+
@touch_pad_temperature.setter
|
|
28
|
+
def touch_pad_temperature(self, value: int) -> None:
|
|
29
|
+
"""Set the touch pad temperature.
|
|
30
|
+
|
|
31
|
+
:param value: The new touch pad temperature to store.
|
|
32
|
+
"""
|
|
33
|
+
self._touch_pad_temperature = value
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def name(self) -> str:
|
|
37
|
+
"""Return the name of this zone."""
|
|
38
|
+
return self._name
|
|
39
|
+
|
|
40
|
+
@name.setter
|
|
41
|
+
def name(self, value: str) -> None:
|
|
42
|
+
"""Set the name of this zone.
|
|
43
|
+
|
|
44
|
+
:param value: The new zone name.
|
|
45
|
+
"""
|
|
46
|
+
self._name = value
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def id(self) -> int:
|
|
50
|
+
"""Return the ID of this zone."""
|
|
51
|
+
return self._id
|
|
52
|
+
|
|
53
|
+
@id.setter
|
|
54
|
+
def id(self, value: int) -> None:
|
|
55
|
+
"""Set the ID for this zone.
|
|
56
|
+
|
|
57
|
+
:param value: The new zone ID.
|
|
58
|
+
"""
|
|
59
|
+
self._id = value
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def status(self) -> ZoneStatus:
|
|
63
|
+
"""Return the current power/status of this zone."""
|
|
64
|
+
return self._status
|
|
65
|
+
|
|
66
|
+
@status.setter
|
|
67
|
+
def status(self, value: ZoneStatus) -> None:
|
|
68
|
+
"""Update the power/status of this zone.
|
|
69
|
+
|
|
70
|
+
:param value: The new zone status.
|
|
71
|
+
"""
|
|
72
|
+
self._status = value
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def desired_temperature(self) -> int:
|
|
76
|
+
"""Return the desired (target) temperature for this zone."""
|
|
77
|
+
return self._desired_temperature
|
|
78
|
+
|
|
79
|
+
@desired_temperature.setter
|
|
80
|
+
def desired_temperature(self, value: int) -> None:
|
|
81
|
+
"""Set the desired (target) temperature for this zone.
|
|
82
|
+
|
|
83
|
+
:param value: The new target temperature.
|
|
84
|
+
"""
|
|
85
|
+
self._desired_temperature = value
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def sensor(self) -> Sensor | None:
|
|
89
|
+
"""Return the Sensor object associated with this zone, if any."""
|
|
90
|
+
return self._sensor
|
|
91
|
+
|
|
92
|
+
@sensor.setter
|
|
93
|
+
def sensor(self, sensor: Sensor) -> None:
|
|
94
|
+
"""Assign a Sensor object to this zone.
|
|
95
|
+
|
|
96
|
+
:param sensor: The Sensor instance that measures this zone's temperature.
|
|
97
|
+
"""
|
|
98
|
+
self._sensor = sensor
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Async TCP client for AirTouch 3 controllers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .airtouch_aircon import Aircon
|
|
8
|
+
from .airtouch_message import AirTouchMessage
|
|
9
|
+
from .message_constants import MessageConstants
|
|
10
|
+
from .message_response_parser import MessageResponseParser
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_PORT = 8899
|
|
15
|
+
RESPONSE_TIMEOUT = 10
|
|
16
|
+
MIN_RESPONSE_LENGTH = (
|
|
17
|
+
MessageConstants.AIRTOUCH_ID_START + MessageConstants.AIRTOUCH_ID_LENGTH
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AirTouchError(Exception):
|
|
22
|
+
"""Error raised when AirTouch communication or parsing fails."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AirTouchClient:
|
|
26
|
+
"""Async client for an AirTouch 3 controller."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
host: str,
|
|
31
|
+
port: int = DEFAULT_PORT,
|
|
32
|
+
*,
|
|
33
|
+
timeout: float = RESPONSE_TIMEOUT,
|
|
34
|
+
logger: logging.Logger | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialize the client."""
|
|
37
|
+
self.host = host
|
|
38
|
+
self.port = port
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
self._logger = logger or _LOGGER
|
|
41
|
+
|
|
42
|
+
async def request_status(self) -> bytes:
|
|
43
|
+
"""Send the status request and return the raw response bytes."""
|
|
44
|
+
writer: asyncio.StreamWriter | None = None
|
|
45
|
+
try:
|
|
46
|
+
self._logger.debug(
|
|
47
|
+
"Fetching AirTouch 3 data from %s:%s", self.host, self.port
|
|
48
|
+
)
|
|
49
|
+
async with asyncio.timeout(self.timeout):
|
|
50
|
+
reader, writer = await asyncio.open_connection(self.host, self.port)
|
|
51
|
+
writer.write(AirTouchMessage().get_init_msg())
|
|
52
|
+
await writer.drain()
|
|
53
|
+
return await reader.read(1024)
|
|
54
|
+
except (TimeoutError, OSError) as err:
|
|
55
|
+
raise AirTouchError(f"Communication error with AirTouch: {err}") from err
|
|
56
|
+
finally:
|
|
57
|
+
if writer:
|
|
58
|
+
writer.close()
|
|
59
|
+
with contextlib.suppress(OSError):
|
|
60
|
+
await writer.wait_closed()
|
|
61
|
+
|
|
62
|
+
async def fetch_aircon(self) -> Aircon:
|
|
63
|
+
"""Fetch and parse the controller status."""
|
|
64
|
+
response_data = await self.request_status()
|
|
65
|
+
self._logger.debug(
|
|
66
|
+
"Received %s bytes from AirTouch 3 controller at %s:%s",
|
|
67
|
+
len(response_data),
|
|
68
|
+
self.host,
|
|
69
|
+
self.port,
|
|
70
|
+
)
|
|
71
|
+
if len(response_data) < MIN_RESPONSE_LENGTH:
|
|
72
|
+
raise AirTouchError(
|
|
73
|
+
f"AirTouch response was too short: {len(response_data)} bytes"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
return MessageResponseParser(bytearray(response_data), self._logger).parse()
|
|
78
|
+
except (ValueError, IndexError) as err:
|
|
79
|
+
raise AirTouchError(f"Communication error with AirTouch: {err}") from err
|
|
80
|
+
|
|
81
|
+
async def send_message(self, message: bytes | bytearray) -> None:
|
|
82
|
+
"""Send a raw AirTouch protocol message."""
|
|
83
|
+
writer: asyncio.StreamWriter | None = None
|
|
84
|
+
try:
|
|
85
|
+
async with asyncio.timeout(self.timeout):
|
|
86
|
+
_, writer = await asyncio.open_connection(self.host, self.port)
|
|
87
|
+
writer.write(message)
|
|
88
|
+
await writer.drain()
|
|
89
|
+
except (TimeoutError, OSError) as err:
|
|
90
|
+
raise AirTouchError(f"Communication error with AirTouch: {err}") from err
|
|
91
|
+
finally:
|
|
92
|
+
if writer:
|
|
93
|
+
writer.close()
|
|
94
|
+
with contextlib.suppress(OSError):
|
|
95
|
+
await writer.wait_closed()
|
|
96
|
+
|
|
97
|
+
async def toggle_zone(self, zone_id: int) -> None:
|
|
98
|
+
"""Toggle power on or off for a zone."""
|
|
99
|
+
await self.send_message(AirTouchMessage().toggle_zone(zone_id))
|
|
100
|
+
|
|
101
|
+
async def adjust_zone_temperature(self, zone_id: int, inc_dec: int) -> None:
|
|
102
|
+
"""Increment or decrement a zone target temperature by one step."""
|
|
103
|
+
await self.send_message(AirTouchMessage().set_fan(zone_id, inc_dec))
|
|
104
|
+
|
|
105
|
+
async def toggle_ac_power(self, ac_id: int) -> None:
|
|
106
|
+
"""Toggle power on or off for an AC."""
|
|
107
|
+
await self.send_message(AirTouchMessage().toggle_ac_on_off(ac_id))
|
|
108
|
+
|
|
109
|
+
async def set_mode(self, ac_id: int, brand_id: int, mode: int) -> None:
|
|
110
|
+
"""Set the AC mode."""
|
|
111
|
+
await self.send_message(AirTouchMessage().set_mode(ac_id, brand_id, mode))
|
|
112
|
+
|
|
113
|
+
async def set_fan_speed(self, ac_id: int, brand_id: int, speed: int) -> None:
|
|
114
|
+
"""Set the AC fan speed."""
|
|
115
|
+
await self.send_message(AirTouchMessage().set_fan_speed(ac_id, brand_id, speed))
|