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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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))