├── .vscode ├── settings.json └── launch.json ├── publish.sh ├── airtouch4pyapi ├── __init__.py ├── helper.py ├── communicate.py ├── packetmap.py └── airtouch.py ├── .gitignore ├── setup.py ├── LICENSE ├── .github └── workflows │ └── python-publish.yml ├── README.md └── demo.py /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/usr/local/bin/python3" 3 | } -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python setup.py sdist bdist_wheel 3 | python -m twine upload dist/* 4 | -------------------------------------------------------------------------------- /airtouch4pyapi/__init__.py: -------------------------------------------------------------------------------- 1 | from airtouch4pyapi.airtouch import AirTouch, AirTouchStatus, AirTouchVersion, autoDiscoverAirtouch 2 | -------------------------------------------------------------------------------- /airtouch4pyapi/helper.py: -------------------------------------------------------------------------------- 1 | #region Helper Functions 2 | def chunks(lst, n): 3 | """Yield successive n-sized chunks from lst.""" 4 | for i in range(0, len(lst), n): 5 | yield lst[i:i + n] 6 | 7 | #endregion -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/* 2 | airtouch4pyapi/__pycache__/* 3 | __pycache__/* 4 | build/* 5 | dist/* 6 | airtouch4pyapi.egg-info/dependency_links.txt 7 | airtouch4pyapi.egg-info/PKG-INFO 8 | airtouch4pyapi.egg-info/SOURCES.txt 9 | airtouch4pyapi.egg-info/top_level.txt 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="airtouch4pyapi", # Replace with your own username 8 | version="1.0.8", 9 | author="Sam Sinnamon", 10 | author_email="samsinnamon@hotmail.com", 11 | description="An api allowing control of AC state (temperature, on/off, mode) of an Airtouch 4 controller locally over TCP", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/LonePurpleWolf/airtouch4pyapi", 15 | packages=setuptools.find_packages(), 16 | install_requires=['numpy'], 17 | classifiers=[ 18 | "Programming Language :: Python :: 3", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | ], 22 | python_requires='>=3.6', 23 | ) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sam Sinnamon 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. -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@master 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.8' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | python -m pip install build 33 | - name: Build package 34 | run: python -m build --sdist --wheel --outdir dist 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | with: 38 | password: ${{ secrets.PYPI_API_TOKEN }} 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Airtouch 4 & 5 Python TCP API 2 | An api allowing control of AC state (temperature, on/off, mode) of an Airtouch 4 controller locally over TCP. Airtouch 5 support is experimental as of 28 Nov 2022, and is fully interface compatible with AT4. 3 | 4 | All you need to do is initialise and specify the correct AirTouchVersion (if you don't, it assumes 4). 5 | 6 | ## Warning 7 | I am using this with my own Airtouch 4 and see no issues. Please don't blame me if you have any issues with your Airtouch 4 or AC system after using this - I don't know much about AC systems and will probably not be able to help! 8 | 9 | Others are using it with Airtouch 5 and see no issues. 10 | 11 | ## Usage 12 | To initialise: 13 | * `airTouch = AirTouch("192.168.1.19")` 14 | * `airTouch = AirTouch("192.168.1.1", AirTouchVersion.AIRTOUCH5)` 15 | 16 | As a test: 17 | 18 | Use the demo.py file and pass in an AirTouch IP or it can try auto discovery if you don't have the ip. It takes you through a few tests. 19 | 20 | ## Notes 21 | AirTouch5: If you turn off all zones, the AC itself turns off. Turning on a zone does not turn the AC back on by itself. You must turn it back on too. Same behaviour in 'official' app. 22 | 23 | To load: 24 | * `await airTouch.UpdateInfo();` -- This loads the config from the AirTouch. Make sure you check for any errors before using it. It will load the Group/Zone info, the AC info, then capabilities. This needs to happen prior to using. 25 | 26 | The following functions are available: 27 | 28 | Group Level Functions: 29 | * `SetGroupToTemperature` (async) 30 | * `TurnGroupOn` (async) 31 | * `TurnGroupOff` (async) 32 | * `SetCoolingModeByGroup` (async) 33 | * `SetFanSpeedByGroup` (async) 34 | * `GetSupportedCoolingModesByGroup` -- Based on the loaded config. 35 | * `GetSupportedFanSpeedsByGroup` -- Based on the loaded config. 36 | 37 | AC Level Functions 38 | * `TurnAcOn` (async) 39 | * `TurnAcOff` (async) 40 | * `SetFanSpeedForAc` (async) 41 | * `SetCoolingModeForAc` (async) 42 | * `GetSupportedCoolingModesForAc` 43 | * `GetSupportedFanSpeedsForAc` 44 | * `GetAcs` 45 | 46 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import asyncio 4 | import time 5 | 6 | from airtouch4pyapi import AirTouch, AirTouchStatus, AirTouchVersion, autoDiscoverAirtouch 7 | 8 | def print_groups(groups): 9 | for group in groups: 10 | print(f"Group Name: {group.GroupName:15s} Group Number: {group.GroupNumber:3d} PowerState: {group.PowerState:3s} IsOn: {group.IsOn} OpenPercent: {group.OpenPercent:3d} Temperature: {group.Temperature:3.1f} Target: {group.TargetSetpoint:3.1f} BelongToAc: {group.BelongsToAc:2d} Spill: {group.Spill}") 11 | 12 | 13 | def print_acs(acs): 14 | for ac in acs: 15 | print(f"AC Name: {ac.AcName:15s} AC Number: {ac.AcNumber:3d} IsOn: {ac.IsOn} PowerState: {ac.PowerState:3s} Target: {ac.AcTargetSetpoint:3.1f} Temp: {ac.Temperature:3.1f} Modes Supported: {ac.ModeSupported} Fans Supported: {ac.FanSpeedSupported} startGroup: {ac.StartGroupNumber: 2d} GroupCount: {ac.GroupCount:2d} Spill: {ac.Spill}") 16 | 17 | async def updateInfoAndDisplay(at) -> asyncio.coroutines: 18 | await at.UpdateInfo() 19 | if(at.Status != AirTouchStatus.OK): 20 | print("Got an error updating info. Exiting") 21 | return 22 | print("Updated Info on Groups (Zones) and ACs") 23 | print("AC INFO") 24 | print("----------") 25 | acs = at.GetAcs() 26 | print_acs(acs) 27 | print("----------\n\n") 28 | print("GROUP/ZONE INFO") 29 | print("----------") 30 | groups = at.GetGroups() 31 | print_groups(groups) 32 | val = input("Do you want to turn them all off, wait 10 seconds, turn them all back on? (y/n): ") 33 | if(val.lower() == "y"): 34 | for group in groups: 35 | await at.TurnGroupOffByName(group.GroupName) 36 | print("GROUP/ZONE INFO AFTER TURNING ALL OFF") 37 | print("----------") 38 | print_groups(groups) 39 | time.sleep(10) 40 | for group in groups: 41 | await at.TurnGroupOnByName(group.GroupName) 42 | await at.TurnAcOn(0) 43 | print("GROUP/ZONE INFO AFTER TURNING ALL ON") 44 | print("----------") 45 | print_groups(groups) 46 | val = input("Do you want to increment set points by 1 degree then back down by 1? (y/n): ") 47 | if(val.lower() == "y"): 48 | for group in groups: 49 | to_temp = int(group.TargetSetpoint) + 1 50 | await at.SetGroupToTemperatureByGroupName(group.GroupName, to_temp) 51 | print("GROUP/ZONE INFO AFTER SET TEMP + 1") 52 | print("----------") 53 | print_groups(groups) 54 | time.sleep(5) 55 | for group in groups: 56 | to_temp = int(group.TargetSetpoint) -1 57 | await at.SetGroupToTemperatureByGroupName(group.GroupName, to_temp) 58 | print("GROUP/ZONE INFO AFTER SET TEMP + 1") 59 | print("----------") 60 | print_groups(groups) 61 | 62 | val = input("Do you want to set group 0's mode to heat then back to cool? (y/n): ") 63 | if(val.lower() == "y"): 64 | await at.SetCoolingModeByGroup(0, 'Heat') 65 | print("AC INFO AFTER SETTING GROUP 0 to HEAT") 66 | print("----------") 67 | print_acs(acs) 68 | time.sleep(5) 69 | await at.SetCoolingModeByGroup(0, 'Cool') 70 | print("AC INFO AFTER SETTING GROUP 0 to COOL") 71 | print("----------") 72 | print_acs(acs) 73 | # await at.TurnGroupOff(0) 74 | # print("Turned off group 0, sleeping 4") 75 | # time.sleep(4); 76 | # await at.TurnGroupOn(0) 77 | # print("Turned on group 0") 78 | 79 | # await at.TurnAcOff(0) 80 | # print("Turned off ac 0, sleeping 4") 81 | # time.sleep(4); 82 | # await at.TurnAcOn(0) 83 | # print("Turned on ac 0") 84 | # print(at.GetSupportedFanSpeedsByGroup(0)) 85 | # await at.SetGroupToPercentByGroupName("Zone 1", 5) 86 | 87 | if __name__ == '__main__': 88 | if len(sys.argv) < 2: 89 | print("Auto-discovering AirTouch console.") 90 | at = autoDiscoverAirtouch() 91 | 92 | if at is None: 93 | print("nom nom nom give me an IP of an AirTouch system") 94 | sys.exit(1) 95 | else: 96 | at = AirTouch(sys.argv[1]) 97 | 98 | asyncio.run(updateInfoAndDisplay(at)) 99 | -------------------------------------------------------------------------------- /airtouch4pyapi/communicate.py: -------------------------------------------------------------------------------- 1 | from airtouch4pyapi import packetmap 2 | import asyncio 3 | import errno 4 | from socket import error as socket_error 5 | #from hexdump import hexdump # for debugging 6 | 7 | def MessageObjectToMessagePacket(messageObject, mapName, atVersion): 8 | if(atVersion.value == 5): 9 | messageString = "80b001c0"; 10 | dataPayload = hex(packetmap.SettingValueTranslator.NamedValueToRawValue("MessageType", messageObject.MessageType, 5))[2:]+"00000000040001"; 11 | groupControlPacketLocationMap = packetmap.DataLocationTranslator.map[5][mapName] 12 | 13 | elif(atVersion.value == 4): 14 | messageString = "80b001"; 15 | messageString += hex(packetmap.SettingValueTranslator.NamedValueToRawValue("MessageType", messageObject.MessageType))[2:] 16 | dataPayload = ""; 17 | groupControlPacketLocationMap = packetmap.DataLocationTranslator.map[4][mapName] 18 | 19 | packetInfoAttributes = [attr for attr in groupControlPacketLocationMap.keys()] 20 | binaryMessagePayloadString = ""; 21 | for attribute in packetInfoAttributes: 22 | binaryMessagePayloadString = AddMapValueToBinaryValue(binaryMessagePayloadString, groupControlPacketLocationMap[attribute], messageObject.MessageValues[attribute]) 23 | 24 | dataPayload += format(int(binaryMessagePayloadString, 2), '08x'); 25 | dataLength = len(dataPayload) / 2; 26 | lengthString = "0000"[0: 4 - (len(hex((int(dataLength)))[2:]))] + hex((int(dataLength)))[2:]; 27 | 28 | if(atVersion.value == 5): 29 | messageString += lengthString 30 | messageString += dataPayload 31 | elif(atVersion.value == 4): 32 | messageString += lengthString + dataPayload 33 | return messageString 34 | 35 | def AddMapValueToBinaryValue(binaryMessagePayloadString, map, value): 36 | byteNumber = int(map.split(":")[0]) 37 | length = 8; 38 | #spec counts bytes backwards so so do we 39 | bitmaskstart = length - (int(map.split(":")[1].split("-")[1]) - 1); 40 | bitmaskend = length - (int(map.split(":")[1].split("-")[0]) - 1); 41 | 42 | #binaryMessage needs to be at least as long as (byteNumber - 1) * 8 + bitmaskstart, so add as many zeroes as required to make that happen 43 | 44 | while(len(binaryMessagePayloadString) < (byteNumber - 1) * 8 + (bitmaskstart - 1)): 45 | binaryMessagePayloadString += "0" 46 | 47 | binOfValueAsString = bin(value)[2:]; 48 | lengthNeededForBinValue = bitmaskend - (bitmaskstart - 1); 49 | binaryMessagePayloadString = binaryMessagePayloadString + "00000000"[0: lengthNeededForBinValue - len(binOfValueAsString)] + binOfValueAsString 50 | return binaryMessagePayloadString 51 | 52 | def TranslateMapValueToValue(groupChunk, map): 53 | byteNumber = int(map.split(":")[0]) 54 | length = 8; 55 | if(int(map.split(":")[1].split("-")[1]) > 8): 56 | length = 16; 57 | #spec counts bytes backwards so so do we 58 | bitmaskstart = length - (int(map.split(":")[1].split("-")[1]) - 1); 59 | bitmaskend = length - (int(map.split(":")[1].split("-")[0]) - 1); 60 | byteAsString = bin(groupChunk[byteNumber - 1]) 61 | byteStringAdjusted = "00000000"[0: 8 - (len(byteAsString) - 2)] + byteAsString[2:]; 62 | 63 | if(length > 8): 64 | byteStringAdjusted += ("00000000"[0: 8 - (len(bin(groupChunk[byteNumber])) - 2)] + bin(groupChunk[byteNumber])[2:]); 65 | 66 | byteSegment = byteStringAdjusted[bitmaskstart - 1: bitmaskend]; 67 | byteSegmentAsValue = int(byteSegment, 2) 68 | return byteSegmentAsValue 69 | 70 | #might raise a socket or os error if connection fails 71 | async def SendMessagePacketToAirtouch(messageString, ipAddress, atVersion, atPort): 72 | #add header, add crc 73 | if(atVersion.value == 5): 74 | messageString = "555555aa" + messageString + format(crc16(bytes.fromhex(messageString)), '08x')[4:] 75 | else: 76 | messageString = "5555" + messageString + format(crc16(bytes.fromhex(messageString)), '08x')[4:] 77 | BUFFER_SIZE = 4096 78 | #hexdump(bytearray.fromhex(messageString)) # for debugging 79 | reader, writer = await asyncio.open_connection(ipAddress, atPort) 80 | writer.write(bytearray.fromhex(messageString)) 81 | response = await asyncio.wait_for(reader.read(BUFFER_SIZE), timeout=2.0) 82 | writer.close() 83 | await writer.wait_closed() 84 | #hexdump(response) # for debugging 85 | return response; 86 | 87 | import numpy as np 88 | 89 | def crc16(data: bytes): 90 | ''' 91 | CRC-16-ModBus Algorithm 92 | ''' 93 | data = bytearray(data) 94 | poly = 0xA001 95 | crc = 0xFFFF 96 | for b in data: 97 | crc ^= (0xFF & b) 98 | for _ in range(0, 8): 99 | if (crc & 0x0001): 100 | crc = ((crc >> 1) & 0xFFFF) ^ poly 101 | else: 102 | crc = ((crc >> 1) & 0xFFFF) 103 | 104 | return np.uint16(crc) -------------------------------------------------------------------------------- /airtouch4pyapi/packetmap.py: -------------------------------------------------------------------------------- 1 | #region Message Mapping Values 2 | class SettingValueTranslator: 3 | map = { 4 | 4 : { 5 | "MessageType" : { 6 | "GroupControl": 0x2a, 7 | "GroupStatus": 0x2b, 8 | "AcControl": 0x2c, 9 | "AcStatus": 0x2d, 10 | "GroupName": 0x1e, 11 | }, 12 | "AcMode" : { 13 | "Auto": 0x00, 14 | "Heat": 0x01, 15 | "Dry": 0x02, 16 | "Fan": 0x03, 17 | "Cool": 0x04, 18 | "AutoHeat": 0x08, 19 | "AutoCool": 0x09, 20 | 21 | }, 22 | "AcFanSpeed" : { 23 | "Auto": 0x00, 24 | "Quiet": 0x01, 25 | "Low": 0x02, 26 | "Medium": 0x03, 27 | "High": 0x04, 28 | "Powerful": 0x05, 29 | "Turbo": 0x06, 30 | }, 31 | "YesNo" : { 32 | "Yes": 0x01, 33 | "No": 0x00, 34 | }, 35 | "PowerState" : { 36 | "Off": 0b00000000, 37 | "On" : 0b00000001, 38 | "Turbo" : 0b00000011 39 | }, 40 | "ControlMethod": { 41 | "TemperatureControl": 0x01, 42 | "PercentageControl": 0x00 43 | }, 44 | "BatteryLow": { 45 | "LowBattery": 0x01, 46 | "Normal": 0x00 47 | }, 48 | "Sensor": { 49 | "Yes": 0x01, 50 | "No": 0x00 51 | }, 52 | "AcTimer": { 53 | "Set": 0x01, 54 | "NotSet": 0x00 55 | }, 56 | "Spill": { 57 | "Active": 0x01, 58 | "Inactive": 0x00 59 | }, 60 | "Turbo": { 61 | "Active": 0x01, 62 | "Inactive": 0x00 63 | }, 64 | "Bypass": { 65 | "Active": 0x01, 66 | "Inactive": 0x00 67 | }, 68 | "Temperature" : { 69 | "TranslateMethod" : lambda x : (x-500) / 10 70 | } 71 | }, 72 | 5 : { 73 | "MessageType" : { 74 | "GroupControl": 0x20, 75 | "GroupStatus": 0x21, 76 | "AcControl": 0x22, 77 | "AcStatus": 0x23, 78 | "GroupName": 0x1e, 79 | }, 80 | "AcMode" : { 81 | "Auto": 0x00, 82 | "Heat": 0x01, 83 | "Dry": 0x02, 84 | "Fan": 0x03, 85 | "Cool": 0x04, 86 | "AutoHeat": 0x08, 87 | "AutoCool": 0x09, 88 | 89 | }, 90 | "AcFanSpeed" : { 91 | "Auto": 0x00, 92 | "Quiet": 0x01, 93 | "Low": 0x02, 94 | "Medium": 0x03, 95 | "High": 0x04, 96 | "Powerful": 0x05, 97 | "Turbo": 0x06, 98 | }, 99 | "YesNo" : { 100 | "Yes": 0x01, 101 | "No": 0x00, 102 | }, 103 | "PowerState" : { 104 | "Off": 0b00000000, 105 | "On" : 0b00000001, 106 | "Turbo" : 0b00000011 107 | }, 108 | "ControlMethod": { 109 | "TemperatureControl": 0x01, 110 | "PercentageControl": 0x00 111 | }, 112 | "BatteryLow": { 113 | "LowBattery": 0x01, 114 | "Normal": 0x00 115 | }, 116 | "Sensor": { 117 | "Yes": 0x01, 118 | "No": 0x00 119 | }, 120 | "AcTimer": { 121 | "Set": 0x01, 122 | "NotSet": 0x00 123 | }, 124 | "Spill": { 125 | "Active": 0x01, 126 | "Inactive": 0x00 127 | }, 128 | "Turbo": { 129 | "Active": 0x01, 130 | "Inactive": 0x00 131 | }, 132 | "Bypass": { 133 | "Active": 0x01, 134 | "Inactive": 0x00 135 | }, 136 | "Temperature" : { 137 | "TranslateMethod" : lambda x : (x-500) / 10 138 | }, 139 | "AcTargetSetpoint" : { 140 | "TranslateMethod" : lambda x : (x+100) / 10 141 | }, 142 | "TargetSetpoint" : { 143 | "TranslateMethod" : lambda x : (x+100) / 10 144 | } 145 | } 146 | } 147 | 148 | def __init__(self): 149 | pass 150 | 151 | @staticmethod 152 | def NamedValueToRawValue(attributeName: str, name: str, atVersion = 4): 153 | return SettingValueTranslator.map[atVersion][attributeName][name]; 154 | 155 | @staticmethod 156 | def RawValueToNamedValue(attributeName: str, rawValue: int, atVersion = 4): 157 | if attributeName not in SettingValueTranslator.map[atVersion].keys(): 158 | return rawValue 159 | for namedValue in SettingValueTranslator.map[atVersion][attributeName].keys(): 160 | if "TranslateMethod" in SettingValueTranslator.map[atVersion][attributeName].keys(): 161 | return SettingValueTranslator.map[atVersion][attributeName]["TranslateMethod"](rawValue) 162 | if(SettingValueTranslator.map[atVersion][attributeName][namedValue] == rawValue): 163 | return namedValue; 164 | return "" 165 | 166 | 167 | class DataLocationTranslator: 168 | map = { 169 | 4 : { 170 | "GroupStatus" : { 171 | "PowerState" : "1:7-8", 172 | "GroupNumber" : "1:1-6", 173 | "ControlMethod" : "2:8-8", 174 | "OpenPercent" : "2:1-7", 175 | "BatteryLow" : "3:8-8", 176 | "TurboSupport" : "3:7-7", 177 | "TargetSetpoint" : "3:1-6", 178 | "Sensor" : "4:8-8", 179 | "Temperature" : "5:6-16", 180 | "Spill": "6:5-5" 181 | }, 182 | "AcStatus" : { 183 | "PowerState" : "1:7-8", 184 | "AcNumber" : "1:1-6", 185 | "AcMode" : "2:5-8", 186 | "AcFanSpeed" : "2:1-4", 187 | "Spill" : "3:8-8", 188 | "AcTimer" : "3:7-7", 189 | "AcTargetSetpoint" : "3:1-6", 190 | "Temperature" : "5:6-16", 191 | }, 192 | "GroupControl" : { 193 | "GroupNumber" : "1:1-8", 194 | "GroupSettingValue" : "2:6-8", 195 | "HaveTemperatureControl" : "2:4-5", 196 | "Power" : "2:1-3", 197 | "TargetSetpoint" : "3:1-8", 198 | "ZeroedByte" : "4:1-8" 199 | }, 200 | "AcControl" : { 201 | "Power" : "1:7-8", 202 | "AcNumber" : "1:1-6", 203 | "AcMode" : "2:5-8", 204 | "AcFanSpeed" : "2:1-4", 205 | "SetpointControlType" : "3:7-8", 206 | "TargetSetpoint" : "3:1-6", 207 | "ZeroedByte" : "4:1-8" 208 | }, 209 | "AcAbility" : { 210 | #byte number - 2 from the spec, due to fixed message at start 211 | "AcNumber" : "1:1-8", 212 | "ChunkSize" : "2:1-8", 213 | "StartGroupNumber" : "19:1-8", 214 | "GroupCount" : "20:1-8", 215 | "CoolModeSupported" : "21:5-5", 216 | "FanModeSupported" : "21:4-4", 217 | "DryModeSupported" : "21:3-3", 218 | "HeatModeSupported" : "21:2-2", 219 | "AutoModeSupported" : "21:1-1", 220 | "TurboFanSpeedSupported" : "22:7-7", 221 | "PowerfulFanSpeedSupported" : "22:6-6", 222 | "HighFanSpeedSupported" : "22:5-5", 223 | "MediumFanSpeedSupported" : "22:4-4", 224 | "LowFanSpeedSupported" : "22:3-3", 225 | "QuietFanSpeedSupported" : "22:2-2", 226 | "AutoFanSpeedSupported" : "22:1-1", 227 | "MinSetpoint" : "23:1-8", 228 | "MaxSetpoint" : "24:1-8" 229 | }, 230 | "GroupName" : { #TODO 231 | } 232 | }, 233 | 5 : { 234 | 235 | "GroupStatus" : { 236 | "PowerState" : "1:7-8", 237 | "GroupNumber" : "1:1-6", 238 | "ControlMethod" : "2:8-8", 239 | "OpenPercent" : "2:1-7", 240 | "BatteryLow" : "7:1-1", 241 | "TargetSetpoint" : "3:1-8", 242 | "Sensor" : "4:8-8", 243 | "Temperature" : "5:1-16", 244 | "Spill": "7:2-2" 245 | }, 246 | 247 | "AcStatus" : { 248 | "PowerState" : "1:5-8", 249 | "AcNumber" : "1:1-4", 250 | "AcMode" : "2:5-8", 251 | "AcFanSpeed" : "2:1-4", 252 | "Spill" : "4:2-2", 253 | "Turbo" : "4:4-4", 254 | "Bypass" : "4:3-3", 255 | "AcTimer" : "4:1-1", 256 | "AcTargetSetpoint" : "3:1-8", 257 | "Temperature" : "5:1-16", 258 | }, 259 | "GroupControl" : { 260 | "GroupNumber" : "1:1-6", 261 | "GroupSettingValue" : "2:6-8", 262 | "Power" : "2:1-3", 263 | "TargetSetpoint" : "3:1-8", 264 | "ZeroedByte" : "4:1-8" 265 | }, 266 | "AcControl" : { 267 | "Power" : "1:5-8", 268 | "AcNumber" : "1:1-4", 269 | "AcMode" : "2:5-8", 270 | "AcFanSpeed" : "2:1-4", 271 | "SetpointControlType" : "3:1-8", 272 | "TargetSetpoint" : "4:1-8" 273 | }, 274 | "AcAbility" : { 275 | #byte number - 2 from the spec, due to fixed message at start 276 | "AcNumber" : "1:1-8", 277 | "ChunkSize" : "2:1-8", 278 | "StartGroupNumber" : "19:1-8", 279 | "GroupCount" : "20:1-8", 280 | "CoolModeSupported" : "21:5-5", 281 | "FanModeSupported" : "21:4-4", 282 | "DryModeSupported" : "21:3-3", 283 | "HeatModeSupported" : "21:2-2", 284 | "AutoModeSupported" : "21:1-1", 285 | "TurboFanSpeedSupported" : "22:7-7", 286 | "PowerfulFanSpeedSupported" : "22:6-6", 287 | "HighFanSpeedSupported" : "22:5-5", 288 | "MediumFanSpeedSupported" : "22:4-4", 289 | "LowFanSpeedSupported" : "22:3-3", 290 | "QuietFanSpeedSupported" : "22:2-2", 291 | "AutoFanSpeedSupported" : "22:1-1", 292 | "MinSetpoint" : "23:1-8", 293 | "MaxSetpoint" : "24:1-8" 294 | }, 295 | "GroupName" : { #TODO 296 | } 297 | } 298 | 299 | } 300 | 301 | class Message(): 302 | def __init__(self, messageType): 303 | self.MessageValues = dict(); 304 | self.MessageType = messageType; 305 | pass 306 | def SetMessageValue(self, propertyName, newValue): 307 | self.MessageValues[propertyName] = newValue 308 | 309 | 310 | class MessageFactory: 311 | @staticmethod 312 | def CreateEmptyMessageOfType(messageType, atVersion): 313 | message = Message(messageType); 314 | for attr in DataLocationTranslator.map[atVersion.value][messageType]: 315 | message.MessageValues[attr] = 0; 316 | return message; 317 | #endregion -------------------------------------------------------------------------------- /airtouch4pyapi/airtouch.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from typing import List 3 | from airtouch4pyapi import helper 4 | from airtouch4pyapi import packetmap 5 | from airtouch4pyapi import communicate 6 | from enum import Enum 7 | # from hexdump import hexdump 8 | #API 9 | 10 | # class Airtouch 11 | #Init - takes IP Address, queries group names and group infos 12 | #GetInfo - takes nothing, returns Groups list 13 | 14 | #SetGroupToTemperature takes group number + temperature 15 | #TurnGroupOn 16 | #TurnGroupOff 17 | 18 | #SetCoolingModeByGroup 19 | #SetFanSpeedByGroup 20 | #GetSupportedCoolingModesByGroup 21 | #GetSupportedFanSpeedsByGroup 22 | 23 | #TurnAcOn 24 | #TurnAcOff 25 | #SetFanSpeedForAc 26 | #SetCoolingModeForAc 27 | #GetSupportedCoolingModesForAc 28 | #GetSupportedFanSpeedsForAc 29 | #GetAcs 30 | 31 | AT4PORT = 9004 32 | AT5PORT = 9005 33 | 34 | class AirTouchStatus(Enum): 35 | NOT_CONNECTED = 0 36 | OK = 1 37 | CONNECTION_INTERRUPTED = 2 38 | CONNECTION_LOST = 3 39 | ERROR = 4 40 | 41 | class AirTouchVersion(Enum): 42 | AIRTOUCH4 = 4 43 | AIRTOUCH5 = 5 44 | 45 | class AirTouchGroup: 46 | def __init__(self): 47 | self.GroupName = "" 48 | self.GroupNumber = 0 49 | self.OpenPercent = 0 50 | self.Temperature = 0 51 | self.TargetSetpoint = 0 52 | self.BelongsToAc = -1 53 | @property 54 | def IsOn(self): 55 | return self.PowerState 56 | 57 | class AirTouchError: 58 | def __init__(self): 59 | self.Message = "" 60 | self.Status = AirTouchStatus.OK 61 | 62 | class AirTouchAc: 63 | def __init__(self): 64 | self.AcName = "" 65 | self.AcNumber = 0 66 | self.StartGroupNumber = 0 67 | self.GroupCount = 0 68 | @property 69 | def IsOn(self): 70 | return self.PowerState 71 | 72 | class AirTouch: 73 | IpAddress = ""; 74 | SettingValueTranslator = packetmap.SettingValueTranslator(); 75 | def __init__(self, ipAddress, atVersion = None, port = None): 76 | self.IpAddress = ipAddress; 77 | self.Status = AirTouchStatus.NOT_CONNECTED; 78 | self.Messages = dict(); 79 | self.atVersion = atVersion; 80 | self.atPort = port; 81 | self.acs = dict(); 82 | self.groups = dict(); 83 | self.Messages:List[AirTouchError] = []; 84 | 85 | # if atVersion is provided but port is not, it'll never get set so do it here. 86 | if self.atVersion is not None and self.atPort is None: 87 | if AirTouchVersion.AIRTOUCH4 == self.atVersion: 88 | self.atPort = AT4PORT 89 | else: 90 | self.atPort = AT5PORT 91 | 92 | async def UpdateInfo(self): 93 | if(self.atPort != None and self.atVersion == None): 94 | self.Status = AirTouchStatus.ERROR 95 | errorMessage = AirTouchError() 96 | errorMessage.Message = "If you specify a port, you must specify a version" 97 | self.Messages.append(errorMessage) 98 | print(self.Status) 99 | for msg in self.Messages: 100 | print(msg.Message); 101 | return; 102 | 103 | if(self.atVersion == None): 104 | await self.findVersion() 105 | 106 | if(self.atVersion == None): 107 | print(self.Status) 108 | for msg in self.Messages: 109 | print(msg.Message); 110 | return; 111 | #get the group infos 112 | await self.UpdateGroupInfo() 113 | 114 | #if the first call means we still have an error status, not worth doing the subsequent ones 115 | if(self.Status != AirTouchStatus.OK): 116 | print(self.Status) 117 | for msg in self.Messages: 118 | print(msg.Message); 119 | return; 120 | 121 | #get the group nicknames 122 | await self.UpdateGroupNames() 123 | 124 | #get ac infos 125 | await self.UpdateAcInfo() 126 | 127 | #get ac abilities 128 | await self.UpdateAcAbility() 129 | 130 | #sort out which AC belongs to which zone/group 131 | self.AssignAcsToGroups() 132 | 133 | async def findVersion(self): 134 | if(await self.isOpen(self.IpAddress, AT4PORT)): 135 | self.atVersion = AirTouchVersion.AIRTOUCH4 136 | self.atPort = AT4PORT 137 | return 138 | elif(await self.isOpen(self.IpAddress, AT5PORT)): 139 | self.atVersion = AirTouchVersion.AIRTOUCH5 140 | self.atPort = AT5PORT 141 | return 142 | else: 143 | self.Status = AirTouchStatus.ERROR 144 | errorMessage = AirTouchError() 145 | errorMessage.Message = "Could not find open port 9004 (v4) or 9005 (v5)" 146 | self.Messages.append(errorMessage) 147 | 148 | async def isOpen(self, ip, port): 149 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 150 | try: 151 | s.connect((ip, int(port))) 152 | s.shutdown(2) 153 | return True 154 | except: 155 | return False 156 | ## Initial Update Calls 157 | async def UpdateAcAbility(self): 158 | acAbilityMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("AcAbility", self.atVersion); 159 | await self.SendMessageToAirtouch(acAbilityMessage) 160 | 161 | async def UpdateAcInfo(self): 162 | message = packetmap.MessageFactory.CreateEmptyMessageOfType("AcStatus", self.atVersion); 163 | await self.SendMessageToAirtouch(message) 164 | 165 | async def UpdateGroupInfo(self): 166 | message = packetmap.MessageFactory.CreateEmptyMessageOfType("GroupStatus", self.atVersion); 167 | await self.SendMessageToAirtouch(message) 168 | 169 | async def UpdateGroupNames(self): 170 | nameMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("GroupName", self.atVersion); 171 | await self.SendMessageToAirtouch(nameMessage) 172 | 173 | def AssignAcsToGroups(self): 174 | ## Assign ACs to groups (zones) based on startgroupnumber, count 175 | for group in self.groups.values(): 176 | #find out which ac this group belongs to 177 | 178 | for ac in self.acs.values(): 179 | if(ac.StartGroupNumber == 0 and ac.GroupCount == 0): 180 | #assuming this means theres only one ac? so every group belongs to this ac? 181 | group.BelongsToAc = ac.AcNumber 182 | if(ac.StartGroupNumber <= group.GroupNumber and group.GroupNumber <= ac.StartGroupNumber + ac.GroupCount): 183 | group.BelongsToAc = ac.AcNumber 184 | 185 | ## END Initial Update Calls 186 | 187 | ## Turn things on/off/temp by name (just finds the number, calls the right function) 188 | async def TurnGroupOnByName(self, groupName): 189 | targetGroup = self._getTargetGroup(groupName) 190 | await self.TurnGroupOn(targetGroup.GroupNumber); 191 | 192 | async def TurnGroupOffByName(self, groupName): 193 | targetGroup = self._getTargetGroup(groupName) 194 | await self.TurnGroupOff(targetGroup.GroupNumber); 195 | 196 | 197 | async def SetGroupToTemperatureByGroupName(self, groupName, temperature): 198 | targetGroup = self._getTargetGroup(groupName) 199 | await self.SetGroupToTemperature(targetGroup.GroupNumber, temperature); 200 | 201 | async def SetGroupToPercentByGroupName(self, groupName, percent): 202 | targetGroup = self._getTargetGroup(groupName) 203 | await self.SetGroupToPercentage(targetGroup.GroupNumber, percent); 204 | 205 | ## END Turn things on/off/temp by name (just finds the number, calls the right function) 206 | 207 | 208 | ## Group/zone modes 209 | async def SetCoolingModeByGroup(self, groupNumber, coolingMode): 210 | await self.SetCoolingModeForAc(self.groups[groupNumber].BelongsToAc, coolingMode); 211 | return self.groups[groupNumber]; 212 | 213 | async def SetFanSpeedByGroup(self, groupNumber, fanSpeed): 214 | await self.SetFanSpeedForAc(self.groups[groupNumber].BelongsToAc, fanSpeed); 215 | return self.groups[groupNumber]; 216 | 217 | def GetSupportedCoolingModesByGroup(self, groupNumber): 218 | return self.GetSupportedCoolingModesForAc(self.groups[groupNumber].BelongsToAc); 219 | 220 | def GetSupportedFanSpeedsByGroup(self, groupNumber): 221 | return self.GetSupportedFanSpeedsForAc(self.groups[groupNumber].BelongsToAc); 222 | ## END Group/zone modes 223 | 224 | # Main control functions 225 | async def TurnGroupOn(self, groupNumber): 226 | controlMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("GroupControl", self.atVersion); 227 | controlMessage.SetMessageValue("Power", 3) 228 | controlMessage.SetMessageValue("GroupNumber", groupNumber) 229 | await self.SendMessageToAirtouch(controlMessage) 230 | return self.groups[groupNumber]; 231 | 232 | async def TurnGroupOff(self, groupNumber): 233 | controlMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("GroupControl", self.atVersion); 234 | controlMessage.SetMessageValue("Power", 2) 235 | controlMessage.SetMessageValue("GroupNumber", groupNumber) 236 | await self.SendMessageToAirtouch(controlMessage) 237 | return self.groups[groupNumber]; 238 | 239 | async def TurnAcOn(self, acNumber): 240 | controlMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("AcControl", self.atVersion); 241 | #these are required to leave these settings unchanged 242 | controlMessage.SetMessageValue("AcMode", 0x0f); 243 | controlMessage.SetMessageValue("AcFanSpeed", 0x0f); 244 | if(self.atVersion == AirTouchVersion.AIRTOUCH4): 245 | controlMessage.SetMessageValue("TargetSetpoint", 0x3f); 246 | if(self.atVersion == AirTouchVersion.AIRTOUCH5): 247 | controlMessage.SetMessageValue("SetpointControlType", 0x00); 248 | controlMessage.SetMessageValue("Power", 3) 249 | controlMessage.SetMessageValue("AcNumber", acNumber) 250 | await self.SendMessageToAirtouch(controlMessage) 251 | 252 | async def TurnAcOff(self, acNumber): 253 | controlMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("AcControl", self.atVersion); 254 | #these are required to leave these settings unchanged 255 | controlMessage.SetMessageValue("AcMode", 0x0f); 256 | controlMessage.SetMessageValue("AcFanSpeed", 0x0f); 257 | 258 | 259 | if(self.atVersion == AirTouchVersion.AIRTOUCH4): 260 | controlMessage.SetMessageValue("TargetSetpoint", 0x3f); 261 | if(self.atVersion == AirTouchVersion.AIRTOUCH5): 262 | controlMessage.SetMessageValue("SetpointControlType", 0x00); 263 | 264 | controlMessage.SetMessageValue("Power", 2) 265 | controlMessage.SetMessageValue("AcNumber", acNumber) 266 | await self.SendMessageToAirtouch(controlMessage) 267 | 268 | # END Main control functions 269 | 270 | #use a fanspeed reported from GetSupportedFanSpeedsForAc 271 | async def SetFanSpeedForAc(self, acNumber, fanSpeed): 272 | controlMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("AcControl", self.atVersion); 273 | #these are required to leave these settings unchanged 274 | controlMessage.SetMessageValue("AcMode", 0x0f); 275 | controlMessage.SetMessageValue("AcFanSpeed", packetmap.SettingValueTranslator.NamedValueToRawValue("AcFanSpeed", fanSpeed)); 276 | controlMessage.SetMessageValue("TargetSetpoint", 0x3f); 277 | 278 | controlMessage.SetMessageValue("AcNumber", acNumber) 279 | await self.SendMessageToAirtouch(controlMessage) 280 | 281 | #use a mode reported from GetSupportedCoolingModesForAc 282 | async def SetCoolingModeForAc(self, acNumber, acMode): 283 | controlMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("AcControl", self.atVersion); 284 | #these are required to leave these settings unchanged 285 | controlMessage.SetMessageValue("AcMode", packetmap.SettingValueTranslator.NamedValueToRawValue("AcMode", acMode)); 286 | controlMessage.SetMessageValue("AcFanSpeed", 0x0f); 287 | controlMessage.SetMessageValue("TargetSetpoint", 0x3f); 288 | 289 | controlMessage.SetMessageValue("AcNumber", acNumber) 290 | await self.SendMessageToAirtouch(controlMessage) 291 | 292 | #GetSupportedCoolingModesForAc 293 | def GetSupportedCoolingModesForAc(self, acNumber): 294 | return self.acs[acNumber].ModeSupported; 295 | 296 | #GetSupportedFanSpeedsForAc 297 | def GetSupportedFanSpeedsForAc(self, acNumber): 298 | return self.acs[acNumber].FanSpeedSupported; 299 | 300 | 301 | 302 | ## Group/Zone temperatures 303 | async def SetGroupToTemperature(self, groupNumber, temperature): 304 | controlMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("GroupControl", self.atVersion); 305 | controlMessage.SetMessageValue("Power", 3) 306 | if(self.atVersion == AirTouchVersion.AIRTOUCH4): 307 | controlMessage.SetMessageValue("HaveTemperatureControl", 3) 308 | controlMessage.SetMessageValue("GroupSettingValue", 5) 309 | if(self.atVersion == AirTouchVersion.AIRTOUCH4): 310 | controlMessage.SetMessageValue("TargetSetpoint", temperature) 311 | elif(self.atVersion == AirTouchVersion.AIRTOUCH5): 312 | controlMessage.SetMessageValue("TargetSetpoint", temperature*10-100) 313 | controlMessage.SetMessageValue("GroupNumber", groupNumber) 314 | await self.SendMessageToAirtouch(controlMessage) 315 | return self.groups[groupNumber]; 316 | #should this turn the group on? 317 | 318 | async def SetGroupToPercentage(self, groupNumber, percent): 319 | controlMessage = packetmap.MessageFactory.CreateEmptyMessageOfType("GroupControl", self.atVersion); 320 | controlMessage.SetMessageValue("Power", 3) 321 | if(self.atVersion == AirTouchVersion.AIRTOUCH4): 322 | controlMessage.SetMessageValue("HaveTemperatureControl", 3) 323 | controlMessage.SetMessageValue("GroupSettingValue", 4) 324 | controlMessage.SetMessageValue("TargetSetpoint", percent) 325 | controlMessage.SetMessageValue("GroupNumber", groupNumber) 326 | await self.SendMessageToAirtouch(controlMessage) 327 | return self.groups[groupNumber]; 328 | 329 | ## END Group/Zone temperatures 330 | 331 | ## Helper functions 332 | def GetAcs(self): 333 | acs = []; 334 | for acNumber in self.acs.keys(): 335 | ac = self.acs[acNumber] 336 | acs.append(ac); 337 | return acs; 338 | 339 | def GetGroupByGroupNumber(self, groupNumber): 340 | return self.groups[groupNumber]; 341 | 342 | def GetGroups(self): 343 | groups = []; 344 | for groupNumber in self.groups.keys(): 345 | groupInfo = self.groups[groupNumber] 346 | groups.append(groupInfo); 347 | return groups; 348 | #returns a list of groups, each group has a name, a number, on or off, current damper opening, current temp and target temp 349 | 350 | def GetVersion(self): 351 | if self.atVersion is not None: 352 | return str(self.atVersion.value) 353 | 354 | return "" 355 | ## END Helper functions 356 | 357 | 358 | ## Major AT comms. Set up a message, send it, get the result, translate packet to message at the end. 359 | async def SendMessageToAirtouch(self, messageObject): 360 | 361 | if(self.atVersion == AirTouchVersion.AIRTOUCH4): 362 | if(messageObject.MessageType == "GroupStatus"): 363 | MESSAGE = "80b0012b0000" 364 | if(messageObject.MessageType == "GroupName"): 365 | MESSAGE = "90b0011f0002ff12" 366 | if(messageObject.MessageType == "AcAbility"): 367 | MESSAGE = "90b0011f0002ff11" 368 | if(messageObject.MessageType == "AcStatus"): 369 | MESSAGE = "80b0012d0000f4cf" 370 | if(messageObject.MessageType == "GroupControl" or messageObject.MessageType == "AcControl"): 371 | MESSAGE = communicate.MessageObjectToMessagePacket(messageObject, messageObject.MessageType, self.atVersion); 372 | 373 | elif(self.atVersion == AirTouchVersion.AIRTOUCH5): 374 | if(messageObject.MessageType == "GroupStatus"): 375 | MESSAGE = "80b001c000082100000000000000" 376 | if(messageObject.MessageType == "GroupName"): 377 | MESSAGE = "90b0011f0002ff13" 378 | if(messageObject.MessageType == "AcAbility"): 379 | MESSAGE = "90b0011f0002ff11" 380 | if(messageObject.MessageType == "AcStatus"): 381 | MESSAGE = "80b001c000082300000000000000" 382 | if(messageObject.MessageType == "GroupControl" or messageObject.MessageType == "AcControl"): 383 | MESSAGE = communicate.MessageObjectToMessagePacket(messageObject, messageObject.MessageType, self.atVersion); 384 | try: 385 | dataResult = await communicate.SendMessagePacketToAirtouch(MESSAGE, self.IpAddress, self.atVersion, self.atPort) 386 | self.Status = AirTouchStatus.OK 387 | except Exception as e: 388 | if(self.Status == AirTouchStatus.OK): 389 | self.Status = AirTouchStatus.CONNECTION_INTERRUPTED 390 | else: 391 | self.Status = AirTouchStatus.CONNECTION_LOST 392 | 393 | errorMessage = AirTouchError() 394 | errorMessage.Message = "Could not send message to airtouch: " + str(e) 395 | self.Messages.append(errorMessage) 396 | return 397 | 398 | return self.TranslatePacketToMessage(dataResult) 399 | 400 | ## Interpret response object 401 | def TranslatePacketToMessage(self, dataResult): 402 | #If the request hasn't gone well, we don't want to update any of the data we have with bad/no data 403 | if(self.Status != AirTouchStatus.OK): 404 | return; 405 | if(self.atVersion == AirTouchVersion.AIRTOUCH4): 406 | 407 | ## AT4 is easy 408 | 409 | address = dataResult[2:4] 410 | messageId = dataResult[4:5] 411 | messageType = dataResult[5:6] 412 | dataLength = dataResult[6:8] 413 | 414 | if(messageType == b'\x2b'): 415 | self.DecodeAirtouchGroupStatusMessage(dataResult[8::]); 416 | if(messageType == b'\x1f'): 417 | self.DecodeAirtouchExtendedMessage(dataResult[8::]); 418 | if(messageType == b'\x2d'): 419 | self.DecodeAirtouchAcStatusMessage(dataResult[8::]); 420 | else: 421 | 422 | ## AT5 requires a bit more knowledge if it's an extended message. 423 | 424 | messageType = dataResult[17:18] 425 | 426 | if(messageType == b'\xc0'): 427 | messageSubType = dataResult[20:21] 428 | ### We got a control message 429 | if(messageSubType == b'\x21'): 430 | ### Zone Status Message 431 | self.DecodeAirtouch5ZoneStatusMessage(dataResult[22::]); 432 | if(messageSubType == b'\x23'): 433 | ### AC Status Message 434 | self.DecodeAirtouch5AcStatusMessage(dataResult[22::]); 435 | if(messageType == b'\x1f'): 436 | messageSubType = dataResult[21:22] 437 | if(messageSubType == b'\x13'): 438 | self.DecodeAirtouch5GroupNames(dataResult[17::]); 439 | if(messageSubType == b'\x11'): 440 | self.DecodeAirtouch5AcAbility(dataResult[17::]); 441 | 442 | 443 | ## Only for AT4, decode extended. 444 | def DecodeAirtouchExtendedMessage(self, payload): 445 | groups = self.groups; 446 | if(payload[0:2] == b'\xff\x12'): 447 | for groupChunk in helper.chunks(payload[2:], 9): 448 | if(len(groupChunk) < 9): 449 | continue 450 | 451 | groupNumber = groupChunk[0] 452 | groupInfo = AirTouchGroup() 453 | if groupNumber not in groups: 454 | groups[groupNumber] = groupInfo; 455 | else: 456 | groupInfo = groups[groupNumber]; 457 | 458 | groupName = groupChunk[1:9].decode("utf-8").rstrip('\0') 459 | groups[groupNumber].GroupName = groupName 460 | 461 | if(payload[0:2] == b'\xff\x11'): 462 | chunkSize = communicate.TranslateMapValueToValue(payload[2:], packetmap.DataLocationTranslator.map[self.atVersion.value]["AcAbility"]["ChunkSize"]); 463 | self.DecodeAirtouchMessage(payload[2:], packetmap.DataLocationTranslator.map[self.atVersion.value]["AcAbility"], False, chunkSize + 2) 464 | 465 | ## Only for AT4, decode a basic message. 466 | def DecodeAirtouchMessage(self, payload, map, isGroupBased, chunkSize): 467 | for chunk in helper.chunks(payload, chunkSize): 468 | if(len(chunk) < chunkSize): 469 | continue 470 | packetInfoLocationMap = map 471 | 472 | resultList = self.groups 473 | resultObject = AirTouchGroup() 474 | if(isGroupBased): 475 | groupNumber = communicate.TranslateMapValueToValue(chunk, packetInfoLocationMap["GroupNumber"]); 476 | if groupNumber not in resultList: 477 | resultList[groupNumber] = resultObject; 478 | else: 479 | resultObject = resultList[groupNumber]; 480 | else: 481 | resultList = self.acs 482 | resultObject = AirTouchAc() 483 | acNumber = communicate.TranslateMapValueToValue(chunk, packetInfoLocationMap["AcNumber"]); 484 | if acNumber not in resultList: 485 | resultObject.AcName = "AC " + str(acNumber) 486 | resultList[acNumber] = resultObject; 487 | else: 488 | resultObject = resultList[acNumber]; 489 | 490 | packetInfoAttributes = [attr for attr in packetInfoLocationMap.keys()] 491 | for attribute in packetInfoAttributes: 492 | mapValue = communicate.TranslateMapValueToValue(chunk, packetInfoLocationMap[attribute]) 493 | translatedValue = packetmap.SettingValueTranslator.RawValueToNamedValue(attribute, mapValue); 494 | #a bit dodgy, to get the mode and fanspeed as lists 495 | if(attribute.endswith("ModeSupported") and translatedValue != 0): 496 | modeSupported = []; 497 | if(hasattr(resultObject, "ModeSupported")): 498 | modeSupported = resultObject.ModeSupported 499 | modeSupported.append(attribute.replace("ModeSupported", "")); 500 | setattr(resultObject, "ModeSupported", modeSupported) 501 | elif(attribute.endswith("FanSpeedSupported") and translatedValue != 0): 502 | modeSupported = []; 503 | if(hasattr(resultObject, "FanSpeedSupported")): 504 | modeSupported = resultObject.FanSpeedSupported 505 | modeSupported.append(attribute.replace("FanSpeedSupported", "")); 506 | setattr(resultObject, "FanSpeedSupported", modeSupported) 507 | else: 508 | setattr(resultObject, attribute, translatedValue) 509 | #read the chunk as a set of bytes concatenated together. 510 | #use the map of attribute locations 511 | #for each entry in the map 512 | #read out entry value from map 513 | #run translate on class matching entry name with entry value 514 | #set property of entry name on the group response 515 | 516 | 517 | ## Only for AT5, get additional details 518 | 519 | def DecodeAirtouch5Message(self, payload, map, isGroupBased): 520 | normalDataLength = int.from_bytes(payload[0:2], byteorder='big') 521 | repeatDataLength = int.from_bytes(payload[2:4], byteorder='big') 522 | repeatCount = int.from_bytes(payload[4:6], byteorder='big') 523 | packetInfoLocationMap = map 524 | 525 | for i in range(repeatCount): 526 | resultList = self.groups 527 | resultObject = AirTouchGroup() 528 | chunkStart = 6+(i*repeatDataLength) 529 | chunk = payload[chunkStart:chunkStart+repeatDataLength] 530 | if(isGroupBased): 531 | groupNumber = communicate.TranslateMapValueToValue(chunk, packetInfoLocationMap["GroupNumber"]); 532 | if groupNumber not in resultList: 533 | resultList[groupNumber] = resultObject; 534 | else: 535 | resultObject = resultList[groupNumber]; 536 | else: 537 | resultList = self.acs 538 | resultObject = AirTouchAc() 539 | acNumber = communicate.TranslateMapValueToValue(chunk, packetInfoLocationMap["AcNumber"]); 540 | if acNumber not in resultList: 541 | resultList[acNumber] = resultObject; 542 | else: 543 | resultObject = resultList[acNumber]; 544 | self.DecodeAttributes(chunk, packetInfoLocationMap, resultObject) 545 | 546 | ## Specific for AC Ability message 547 | def DecodeAirtouch5AcAbility(self, payload): 548 | #decodes AC Abilities based on page 12 of the comms protocol. 549 | 550 | 551 | dataLength = int.from_bytes(payload[1:3], byteorder='big')-2 #get the data length, subtract the CRC bytes and "header". This will allow us to track size. 552 | AcCount = 0 553 | if( dataLength % 26 != 0): 554 | self.Status = AirTouchStatus.ERROR 555 | errorMessage = AirTouchError() 556 | errorMessage.Message = "Got a response to ACAbility without correct field details" 557 | self.Messages.append(errorMessage) 558 | return 559 | else: 560 | AcCount = dataLength / 26 561 | 562 | payload = payload[5::] 563 | 564 | packetInfoLocationMap = packetmap.DataLocationTranslator.map[self.atVersion.value]["AcAbility"] 565 | for i in range(int(AcCount)): 566 | AcPayload = payload[i*26:i*26+26] 567 | resultList = self.acs 568 | resultObject = AirTouchAc() 569 | acNumber = communicate.TranslateMapValueToValue(AcPayload, packetInfoLocationMap["AcNumber"]); 570 | if acNumber not in resultList: 571 | resultList[acNumber] = resultObject; 572 | else: 573 | resultObject = resultList[acNumber]; 574 | self.DecodeAttributes(AcPayload, packetInfoLocationMap, resultObject) 575 | zoneName = AcPayload[2:18].decode("utf-8").rstrip('\0') 576 | resultObject.AcName = zoneName 577 | 578 | 579 | def DecodeAirtouch5GroupNames(self, payload): 580 | #decodes group names based on page 14 of the comms protocol. 581 | 582 | groups = self.groups; 583 | dataLength = int.from_bytes(payload[1:3], byteorder='big')-2 #get the data length, subtract the CRC bytes. This will allow us to track size. 584 | tracker = 3 585 | if(payload[tracker:tracker+2] == b'\xff\x13'): 586 | tracker = 5 587 | while(tracker < dataLength): 588 | zoneNumber = int.from_bytes(payload[tracker:tracker+1], byteorder='big') 589 | nameLength = int.from_bytes(payload[tracker+1:tracker+2], byteorder='big') 590 | zoneName = payload[tracker+2:tracker+2+nameLength].decode("utf-8").rstrip('\0') 591 | tracker += (2+nameLength) 592 | groups[zoneNumber].GroupName = zoneName 593 | else: 594 | self.Status = AirTouchStatus.ERROR 595 | errorMessage = AirTouchError() 596 | errorMessage.Message = "Got a response to GroupNames without correct field details" 597 | self.Messages.append(errorMessage) 598 | for group in self.groups.values(): 599 | group.GroupName = "Zone "+str(group.GroupNumber); 600 | 601 | 602 | def DecodeAttributes(self, chunk, packetInfoLocationMap, resultObject): 603 | packetInfoAttributes = [attr for attr in packetInfoLocationMap.keys()] 604 | for attribute in packetInfoAttributes: 605 | mapValue = communicate.TranslateMapValueToValue(chunk, packetInfoLocationMap[attribute]) 606 | translatedValue = packetmap.SettingValueTranslator.RawValueToNamedValue(attribute, mapValue, AirTouchVersion.AIRTOUCH5.value); 607 | if(attribute.endswith("ModeSupported") and translatedValue != 0): 608 | modeSupported = []; 609 | if(hasattr(resultObject, "ModeSupported")): 610 | modeSupported = resultObject.ModeSupported 611 | if attribute.replace("ModeSupported", "") not in modeSupported: 612 | modeSupported.append(attribute.replace("ModeSupported", "")); 613 | setattr(resultObject, "ModeSupported", modeSupported) 614 | elif(attribute.endswith("FanSpeedSupported") and translatedValue != 0): 615 | modeSupported = []; 616 | if(hasattr(resultObject, "FanSpeedSupported")): 617 | modeSupported = resultObject.FanSpeedSupported 618 | if attribute.replace("FanSpeedSupported", "") not in modeSupported: 619 | modeSupported.append(attribute.replace("FanSpeedSupported", "")); 620 | setattr(resultObject, "FanSpeedSupported", modeSupported) 621 | else: 622 | setattr(resultObject, attribute, translatedValue) 623 | 624 | def DecodeAirtouchGroupStatusMessage(self, payload): 625 | self.DecodeAirtouchMessage(payload, packetmap.DataLocationTranslator.map[self.atVersion.value]["GroupStatus"], True, 6); 626 | 627 | def DecodeAirtouch5ZoneStatusMessage(self, payload): 628 | self.DecodeAirtouch5Message(payload, packetmap.DataLocationTranslator.map[self.atVersion.value]["GroupStatus"], True); 629 | 630 | def DecodeAirtouchAcStatusMessage(self, payload): 631 | self.DecodeAirtouchMessage(payload, packetmap.DataLocationTranslator.map[self.atVersion.value]["AcStatus"], False, 8); 632 | #read the chunk as a set of bytes concatenated together. 633 | #use the map of attribute locations 634 | #for each entry in the map 635 | #read out entry value from map 636 | #run translate on class matching entry name with entry value 637 | #set property of entry name on the group response 638 | def DecodeAirtouch5AcStatusMessage(self, payload): 639 | self.DecodeAirtouch5Message(payload, packetmap.DataLocationTranslator.map[self.atVersion.value]["AcStatus"], False); 640 | 641 | def _getTargetGroup(self, groupName): 642 | return [group for group in self.groups.values() if group.GroupName == groupName][0] 643 | 644 | 645 | def autoDiscoverAirtouch(attempts=1, timeout=5): 646 | """ Attempts to discover the AirTouch console automatically using the search port and search packet defined in 647 | the AirTouch_Tab apk's source installed on the console. 648 | 649 | We send the special search packet below and the AirTouchTab responds on our port 49004 with the message: 650 | ',,,' 651 | 652 | TODO It responding with AirTouch5 is just a guess at this stage as I don't have one to test with. Validate this. 653 | 654 | param attempts: 655 | The number of times we attempt discovery. 656 | param timeout: 657 | The number of seconds the socket should remain listening 658 | 659 | returns: 660 | An AirTouch object if discovery was successful or None if not. 661 | """ 662 | 663 | # TODO determine if this port should be different for AirTouch 5. Looking at the main control ports defined above 664 | # (9004 AT4 and 9005 AT5) it seems likely that if they kept this discovery method for AT5 they may have bumped the 665 | # search port to 59005 or 49005 (I think the former more likely). Hopefully someone with an AirTouch 5 will be able 666 | # test this since I only have an AT4 667 | at4SearchPort = 49004 # UDP port. Found by reversing the Tablet app. 668 | bdcastAddr = "255.255.255.255" 669 | 670 | # The data the console's apk listener thread is expecting. HF-A11 refers to the wifi module on the console's PCB 671 | # it seems. 672 | searchPacket = b"HF-A11ASSISTHREAD" 673 | 674 | airTouch = None 675 | 676 | try: 677 | searchSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 678 | searchSock.settimeout(timeout) 679 | searchSock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 680 | 681 | searchSock.bind(("0.0.0.0", at4SearchPort)) 682 | 683 | for _ in range(attempts): 684 | try: 685 | searchSock.sendto(searchPacket, (bdcastAddr, at4SearchPort)) 686 | 687 | # We get our own broadcast so ignore those bytes first. 688 | _, _ = searchSock.recvfrom(len(searchPacket)) 689 | 690 | data, addr = searchSock.recvfrom(256) 691 | data = data.decode("utf-8") 692 | 693 | if "airtouch" in data.lower(): 694 | ip, _, at, _ = data.split(",") 695 | 696 | try: 697 | atVersion = AirTouchVersion[at.upper()] 698 | except KeyError: 699 | raise Exception(f"Unsupported AirTouch version discovered {at}") 700 | 701 | airTouch = AirTouch(ip, atVersion) 702 | except socket.timeout: 703 | continue 704 | except Exception as err: 705 | print(f"AirTouch auto discovery failed: {err}") 706 | 707 | return airTouch 708 | --------------------------------------------------------------------------------