├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── aiocomfoconnect ├── __init__.py ├── __main__.py ├── bridge.py ├── comfoconnect.py ├── const.py ├── discovery.py ├── exceptions.py ├── properties.py ├── protobuf │ ├── nanopb_pb2.py │ └── zehnder_pb2.py ├── sensors.py └── util.py ├── docs ├── PROTOCOL-PDO.md ├── PROTOCOL-RMI.md └── PROTOCOL.md ├── poetry.lock ├── protobuf ├── nanopb.proto └── zehnder.proto ├── pyproject.toml ├── script ├── decode_pcap.py └── tcpsession │ ├── __init__.py │ └── tcpsession.py └── tests └── __init__.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Include any files or directories that you don't want to be copied to your 2 | # container here (e.g., local build artifacts, temporary files, etc.). 3 | # 4 | # For more help, visit the .dockerignore file reference guide at 5 | # https://docs.docker.com/go/build-context-dockerignore/ 6 | 7 | **/.DS_Store 8 | **/__pycache__ 9 | **/.venv 10 | **/.classpath 11 | **/.dockerignore 12 | **/.env 13 | **/.git 14 | **/.gitignore 15 | **/.project 16 | **/.settings 17 | **/.toolstarget 18 | **/.vs 19 | **/.vscode 20 | **/*.*proj.user 21 | **/*.dbmdl 22 | **/*.jfm 23 | **/bin 24 | **/charts 25 | **/docker-compose* 26 | **/compose.y*ml 27 | **/Dockerfile* 28 | **/node_modules 29 | **/npm-debug.log 30 | **/obj 31 | **/secrets.dev.yaml 32 | **/values.dev.yaml 33 | LICENSE 34 | README.md 35 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: michaelarnauts 2 | ko_fi: michaelarnauts 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # Run action when pushed to master, or for commits in a pull request. 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | 12 | defaults: 13 | run: 14 | shell: bash 15 | 16 | jobs: 17 | checks: 18 | name: Code checks 19 | runs-on: ${{ matrix.os }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | os: [ ubuntu-latest ] 24 | python-version: [ "3.10", "3.11", "3.12", "3.13" ] 25 | steps: 26 | - name: Check out ${{ github.sha }} from repository ${{ github.repository }} 27 | uses: actions/checkout@v4 28 | 29 | - name: Install poetry 30 | run: pipx install poetry 31 | 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | cache: 'poetry' 37 | 38 | - name: Install dependencies 39 | run: poetry install --no-interaction 40 | 41 | - name: Run checks 42 | run: make check 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py 2 | .idea/ 3 | __pycache__ 4 | dist/ 5 | script/*.pcap 6 | .env 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | ARG PYTHON_VERSION=3.12.3 4 | FROM python:${PYTHON_VERSION}-slim as base 5 | 6 | # Prevents Python from writing pyc files. 7 | ENV PYTHONDONTWRITEBYTECODE=1 8 | 9 | # Keeps Python from buffering stdout and stderr to avoid situations where 10 | # the application crashes without emitting any logs due to buffering. 11 | ENV PYTHONUNBUFFERED=1 12 | 13 | # Choose poetry version 14 | ENV POETRY_VERSION=1.8.2 15 | 16 | # Download dependencies as a separate step to take advantage of Docker's caching. 17 | # Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. 18 | # Leverage a bind mount to requirements.txt to avoid having to copy them into 19 | # into this layer. 20 | RUN --mount=type=cache,target=/root/.cache/pip \ 21 | python3 -m pip install "poetry==$POETRY_VERSION" 22 | 23 | # Install all dependencies for aiocomfoconnect 24 | COPY pyproject.toml poetry.lock . 25 | RUN poetry export -f requirements.txt | python3 -m pip install -r /dev/stdin 26 | 27 | # Copy the source code into the container. 28 | COPY . . 29 | 30 | FROM base as final 31 | 32 | # Run the application. 33 | ENTRYPOINT ["python3", "-m", "aiocomfoconnect"] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Michaël Arnauts 4 | https://github.com/michaelarnauts/aiocomfoconnect 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | check: check-pylint check-black 2 | 3 | check-pylint: 4 | @poetry run pylint aiocomfoconnect/*.py 5 | 6 | check-black: 7 | @poetry run black --check aiocomfoconnect/*.py 8 | 9 | codefix: 10 | @poetry run isort aiocomfoconnect/*.py 11 | @poetry run black aiocomfoconnect/*.py 12 | 13 | test: 14 | @poetry run pytest 15 | 16 | build: 17 | docker build -t aiocomfoconnect . 18 | 19 | .PHONY: check codefix test 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiocomfoconnect 2 | 3 | `aiocomfoconnect` is an asyncio Python 3 library for communicating with a Zehnder ComfoAir Q350/450/600 ventilation system. It's the successor of 4 | [comfoconnect](https://github.com/michaelarnauts/comfoconnect). 5 | 6 | The [home-assistant-comfoconnect](https://github.com/michaelarnauts/home-assistant-comfoconnect) is using this library. 7 | 8 | It's compatible with Python 3.10 and higher. 9 | 10 | ## Installation 11 | 12 | ```shell 13 | pip3 install aiocomfoconnect 14 | ``` 15 | 16 | ## CLI Usage 17 | 18 | ```shell 19 | $ python -m aiocomfoconnect --help 20 | 21 | $ python -m aiocomfoconnect discover 22 | 23 | $ python -m aiocomfoconnect register --host 192.168.1.213 24 | 25 | $ python -m aiocomfoconnect set-speed away --host 192.168.1.213 26 | $ python -m aiocomfoconnect set-speed low --host 192.168.1.213 27 | $ python -m aiocomfoconnect set-mode auto --host 192.168.1.213 28 | $ python -m aiocomfoconnect set-speed medium --host 192.168.1.213 29 | $ python -m aiocomfoconnect set-speed high --host 192.168.1.213 30 | $ python -m aiocomfoconnect set-boost on --host 192.168.1.213 --timeout 1200 31 | 32 | $ python -m aiocomfoconnect set-comfocool auto --host 192.168.1.213 33 | $ python -m aiocomfoconnect set-comfocool off --host 192.168.1.213 34 | 35 | $ python -m aiocomfoconnect show-sensors --host 192.168.1.213 36 | $ python -m aiocomfoconnect show-sensor 276 --host 192.168.1.213 37 | $ python -m aiocomfoconnect show-sensor 276 --host 192.168.1.213 -f 38 | 39 | $ python -m aiocomfoconnect get-property --host 192.168.1.213 1 1 8 9 # Unit 0x01, SubUnit 0x01, Property 0x08, Type STRING. See PROTOCOL-RMI.md 40 | ``` 41 | 42 | ## Available methods 43 | 44 | - `async connect()`: Connect to the bridge. 45 | - `async disconnect()`: Disconnect from the bridge. 46 | - `async register_sensor(sensor)`: Register a sensor. 47 | - `async deregister_sensor(sensor)`: Deregister a sensor. 48 | - `async get_mode()`: Get the ventilation mode. 49 | - `async set_mode(mode)`: Set the ventilation mode. (auto / manual) 50 | - `async get_comfocool_mode()`: Get Comfocool mode 51 | - `async set_comfocool_mode()`: Set Comfocool mode. (auto / off) 52 | - `async get_speed()`: Get the ventilation speed. 53 | - `async set_speed(speed)`: Set the ventilation speed. (away / low / medium / high) 54 | - `async get_bypass()`: Get the bypass mode. 55 | - `async set_bypass(mode, timeout=-1)`: Set the bypass mode. (auto / on / off) 56 | - `async get_balance_mode()`: Get the balance mode. 57 | - `async set_balance_mode(mode, timeout=-1)`: Set the balance mode. (balance / supply only / exhaust only) 58 | - `async get_boost()`: Get the boost mode. 59 | - `async set_boost(mode, timeout=-1)`: Set the boost mode. (boolean) 60 | - `async get_away()`: Get the away mode. 61 | - `async set_away(mode, timeout=-1)`: Set the away mode. (boolean) 62 | - `async get_temperature_profile()`: Get the temperature profile. 63 | - `async set_temperature_profile(profile)`: Set the temperature profile. (warm / normal / cool) 64 | - `async get_sensor_ventmode_temperature_passive()`: Get the sensor based ventilation passive temperature control setting. 65 | - `async set_sensor_ventmode_temperature_passive(mode)`: Set the sensor based ventilation passive temperature control setting. (auto / on / off) 66 | - `async get_sensor_ventmode_humidity_comfort()`: Get the sensor based ventilation humidity comfort setting. 67 | - `async set_sensor_ventmode_humidity_comfort(mode)`: Set the sensor based ventilation humidity comfort setting. (auto / on / off) 68 | - `async get_sensor_ventmode_humidity_protection()`: Get the sensor based ventilation humidity protection setting. 69 | - `async set_sensor_ventmode_humidity_protection(mode)`: Set the sensor based ventilation humidity protection setting. (auto / on / off) 70 | 71 | ### Low-level API 72 | 73 | - `async cmd_start_session()`: Start a session. 74 | - `async cmd_close_session()`: Close a session. 75 | - `async cmd_list_registered_apps()`: List registered apps. 76 | - `async cmd_register_app(uuid, device_name, pin)`: Register an app. 77 | - `async cmd_deregister_app(uuid)`: Deregister an app. 78 | - `async cmd_version_request()`: Request the bridge's version. 79 | - `async cmd_time_request()`: Request the bridge's time. 80 | - `async cmd_rmi_request(message, node_id)`: Send a RMI request. 81 | - `async cmd_rpdo_request(pdid, type, zone, timeout)`: Send a RPDO request. 82 | - `async cmd_keepalive()`: Send a keepalive message. 83 | 84 | ## Examples 85 | 86 | ### Discovery of ComfoConnect LAN C Bridges 87 | 88 | ```python 89 | import asyncio 90 | 91 | from aiocomfoconnect import discover_bridges 92 | 93 | 94 | async def main(): 95 | """ ComfoConnect LAN C Bridge discovery example.""" 96 | 97 | # Discover all ComfoConnect LAN C Bridges on the subnet. 98 | bridges = await discover_bridges() 99 | print(bridges) 100 | 101 | 102 | if __name__ == "__main__": 103 | asyncio.run(main()) 104 | ``` 105 | 106 | ### Basic Example 107 | 108 | ```python 109 | import asyncio 110 | 111 | from aiocomfoconnect import ComfoConnect 112 | from aiocomfoconnect.const import VentilationSpeed 113 | from aiocomfoconnect.sensors import SENSORS 114 | 115 | 116 | async def main(local_uuid, host, uuid): 117 | """ Basic example.""" 118 | 119 | def sensor_callback(sensor, value): 120 | """ Print sensor updates. """ 121 | print(f"{sensor.name} = {value}") 122 | 123 | # Connect to the Bridge 124 | comfoconnect = ComfoConnect(host, uuid, sensor_callback=sensor_callback) 125 | await comfoconnect.connect(local_uuid) 126 | 127 | # Register all sensors 128 | for key in SENSORS: 129 | await comfoconnect.register_sensor(SENSORS[key]) 130 | 131 | # Set speed to LOW 132 | await comfoconnect.set_speed(VentilationSpeed.LOW) 133 | 134 | # Wait 2 minutes so we can see some sensor updates 135 | await asyncio.sleep(120) 136 | 137 | # Disconnect from the bridge 138 | await comfoconnect.disconnect() 139 | 140 | 141 | if __name__ == "__main__": 142 | asyncio.run(main(local_uuid='00000000000000000000000000001337', host='192.168.1.20', uuid='00000000000000000000000000000055')) # Replace with your bridge's IP and UUID 143 | ``` 144 | 145 | ## Development Notes 146 | 147 | ### Protocol Documentation 148 | 149 | - [ComfoConnect LAN C Protocol](docs/PROTOCOL.md) 150 | - [PDO Sensors](docs/PROTOCOL-PDO.md) 151 | - [RMI commands](docs/PROTOCOL-RMI.md) 152 | 153 | ### Decode network traffic 154 | 155 | You can use the `scripts/decode_pcap.py` file to decode network traffic between the Mobile App and the ComfoConnect LAN C. 156 | Make sure that the first TCP session in the capture is the connection between the bridge and the app. It's therefore recommended to start the capture before you open the app. 157 | 158 | ```shell 159 | $ sudo tcpdump -i any -s 0 -w /tmp/capture.pcap tcp and port 56747 160 | $ python3 script/decode_pcap.py /tmp/capture.pcap 161 | ``` 162 | 163 | ### Generate zehnder_pb2.py file 164 | 165 | ```shell 166 | python3 -m pip install grpcio-tools==1.73.0 167 | python3 -m grpc_tools.protoc -Iprotobuf --python_out=aiocomfoconnect/protobuf protobuf/*.proto 168 | ``` 169 | 170 | ### Docker 171 | 172 | You can build a Docker image to make it easier to develop and experiment on your local machine. You can use the `docker build -t aiocomfoconnect .` or the shortcut `make build` command to create a docker image. 173 | 174 | Next, you can run this image by running `docker run aiocomfoconnect`. Any args from `aiocomfoconnect` can be passed into this command, just like the `python3 -m aiocomfoconnect` command. 175 | 176 | ## Interesting 3th party repositories 177 | 178 | * https://github.com/oysteing/comfoconnect-mqtt-bridge 179 | -------------------------------------------------------------------------------- /aiocomfoconnect/__init__.py: -------------------------------------------------------------------------------- 1 | """ aiocomfoconnect library """ 2 | 3 | from .bridge import Bridge # noqa 4 | from .comfoconnect import ComfoConnect # noqa 5 | from .discovery import discover_bridges # noqa 6 | 7 | DEFAULT_UUID = "00000000000000000000000000000001" 8 | DEFAULT_PIN = 0 9 | DEFAULT_NAME = "aiocomfoconnect" 10 | -------------------------------------------------------------------------------- /aiocomfoconnect/__main__.py: -------------------------------------------------------------------------------- 1 | """ aiocomfoconnect CLI application """ 2 | 3 | from __future__ import annotations 4 | 5 | import argparse 6 | import asyncio 7 | import logging 8 | import sys 9 | from asyncio import Future 10 | from typing import Literal 11 | 12 | from aiocomfoconnect import DEFAULT_NAME, DEFAULT_PIN, DEFAULT_UUID 13 | from aiocomfoconnect.comfoconnect import ComfoConnect 14 | from aiocomfoconnect.discovery import discover_bridges 15 | from aiocomfoconnect.exceptions import ( 16 | AioComfoConnectNotConnected, 17 | AioComfoConnectTimeout, 18 | BridgeNotFoundException, 19 | ComfoConnectNotAllowed, 20 | UnknownActionException, 21 | ) 22 | from aiocomfoconnect.properties import Property 23 | from aiocomfoconnect.sensors import SENSORS 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | 28 | async def main(args): 29 | """Main function.""" 30 | if args.action == "discover": 31 | await run_discover(args.host) 32 | 33 | elif args.action == "register": 34 | await run_register(args.host, args.uuid, args.name, args.pin) 35 | 36 | elif args.action == "deregister": 37 | await run_deregister(args.host, args.uuid, args.uuid2) 38 | 39 | elif args.action == "set-speed": 40 | await run_set_speed(args.host, args.uuid, args.speed) 41 | 42 | elif args.action == "set-mode": 43 | await run_set_mode(args.host, args.uuid, args.mode) 44 | 45 | elif args.action == "set-comfocool": 46 | await run_set_comfocool(args.host, args.uuid, args.mode) 47 | 48 | elif args.action == "set-boost": 49 | await run_set_boost(args.host, args.uuid, args.mode, args.timeout) 50 | 51 | elif args.action == "show-sensors": 52 | await run_show_sensors(args.host, args.uuid) 53 | 54 | elif args.action == "show-sensor": 55 | await run_show_sensor(args.host, args.uuid, args.sensor, args.follow) 56 | 57 | elif args.action == "get-property": 58 | await run_get_property(args.host, args.uuid, args.node_id, args.unit, args.subunit, args.property_id, args.property_type) 59 | 60 | elif args.action == "get-flow-for-speed": 61 | await run_get_flow_for_speed(args.host, args.uuid, args.speed) 62 | 63 | elif args.action == "set-flow-for-speed": 64 | await run_set_flow_for_speed(args.host, args.uuid, args.speed, args.flow) 65 | 66 | else: 67 | raise UnknownActionException("Unknown action: " + args.action) 68 | 69 | 70 | async def run_discover(host: str = None): 71 | """Discover all bridges on the network.""" 72 | bridges = await discover_bridges(host) 73 | print("Discovered bridges:") 74 | for bridge in bridges: 75 | print(bridge) 76 | print() 77 | 78 | 79 | async def run_register(host: str, uuid: str, name: str, pin: int): 80 | """Register an app on the bridge.""" 81 | # Discover bridge so we know the UUID 82 | bridges = await discover_bridges(host) 83 | if not bridges: 84 | raise BridgeNotFoundException("No bridge found") 85 | 86 | # Connect to the bridge 87 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid) 88 | 89 | try: 90 | # Login with the bridge 91 | await comfoconnect.connect(uuid) 92 | print(f"UUID {uuid} is already registered.") 93 | 94 | except ComfoConnectNotAllowed: 95 | # We probably are not registered yet... 96 | try: 97 | await comfoconnect.cmd_register_app(uuid, name, pin) 98 | except ComfoConnectNotAllowed: 99 | await comfoconnect.disconnect() 100 | print("Registration failed. Please check the PIN.") 101 | sys.exit(1) 102 | 103 | print(f"UUID {uuid} is now registered.") 104 | 105 | # Connect to the bridge 106 | await comfoconnect.cmd_start_session(True) 107 | 108 | # ListRegisteredApps 109 | print() 110 | print("Registered applications:") 111 | reply = await comfoconnect.cmd_list_registered_apps() 112 | for app in reply.apps: 113 | print(f"* {app.uuid.hex()}: {app.devicename}") 114 | 115 | await comfoconnect.disconnect() 116 | 117 | 118 | async def run_deregister(host: str, uuid: str, uuid2: str): 119 | """Deregister an app on the bridge.""" 120 | # Discover bridge so we know the UUID 121 | bridges = await discover_bridges(host) 122 | if not bridges: 123 | raise BridgeNotFoundException("No bridge found") 124 | 125 | # Connect to the bridge 126 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid) 127 | try: 128 | await comfoconnect.connect(uuid) 129 | except ComfoConnectNotAllowed: 130 | print("Could not connect to bridge. Please register first.") 131 | sys.exit(1) 132 | 133 | if uuid2: 134 | await comfoconnect.cmd_deregister_app(uuid2) 135 | 136 | # ListRegisteredApps 137 | print() 138 | print("Registered applications:") 139 | reply = await comfoconnect.cmd_list_registered_apps() 140 | for app in reply.apps: 141 | print(f"* {app.uuid.hex()}: {app.devicename}") 142 | 143 | await comfoconnect.disconnect() 144 | 145 | 146 | async def run_set_speed(host: str, uuid: str, speed: Literal["away", "low", "medium", "high"]): 147 | """Set ventilation speed.""" 148 | # Discover bridge so we know the UUID 149 | bridges = await discover_bridges(host) 150 | if not bridges: 151 | raise BridgeNotFoundException("No bridge found") 152 | 153 | # Connect to the bridge 154 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid) 155 | try: 156 | await comfoconnect.connect(uuid) 157 | except ComfoConnectNotAllowed: 158 | print("Could not connect to bridge. Please register first.") 159 | sys.exit(1) 160 | 161 | await comfoconnect.set_speed(speed) 162 | 163 | await comfoconnect.disconnect() 164 | 165 | 166 | async def run_set_mode(host: str, uuid: str, mode: Literal["auto", "manual"]): 167 | """Set ventilation mode.""" 168 | # Discover bridge so we know the UUID 169 | bridges = await discover_bridges(host) 170 | if not bridges: 171 | raise BridgeNotFoundException("No bridge found") 172 | 173 | # Connect to the bridge 174 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid) 175 | try: 176 | await comfoconnect.connect(uuid) 177 | except ComfoConnectNotAllowed: 178 | print("Could not connect to bridge. Please register first.") 179 | sys.exit(1) 180 | 181 | await comfoconnect.set_mode(mode) 182 | 183 | await comfoconnect.disconnect() 184 | 185 | 186 | async def run_set_comfocool(host: str, uuid: str, mode: Literal["auto", "off"]): 187 | """Set comfocool mode.""" 188 | # Discover bridge so we know the UUID 189 | bridges = await discover_bridges(host) 190 | if not bridges: 191 | raise BridgeNotFoundException("No bridge found") 192 | 193 | # Connect to the bridge 194 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid) 195 | try: 196 | await comfoconnect.connect(uuid) 197 | except ComfoConnectNotAllowed: 198 | print("Could not connect to bridge. Please register first.") 199 | sys.exit(1) 200 | 201 | await comfoconnect.set_comfocool_mode(mode) 202 | 203 | await comfoconnect.disconnect() 204 | 205 | 206 | async def run_set_boost(host: str, uuid: str, mode: Literal["on", "off"], timeout: int): 207 | """Set boost.""" 208 | # Discover bridge so we know the UUID 209 | bridges = await discover_bridges(host) 210 | if not bridges: 211 | raise BridgeNotFoundException("No bridge found") 212 | 213 | # Connect to the bridge 214 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid) 215 | try: 216 | await comfoconnect.connect(uuid) 217 | except ComfoConnectNotAllowed: 218 | print("Could not connect to bridge. Please register first.") 219 | sys.exit(1) 220 | 221 | await comfoconnect.set_boost(mode == "on", timeout) 222 | 223 | await comfoconnect.disconnect() 224 | 225 | 226 | async def run_show_sensors(host: str, uuid: str): 227 | """Show all sensors.""" 228 | # Discover bridge so we know the UUID 229 | bridges = await discover_bridges(host) 230 | if not bridges: 231 | raise BridgeNotFoundException("No bridge found") 232 | 233 | def alarm_callback(node_id, errors): 234 | """Print alarm updates.""" 235 | print(f"Alarm received for Node {node_id}:") 236 | for error_id, error in errors.items(): 237 | print(f"* {error_id}: {error}") 238 | 239 | def sensor_callback(sensor, value): 240 | """Print sensor updates.""" 241 | print(f"{sensor.name:>40}: {value} {sensor.unit or ''}") 242 | 243 | # Connect to the bridge 244 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid, sensor_callback=sensor_callback, alarm_callback=alarm_callback) 245 | try: 246 | await comfoconnect.connect(uuid) 247 | except ComfoConnectNotAllowed: 248 | print("Could not connect to bridge. Please register first.") 249 | sys.exit(1) 250 | 251 | # Register all sensors 252 | for sensor in SENSORS.values(): 253 | await comfoconnect.register_sensor(sensor) 254 | 255 | try: 256 | while True: 257 | # Wait for updates and send a keepalive every 30 seconds 258 | await asyncio.sleep(30) 259 | 260 | try: 261 | print("Sending keepalive...") 262 | # Use cmd_time_request as a keepalive since cmd_keepalive doesn't send back a reply we can wait for 263 | await comfoconnect.cmd_time_request() 264 | except AioComfoConnectNotConnected: 265 | print("Got AioComfoConnectNotConnected") 266 | except AioComfoConnectTimeout: 267 | print("Got AioComfoConnectTimeout") 268 | 269 | except KeyboardInterrupt: 270 | pass 271 | 272 | print("Disconnecting...") 273 | await comfoconnect.disconnect() 274 | 275 | 276 | async def run_show_sensor(host: str, uuid: str, sensor: int, follow=False): 277 | """Show a sensor.""" 278 | result = Future() 279 | 280 | # Discover bridge so we know the UUID 281 | bridges = await discover_bridges(host) 282 | if not bridges: 283 | raise BridgeNotFoundException("No bridge found") 284 | 285 | def sensor_callback(sensor_, value): 286 | """Print sensor update.""" 287 | print(value) 288 | if not result.done(): 289 | result.set_result(value) 290 | 291 | # Connect to the bridge 292 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid, sensor_callback=sensor_callback) 293 | try: 294 | await comfoconnect.connect(uuid) 295 | except ComfoConnectNotAllowed: 296 | print("Could not connect to bridge. Please register first.") 297 | sys.exit(1) 298 | 299 | if not sensor in SENSORS: 300 | print(f"Unknown sensor with ID {sensor}") 301 | sys.exit(1) 302 | 303 | # Register sensors 304 | await comfoconnect.register_sensor(SENSORS[sensor]) 305 | 306 | # Wait for value 307 | await result 308 | 309 | # Follow for updates if requested 310 | if follow: 311 | try: 312 | while True: 313 | # Wait for updates and send a keepalive every 30 seconds 314 | await asyncio.sleep(30) 315 | 316 | try: 317 | print("Sending keepalive...") 318 | # Use cmd_time_request as a keepalive since cmd_keepalive doesn't send back a reply we can wait for 319 | await comfoconnect.cmd_time_request() 320 | except AioComfoConnectNotConnected: 321 | print("Got AioComfoConnectNotConnected") 322 | except AioComfoConnectTimeout: 323 | print("Got AioComfoConnectTimeout") 324 | 325 | except KeyboardInterrupt: 326 | pass 327 | 328 | # Disconnect 329 | await comfoconnect.disconnect() 330 | 331 | 332 | async def run_get_property(host: str, uuid: str, node_id: int, unit: int, subunit: int, property_id: int, property_type: int): 333 | """Get a property.""" 334 | # Discover bridge so we know the UUID 335 | bridges = await discover_bridges(host) 336 | if not bridges: 337 | raise BridgeNotFoundException("No bridge found") 338 | 339 | # Connect to the bridge 340 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid) 341 | try: 342 | await comfoconnect.connect(uuid) 343 | except ComfoConnectNotAllowed: 344 | print("Could not connect to bridge. Please register first.") 345 | sys.exit(1) 346 | 347 | print(await comfoconnect.get_property(Property(unit, subunit, property_id, property_type), node_id)) 348 | 349 | await comfoconnect.disconnect() 350 | 351 | 352 | async def run_get_flow_for_speed(host: str, uuid: str, speed: Literal["away", "low", "medium", "high"]): 353 | """Get the configured airflow for the specified speed.""" 354 | # Discover bridge so we know the UUID 355 | bridges = await discover_bridges(host) 356 | if not bridges: 357 | raise BridgeNotFoundException("No bridge found") 358 | 359 | # Connect to the bridge 360 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid) 361 | try: 362 | await comfoconnect.connect(uuid) 363 | except ComfoConnectNotAllowed: 364 | print("Could not connect to bridge. Please register first.") 365 | sys.exit(1) 366 | 367 | print(await comfoconnect.get_flow_for_speed(speed)) 368 | 369 | await comfoconnect.disconnect() 370 | 371 | 372 | async def run_set_flow_for_speed(host: str, uuid: str, speed: Literal["away", "low", "medium", "high"], desired_flow: int): 373 | """Set the configured airflow for the specified speed.""" 374 | # Discover bridge so we know the UUID 375 | bridges = await discover_bridges(host) 376 | if not bridges: 377 | raise BridgeNotFoundException("No bridge found") 378 | 379 | # Connect to the bridge 380 | comfoconnect = ComfoConnect(bridges[0].host, bridges[0].uuid) 381 | try: 382 | await comfoconnect.connect(uuid) 383 | except ComfoConnectNotAllowed: 384 | print("Could not connect to bridge. Please register first.") 385 | sys.exit(1) 386 | 387 | await comfoconnect.set_flow_for_speed(speed, desired_flow) 388 | 389 | await comfoconnect.disconnect() 390 | 391 | 392 | if __name__ == "__main__": 393 | parser = argparse.ArgumentParser() 394 | parser.add_argument("--debug", "-d", help="Enable debug logging", default=False, action="store_true") 395 | subparsers = parser.add_subparsers(required=True, dest="action") 396 | 397 | p_discover = subparsers.add_parser("discover", help="discover ComfoConnect LAN C devices on your network") 398 | p_discover.add_argument("--host", help="Host address of the bridge") 399 | 400 | p_register = subparsers.add_parser("register", help="register on a ComfoConnect LAN C device") 401 | p_register.add_argument("--pin", help="PIN code to register on the bridge", default=DEFAULT_PIN) 402 | p_register.add_argument("--host", help="Host address of the bridge") 403 | p_register.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 404 | p_register.add_argument("--name", help="Name of this app", default=DEFAULT_NAME) 405 | 406 | p_register = subparsers.add_parser("deregister", help="deregister on a ComfoConnect LAN C device") 407 | p_register.add_argument("uuid2", help="UUID of the app to deregister", default=None) 408 | p_register.add_argument("--pin", help="PIN code to register on the bridge", default=DEFAULT_PIN) 409 | p_register.add_argument("--host", help="Host address of the bridge") 410 | p_register.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 411 | 412 | p_set_speed = subparsers.add_parser("set-speed", help="set the fan speed") 413 | p_set_speed.add_argument("speed", help="Fan speed", choices=["low", "medium", "high", "away"]) 414 | p_set_speed.add_argument("--host", help="Host address of the bridge") 415 | p_set_speed.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 416 | 417 | p_set_mode = subparsers.add_parser("set-mode", help="set operation mode") 418 | p_set_mode.add_argument("mode", help="Operation mode", choices=["auto", "manual"]) 419 | p_set_mode.add_argument("--host", help="Host address of the bridge") 420 | p_set_mode.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 421 | 422 | p_set_mode = subparsers.add_parser("set-comfocool", help="set comfocool mode") 423 | p_set_mode.add_argument("mode", help="Comfocool mode", choices=["auto", "off"]) 424 | p_set_mode.add_argument("--host", help="Host address of the bridge") 425 | p_set_mode.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 426 | 427 | p_set_boost = subparsers.add_parser("set-boost", help="trigger or cancel a boost") 428 | p_set_boost.add_argument("mode", help="Boost mode", choices=["on", "off"]) 429 | p_set_boost.add_argument("--host", help="Host address of the bridge") 430 | p_set_boost.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 431 | p_set_boost.add_argument("--timeout", "-t", help="Timeout in seconds", type=int, default=600) 432 | 433 | p_sensors = subparsers.add_parser("show-sensors", help="show the sensor values") 434 | p_sensors.add_argument("--host", help="Host address of the bridge") 435 | p_sensors.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 436 | 437 | p_sensor = subparsers.add_parser("show-sensor", help="show a single sensor value") 438 | p_sensor.add_argument("sensor", help="The ID of the sensor", type=int) 439 | p_sensor.add_argument("--host", help="Host address of the bridge") 440 | p_sensor.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 441 | p_sensor.add_argument("--follow", "-f", help="Follow", default=False, action="store_true") 442 | 443 | p_sensor = subparsers.add_parser("get-property", help="show a property value") 444 | p_sensor.add_argument("unit", help="The Unit of the property", type=int) 445 | p_sensor.add_argument("subunit", help="The Subunit of the property", type=int) 446 | p_sensor.add_argument("property_id", help="The id of the property", type=int) 447 | p_sensor.add_argument("property_type", help="The type of the property", type=int, default=0x09) 448 | 449 | p_sensor.add_argument("--node_id", help="The Node ID of the query", type=int, default=0x01) 450 | p_sensor.add_argument("--host", help="Host address of the bridge") 451 | p_sensor.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 452 | 453 | p_get_flow_speed = subparsers.add_parser("get-flow-for-speed", help="Get m³/h for given speed") 454 | p_get_flow_speed.add_argument("speed", help="Fan speed", choices=["low", "medium", "high", "away"]) 455 | p_get_flow_speed.add_argument("--host", help="Host address of the bridge") 456 | p_get_flow_speed.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 457 | 458 | p_set_flow_speed = subparsers.add_parser("set-flow-for-speed", help="Set m³/h for given speed") 459 | p_set_flow_speed.add_argument("speed", help="Fan speed", choices=["low", "medium", "high", "away"]) 460 | p_set_flow_speed.add_argument("flow", help="Desired airflow in m³/h", type=int) 461 | p_set_flow_speed.add_argument("--host", help="Host address of the bridge") 462 | p_set_flow_speed.add_argument("--uuid", help="UUID of this app", default=DEFAULT_UUID) 463 | 464 | arguments = parser.parse_args() 465 | 466 | if arguments.debug: 467 | logging.basicConfig(level=logging.DEBUG) 468 | else: 469 | logging.basicConfig(level=logging.WARNING) 470 | 471 | try: 472 | asyncio.run(main(arguments), debug=True) 473 | except KeyboardInterrupt: 474 | pass 475 | -------------------------------------------------------------------------------- /aiocomfoconnect/bridge.py: -------------------------------------------------------------------------------- 1 | """ ComfoConnect Bridge API """ 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | import struct 8 | from asyncio import StreamReader, StreamWriter 9 | from typing import Awaitable 10 | 11 | from google.protobuf.message import DecodeError 12 | from google.protobuf.message import Message as ProtobufMessage 13 | 14 | from .exceptions import ( 15 | AioComfoConnectNotConnected, 16 | AioComfoConnectTimeout, 17 | ComfoConnectBadRequest, 18 | ComfoConnectError, 19 | ComfoConnectInternalError, 20 | ComfoConnectNoResources, 21 | ComfoConnectNotAllowed, 22 | ComfoConnectNotExist, 23 | ComfoConnectNotReachable, 24 | ComfoConnectOtherSession, 25 | ComfoConnectRmiError, 26 | ) 27 | from .protobuf import zehnder_pb2 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | TIMEOUT = 5 32 | 33 | 34 | class SelfDeregistrationError(Exception): 35 | """Exception raised when trying to deregister self.""" 36 | 37 | 38 | class EventBus: 39 | """An event bus for async replies.""" 40 | 41 | def __init__(self): 42 | self.listeners = {} 43 | 44 | def add_listener(self, event_name, future): 45 | """Add a listener to the event bus.""" 46 | _LOGGER.debug("Adding listener for event %s", event_name) 47 | if not self.listeners.get(event_name, None): 48 | self.listeners[event_name] = {future} 49 | else: 50 | self.listeners[event_name].add(future) 51 | 52 | def emit(self, event_name, event): 53 | """Emit an event to the event bus.""" 54 | _LOGGER.debug("Emitting for event %s", event_name) 55 | futures = self.listeners.get(event_name, []) 56 | for future in futures: 57 | if isinstance(event, Exception): 58 | future.set_exception(event) 59 | else: 60 | future.set_result(event) 61 | del self.listeners[event_name] 62 | 63 | 64 | class Bridge: 65 | """ComfoConnect LAN C API.""" 66 | 67 | PORT = 56747 68 | 69 | def __init__(self, host: str, uuid: str, loop=None): 70 | self.host: str = host 71 | self.uuid: str = uuid 72 | self._local_uuid: str = None 73 | 74 | self._reader: StreamReader = None 75 | self._writer: StreamWriter = None 76 | self._reference = None 77 | 78 | self._event_bus: EventBus = None 79 | 80 | self.__sensor_callback_fn: callable = None 81 | self.__alarm_callback_fn: callable = None 82 | 83 | self._loop = loop or asyncio.get_running_loop() 84 | 85 | def __repr__(self): 86 | return f"" 87 | 88 | def set_sensor_callback(self, callback: callable): 89 | """Set a callback to be called when a message is received.""" 90 | self.__sensor_callback_fn = callback 91 | 92 | def set_alarm_callback(self, callback: callable): 93 | """Set a callback to be called when an alarm is received.""" 94 | self.__alarm_callback_fn = callback 95 | 96 | async def _connect(self, uuid: str): 97 | """Connect to the bridge.""" 98 | _LOGGER.debug("Connecting to bridge %s", self.host) 99 | try: 100 | self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(self.host, self.PORT), TIMEOUT) 101 | except asyncio.TimeoutError as exc: 102 | _LOGGER.warning("Timeout while connecting to bridge %s", self.host) 103 | raise AioComfoConnectTimeout("Timeout while connecting to bridge") from exc 104 | 105 | self._reference = 1 106 | self._local_uuid = uuid 107 | self._event_bus = EventBus() 108 | 109 | async def _read_messages(): 110 | while True: 111 | try: 112 | # Keep processing messages until we are disconnected or shutting down 113 | await self._process_message() 114 | 115 | except asyncio.exceptions.CancelledError: 116 | # We are shutting down. Return to stop the background task 117 | return False 118 | 119 | except AioComfoConnectNotConnected as exc: 120 | # We have been disconnected 121 | raise AioComfoConnectNotConnected("We have been disconnected") from exc 122 | 123 | read_task = self._loop.create_task(_read_messages()) 124 | _LOGGER.debug("Connected to bridge %s", self.host) 125 | 126 | return read_task 127 | 128 | async def _disconnect(self): 129 | """Disconnect from the bridge.""" 130 | if self._writer: 131 | self._writer.close() 132 | await self._writer.wait_closed() 133 | 134 | def is_connected(self) -> bool: 135 | """Returns True if the bridge is connected.""" 136 | return self._writer is not None and not self._writer.is_closing() 137 | 138 | async def _send(self, request, request_type, params: dict = None, reply: bool = True) -> Message: 139 | """Sends a command and wait for a response if the request is known to return a result.""" 140 | # Check if we are actually connected 141 | if not self.is_connected(): 142 | raise AioComfoConnectNotConnected 143 | 144 | # Construct the message 145 | cmd = zehnder_pb2.GatewayOperation() # pylint: disable=no-member 146 | cmd.type = request_type 147 | cmd.reference = self._reference 148 | 149 | msg = request() 150 | if params is not None: 151 | for param in params: 152 | if params[param] is not None: 153 | setattr(msg, param, params[param]) 154 | 155 | message = Message(cmd, msg, self._local_uuid, self.uuid) 156 | 157 | # Create the future that will contain the response 158 | fut = asyncio.Future() 159 | if reply: 160 | self._event_bus.add_listener(self._reference, fut) 161 | else: 162 | fut.set_result(None) 163 | 164 | # Send the message 165 | _LOGGER.debug("TX %s", message) 166 | self._writer.write(message.encode()) 167 | await self._writer.drain() 168 | 169 | # Increase message reference for next message 170 | self._reference += 1 171 | 172 | try: 173 | return await asyncio.wait_for(fut, TIMEOUT) 174 | except asyncio.TimeoutError as exc: 175 | _LOGGER.warning("Timeout while waiting for response from bridge") 176 | await self._disconnect() 177 | raise AioComfoConnectTimeout("Timeout while waiting for response from bridge") from exc 178 | 179 | async def _read(self) -> Message: 180 | # Read packet size 181 | msg_len_buf = await self._reader.readexactly(4) 182 | 183 | # Read rest of packet 184 | msg_len = int.from_bytes(msg_len_buf, byteorder="big") 185 | msg_buf = await self._reader.readexactly(msg_len) 186 | 187 | # Decode message 188 | message = Message.decode(msg_buf) 189 | 190 | _LOGGER.debug("RX %s", message) 191 | 192 | # Check status code 193 | # pylint: disable=no-member 194 | if message.cmd.result == zehnder_pb2.GatewayOperation.OK: 195 | pass 196 | elif message.cmd.result == zehnder_pb2.GatewayOperation.BAD_REQUEST: 197 | raise ComfoConnectBadRequest(message) 198 | elif message.cmd.result == zehnder_pb2.GatewayOperation.INTERNAL_ERROR: 199 | raise ComfoConnectInternalError(message) 200 | elif message.cmd.result == zehnder_pb2.GatewayOperation.NOT_REACHABLE: 201 | raise ComfoConnectNotReachable(message) 202 | elif message.cmd.result == zehnder_pb2.GatewayOperation.OTHER_SESSION: 203 | raise ComfoConnectOtherSession(message) 204 | elif message.cmd.result == zehnder_pb2.GatewayOperation.NOT_ALLOWED: 205 | raise ComfoConnectNotAllowed(message) 206 | elif message.cmd.result == zehnder_pb2.GatewayOperation.NO_RESOURCES: 207 | raise ComfoConnectNoResources(message) 208 | elif message.cmd.result == zehnder_pb2.GatewayOperation.NOT_EXIST: 209 | raise ComfoConnectNotExist(message) 210 | elif message.cmd.result == zehnder_pb2.GatewayOperation.RMI_ERROR: 211 | raise ComfoConnectRmiError(message) 212 | 213 | return message 214 | 215 | async def _process_message(self): 216 | """Process a message from the bridge.""" 217 | try: 218 | message = await self._read() 219 | 220 | # pylint: disable=no-member 221 | if message.cmd.type == zehnder_pb2.GatewayOperation.CnRpdoNotificationType: 222 | if self.__sensor_callback_fn: 223 | self.__sensor_callback_fn(message.msg.pdid, int.from_bytes(message.msg.data, byteorder="little", signed=True)) 224 | else: 225 | _LOGGER.info("Unhandled CnRpdoNotificationType since no callback is registered.") 226 | 227 | elif message.cmd.type == zehnder_pb2.GatewayOperation.GatewayNotificationType: 228 | _LOGGER.debug("Unhandled GatewayNotificationType") 229 | 230 | elif message.cmd.type == zehnder_pb2.GatewayOperation.CnNodeNotificationType: 231 | _LOGGER.debug("Unhandled CnNodeNotificationType") 232 | 233 | elif message.cmd.type == zehnder_pb2.GatewayOperation.CnAlarmNotificationType: 234 | if self.__alarm_callback_fn: 235 | self.__alarm_callback_fn(message.msg.nodeId, message.msg) 236 | else: 237 | _LOGGER.info("Unhandled CnAlarmNotificationType since no callback is registered.") 238 | 239 | elif message.cmd.type == zehnder_pb2.GatewayOperation.CloseSessionRequestType: 240 | _LOGGER.info("The Bridge has asked us to close the connection.") 241 | 242 | elif message.cmd.reference: 243 | # Emit to the event bus 244 | self._event_bus.emit(message.cmd.reference, message.msg) 245 | 246 | else: 247 | _LOGGER.warning("Unhandled message type %s: %s", message.cmd.type, message) 248 | 249 | except asyncio.exceptions.IncompleteReadError as exc: 250 | _LOGGER.info("The connection was closed.") 251 | await self._disconnect() 252 | raise AioComfoConnectNotConnected("The connection was closed.") from exc 253 | 254 | except ComfoConnectError as exc: 255 | if exc.message.cmd.reference: 256 | self._event_bus.emit(exc.message.cmd.reference, exc) 257 | 258 | except DecodeError as exc: 259 | _LOGGER.error("Failed to decode message: %s", exc) 260 | 261 | def cmd_start_session(self, take_over: bool = False) -> Awaitable[Message]: 262 | """Starts the session on the device by logging in and optionally disconnecting an already existing session.""" 263 | _LOGGER.debug("StartSessionRequest") 264 | # pylint: disable=no-member 265 | return self._send( 266 | zehnder_pb2.StartSessionRequest, 267 | zehnder_pb2.GatewayOperation.StartSessionRequestType, 268 | {"takeover": take_over}, 269 | ) 270 | 271 | def cmd_close_session(self) -> Awaitable[Message]: 272 | """Stops the current session.""" 273 | _LOGGER.debug("CloseSessionRequest") 274 | # pylint: disable=no-member 275 | return self._send( 276 | zehnder_pb2.CloseSessionRequest, 277 | zehnder_pb2.GatewayOperation.CloseSessionRequestType, 278 | reply=False, # Don't wait for a reply 279 | ) 280 | 281 | def cmd_list_registered_apps(self) -> Awaitable[Message]: 282 | """Returns a list of all the registered clients.""" 283 | _LOGGER.debug("ListRegisteredAppsRequest") 284 | # pylint: disable=no-member 285 | return self._send( 286 | zehnder_pb2.ListRegisteredAppsRequest, 287 | zehnder_pb2.GatewayOperation.ListRegisteredAppsRequestType, 288 | ) 289 | 290 | def cmd_register_app(self, uuid: str, device_name: str, pin: int) -> Awaitable[Message]: 291 | """Register a new app by specifying our own uuid, device_name and pin code.""" 292 | _LOGGER.debug("RegisterAppRequest") 293 | # pylint: disable=no-member 294 | return self._send( 295 | zehnder_pb2.RegisterAppRequest, 296 | zehnder_pb2.GatewayOperation.RegisterAppRequestType, 297 | { 298 | "uuid": bytes.fromhex(uuid), 299 | "devicename": device_name, 300 | "pin": int(pin), 301 | }, 302 | ) 303 | 304 | def cmd_deregister_app(self, uuid: str) -> Awaitable[Message]: 305 | """Remove the specified app from the registration list.""" 306 | _LOGGER.debug("DeregisterAppRequest") 307 | if uuid == self._local_uuid: 308 | raise SelfDeregistrationError("You should not deregister yourself.") 309 | 310 | # pylint: disable=no-member 311 | return self._send( 312 | zehnder_pb2.DeregisterAppRequest, 313 | zehnder_pb2.GatewayOperation.DeregisterAppRequestType, 314 | {"uuid": bytes.fromhex(uuid)}, 315 | ) 316 | 317 | def cmd_version_request(self) -> Awaitable[Message]: 318 | """Returns version information.""" 319 | _LOGGER.debug("VersionRequest") 320 | # pylint: disable=no-member 321 | return self._send( 322 | zehnder_pb2.VersionRequest, 323 | zehnder_pb2.GatewayOperation.VersionRequestType, 324 | ) 325 | 326 | def cmd_time_request(self) -> Awaitable[Message]: 327 | """Returns the current time on the device.""" 328 | _LOGGER.debug("CnTimeRequest") 329 | # pylint: disable=no-member 330 | return self._send( 331 | zehnder_pb2.CnTimeRequest, 332 | zehnder_pb2.GatewayOperation.CnTimeRequestType, 333 | ) 334 | 335 | def cmd_rmi_request(self, message, node_id: int = 1) -> Awaitable[Message]: 336 | """Sends a RMI request.""" 337 | _LOGGER.debug("CnRmiRequest") 338 | # pylint: disable=no-member 339 | return self._send( 340 | zehnder_pb2.CnRmiRequest, 341 | zehnder_pb2.GatewayOperation.CnRmiRequestType, 342 | {"nodeId": node_id or 1, "message": message}, 343 | ) 344 | 345 | def cmd_rpdo_request(self, pdid: int, pdo_type: int = 1, zone: int = 1, timeout=None) -> Awaitable[Message]: 346 | """Register a RPDO request.""" 347 | _LOGGER.debug("CnRpdoRequest") 348 | # pylint: disable=no-member 349 | return self._send( 350 | zehnder_pb2.CnRpdoRequest, 351 | zehnder_pb2.GatewayOperation.CnRpdoRequestType, 352 | {"pdid": pdid, "type": pdo_type, "zone": zone or 1, "timeout": timeout}, 353 | ) 354 | 355 | def cmd_keepalive(self) -> Awaitable[Message]: 356 | """Sends a keepalive.""" 357 | _LOGGER.debug("KeepAlive") 358 | # pylint: disable=no-member 359 | return self._send( 360 | zehnder_pb2.KeepAlive, 361 | zehnder_pb2.GatewayOperation.KeepAliveType, 362 | reply=False, # Don't wait for a reply 363 | ) 364 | 365 | 366 | class Message: 367 | """A message that is sent to the bridge.""" 368 | 369 | # pylint: disable=no-member 370 | REQUEST_MAPPING = { 371 | zehnder_pb2.GatewayOperation.SetAddressRequestType: zehnder_pb2.SetAddressRequest, 372 | zehnder_pb2.GatewayOperation.RegisterAppRequestType: zehnder_pb2.RegisterAppRequest, 373 | zehnder_pb2.GatewayOperation.StartSessionRequestType: zehnder_pb2.StartSessionRequest, 374 | zehnder_pb2.GatewayOperation.CloseSessionRequestType: zehnder_pb2.CloseSessionRequest, 375 | zehnder_pb2.GatewayOperation.ListRegisteredAppsRequestType: zehnder_pb2.ListRegisteredAppsRequest, 376 | zehnder_pb2.GatewayOperation.DeregisterAppRequestType: zehnder_pb2.DeregisterAppRequest, 377 | zehnder_pb2.GatewayOperation.ChangePinRequestType: zehnder_pb2.ChangePinRequest, 378 | zehnder_pb2.GatewayOperation.GetRemoteAccessIdRequestType: zehnder_pb2.GetRemoteAccessIdRequest, 379 | zehnder_pb2.GatewayOperation.SetRemoteAccessIdRequestType: zehnder_pb2.SetRemoteAccessIdRequest, 380 | zehnder_pb2.GatewayOperation.GetSupportIdRequestType: zehnder_pb2.GetSupportIdRequest, 381 | zehnder_pb2.GatewayOperation.SetSupportIdRequestType: zehnder_pb2.SetSupportIdRequest, 382 | zehnder_pb2.GatewayOperation.GetWebIdRequestType: zehnder_pb2.GetWebIdRequest, 383 | zehnder_pb2.GatewayOperation.SetWebIdRequestType: zehnder_pb2.SetWebIdRequest, 384 | zehnder_pb2.GatewayOperation.SetPushIdRequestType: zehnder_pb2.SetPushIdRequest, 385 | zehnder_pb2.GatewayOperation.DebugRequestType: zehnder_pb2.DebugRequest, 386 | zehnder_pb2.GatewayOperation.UpgradeRequestType: zehnder_pb2.UpgradeRequest, 387 | zehnder_pb2.GatewayOperation.SetDeviceSettingsRequestType: zehnder_pb2.SetDeviceSettingsRequest, 388 | zehnder_pb2.GatewayOperation.VersionRequestType: zehnder_pb2.VersionRequest, 389 | zehnder_pb2.GatewayOperation.SetAddressConfirmType: zehnder_pb2.SetAddressConfirm, 390 | zehnder_pb2.GatewayOperation.RegisterAppConfirmType: zehnder_pb2.RegisterAppConfirm, 391 | zehnder_pb2.GatewayOperation.StartSessionConfirmType: zehnder_pb2.StartSessionConfirm, 392 | zehnder_pb2.GatewayOperation.CloseSessionConfirmType: zehnder_pb2.CloseSessionConfirm, 393 | zehnder_pb2.GatewayOperation.ListRegisteredAppsConfirmType: zehnder_pb2.ListRegisteredAppsConfirm, 394 | zehnder_pb2.GatewayOperation.DeregisterAppConfirmType: zehnder_pb2.DeregisterAppConfirm, 395 | zehnder_pb2.GatewayOperation.ChangePinConfirmType: zehnder_pb2.ChangePinConfirm, 396 | zehnder_pb2.GatewayOperation.GetRemoteAccessIdConfirmType: zehnder_pb2.GetRemoteAccessIdConfirm, 397 | zehnder_pb2.GatewayOperation.SetRemoteAccessIdConfirmType: zehnder_pb2.SetRemoteAccessIdConfirm, 398 | zehnder_pb2.GatewayOperation.GetSupportIdConfirmType: zehnder_pb2.GetSupportIdConfirm, 399 | zehnder_pb2.GatewayOperation.SetSupportIdConfirmType: zehnder_pb2.SetSupportIdConfirm, 400 | zehnder_pb2.GatewayOperation.GetWebIdConfirmType: zehnder_pb2.GetWebIdConfirm, 401 | zehnder_pb2.GatewayOperation.SetWebIdConfirmType: zehnder_pb2.SetWebIdConfirm, 402 | zehnder_pb2.GatewayOperation.SetPushIdConfirmType: zehnder_pb2.SetPushIdConfirm, 403 | zehnder_pb2.GatewayOperation.DebugConfirmType: zehnder_pb2.DebugConfirm, 404 | zehnder_pb2.GatewayOperation.UpgradeConfirmType: zehnder_pb2.UpgradeConfirm, 405 | zehnder_pb2.GatewayOperation.SetDeviceSettingsConfirmType: zehnder_pb2.SetDeviceSettingsConfirm, 406 | zehnder_pb2.GatewayOperation.VersionConfirmType: zehnder_pb2.VersionConfirm, 407 | zehnder_pb2.GatewayOperation.GatewayNotificationType: zehnder_pb2.GatewayNotification, 408 | zehnder_pb2.GatewayOperation.KeepAliveType: zehnder_pb2.KeepAlive, 409 | zehnder_pb2.GatewayOperation.FactoryResetType: zehnder_pb2.FactoryReset, 410 | zehnder_pb2.GatewayOperation.CnTimeRequestType: zehnder_pb2.CnTimeRequest, 411 | zehnder_pb2.GatewayOperation.CnTimeConfirmType: zehnder_pb2.CnTimeConfirm, 412 | zehnder_pb2.GatewayOperation.CnNodeRequestType: zehnder_pb2.CnNodeRequest, 413 | zehnder_pb2.GatewayOperation.CnNodeNotificationType: zehnder_pb2.CnNodeNotification, 414 | zehnder_pb2.GatewayOperation.CnRmiRequestType: zehnder_pb2.CnRmiRequest, 415 | zehnder_pb2.GatewayOperation.CnRmiResponseType: zehnder_pb2.CnRmiResponse, 416 | zehnder_pb2.GatewayOperation.CnRmiAsyncRequestType: zehnder_pb2.CnRmiAsyncRequest, 417 | zehnder_pb2.GatewayOperation.CnRmiAsyncConfirmType: zehnder_pb2.CnRmiAsyncConfirm, 418 | zehnder_pb2.GatewayOperation.CnRmiAsyncResponseType: zehnder_pb2.CnRmiAsyncResponse, 419 | zehnder_pb2.GatewayOperation.CnRpdoRequestType: zehnder_pb2.CnRpdoRequest, 420 | zehnder_pb2.GatewayOperation.CnRpdoConfirmType: zehnder_pb2.CnRpdoConfirm, 421 | zehnder_pb2.GatewayOperation.CnRpdoNotificationType: zehnder_pb2.CnRpdoNotification, 422 | zehnder_pb2.GatewayOperation.CnAlarmNotificationType: zehnder_pb2.CnAlarmNotification, 423 | zehnder_pb2.GatewayOperation.CnFupReadRegisterRequestType: zehnder_pb2.CnFupReadRegisterRequest, 424 | zehnder_pb2.GatewayOperation.CnFupReadRegisterConfirmType: zehnder_pb2.CnFupReadRegisterConfirm, 425 | zehnder_pb2.GatewayOperation.CnFupProgramBeginRequestType: zehnder_pb2.CnFupProgramBeginRequest, 426 | zehnder_pb2.GatewayOperation.CnFupProgramBeginConfirmType: zehnder_pb2.CnFupProgramBeginConfirm, 427 | zehnder_pb2.GatewayOperation.CnFupProgramRequestType: zehnder_pb2.CnFupProgramRequest, 428 | zehnder_pb2.GatewayOperation.CnFupProgramConfirmType: zehnder_pb2.CnFupProgramConfirm, 429 | zehnder_pb2.GatewayOperation.CnFupProgramEndRequestType: zehnder_pb2.CnFupProgramEndRequest, 430 | zehnder_pb2.GatewayOperation.CnFupProgramEndConfirmType: zehnder_pb2.CnFupProgramEndConfirm, 431 | zehnder_pb2.GatewayOperation.CnFupReadRequestType: zehnder_pb2.CnFupReadRequest, 432 | zehnder_pb2.GatewayOperation.CnFupReadConfirmType: zehnder_pb2.CnFupReadConfirm, 433 | zehnder_pb2.GatewayOperation.CnFupResetRequestType: zehnder_pb2.CnFupResetRequest, 434 | zehnder_pb2.GatewayOperation.CnFupResetConfirmType: zehnder_pb2.CnFupResetConfirm, 435 | } 436 | 437 | def __init__(self, cmd, msg, src, dst): 438 | self.cmd: ProtobufMessage = cmd 439 | self.msg: ProtobufMessage = msg 440 | self.src: str = src 441 | self.dst: str = dst 442 | 443 | def __str__(self): 444 | return f"{self.src} -> {self.dst}: {self.cmd.SerializeToString().hex()} {self.msg.SerializeToString().hex()}\n{self.cmd}\n{self.msg}" 445 | 446 | def encode(self) -> bytes: 447 | """Encode the message into a byte array""" 448 | cmd_buf = self.cmd.SerializeToString() 449 | msg_buf = self.msg.SerializeToString() 450 | cmd_len_buf = struct.pack(">H", len(cmd_buf)) 451 | msg_len_buf = struct.pack(">L", 16 + 16 + 2 + len(cmd_buf) + len(msg_buf)) 452 | 453 | return msg_len_buf + bytes.fromhex(self.src) + bytes.fromhex(self.dst) + cmd_len_buf + cmd_buf + msg_buf 454 | 455 | @classmethod 456 | def decode(cls, packet) -> Message: 457 | """Decode a packet from a byte buffer""" 458 | src_buf = packet[0:16] 459 | dst_buf = packet[16:32] 460 | cmd_len = struct.unpack(">H", packet[32:34])[0] 461 | cmd_buf = packet[34 : 34 + cmd_len] 462 | msg_buf = packet[34 + cmd_len :] 463 | 464 | # Parse command 465 | cmd = zehnder_pb2.GatewayOperation() 466 | cmd.ParseFromString(cmd_buf) 467 | 468 | # Parse message 469 | cmd_type = cls.REQUEST_MAPPING.get(cmd.type) 470 | msg = cmd_type() 471 | msg.ParseFromString(msg_buf) 472 | 473 | return Message(cmd, msg, src_buf.hex(), dst_buf.hex()) 474 | -------------------------------------------------------------------------------- /aiocomfoconnect/comfoconnect.py: -------------------------------------------------------------------------------- 1 | """ ComfoConnect Bridge API abstraction """ 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from asyncio import Future 8 | from typing import Callable, Dict, List, Literal 9 | 10 | from aiocomfoconnect import Bridge 11 | from aiocomfoconnect.const import ( 12 | ERRORS, 13 | ERRORS_140, 14 | SUBUNIT_01, 15 | SUBUNIT_02, 16 | SUBUNIT_03, 17 | SUBUNIT_05, 18 | SUBUNIT_06, 19 | SUBUNIT_07, 20 | SUBUNIT_08, 21 | UNIT_ERROR, 22 | UNIT_SCHEDULE, 23 | UNIT_TEMPHUMCONTROL, 24 | UNIT_VENTILATIONCONFIG, 25 | ComfoCoolMode, 26 | PdoType, 27 | VentilationBalance, 28 | VentilationMode, 29 | VentilationSetting, 30 | VentilationSpeed, 31 | VentilationTemperatureProfile, 32 | ) 33 | from aiocomfoconnect.exceptions import ( 34 | AioComfoConnectNotConnected, 35 | AioComfoConnectTimeout, 36 | ComfoConnectNotAllowed, 37 | ) 38 | from aiocomfoconnect.properties import Property 39 | from aiocomfoconnect.sensors import Sensor 40 | from aiocomfoconnect.util import bytearray_to_bits, bytestring, encode_pdo_value 41 | 42 | _LOGGER = logging.getLogger(__name__) 43 | 44 | 45 | class ComfoConnect(Bridge): 46 | """Abstraction layer over the ComfoConnect LAN C API.""" 47 | 48 | def __init__(self, host: str, uuid: str, loop=None, sensor_callback=None, alarm_callback=None, sensor_delay=2): 49 | """Initialize the ComfoConnect class.""" 50 | super().__init__(host, uuid, loop) 51 | 52 | self.set_sensor_callback(self._sensor_callback) # Set the callback to our _sensor_callback method, so we can proces the callbacks. 53 | self.set_alarm_callback(self._alarm_callback) # Set the callback to our _alarm_callback method, so we can proces the callbacks. 54 | self.sensor_delay = sensor_delay 55 | 56 | self._sensor_callback_fn: Callable = sensor_callback 57 | self._alarm_callback_fn: Callable = alarm_callback 58 | self._sensors: Dict[int, Sensor] = {} 59 | self._sensors_values: Dict[int, any] = {} 60 | self._sensor_hold = None 61 | 62 | self._tasks = set() 63 | 64 | def _unhold_sensors(self): 65 | """Unhold the sensors.""" 66 | _LOGGER.debug("Unholding sensors") 67 | self._sensor_hold = None 68 | 69 | # Emit the current cached values of the sensors, by now, they should have received a correct update. 70 | for sensor_id, _ in self._sensors.items(): 71 | if self._sensors_values.get(sensor_id) is not None: 72 | self._sensor_callback(sensor_id, self._sensors_values.get(sensor_id)) 73 | 74 | async def connect(self, uuid: str): 75 | """Connect to the bridge.""" 76 | connected: Future = Future() 77 | 78 | async def _reconnect_loop(): 79 | while True: 80 | try: 81 | # Connect to the bridge 82 | read_task = await self._connect(uuid) 83 | 84 | # Start session 85 | await self.cmd_start_session(True) 86 | 87 | # Wait for a specified amount of seconds to buffer sensor values. 88 | # This is to work around a bug where the bridge sends invalid sensor values when connecting. 89 | if self.sensor_delay: 90 | _LOGGER.debug("Holding sensors for %s second(s)", self.sensor_delay) 91 | self._sensors_values = {} 92 | self._sensor_hold = self._loop.call_later(self.sensor_delay, self._unhold_sensors) 93 | 94 | # Register the sensors again (in case we lost the connection) 95 | for sensor in self._sensors.values(): 96 | await self.cmd_rpdo_request(sensor.id, sensor.type) 97 | 98 | if not connected.done(): 99 | connected.set_result(True) 100 | 101 | # Wait for the read task to finish or throw an exception 102 | await read_task 103 | 104 | if read_task.result() is False: 105 | # We are shutting down. 106 | return 107 | 108 | except AioComfoConnectTimeout: 109 | # Reconnect after 5 seconds when we could not connect 110 | _LOGGER.info("Could not reconnect. Retrying after 5 seconds.") 111 | await asyncio.sleep(5) 112 | 113 | except AioComfoConnectNotConnected: 114 | # Reconnect when connection has been dropped 115 | _LOGGER.info("We got disconnected. Reconnecting.") 116 | 117 | except ComfoConnectNotAllowed as exception: 118 | # Passthrough exception if not allowed (because not registered uuid for example ) 119 | connected.set_exception(exception) 120 | return 121 | 122 | reconnect_task = self._loop.create_task(_reconnect_loop()) 123 | self._tasks.add(reconnect_task) 124 | reconnect_task.add_done_callback(self._tasks.discard) 125 | 126 | await connected 127 | 128 | async def disconnect(self): 129 | """Disconnect from the bridge.""" 130 | await self._disconnect() 131 | 132 | async def register_sensor(self, sensor: Sensor): 133 | """Register a sensor on the bridge.""" 134 | self._sensors[sensor.id] = sensor 135 | self._sensors_values[sensor.id] = None 136 | await self.cmd_rpdo_request(sensor.id, sensor.type) 137 | 138 | async def deregister_sensor(self, sensor: Sensor): 139 | """Deregister a sensor on the bridge.""" 140 | await self.cmd_rpdo_request(sensor.id, sensor.type, timeout=0) 141 | del self._sensors[sensor.id] 142 | del self._sensors_values[sensor.id] 143 | 144 | async def get_property(self, prop: Property, node_id=1) -> any: 145 | """Get a property and convert to the right type.""" 146 | return await self.get_single_property(prop.unit, prop.subunit, prop.property_id, prop.property_type, node_id=node_id) 147 | 148 | async def get_single_property(self, unit: int, subunit: int, property_id: int, property_type: int = None, node_id=1) -> any: 149 | """Get a property and convert to the right type.""" 150 | result = await self.cmd_rmi_request(bytes([0x01, unit, subunit, 0x10, property_id]), node_id=node_id) 151 | 152 | if property_type == PdoType.TYPE_CN_STRING: 153 | return result.message.decode("utf-8").rstrip("\x00") 154 | if property_type in [PdoType.TYPE_CN_INT8, PdoType.TYPE_CN_INT16, PdoType.TYPE_CN_INT64]: 155 | return int.from_bytes(result.message, byteorder="little", signed=True) 156 | if property_type in [PdoType.TYPE_CN_UINT8, PdoType.TYPE_CN_UINT16, PdoType.TYPE_CN_UINT32]: 157 | return int.from_bytes(result.message, byteorder="little", signed=False) 158 | if property_type in [PdoType.TYPE_CN_BOOL]: 159 | return result.message[0] == 1 160 | 161 | return result.message 162 | 163 | async def get_multiple_properties(self, unit: int, subunit: int, property_ids: List[int], node_id=1) -> any: 164 | """Get multiple properties.""" 165 | result = await self.cmd_rmi_request(bytestring([0x02, unit, subunit, 0x01, 0x10 | len(property_ids), bytes(property_ids)]), node_id=node_id) 166 | 167 | return result.message 168 | 169 | async def set_property(self, unit: int, subunit: int, property_id: int, value: int, node_id=1) -> any: 170 | """Set a property.""" 171 | result = await self.cmd_rmi_request(bytes([0x03, unit, subunit, property_id, value]), node_id=node_id) 172 | 173 | return result.message 174 | 175 | async def set_property_typed(self, unit: int, subunit: int, property_id: int, value: int, pdo_type: PdoType, node_id=1) -> any: 176 | """Set a typed property.""" 177 | value_bytes = encode_pdo_value(value, pdo_type) 178 | message_bytes = bytes([0x03, unit, subunit, property_id]) + value_bytes 179 | 180 | result = await self.cmd_rmi_request(message_bytes, node_id=node_id) 181 | 182 | return result.message 183 | 184 | def _sensor_callback(self, sensor_id, sensor_value): 185 | """Callback function for sensor updates.""" 186 | if self._sensor_callback_fn is None: 187 | return 188 | 189 | sensor = self._sensors.get(sensor_id) 190 | if sensor is None: 191 | _LOGGER.error("Unknown sensor id: %s", sensor_id) 192 | return 193 | 194 | self._sensors_values[sensor_id] = sensor_value 195 | 196 | # Don't emit sensor values until we have received all the initial values. 197 | if self._sensor_hold is not None: 198 | return 199 | 200 | if sensor.value_fn: 201 | val = sensor.value_fn(sensor_value) 202 | else: 203 | val = round(sensor_value, 2) 204 | self._sensor_callback_fn(sensor, val) 205 | 206 | def _alarm_callback(self, node_id, alarm): 207 | """Callback function for alarm updates.""" 208 | if self._alarm_callback_fn is None: 209 | return 210 | 211 | if alarm.swProgramVersion <= 3222278144: 212 | # Firmware 1.4.0 and below 213 | error_messages = ERRORS_140 214 | else: 215 | error_messages = ERRORS 216 | 217 | errors = {bit: error_messages[bit] for bit in bytearray_to_bits(alarm.errors)} 218 | self._alarm_callback_fn(node_id, errors) 219 | 220 | async def get_mode(self): 221 | """Get the current mode.""" 222 | result = await self.cmd_rmi_request(bytes([0x83, UNIT_SCHEDULE, SUBUNIT_08, 0x01])) 223 | # 0000000000ffffffff0000000001 = auto 224 | # 0100000000ffffffffffffffff01 = manual 225 | mode = result.message[0] 226 | 227 | return VentilationMode.MANUAL if mode == 1 else VentilationMode.AUTO 228 | 229 | async def set_mode(self, mode: Literal["auto", "manual"]): 230 | """Set the ventilation mode (auto / manual).""" 231 | if mode == VentilationMode.AUTO: 232 | await self.cmd_rmi_request(bytes([0x85, UNIT_SCHEDULE, SUBUNIT_08, 0x01])) 233 | elif mode == VentilationMode.MANUAL: 234 | await self.cmd_rmi_request(bytes([0x84, UNIT_SCHEDULE, SUBUNIT_08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01])) 235 | else: 236 | raise ValueError(f"Invalid mode: {mode}") 237 | 238 | async def get_speed(self): 239 | """Set the ventilation speed (away / low / medium / high).""" 240 | result = await self.cmd_rmi_request(bytes([0x83, UNIT_SCHEDULE, SUBUNIT_01, 0x01])) 241 | # 0100000000ffffffffffffffff00 = away 242 | # 0100000000ffffffffffffffff01 = low 243 | # 0100000000ffffffffffffffff02 = medium 244 | # 0100000000ffffffffffffffff03 = high 245 | speed = result.message[-1] 246 | 247 | if speed == 0: 248 | return VentilationSpeed.AWAY 249 | if speed == 1: 250 | return VentilationSpeed.LOW 251 | if speed == 2: 252 | return VentilationSpeed.MEDIUM 253 | if speed == 3: 254 | return VentilationSpeed.HIGH 255 | 256 | raise ValueError(f"Invalid speed: {speed}") 257 | 258 | async def set_speed(self, speed: Literal["away", "low", "medium", "high"]): 259 | """Get the ventilation speed (away / low / medium / high).""" 260 | if speed == VentilationSpeed.AWAY: 261 | await self.cmd_rmi_request(bytes([0x84, UNIT_SCHEDULE, SUBUNIT_01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00])) 262 | elif speed == VentilationSpeed.LOW: 263 | await self.cmd_rmi_request(bytes([0x84, UNIT_SCHEDULE, SUBUNIT_01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01])) 264 | elif speed == VentilationSpeed.MEDIUM: 265 | await self.cmd_rmi_request(bytes([0x84, UNIT_SCHEDULE, SUBUNIT_01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02])) 266 | elif speed == VentilationSpeed.HIGH: 267 | await self.cmd_rmi_request(bytes([0x84, UNIT_SCHEDULE, SUBUNIT_01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x03])) 268 | else: 269 | raise ValueError(f"Invalid speed: {speed}") 270 | 271 | async def get_flow_for_speed(self, speed: Literal["away", "low", "medium", "high"]) -> int: 272 | """Get the targeted airflow in m³/h for the given VentilationSpeed (away / low / medium / high).""" 273 | 274 | match speed: 275 | case VentilationSpeed.AWAY: 276 | property_id = 3 277 | case VentilationSpeed.LOW: 278 | property_id = 4 279 | case VentilationSpeed.MEDIUM: 280 | property_id = 5 281 | case VentilationSpeed.HIGH: 282 | property_id = 6 283 | 284 | return await self.get_single_property(UNIT_VENTILATIONCONFIG, SUBUNIT_01, property_id, PdoType.TYPE_CN_INT16) 285 | 286 | async def set_flow_for_speed(self, speed: Literal["away", "low", "medium", "high"], desired_flow: int): 287 | """Set the targeted airflow in m³/h for the given VentilationSpeed (away / low / medium / high).""" 288 | 289 | match speed: 290 | case VentilationSpeed.AWAY: 291 | property_id = 3 292 | case VentilationSpeed.LOW: 293 | property_id = 4 294 | case VentilationSpeed.MEDIUM: 295 | property_id = 5 296 | case VentilationSpeed.HIGH: 297 | property_id = 6 298 | 299 | await self.set_property_typed(UNIT_VENTILATIONCONFIG, SUBUNIT_01, property_id, desired_flow, PdoType.TYPE_CN_INT16) 300 | 301 | async def get_bypass(self): 302 | """Get the bypass mode (auto / on / off).""" 303 | result = await self.cmd_rmi_request(bytes([0x83, UNIT_SCHEDULE, SUBUNIT_02, 0x01])) 304 | # 0000000000080700000000000000 = auto 305 | # 0100000000100e00000b0e000001 = open 306 | # 0100000000100e00000d0e000002 = close 307 | mode = result.message[-1] 308 | 309 | if mode == 0: 310 | return VentilationSetting.AUTO 311 | if mode == 1: 312 | return VentilationSetting.ON 313 | if mode == 2: 314 | return VentilationSetting.OFF 315 | 316 | raise ValueError(f"Invalid mode: {mode}") 317 | 318 | async def set_bypass(self, mode: Literal["auto", "on", "off"], timeout=-1): 319 | """Set the bypass mode (auto / on / off).""" 320 | if mode == VentilationSetting.AUTO: 321 | await self.cmd_rmi_request(bytes([0x85, UNIT_SCHEDULE, SUBUNIT_02, 0x01])) 322 | elif mode == VentilationSetting.ON: 323 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_02, 0x01, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x01])) 324 | elif mode == VentilationSetting.OFF: 325 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_02, 0x01, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x02])) 326 | else: 327 | raise ValueError(f"Invalid mode: {mode}") 328 | 329 | async def get_balance_mode(self): 330 | """Get the ventilation balance mode (balance / supply only / exhaust only).""" 331 | result_06 = await self.cmd_rmi_request(bytes([0x83, UNIT_SCHEDULE, SUBUNIT_06, 0x01])) 332 | result_07 = await self.cmd_rmi_request(bytes([0x83, UNIT_SCHEDULE, SUBUNIT_07, 0x01])) 333 | # result_06: 334 | # 0000000000080700000000000001 = balance 335 | # 0100000000100e00000e0e000001 = supply only 336 | # 0000000000080700000000000001 = exhaust only 337 | 338 | # result_07: 339 | # 0000000000080700000000000001 = balance 340 | # 0000000000080700000000000001 = supply only 341 | # 0100000000100e00000e0e000001 = exhaust only 342 | mode_06 = result_06.message[0] 343 | mode_07 = result_07.message[0] 344 | 345 | if mode_06 == mode_07: 346 | return VentilationBalance.BALANCE 347 | if mode_06 == 1 and mode_07 == 0: 348 | return VentilationBalance.SUPPLY_ONLY 349 | if mode_06 == 0 and mode_07 == 1: 350 | return VentilationBalance.EXHAUST_ONLY 351 | 352 | raise ValueError(f"Invalid mode: 6={mode_06}, 7={mode_07}") 353 | 354 | async def set_balance_mode(self, mode: Literal["balance", "supply_only", "exhaust_only"], timeout=-1): 355 | """Set the ventilation balance mode (balance / supply only / exhaust only).""" 356 | if mode == VentilationBalance.BALANCE: 357 | await self.cmd_rmi_request(bytes([0x85, UNIT_SCHEDULE, SUBUNIT_06, 0x01])) 358 | await self.cmd_rmi_request(bytes([0x85, UNIT_SCHEDULE, SUBUNIT_07, 0x01])) 359 | elif mode == VentilationBalance.SUPPLY_ONLY: 360 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_06, 0x01, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x01])) 361 | await self.cmd_rmi_request(bytes([0x85, UNIT_SCHEDULE, SUBUNIT_07, 0x01])) 362 | elif mode == VentilationBalance.EXHAUST_ONLY: 363 | await self.cmd_rmi_request(bytes([0x85, UNIT_SCHEDULE, SUBUNIT_06, 0x01])) 364 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_07, 0x01, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x01])) 365 | else: 366 | raise ValueError(f"Invalid mode: {mode}") 367 | 368 | async def get_boost(self): 369 | """Get boost mode.""" 370 | result = await self.cmd_rmi_request(bytes([0x83, UNIT_SCHEDULE, SUBUNIT_01, 0x06])) 371 | # 0000000000580200000000000003 = not active 372 | # 0100000000580200005602000003 = active 373 | mode = result.message[0] 374 | 375 | return mode == 1 376 | 377 | async def set_boost(self, mode: bool, timeout=3600): 378 | """Activate boost mode.""" 379 | if mode: 380 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_01, 0x06, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x03])) 381 | else: 382 | await self.cmd_rmi_request(bytes([0x85, UNIT_SCHEDULE, SUBUNIT_01, 0x06])) 383 | 384 | async def get_away(self): 385 | """Get away mode.""" 386 | result = await self.cmd_rmi_request(bytes([0x83, UNIT_SCHEDULE, SUBUNIT_01, 0x0B])) 387 | # 0000000000b00400000000000000 = not active 388 | # 0100000000550200005302000000 = active 389 | mode = result.message[0] 390 | 391 | return mode == 1 392 | 393 | async def set_away(self, mode: bool, timeout=3600): 394 | """Activate away mode.""" 395 | if mode: 396 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_01, 0x0B, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x00])) 397 | else: 398 | await self.cmd_rmi_request(bytes([0x85, UNIT_SCHEDULE, SUBUNIT_01, 0x0B])) 399 | 400 | async def get_comfocool_mode(self): 401 | """Get the current comfocool mode.""" 402 | result = await self.cmd_rmi_request(bytes([0x83, UNIT_SCHEDULE, SUBUNIT_05, 0x01])) 403 | mode = result.message[0] 404 | return mode == 0 405 | 406 | async def set_comfocool_mode(self, mode: Literal["auto", "off"], timeout=-1): 407 | """Set the comfocool mode (auto / off).""" 408 | if mode == ComfoCoolMode.AUTO: 409 | await self.cmd_rmi_request(bytes([0x85, UNIT_SCHEDULE, SUBUNIT_05, 0x01])) 410 | elif mode == ComfoCoolMode.OFF: 411 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_05, 0x01, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x00])) 412 | 413 | async def get_temperature_profile(self): 414 | """Get the temperature profile (warm / normal / cool).""" 415 | result = await self.cmd_rmi_request(bytes([0x83, UNIT_SCHEDULE, SUBUNIT_03, 0x01])) 416 | # 0100000000ffffffffffffffff02 = warm 417 | # 0100000000ffffffffffffffff00 = normal 418 | # 0100000000ffffffffffffffff01 = cool 419 | mode = result.message[-1] 420 | 421 | if mode == 2: 422 | return VentilationTemperatureProfile.WARM 423 | if mode == 0: 424 | return VentilationTemperatureProfile.NORMAL 425 | if mode == 1: 426 | return VentilationTemperatureProfile.COOL 427 | 428 | raise ValueError(f"Invalid mode: {mode}") 429 | 430 | async def set_temperature_profile(self, profile: Literal["warm", "normal", "cool"], timeout=-1): 431 | """Set the temperature profile (warm / normal / cool).""" 432 | if profile == VentilationTemperatureProfile.WARM: 433 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_03, 0x01, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x02])) 434 | elif profile == VentilationTemperatureProfile.NORMAL: 435 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_03, 0x01, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x00])) 436 | elif profile == VentilationTemperatureProfile.COOL: 437 | await self.cmd_rmi_request(bytestring([0x84, UNIT_SCHEDULE, SUBUNIT_03, 0x01, 0x00, 0x00, 0x00, 0x00, timeout.to_bytes(4, "little", signed=True), 0x01])) 438 | else: 439 | raise ValueError(f"Invalid profile: {profile}") 440 | 441 | async def get_sensor_ventmode_temperature_passive(self): 442 | """Get sensor based ventilation mode - temperature passive (auto / on / off).""" 443 | result = await self.cmd_rmi_request(bytes([0x01, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x10, 0x04])) 444 | # 00 = off 445 | # 01 = auto 446 | # 02 = on 447 | mode = int.from_bytes(result.message, "little") 448 | 449 | if mode == 1: 450 | return VentilationSetting.AUTO 451 | if mode == 2: 452 | return VentilationSetting.ON 453 | if mode == 0: 454 | return VentilationSetting.OFF 455 | 456 | raise ValueError(f"Invalid mode: {mode}") 457 | 458 | async def set_sensor_ventmode_temperature_passive(self, mode: Literal["auto", "on", "off"]): 459 | """Configure sensor based ventilation mode - temperature passive (auto / on / off).""" 460 | if mode == VentilationSetting.AUTO: 461 | await self.cmd_rmi_request(bytes([0x03, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x04, 0x01])) 462 | elif mode == VentilationSetting.ON: 463 | await self.cmd_rmi_request(bytes([0x03, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x04, 0x02])) 464 | elif mode == VentilationSetting.OFF: 465 | await self.cmd_rmi_request(bytes([0x03, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x04, 0x00])) 466 | else: 467 | raise ValueError(f"Invalid mode: {mode}") 468 | 469 | async def get_sensor_ventmode_humidity_comfort(self): 470 | """Get sensor based ventilation mode - humidity comfort (auto / on / off).""" 471 | result = await self.cmd_rmi_request(bytes([0x01, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x10, 0x06])) 472 | # 00 = off 473 | # 01 = auto 474 | # 02 = on 475 | mode = int.from_bytes(result.message, "little") 476 | 477 | if mode == 1: 478 | return VentilationSetting.AUTO 479 | if mode == 2: 480 | return VentilationSetting.ON 481 | if mode == 0: 482 | return VentilationSetting.OFF 483 | 484 | raise ValueError(f"Invalid mode: {mode}") 485 | 486 | async def set_sensor_ventmode_humidity_comfort(self, mode: Literal["auto", "on", "off"]): 487 | """Configure sensor based ventilation mode - humidity comfort (auto / on / off).""" 488 | if mode == VentilationSetting.AUTO: 489 | await self.cmd_rmi_request(bytes([0x03, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x06, 0x01])) 490 | elif mode == VentilationSetting.ON: 491 | await self.cmd_rmi_request(bytes([0x03, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x06, 0x02])) 492 | elif mode == VentilationSetting.OFF: 493 | await self.cmd_rmi_request(bytes([0x03, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x06, 0x00])) 494 | else: 495 | raise ValueError(f"Invalid mode: {mode}") 496 | 497 | async def get_sensor_ventmode_humidity_protection(self): 498 | """Get sensor based ventilation mode - humidity protection (auto / on / off).""" 499 | result = await self.cmd_rmi_request(bytes([0x01, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x10, 0x07])) 500 | # 00 = off 501 | # 01 = auto 502 | # 02 = on 503 | mode = int.from_bytes(result.message, "little") 504 | 505 | if mode == 1: 506 | return VentilationSetting.AUTO 507 | if mode == 2: 508 | return VentilationSetting.ON 509 | if mode == 0: 510 | return VentilationSetting.OFF 511 | 512 | raise ValueError(f"Invalid mode: {mode}") 513 | 514 | async def set_sensor_ventmode_humidity_protection(self, mode: Literal["auto", "on", "off"]): 515 | """Configure sensor based ventilation mode - humidity protection (auto / on / off).""" 516 | if mode == VentilationSetting.AUTO: 517 | await self.cmd_rmi_request(bytes([0x03, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x07, 0x01])) 518 | elif mode == VentilationSetting.ON: 519 | await self.cmd_rmi_request(bytes([0x03, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x07, 0x02])) 520 | elif mode == VentilationSetting.OFF: 521 | await self.cmd_rmi_request(bytes([0x03, UNIT_TEMPHUMCONTROL, SUBUNIT_01, 0x07, 0x00])) 522 | else: 523 | raise ValueError(f"Invalid mode: {mode}") 524 | 525 | async def clear_errors(self): 526 | """Clear the errors.""" 527 | await self.cmd_rmi_request(bytes([0x82, UNIT_ERROR, 0x01])) 528 | -------------------------------------------------------------------------------- /aiocomfoconnect/const.py: -------------------------------------------------------------------------------- 1 | """ Constants """ 2 | 3 | 4 | # PDO Types 5 | class PdoType: 6 | """Defines a PDO type.""" 7 | 8 | TYPE_CN_BOOL = 0x00 9 | TYPE_CN_UINT8 = 0x01 10 | TYPE_CN_UINT16 = 0x02 11 | TYPE_CN_UINT32 = 0x03 12 | TYPE_CN_INT8 = 0x05 13 | TYPE_CN_INT16 = 0x06 14 | TYPE_CN_INT64 = 0x08 15 | TYPE_CN_STRING = 0x09 16 | TYPE_CN_TIME = 0x10 17 | TYPE_CN_VERSION = 0x11 18 | 19 | 20 | # ComfoConnect Units 21 | UNIT_NODE = 0x01 22 | UNIT_COMFOBUS = 0x02 23 | UNIT_ERROR = 0x03 24 | UNIT_SCHEDULE = 0x15 25 | UNIT_VALVE = 0x16 26 | UNIT_FAN = 0x17 27 | UNIT_POWERSENSOR = 0x18 28 | UNIT_PREHEATER = 0x19 29 | UNIT_HMI = 0x1A 30 | UNIT_RFCOMMUNICATION = 0x1B 31 | UNIT_FILTER = 0x1C 32 | UNIT_TEMPHUMCONTROL = 0x1D 33 | UNIT_VENTILATIONCONFIG = 0x1E 34 | UNIT_NODECONFIGURATION = 0x20 35 | UNIT_TEMPERATURESENSOR = 0x21 36 | UNIT_HUMIDITYSENSOR = 0x22 37 | UNIT_PRESSURESENSOR = 0x23 38 | UNIT_PERIPHERALS = 0x24 39 | UNIT_ANALOGINPUT = 0x25 40 | UNIT_COOKERHOOD = 0x26 41 | UNIT_POSTHEATER = 0x27 42 | UNIT_COMFOFOND = 0x28 43 | UNIT_CO2SENSOR = 0x2B 44 | UNIT_SERVICEPRINT = 0x2C 45 | # UNIT_COOLER(21) = 0x15 46 | # UNIT_CC_TEMPERATURESENSOR(22) = 0x16 47 | # UNIT_IOSENSOR(21) = 0x15 48 | # UNIT_CONNECTIONBOARD_IOSENSOR(21) = 0x15 49 | # UNIT_CONNECTIONBOARD_NODECONFIGURATION(22) = 0x16 50 | # UNIT_CONNECTIONBOARD_WIFI(23) = 0x17 51 | # UNIT_CO2SENSOR_CO2SENSOR(21) = 0x15 52 | # UNIT_CO2SENSOR_TEMPERATURESENSOR(22) = 0x16 53 | # UNIT_CO2SENSOR_HUMIDITYSENSOR(23) = 0x17 54 | # UNIT_CO2SENSOR_ENVPRESSURESENSOR(24) = 0x18 55 | # UNIT_CO2SENSOR_HMI(25) = 0x19 56 | 57 | SUBUNIT_01 = 0x01 58 | SUBUNIT_02 = 0x02 59 | SUBUNIT_03 = 0x03 60 | SUBUNIT_04 = 0x04 61 | SUBUNIT_05 = 0x05 62 | SUBUNIT_06 = 0x06 63 | SUBUNIT_07 = 0x07 64 | SUBUNIT_08 = 0x08 65 | 66 | ERRORS_BASE = { 67 | 21: "DANGER! OVERHEATING! Two or more sensors are detecting an incorrect temperature. Ventilation has stopped.", 68 | 22: "Temperature too high for ComfoAir Q (TEMP_HRU ERROR)", 69 | 23: "The extract air temperature sensor has a malfunction (SENSOR_ETA ERROR)", 70 | 24: "The extract air temperature sensor is detecting an incorrect temperature (TEMP_SENSOR_ETA ERROR)", 71 | 25: "The exhaust air temperature sensor has a malfunction (SENSOR_EHA ERROR)", 72 | 26: "The exhaust air temperature sensor is detecting an incorrect temperature (TEMP_SENSOR_EHA ERROR)", 73 | 27: "The outdoor air temperature sensor has a malfunction (SENSOR_ODA ERROR)", 74 | 28: "The outdoor air temperature sensor is detecting an incorrect temperature (TEMP_SENSOR_ODA ERROR)", 75 | 29: "The pre-conditioned outdoor air temperature sensor has a malfunction", 76 | 30: "The pre-conditioned outdoor air temperature sensor is detecting an incorrect temperature (TEMP_SENSOR_P-ODA ERROR)", 77 | 31: "The supply air temperature sensor has a malfunction (SENSOR_SUP ERROR)", 78 | 32: "The supply air temperature sensor is detecting an incorrect temperature (TEMP_SENSOR_SUP ERROR)", 79 | 33: "The Ventilation Unit has not been commissioned (INIT ERROR)", 80 | 34: "The front door is open", 81 | 35: "The Pre-heater is present, but not in the correct position (right/left). (PREHEAT_LOCATION ERROR)", 82 | 37: "The pre-heater has a malfunction (PREHEAT ERROR)", 83 | 38: "The pre-heater has a malfunction (PREHEAT ERROR)", 84 | 39: "The extract air humidity sensor has a malfunction (SENSOR_ETA ERROR)", 85 | 41: "The exhaust air humidity sensor has a malfunction (SENSOR_EHA ERROR)", 86 | 43: "The outdoor air humidity sensor has a malfunction (SENSOR_ODA ERROR)", 87 | 45: "The outdoor air humidity sensor has a malfunction (SENSOR_P-ODA ERROR)", 88 | 47: "The supply air humidity sensor has a malfunction (SENSOR_SUP ERROR)", 89 | 49: "The exhaust air flow sensor has a malfunction (SENSOR_EHA ERROR)", 90 | 50: "The supply air flow sensor has a malfunction (SENSOR_SUP ERROR)", 91 | 51: "The extract air fan has a malfunction (FAN_EHA ERROR)", 92 | 52: "The supply air fan has a malfunction (FAN_SUP ERROR)", 93 | 53: "Exhaust air pressure too high. Check air outlets, ducts and filters for pollution and obstructions. Check valve settings (EXT_PRESSURE_EHA ERROR)", 94 | 54: "Supply air pressure too high. Check air outlets, ducts and filters for pollution and obstructions. Check valve settings. (EXT_PRESSURE_SUP ERROR)", 95 | 55: "The extract air fan has a malfunction (FAN_EHA ERROR)", 96 | 56: "The supply air fan has a malfunction (FAN_SUP ERROR)", 97 | 57: "The exhaust air flow is not reaching its set point (AIRFLOW_EHA ERROR)", 98 | 58: "The supply air flow is not reaching its set point (AIRFLOW_SUP ERROR)", 99 | 59: "Failed to reach required temperature too often for outdoor air after pre-heater (TEMPCONTROL_P-ODA ERROR)", 100 | 60: "Failed to reach required temperature too often for supply air. The modulating by-pass may have a malfunction. (TEMPCONTROL_SUP ERROR)", 101 | 61: "Supply air temperature is too low too often (TEMP_SUP_MIN ERROR)", 102 | 62: "Unbalance occurred too often beyond tolerance levels in past period (UNBALANCE ERROR)", 103 | 63: "Postheater was present, but is no longer detected (POSTHEAT_CONNECT ERROR)", 104 | 64: "Temperature sensor value for supply air ComfoCool exceeded limit too often (CCOOL_TEMP ERROR)", 105 | 65: "Room temperature sensor was present, but is no longer detected (T_ROOM_PRES ERROR)", 106 | 66: "RF Communication hardware was present, but is no longer detected (RF_PRES ERROR)", 107 | 67: "Option Box was present, but is no longer detected (OPTION_BOX CONNECT ERROR)", 108 | 68: "Pre-heater was present, but is no longer detected (PREHEAT_PRES ERROR)", 109 | 69: "Postheater was present, but is no longer detected (POSTHEAT_CONNECT ERROR)", 110 | } 111 | 112 | ERRORS = { 113 | **ERRORS_BASE, 114 | 70: "Analog input 1 was present, but is no longer detected (ANALOG_1_PRES ERROR)", 115 | 71: "Analog input 2 was present, but is no longer detected (ANALOG_2_PRES ERROR)", 116 | 72: "Analog input 3 was present, but is no longer detected (ANALOG_3_PRES ERROR)", 117 | 73: "Analog input 4 was present, but is no longer detected (ANALOG_4_PRES ERROR)", 118 | 74: "ComfoHood was present, but is no longer detected (HOOD_CONNECT ERROR)", 119 | 75: "ComfoCool was present, but is no longer detected (CCOOL_CONNECT ERROR)", 120 | 76: "ComfoFond was present, but is no longer detected (GROUND_HEAT_CONNECT ERROR)", 121 | 77: "The filters of the Ventilation Unit must be replaced now", 122 | 78: "It is necessary to replace or clean the external filter", 123 | 79: "Order new filters now, because the remaining filter life time is limited", 124 | 80: "Service mode is active (SERVICE MODE)", 125 | 81: "Preheater has no communication with the ComfoAir unit (PREHEAT ERROR , 1081)", 126 | 82: "ComfoHood temperature error (HOOD_TEMP ERROR)", 127 | 83: "Postheater temperature error (POSTHEAT_TEMP ERROR)", 128 | 84: "Outdoor temperature of ComfoFond error (GROUND_HEAT_TEMP ERROR)", 129 | 85: "Analog input 1 error (ANALOG_1_IN ERROR)", 130 | 86: "Analog input 2 error (ANALOG_2_IN ERROR)", 131 | 87: "Analog input 3 error (ANALOG_3_IN ERROR)", 132 | 88: "Analog input 4 error (ANALOG_4_IN ERROR)", 133 | 89: "Bypass is in manual mode", 134 | 90: "ComfoCool is overheating", 135 | 91: "ComfoCool compressor error (CCOOL_COMPRESSOR ERROR)", 136 | 92: "ComfoCool room temperature sensor error (CCOOL_TEMP ERROR)", 137 | 93: "ComfoCool condensor temperature sensor error (CCOOL_TEMP ERROR)", 138 | 94: "ComfoCool supply air temperature sensor error (CCOOL_TEMP ERROR)", 139 | 95: "ComfoHood temperature is too high (HOOD_TEMP ERROR)", 140 | 96: "ComfoHood is activated", 141 | 97: "QM_Constraint_min_ERR", # Unknown error 142 | 98: "H_21_qm_min_ERR", # Unknown error 143 | 99: "Configuration error", 144 | 100: "Error analysis is in progress…", 145 | 101: "ComfoNet Error", 146 | 102: "The number of CO2 sensors has decreased – one or more sensors are no longer detected", 147 | 103: "More than 8 sensors detected in a zone", 148 | 104: "CO₂ Sensor C error", 149 | } 150 | 151 | ERRORS_140 = { 152 | **ERRORS_BASE, 153 | 70: "ComfoHood was present, but is no longer detected (HOOD_CONNECT ERROR)", 154 | 71: "ComfoCool was present, but is no longer detected (CCOOL_CONNECT ERROR)", 155 | 72: "ComfoFond was present, but is no longer detected (GROUND_HEAT_CONNECT ERROR)", 156 | 73: "The filters of the Ventilation Unit must be replaced now", 157 | 74: "It is necessary to replace or clean the external filter", 158 | 75: "Order new filters now, because the remaining filter life time is limited", 159 | 76: "Service mode is active (SERVICE MODE)", 160 | 77: "Preheater has no communication with the ComfoAir unit (PREHEAT ERROR , 1081)", 161 | 78: "ComfoHood temperature error (HOOD_TEMP ERROR)", 162 | 79: "Postheater temperature error (POSTHEAT_TEMP ERROR)", 163 | 80: "Outdoor temperature of ComfoFond error (GROUND_HEAT_TEMP ERROR)", 164 | 81: "Bypass is in manual mode", 165 | 82: "ComfoCool is overheating", 166 | 83: "ComfoCool compressor error (CCOOL_COMPRESSOR ERROR)", 167 | 84: "ComfoCool room temperature sensor error (CCOOL_TEMP ERROR)", 168 | 85: "ComfoCool condensor temperature sensor error (CCOOL_TEMP ERROR)", 169 | 86: "ComfoCool supply air temperature sensor error (CCOOL_TEMP ERROR)", 170 | } 171 | 172 | 173 | class VentilationMode: 174 | """Enum for ventilation modes.""" 175 | 176 | MANUAL = "manual" 177 | AUTO = "auto" 178 | 179 | 180 | class VentilationSetting: 181 | """Enum for ventilation settings.""" 182 | 183 | AUTO = "auto" 184 | ON = "on" 185 | OFF = "off" 186 | 187 | 188 | class VentilationBalance: 189 | """Enum for ventilation balance.""" 190 | 191 | BALANCE = "balance" 192 | SUPPLY_ONLY = "supply_only" 193 | EXHAUST_ONLY = "exhaust_only" 194 | 195 | 196 | class VentilationTemperatureProfile: 197 | """Enum for ventilation temperature profiles.""" 198 | 199 | WARM = "warm" 200 | NORMAL = "normal" 201 | COOL = "cool" 202 | 203 | 204 | class VentilationSpeed: 205 | """Enum for ventilation speeds.""" 206 | 207 | AWAY = "away" 208 | LOW = "low" 209 | MEDIUM = "medium" 210 | HIGH = "high" 211 | 212 | 213 | class ComfoCoolMode: 214 | """Enum for ventilation settings.""" 215 | 216 | AUTO = "auto" 217 | OFF = "off" 218 | -------------------------------------------------------------------------------- /aiocomfoconnect/discovery.py: -------------------------------------------------------------------------------- 1 | """ Bridge discovery """ 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from typing import Any, List, Union 8 | 9 | from .bridge import Bridge 10 | from .protobuf import zehnder_pb2 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class BridgeDiscoveryProtocol(asyncio.DatagramProtocol): 16 | """UDP Protocol for the ComfoConnect LAN C bridge discovery.""" 17 | 18 | def __init__(self, target: str = None, timeout: int = 5): 19 | loop = asyncio.get_running_loop() 20 | 21 | self._bridges: List[Bridge] = [] 22 | self._target = target 23 | self._future = loop.create_future() 24 | self.transport = None 25 | self._timeout = loop.call_later(timeout, self.disconnect) 26 | 27 | def connection_made(self, transport: asyncio.transports.DatagramTransport): 28 | """Called when a connection is made.""" 29 | _LOGGER.debug("Socket has been created") 30 | self.transport = transport 31 | 32 | if self._target: 33 | _LOGGER.debug("Sending discovery request to %s:%d", self._target, Bridge.PORT) 34 | self.transport.sendto(b"\x0a\x00", (self._target, Bridge.PORT)) 35 | else: 36 | _LOGGER.debug("Sending discovery request to broadcast:%d", Bridge.PORT) 37 | self.transport.sendto(b"\x0a\x00", ("", Bridge.PORT)) 38 | 39 | def datagram_received(self, data: Union[bytes, str], addr: tuple[str | Any, int]): 40 | """Called when some datagram is received.""" 41 | if data == b"\x0a\x00": 42 | _LOGGER.debug("Ignoring discovery request from %s:%d", addr[0], addr[1]) 43 | return 44 | 45 | _LOGGER.debug("Data received from %s: %s", addr, data) 46 | 47 | # Decode the response 48 | parser = zehnder_pb2.DiscoveryOperation() # pylint: disable=no-member 49 | parser.ParseFromString(data) 50 | 51 | self._bridges.append(Bridge(host=parser.searchGatewayResponse.ipaddress, uuid=parser.searchGatewayResponse.uuid.hex())) 52 | 53 | # When we have passed a target, we only want to listen for that one 54 | if self._target: 55 | self._timeout.cancel() 56 | self.disconnect() 57 | 58 | def disconnect(self): 59 | """Disconnect the socket.""" 60 | if self.transport: 61 | self.transport.close() 62 | self._future.set_result(self._bridges) 63 | 64 | def get_bridges(self): 65 | """Return the discovered bridges.""" 66 | return self._future 67 | 68 | 69 | async def discover_bridges(host: str = None, timeout: int = 1, loop=None) -> List[Bridge]: 70 | """Discover a bridge by IP.""" 71 | 72 | if loop is None: 73 | loop = asyncio.get_event_loop() 74 | 75 | transport, protocol = await loop.create_datagram_endpoint( 76 | lambda: BridgeDiscoveryProtocol(host, timeout), 77 | local_addr=("0.0.0.0", 0), 78 | allow_broadcast=not host, 79 | ) 80 | 81 | try: 82 | return await protocol.get_bridges() 83 | finally: 84 | transport.close() 85 | -------------------------------------------------------------------------------- /aiocomfoconnect/exceptions.py: -------------------------------------------------------------------------------- 1 | """ Error definitions """ 2 | 3 | from __future__ import annotations 4 | 5 | 6 | class ComfoConnectError(Exception): 7 | """Base error for ComfoConnect""" 8 | 9 | def __init__(self, message): 10 | self.message = message 11 | 12 | 13 | class ComfoConnectBadRequest(ComfoConnectError): 14 | """Something was wrong with the request.""" 15 | 16 | 17 | class ComfoConnectInternalError(ComfoConnectError): 18 | """The request was ok, but the handling of the request failed.""" 19 | 20 | 21 | class ComfoConnectNotReachable(ComfoConnectError): 22 | """The backend cannot route the request.""" 23 | 24 | 25 | class ComfoConnectOtherSession(ComfoConnectError): 26 | """The gateway already has an active session with another client.""" 27 | 28 | 29 | class ComfoConnectNotAllowed(ComfoConnectError): 30 | """Request is not allowed.""" 31 | 32 | 33 | class ComfoConnectNoResources(ComfoConnectError): 34 | """Not enough resources, e.g., memory, to complete request""" 35 | 36 | 37 | class ComfoConnectNotExist(ComfoConnectError): 38 | """ComfoNet node or property does not exist.""" 39 | 40 | 41 | class ComfoConnectRmiError(ComfoConnectError): 42 | """The RMI failed, the message contains the error response.""" 43 | 44 | 45 | class AioComfoConnectNotConnected(Exception): 46 | """An error occurred because the bridge is not connected.""" 47 | 48 | 49 | class AioComfoConnectTimeout(Exception): 50 | """An error occurred because the bridge didn't reply in time.""" 51 | 52 | 53 | class BridgeNotFoundException(Exception): 54 | """Exception raised when no bridge is found.""" 55 | 56 | 57 | class UnknownActionException(Exception): 58 | """Exception raised when an unknown action is provided.""" 59 | -------------------------------------------------------------------------------- /aiocomfoconnect/properties.py: -------------------------------------------------------------------------------- 1 | """ Property definitions """ 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | 7 | from .const import UNIT_NODE, UNIT_NODECONFIGURATION, UNIT_TEMPHUMCONTROL, PdoType 8 | 9 | 10 | @dataclass 11 | class Property: 12 | """Dataclass for a Property""" 13 | 14 | unit: int 15 | subunit: int 16 | property_id: int 17 | property_type: int 18 | 19 | 20 | PROPERTY_SERIAL_NUMBER = Property(UNIT_NODE, 0x01, 0x04, PdoType.TYPE_CN_STRING) 21 | PROPERTY_FIRMWARE_VERSION = Property(UNIT_NODE, 0x01, 0x06, PdoType.TYPE_CN_UINT32) 22 | PROPERTY_MODEL = Property(UNIT_NODE, 0x01, 0x08, PdoType.TYPE_CN_STRING) 23 | PROPERTY_ARTICLE = Property(UNIT_NODE, 0x01, 0x0B, PdoType.TYPE_CN_STRING) 24 | PROPERTY_COUNTRY = Property(UNIT_NODE, 0x01, 0x0D, PdoType.TYPE_CN_STRING) 25 | PROPERTY_NAME = Property(UNIT_NODE, 0x01, 0x14, PdoType.TYPE_CN_STRING) 26 | 27 | PROPERTY_MAINTAINER_PASSWORD = Property(UNIT_NODECONFIGURATION, 0x01, 0x03, PdoType.TYPE_CN_STRING) 28 | 29 | PROPERTY_SENSOR_VENTILATION_TEMP_PASSIVE = Property(UNIT_TEMPHUMCONTROL, 0x01, 0x04, PdoType.TYPE_CN_UINT32) 30 | PROPERTY_SENSOR_VENTILATION_HUMIDITY_COMFORT = Property(UNIT_TEMPHUMCONTROL, 0x01, 0x06, PdoType.TYPE_CN_UINT32) 31 | PROPERTY_SENSOR_VENTILATION_HUMIDITY_PROTECTION = Property(UNIT_TEMPHUMCONTROL, 0x01, 0x07, PdoType.TYPE_CN_UINT32) 32 | -------------------------------------------------------------------------------- /aiocomfoconnect/protobuf/nanopb_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # NO CHECKED-IN PROTOBUF GENCODE 4 | # source: nanopb.proto 5 | # Protobuf Python Version: 6.31.0 6 | """Generated protocol buffer code.""" 7 | from google.protobuf import descriptor as _descriptor 8 | from google.protobuf import descriptor_pool as _descriptor_pool 9 | from google.protobuf import runtime_version as _runtime_version 10 | from google.protobuf import symbol_database as _symbol_database 11 | from google.protobuf.internal import builder as _builder 12 | _runtime_version.ValidateProtobufRuntimeVersion( 13 | _runtime_version.Domain.PUBLIC, 14 | 6, 15 | 31, 16 | 0, 17 | '', 18 | 'nanopb.proto' 19 | ) 20 | # @@protoc_insertion_point(imports) 21 | 22 | _sym_db = _symbol_database.Default() 23 | 24 | 25 | from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 26 | 27 | 28 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cnanopb.proto\x1a google/protobuf/descriptor.proto\"\x80\x02\n\rNanoPBOptions\x12\x10\n\x08max_size\x18\x01 \x01(\x05\x12\x11\n\tmax_count\x18\x02 \x01(\x05\x12&\n\x08int_size\x18\x07 \x01(\x0e\x32\x08.IntSize:\nIS_DEFAULT\x12$\n\x04type\x18\x03 \x01(\x0e\x32\n.FieldType:\nFT_DEFAULT\x12\x18\n\nlong_names\x18\x04 \x01(\x08:\x04true\x12\x1c\n\rpacked_struct\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1b\n\x0cskip_message\x18\x06 \x01(\x08:\x05\x66\x61lse\x12\x18\n\tno_unions\x18\x08 \x01(\x08:\x05\x66\x61lse\x12\r\n\x05msgid\x18\t \x01(\r*Z\n\tFieldType\x12\x0e\n\nFT_DEFAULT\x10\x00\x12\x0f\n\x0b\x46T_CALLBACK\x10\x01\x12\x0e\n\nFT_POINTER\x10\x04\x12\r\n\tFT_STATIC\x10\x02\x12\r\n\tFT_IGNORE\x10\x03*D\n\x07IntSize\x12\x0e\n\nIS_DEFAULT\x10\x00\x12\x08\n\x04IS_8\x10\x08\x12\t\n\x05IS_16\x10\x10\x12\t\n\x05IS_32\x10 \x12\t\n\x05IS_64\x10@:E\n\x0enanopb_fileopt\x12\x1c.google.protobuf.FileOptions\x18\xf2\x07 \x01(\x0b\x32\x0e.NanoPBOptions:G\n\rnanopb_msgopt\x12\x1f.google.protobuf.MessageOptions\x18\xf2\x07 \x01(\x0b\x32\x0e.NanoPBOptions:E\n\x0enanopb_enumopt\x12\x1c.google.protobuf.EnumOptions\x18\xf2\x07 \x01(\x0b\x32\x0e.NanoPBOptions:>\n\x06nanopb\x12\x1d.google.protobuf.FieldOptions\x18\xf2\x07 \x01(\x0b\x32\x0e.NanoPBOptionsB\x1a\n\x18\x66i.kapsi.koti.jpa.nanopb') 29 | 30 | _globals = globals() 31 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 32 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'nanopb_pb2', _globals) 33 | if not _descriptor._USE_C_DESCRIPTORS: 34 | _globals['DESCRIPTOR']._loaded_options = None 35 | _globals['DESCRIPTOR']._serialized_options = b'\n\030fi.kapsi.koti.jpa.nanopb' 36 | _globals['_FIELDTYPE']._serialized_start=309 37 | _globals['_FIELDTYPE']._serialized_end=399 38 | _globals['_INTSIZE']._serialized_start=401 39 | _globals['_INTSIZE']._serialized_end=469 40 | _globals['_NANOPBOPTIONS']._serialized_start=51 41 | _globals['_NANOPBOPTIONS']._serialized_end=307 42 | # @@protoc_insertion_point(module_scope) 43 | -------------------------------------------------------------------------------- /aiocomfoconnect/sensors.py: -------------------------------------------------------------------------------- 1 | """ Sensor definitions. """ 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import Callable, Dict 7 | 8 | from .const import PdoType 9 | from .util import calculate_airflow_constraints 10 | 11 | # Sensors 12 | SENSOR_AIRFLOW_CONSTRAINTS = 230 13 | SENSOR_ANALOG_INPUT_1 = 369 14 | SENSOR_ANALOG_INPUT_2 = 370 15 | SENSOR_ANALOG_INPUT_3 = 371 16 | SENSOR_ANALOG_INPUT_4 = 372 17 | SENSOR_AVOIDED_COOLING = 216 18 | SENSOR_AVOIDED_COOLING_TOTAL = 218 19 | SENSOR_AVOIDED_COOLING_TOTAL_YEAR = 217 20 | SENSOR_AVOIDED_HEATING = 213 21 | SENSOR_AVOIDED_HEATING_TOTAL = 215 22 | SENSOR_AVOIDED_HEATING_TOTAL_YEAR = 214 23 | SENSOR_BYPASS_ACTIVATION_STATE = 66 24 | SENSOR_BYPASS_OVERRIDE = 338 25 | SENSOR_BYPASS_STATE = 227 26 | SENSOR_CHANGING_FILTERS = 18 27 | SENSOR_COMFOFOND_GHE_PRESENT = 419 28 | SENSOR_COMFOFOND_GHE_STATE = 418 29 | SENSOR_COMFOFOND_TEMP_GROUND = 417 30 | SENSOR_COMFOFOND_TEMP_OUTDOOR = 416 31 | SENSOR_COMFORTCONTROL_MODE = 225 32 | SENSOR_DAYS_TO_REPLACE_FILTER = 192 33 | SENSOR_DEVICE_STATE = 16 34 | SENSOR_FAN_EXHAUST_DUTY = 117 35 | SENSOR_FAN_EXHAUST_FLOW = 119 36 | SENSOR_FAN_EXHAUST_SPEED = 121 37 | SENSOR_FAN_MODE_EXHAUST = 71 38 | SENSOR_FAN_MODE_EXHAUST_2 = 55 39 | SENSOR_FAN_MODE_EXHAUST_3 = 343 40 | SENSOR_FAN_MODE_SUPPLY = 70 41 | SENSOR_FAN_MODE_SUPPLY_2 = 54 42 | SENSOR_FAN_MODE_SUPPLY_3 = 342 43 | SENSOR_FAN_SPEED_MODE = 65 44 | SENSOR_FAN_SPEED_MODE_MODULATED = 226 45 | SENSOR_FAN_SUPPLY_DUTY = 118 46 | SENSOR_FAN_SUPPLY_FLOW = 120 47 | SENSOR_FAN_SUPPLY_SPEED = 122 48 | SENSOR_FROSTPROTECTION_UNBALANCE = 228 49 | SENSOR_HUMIDITY_AFTER_PREHEATER = 293 50 | SENSOR_HUMIDITY_EXHAUST = 291 51 | SENSOR_HUMIDITY_EXTRACT = 290 52 | SENSOR_HUMIDITY_OUTDOOR = 292 53 | SENSOR_HUMIDITY_SUPPLY = 294 54 | SENSOR_NEXT_CHANGE_BYPASS = 82 55 | SENSOR_NEXT_CHANGE_FAN = 81 56 | SENSOR_NEXT_CHANGE_FAN_EXHAUST = 87 57 | SENSOR_NEXT_CHANGE_FAN_SUPPLY = 86 58 | SENSOR_OPERATING_MODE = 56 59 | SENSOR_OPERATING_MODE_2 = 49 60 | SENSOR_POWER_USAGE = 128 61 | SENSOR_POWER_USAGE_TOTAL = 130 62 | SENSOR_POWER_USAGE_TOTAL_YEAR = 129 63 | SENSOR_PREHEATER_POWER = 146 64 | SENSOR_PREHEATER_POWER_TOTAL = 145 65 | SENSOR_PREHEATER_POWER_TOTAL_YEAR = 144 66 | SENSOR_PROFILE_TEMPERATURE = 67 67 | SENSOR_RF_PAIRING_MODE = 176 68 | SENSOR_RMOT = 209 69 | SENSOR_SEASON_COOLING_ACTIVE = 211 70 | SENSOR_SEASON_HEATING_ACTIVE = 210 71 | SENSOR_TARGET_TEMPERATURE = 212 72 | SENSOR_TEMPERATURE_EXHAUST = 275 73 | SENSOR_TEMPERATURE_EXTRACT = 274 74 | SENSOR_TEMPERATURE_OUTDOOR = 276 75 | SENSOR_TEMPERATURE_SUPPLY = 221 76 | SENSOR_UNIT_AIRFLOW = 224 77 | SENSOR_UNIT_TEMPERATURE = 208 78 | SENSOR_COMFOCOOL_STATE = 784 79 | SENSOR_COMFOCOOL_CONDENSOR_TEMP = 802 80 | 81 | UNIT_WATT = "W" 82 | UNIT_KWH = "kWh" 83 | UNIT_VOLT = "V" 84 | UNIT_CELCIUS = "°C" 85 | UNIT_PERCENT = "%" 86 | UNIT_RPM = "rpm" 87 | UNIT_M3H = "m³/h" 88 | 89 | 90 | @dataclass 91 | class Sensor: 92 | """Dataclass for a Sensor""" 93 | 94 | name: str 95 | unit: str | None 96 | id: int # pylint: disable=invalid-name 97 | type: int 98 | value_fn: Callable[[int], any] = None 99 | 100 | 101 | # For more information, see PROTOCOL-PDO.md 102 | SENSORS: Dict[int, Sensor] = { 103 | SENSOR_DEVICE_STATE: Sensor("Device State", None, 16, PdoType.TYPE_CN_UINT8), 104 | SENSOR_CHANGING_FILTERS: Sensor("Changing filters", None, 18, PdoType.TYPE_CN_UINT8), 105 | 33: Sensor("sensor_33", None, 33, PdoType.TYPE_CN_UINT8), 106 | 37: Sensor("sensor_37", None, 37, PdoType.TYPE_CN_UINT8), 107 | SENSOR_OPERATING_MODE_2: Sensor("Operating Mode", None, 49, PdoType.TYPE_CN_UINT8), 108 | 53: Sensor("sensor_53", None, 53, PdoType.TYPE_CN_UINT8), 109 | SENSOR_FAN_MODE_SUPPLY_2: Sensor("Supply Fan Mode", None, 54, PdoType.TYPE_CN_UINT8), 110 | SENSOR_FAN_MODE_EXHAUST_2: Sensor("Exhaust Fan Mode", None, 55, PdoType.TYPE_CN_UINT8), 111 | SENSOR_OPERATING_MODE: Sensor("Operating Mode", None, 56, PdoType.TYPE_CN_UINT8), 112 | SENSOR_FAN_SPEED_MODE: Sensor("Fan Speed", None, 65, PdoType.TYPE_CN_UINT8), 113 | SENSOR_BYPASS_ACTIVATION_STATE: Sensor("Bypass Activation State", None, 66, PdoType.TYPE_CN_UINT8), 114 | SENSOR_PROFILE_TEMPERATURE: Sensor("Temperature Profile Mode", None, 67, PdoType.TYPE_CN_UINT8), 115 | SENSOR_FAN_MODE_SUPPLY: Sensor("Supply Fan Mode", None, 70, PdoType.TYPE_CN_UINT8), 116 | SENSOR_FAN_MODE_EXHAUST: Sensor("Exhaust Fan Mode", None, 71, PdoType.TYPE_CN_UINT8), 117 | SENSOR_NEXT_CHANGE_FAN: Sensor("Fan Speed Next Change", None, 81, PdoType.TYPE_CN_UINT32), 118 | SENSOR_NEXT_CHANGE_BYPASS: Sensor("Bypass Next Change", None, 82, PdoType.TYPE_CN_UINT32), 119 | 85: Sensor("sensor_85", None, 85, PdoType.TYPE_CN_UINT32), 120 | SENSOR_NEXT_CHANGE_FAN_SUPPLY: Sensor("Supply Fan Next Change", None, 86, PdoType.TYPE_CN_UINT32), 121 | SENSOR_NEXT_CHANGE_FAN_EXHAUST: Sensor("Exhaust Fan Next Change", None, 87, PdoType.TYPE_CN_UINT32), 122 | SENSOR_FAN_EXHAUST_DUTY: Sensor("Exhaust Fan Duty", UNIT_PERCENT, 117, PdoType.TYPE_CN_UINT8), 123 | SENSOR_FAN_SUPPLY_DUTY: Sensor("Supply Fan Duty", UNIT_PERCENT, 118, PdoType.TYPE_CN_UINT8), 124 | SENSOR_FAN_EXHAUST_FLOW: Sensor("Exhaust Fan Flow", UNIT_M3H, 119, PdoType.TYPE_CN_UINT16), 125 | SENSOR_FAN_SUPPLY_FLOW: Sensor("Supply Fan Flow", UNIT_M3H, 120, PdoType.TYPE_CN_UINT16), 126 | SENSOR_FAN_EXHAUST_SPEED: Sensor("Exhaust Fan Speed", UNIT_RPM, 121, PdoType.TYPE_CN_UINT16), 127 | SENSOR_FAN_SUPPLY_SPEED: Sensor("Supply Fan Speed", UNIT_RPM, 122, PdoType.TYPE_CN_UINT16), 128 | SENSOR_POWER_USAGE: Sensor("Power Usage", UNIT_WATT, 128, PdoType.TYPE_CN_UINT16), 129 | SENSOR_POWER_USAGE_TOTAL_YEAR: Sensor("Power Usage (year)", UNIT_KWH, 129, PdoType.TYPE_CN_UINT16), 130 | SENSOR_POWER_USAGE_TOTAL: Sensor("Power Usage (total)", UNIT_KWH, 130, PdoType.TYPE_CN_UINT16), 131 | SENSOR_PREHEATER_POWER_TOTAL_YEAR: Sensor("Preheater Power Usage (year)", UNIT_KWH, 144, PdoType.TYPE_CN_UINT16), 132 | SENSOR_PREHEATER_POWER_TOTAL: Sensor("Preheater Power Usage (total)", UNIT_KWH, 145, PdoType.TYPE_CN_UINT16), 133 | SENSOR_PREHEATER_POWER: Sensor("Preheater Power Usage", UNIT_WATT, 146, PdoType.TYPE_CN_UINT16), 134 | SENSOR_RF_PAIRING_MODE: Sensor("RF Pairing Mode", None, 176, PdoType.TYPE_CN_UINT8), 135 | SENSOR_DAYS_TO_REPLACE_FILTER: Sensor("Days remaining to replace the filter", None, 192, PdoType.TYPE_CN_UINT16), 136 | SENSOR_UNIT_TEMPERATURE: Sensor("Device Temperature Unit", None, 208, PdoType.TYPE_CN_UINT8, lambda x: "celcius" if x == 0 else "farenheit"), 137 | SENSOR_RMOT: Sensor("Running Mean Outdoor Temperature (RMOT)", UNIT_CELCIUS, 209, PdoType.TYPE_CN_INT16, lambda x: x / 10), 138 | SENSOR_SEASON_HEATING_ACTIVE: Sensor("Heating Season is active", None, 210, PdoType.TYPE_CN_BOOL, bool), 139 | SENSOR_SEASON_COOLING_ACTIVE: Sensor("Cooling Season is active", None, 211, PdoType.TYPE_CN_BOOL, bool), 140 | SENSOR_TARGET_TEMPERATURE: Sensor("Target Temperature", UNIT_CELCIUS, 212, PdoType.TYPE_CN_INT16, lambda x: x / 10), 141 | SENSOR_AVOIDED_HEATING: Sensor("Avoided Heating Power Usage", UNIT_WATT, 213, PdoType.TYPE_CN_UINT16), 142 | SENSOR_AVOIDED_HEATING_TOTAL_YEAR: Sensor("Avoided Heating Power Usage (year)", UNIT_KWH, 214, PdoType.TYPE_CN_UINT16), 143 | SENSOR_AVOIDED_HEATING_TOTAL: Sensor("Avoided Heating Power Usage (total)", UNIT_KWH, 215, PdoType.TYPE_CN_UINT16), 144 | SENSOR_AVOIDED_COOLING: Sensor("Avoided Cooling Power Usage", UNIT_WATT, 216, PdoType.TYPE_CN_UINT16), 145 | SENSOR_AVOIDED_COOLING_TOTAL_YEAR: Sensor("Avoided Cooling Power Usage (year)", UNIT_KWH, 217, PdoType.TYPE_CN_UINT16), 146 | SENSOR_AVOIDED_COOLING_TOTAL: Sensor("Avoided Cooling Power Usage (total)", UNIT_KWH, 218, PdoType.TYPE_CN_UINT16), 147 | 219: Sensor("sensor_219", None, 219, PdoType.TYPE_CN_UINT16), 148 | 220: Sensor("Outdoor Air Temperature (?)", None, 220, PdoType.TYPE_CN_INT16, lambda x: x / 10), 149 | SENSOR_TEMPERATURE_SUPPLY: Sensor("Supply Air Temperature", UNIT_CELCIUS, 221, PdoType.TYPE_CN_INT16, lambda x: x / 10), 150 | SENSOR_UNIT_AIRFLOW: Sensor("Device Airflow Unit", None, 224, PdoType.TYPE_CN_UINT8, lambda x: "m3ph" if x == 3 else "lps"), 151 | SENSOR_COMFORTCONTROL_MODE: Sensor("Sensor based ventilation mode", None, 225, PdoType.TYPE_CN_UINT8), 152 | SENSOR_FAN_SPEED_MODE_MODULATED: Sensor("Fan Speed (modulated)", None, 226, PdoType.TYPE_CN_UINT16), 153 | SENSOR_BYPASS_STATE: Sensor("Bypass State", UNIT_PERCENT, 227, PdoType.TYPE_CN_UINT8), 154 | SENSOR_FROSTPROTECTION_UNBALANCE: Sensor("frostprotection_unbalance", None, 228, PdoType.TYPE_CN_UINT8), 155 | SENSOR_AIRFLOW_CONSTRAINTS: Sensor("Airflow constraints", None, 230, PdoType.TYPE_CN_INT64, calculate_airflow_constraints), 156 | SENSOR_TEMPERATURE_EXTRACT: Sensor("Extract Air Temperature", UNIT_CELCIUS, 274, PdoType.TYPE_CN_INT16, lambda x: x / 10), 157 | SENSOR_TEMPERATURE_EXHAUST: Sensor("Exhaust Air Temperature", UNIT_CELCIUS, 275, PdoType.TYPE_CN_INT16, lambda x: x / 10), 158 | SENSOR_TEMPERATURE_OUTDOOR: Sensor("Outdoor Air Temperature", UNIT_CELCIUS, 276, PdoType.TYPE_CN_INT16, lambda x: x / 10), 159 | 277: Sensor("Outdoor Air Temperature (?)", UNIT_CELCIUS, 277, PdoType.TYPE_CN_INT16, lambda x: x / 10), 160 | 278: Sensor("Supply Air Temperature (?)", UNIT_CELCIUS, 278, PdoType.TYPE_CN_INT16, lambda x: x / 10), 161 | SENSOR_HUMIDITY_EXTRACT: Sensor("Extract Air Humidity", UNIT_PERCENT, 290, PdoType.TYPE_CN_UINT8), 162 | SENSOR_HUMIDITY_EXHAUST: Sensor("Exhaust Air Humidity", UNIT_PERCENT, 291, PdoType.TYPE_CN_UINT8), 163 | SENSOR_HUMIDITY_OUTDOOR: Sensor("Outdoor Air Humidity", UNIT_PERCENT, 292, PdoType.TYPE_CN_UINT8), 164 | SENSOR_HUMIDITY_AFTER_PREHEATER: Sensor("Outdoor Air Humidity (after preheater)", UNIT_PERCENT, 293, PdoType.TYPE_CN_UINT8), 165 | SENSOR_HUMIDITY_SUPPLY: Sensor("Supply Air Humidity", UNIT_PERCENT, 294, PdoType.TYPE_CN_UINT8), 166 | 321: Sensor("sensor_321", None, 321, PdoType.TYPE_CN_UINT16), 167 | 325: Sensor("sensor_325", None, 325, PdoType.TYPE_CN_UINT16), 168 | 337: Sensor("sensor_337", None, 337, PdoType.TYPE_CN_UINT32), 169 | SENSOR_BYPASS_OVERRIDE: Sensor("Bypass Override", None, 338, PdoType.TYPE_CN_UINT32), 170 | 341: Sensor("sensor_341", None, 341, PdoType.TYPE_CN_UINT32), 171 | SENSOR_FAN_MODE_SUPPLY_3: Sensor("Supply Fan Mode", None, 342, PdoType.TYPE_CN_UINT32), 172 | SENSOR_FAN_MODE_EXHAUST_3: Sensor("Exhaust Fan Mode", None, 343, PdoType.TYPE_CN_UINT32), 173 | SENSOR_ANALOG_INPUT_1: Sensor("Analog Input 1", UNIT_VOLT, 369, PdoType.TYPE_CN_UINT8, lambda x: x / 10), 174 | SENSOR_ANALOG_INPUT_2: Sensor("Analog Input 2", UNIT_VOLT, 370, PdoType.TYPE_CN_UINT8, lambda x: x / 10), 175 | SENSOR_ANALOG_INPUT_3: Sensor("Analog Input 3", UNIT_VOLT, 371, PdoType.TYPE_CN_UINT8, lambda x: x / 10), 176 | SENSOR_ANALOG_INPUT_4: Sensor("Analog Input 4", UNIT_VOLT, 372, PdoType.TYPE_CN_UINT8, lambda x: x / 10), 177 | 384: Sensor("sensor_384", None, 384, PdoType.TYPE_CN_INT16, lambda x: x / 10), 178 | 386: Sensor("sensor_386", None, 386, PdoType.TYPE_CN_BOOL, bool), 179 | 400: Sensor("sensor_400", None, 400, PdoType.TYPE_CN_INT16, lambda x: x / 10), 180 | 401: Sensor("sensor_401", None, 401, PdoType.TYPE_CN_UINT8), 181 | 402: Sensor("sensor_402", None, 402, PdoType.TYPE_CN_BOOL, bool), 182 | SENSOR_COMFOFOND_TEMP_OUTDOOR: Sensor("ComfoFond Outdoor Air Temperature", None, 416, PdoType.TYPE_CN_INT16, lambda x: x / 10), 183 | SENSOR_COMFOFOND_TEMP_GROUND: Sensor("ComfoFond Ground Temperature", None, 417, PdoType.TYPE_CN_INT16, lambda x: x / 10), 184 | SENSOR_COMFOFOND_GHE_STATE: Sensor("ComfoFond GHE State Percentage", None, 418, PdoType.TYPE_CN_UINT8), 185 | SENSOR_COMFOFOND_GHE_PRESENT: Sensor("ComfoFond GHE Present", None, 419, PdoType.TYPE_CN_BOOL, bool), 186 | SENSOR_COMFOCOOL_STATE: Sensor("ComfoCool State", None, 784, PdoType.TYPE_CN_UINT8), 187 | 785: Sensor("sensor_785", None, 785, PdoType.TYPE_CN_BOOL), 188 | SENSOR_COMFOCOOL_CONDENSOR_TEMP: Sensor("ComfoCool Condensor Temperature", None, 802, PdoType.TYPE_CN_INT16, lambda x: x / 10), 189 | } 190 | -------------------------------------------------------------------------------- /aiocomfoconnect/util.py: -------------------------------------------------------------------------------- 1 | """ Helper methods. """ 2 | 3 | from __future__ import annotations 4 | 5 | from aiocomfoconnect.const import PdoType 6 | 7 | 8 | def bytestring(arr): 9 | """Join an array of bytes into a bytestring. Unlike `bytes()`, this method supports a mixed array with integers and bytes.""" 10 | return b"".join([i if isinstance(i, bytes) else bytes([i]) for i in arr]) 11 | 12 | 13 | def bytearray_to_bits(arr): 14 | """Convert a bytearray to a list of set bits.""" 15 | bits = [] 16 | j = 0 17 | for byte in arr: 18 | for i in range(8): 19 | if byte & (1 << i): 20 | bits.append(j) 21 | j += 1 22 | return bits 23 | 24 | 25 | def uint_to_bits(value): 26 | """Convert an unsigned integer to a list of set bits.""" 27 | bits = [] 28 | j = 0 29 | for i in range(64): 30 | if value & (1 << i): 31 | bits.append(j) 32 | j += 1 33 | return bits 34 | 35 | 36 | def version_decode(version): 37 | """Decode the version number to a string.""" 38 | v_1 = (version >> 30) & 3 39 | v_2 = (version >> 20) & 1023 40 | v_3 = (version >> 10) & 1023 41 | v_4 = version & 1023 42 | 43 | if v_1 == 0: 44 | v_1 = "U" 45 | elif v_1 == 1: 46 | v_1 = "D" 47 | elif v_1 == 2: 48 | v_1 = "P" 49 | elif v_1 == 3: 50 | v_1 = "R" 51 | 52 | return f"{v_1}{v_2}.{v_3}.{v_4}" 53 | 54 | 55 | def pdo_to_can(pdo, node_id=1): 56 | """Convert a PDO-ID to a CAN-ID.""" 57 | return ((pdo << 14) + 0x40 + node_id).to_bytes(4, byteorder="big").hex() 58 | 59 | 60 | def can_to_pdo(can, node_id=1): 61 | """Convert a CAN-ID to a PDO-ID.""" 62 | return (int(can, 16) - 0x40 - node_id) >> 14 63 | 64 | 65 | def calculate_airflow_constraints(value): 66 | """Calculate the airflow constraints based on the bitshift value.""" 67 | bits = uint_to_bits(value) 68 | if 45 not in bits: 69 | return None 70 | 71 | constraints = [] 72 | if 2 in bits or 3 in bits: 73 | constraints.append("Resistance") 74 | if 4 in bits: 75 | constraints.append("PreheaterNegative") 76 | if 5 in bits or 7 in bits: 77 | constraints.append("NoiseGuard") 78 | if 6 in bits or 8 in bits: 79 | constraints.append("ResistanceGuard") 80 | if 9 in bits: 81 | constraints.append("FrostProtection") 82 | if 10 in bits: 83 | constraints.append("Bypass") 84 | if 12 in bits: 85 | constraints.append("AnalogInput1") 86 | if 13 in bits: 87 | constraints.append("AnalogInput2") 88 | if 14 in bits: 89 | constraints.append("AnalogInput3") 90 | if 15 in bits: 91 | constraints.append("AnalogInput4") 92 | if 16 in bits: 93 | constraints.append("Hood") 94 | if 18 in bits: 95 | constraints.append("AnalogPreset") 96 | if 19 in bits: 97 | constraints.append("ComfoCool") 98 | if 22 in bits: 99 | constraints.append("PreheaterPositive") 100 | if 23 in bits: 101 | constraints.append("RFSensorFlowPreset") 102 | if 24 in bits: 103 | constraints.append("RFSensorFlowProportional") 104 | if 25 in bits: 105 | constraints.append("TemperatureComfort") 106 | if 26 in bits: 107 | constraints.append("HumidityComfort") 108 | if 27 in bits: 109 | constraints.append("HumidityProtection") 110 | if 47 in bits: 111 | constraints.append("CO2ZoneX1") 112 | if 48 in bits: 113 | constraints.append("CO2ZoneX2") 114 | if 49 in bits: 115 | constraints.append("CO2ZoneX3") 116 | if 50 in bits: 117 | constraints.append("CO2ZoneX4") 118 | if 51 in bits: 119 | constraints.append("CO2ZoneX5") 120 | if 52 in bits: 121 | constraints.append("CO2ZoneX6") 122 | if 53 in bits: 123 | constraints.append("CO2ZoneX7") 124 | if 54 in bits: 125 | constraints.append("CO2ZoneX8") 126 | 127 | return constraints 128 | 129 | 130 | def encode_pdo_value(value: int, pdo_type: PdoType) -> bytes: 131 | """Encode a PDO value to the raw equivalent.""" 132 | match pdo_type: 133 | case PdoType.TYPE_CN_BOOL: 134 | return bool(value).to_bytes() 135 | case PdoType.TYPE_CN_UINT8 | PdoType.TYPE_CN_UINT16 | PdoType.TYPE_CN_UINT32: 136 | signed = False 137 | case PdoType.TYPE_CN_INT8 | PdoType.TYPE_CN_INT16 | PdoType.TYPE_CN_INT64: 138 | signed = True 139 | case _: 140 | raise ValueError("Type is not supported at this time", pdo_type) 141 | 142 | match pdo_type: 143 | case PdoType.TYPE_CN_INT8 | PdoType.TYPE_CN_UINT8: 144 | length = 1 145 | case PdoType.TYPE_CN_INT16 | PdoType.TYPE_CN_UINT16: 146 | length = 2 147 | case PdoType.TYPE_CN_UINT32: 148 | length = 4 149 | case PdoType.TYPE_CN_INT64: 150 | length = 8 151 | case _: 152 | raise ValueError("Type is not supported at this time", pdo_type) 153 | 154 | return value.to_bytes(length, "little", signed=signed) 155 | -------------------------------------------------------------------------------- /docs/PROTOCOL-PDO.md: -------------------------------------------------------------------------------- 1 | # PDO sensors 2 | 3 | ## PDO data types 4 | 5 | Numbers are stored in little endian format. 6 | 7 | | type | description | remark | 8 | |------|-------------|---------------------------| 9 | | 0 | CN_BOOL | `00` (false), `01` (true) | 10 | | 1 | CN_UINT8 | `00` (0) until `ff` (255) | 11 | | 2 | CN_UINT16 | `3412` = 1234 | 12 | | 3 | CN_UINT32 | `7856 3412` = 12345678 | 13 | | 5 | CN_INT8 | | 14 | | 6 | CN_INT16 | `3412` = 1234 | 15 | | 8 | CN_INT64 | | 16 | | 9 | CN_STRING | | 17 | | 10 | CN_TIME | | 18 | | 11 | CN_VERSION | | 19 | 20 | # Overview of known sensors 21 | 22 | | pdid | type | description | examples | 23 | |------|-----------|--------------------------------------------------|------------------------------------------------------------------------------------------------------------------| 24 | | 16 | CN_UINT8 | Device state | 0=init, 1=normal, 2=filterwizard, 3=commissioning, 4=supplierfactory, 5=zehnderfactory, 6=standby, 7=away, 8=DFC | 25 | | 17 | CN_UINT8 | ?? ROOM_T10 | | 26 | | 18 | CN_UINT8 | Changing filters | 1=active, 2=changing filter | 27 | | 33 | CN_UINT8 | ?? Preset | 0, 1 | 28 | | 35 | CN_UINT8 | ?? Temperature Profile | 1 | 29 | | 36 | CN_UINT8 | ?? STANDBY | | 30 | | 37 | CN_UINT8 | | 0 | 31 | | 40 | CN_UINT8 | ?? MANUALMODE | | 32 | | 42 | CN_UINT8 | | 0 | 33 | | 49 | CN_UINT8 | Operating mode | -1=auto, 1=limited manual, 5=unlimited manual, 6=boost | 34 | | 50 | CN_UINT8 | ?? Bypass | | 35 | | 51 | CN_UINT8 | ?? Temperature Profile | | 36 | | 52 | CN_UINT8 | ?? STANDBY | | 37 | | 53 | CN_UINT8 | ?? COMFOCOOLOFF | -1, 0, 1 | 38 | | 54 | CN_UINT8 | Supply Fan Mode | -1=balanced, 1=supply only | 39 | | 55 | CN_UINT8 | Exhaust Fan Mode | -1=balanced, 1=exhaust only | 40 | | 56 | CN_UINT8 | Manual Mode | -1=auto, 1=unlimited manual | 41 | | 57 | CN_UINT8 | | -1, 0 | 42 | | 58 | CN_UINT8 | | -1, 0 | 43 | | 65 | CN_UINT8 | Fans: Fan speed setting | 0=away, 1=low, 2=medium, 3=high | 44 | | 66 | CN_UINT8 | Bypass activation mode | 0=auto, 1=full, 2=none | 45 | | 67 | CN_UINT8 | Temperature Profile | 0=normal, 1=cold, 2=warm | 46 | | 68 | CN_UINT8 | ?? STANDBY | 0 | 47 | | 69 | | ?? COMFOCOOLOFF | 0=auto, 1=off | 48 | | 70 | CN_UINT8 | Supply Fan Mode | 0=balanced, 1=supply only | 49 | | 71 | CN_UINT8 | Exhaust Fan Mode | 0=balanced, 1=exhaust only | 50 | | 72 | CN_UINT8 | ?? MANUALMODE | | 51 | | 73 | CN_UINT8 | | 0 | 52 | | 74 | CN_UINT8 | | 0 | 53 | | 81 | CN_UINT32 | Fan Speed Next Change | -1=no change, else countdown in seconds | 54 | | 82 | CN_UINT32 | Bypass Next Change | -1=no change, else countdown in seconds | 55 | | 85 | CN_UINT32 | ComfoCool Next Change | -1=no change, else countdown in seconds | 56 | | 86 | CN_UINT32 | Supply Fan Next Change | -1=no change, else countdown in seconds | 57 | | 87 | CN_UINT32 | Exhaust Fan Next Change | -1=no change, else countdown in seconds | 58 | | 88 | CN_UINT32 | ?? MANUALMODE | | 59 | | 89 | CN_UINT32 | | -1, 0 | 60 | | 90 | CN_UINT32 | | -1, 0 | 61 | | 96 | CN_BOOL | | | 62 | | 115 | CN_BOOL | ?? EXHAUST_F12 | | 63 | | 116 | CN_BOOL | ?? SUPPLY_F22 | | 64 | | 117 | CN_UINT8 | Fans: Exhaust fan duty | value in % (28%) | 65 | | 118 | CN_UINT8 | Fans: Supply fan duty | value in % (29%) | 66 | | 119 | CN_UINT16 | Fans: Exhaust fan flow | value in m³/h (110 m³/h) | 67 | | 120 | CN_UINT16 | Fans: Supply fan flow | value in m³/h (105 m³/h | 68 | | 121 | CN_UINT16 | Fans: Exhaust fan speed | value in rpm (1069 rpm) | 69 | | 122 | CN_UINT16 | Fans: Supply fan speed | value in rpm (1113 rpm) | 70 | | 128 | CN_UINT16 | Power Consumption: Current Ventilation | value in Watt (15 W) | 71 | | 129 | CN_UINT16 | Power Consumption: Total year-to-date | value in kWh (23 kWh) | 72 | | 130 | CN_UINT16 | Power Consumption: Total from start | value in kWh (23 kWh) | 73 | | 144 | CN_UINT16 | Preheater Power Consumption: Total year-to-date | value in kWh (23 kWh) | 74 | | 145 | CN_UINT16 | Preheater Power Consumption: Total from start | value in kWh (23 kWh) | 75 | | 146 | CN_UINT16 | Preheater Power Consumption: Current Ventilation | value in Watt (15 W) | 76 | | 176 | CN_UINT8 | RF Pairing Mode | 0=not running, 1=running, 2=done, 3=failed, 4=aborted | 77 | | 192 | CN_UINT16 | Days left before filters must be replaced | value in days (130 days) | 78 | | 208 | CN_UINT8 | Device Temperature Unit | 0=celsius, 1=farenheit | 79 | | 209 | CN_INT16 | Running Mean Outdoor Temperature (RMOT) | value in °C (117 -> 11.7 °C) | 80 | | 210 | CN_BOOL | Heating Season is active | 0=inactive, 1=active | 81 | | 211 | CN_BOOL | Cooling Season is active | 0=inactive, 1=active | 82 | | 212 | CN_UINT8 | Temperature profile target | value in °C (238 -> 23.8 °C) | 83 | | 213 | CN_UINT16 | Avoided Heating: Avoided actual | value in Watt (441 -> 4.41 W) | 84 | | 214 | CN_UINT16 | Avoided Heating: Avoided year-to-date | value in kWh (477 kWh) | 85 | | 215 | CN_UINT16 | Avoided Heating: Avoided total | value in kWh (477 kWh) | 86 | | 216 | CN_UINT16 | Avoided Cooling: Avoided actual | value in Watt (441 -> 4.41 W) | 87 | | 217 | CN_UINT16 | Avoided Cooling: Avoided year-to-date | value in kWh (477 kWh) | 88 | | 218 | CN_UINT16 | Avoided Cooling: Avoided total | value in kWh (477 kWh) | 89 | | 219 | CN_UINT16 | | 0 | 90 | | 220 | CN_INT16 | ?? Outdoor Air Temperature (Preheated) | value in °C (75 -> 7.5 °C) | 91 | | 221 | CN_INT16 | ?? Temperature: Supply Air (PostHeated) | value in °C (170 -> 17.0 °C) | 92 | | 224 | CN_UINT8 | Device Airflow Unit | 1=kg/h, 2=l/s, 3=m³/h | 93 | | 225 | CN_UINT8 | Sensor based ventilation mode | 0=disabled, 1=active, 2=overruling | 94 | | 226 | CN_UINT16 | Fan Speed (modulated) | 0, 100, 200, 300 (0-300 when modulating and PDO 225=2) | 95 | | 227 | CN_UINT8 | Bypass state | value in % (100% = fully open) | 96 | | 228 | CN_UINT8 | ?? FrostProtectionUnbalance | 0 | 97 | | 229 | CN_BOOL | | 1 | 98 | | 230 | CN_INT64 | Ventilation Constraints Bitset | See calculate_airflow_constraints() | 99 | | 256 | CN_UINT8 | | 1=basic, 2=advanced, 3=installer | 100 | | 257 | CN_UINT8 | | | 101 | | 274 | CN_INT16 | Temperature: Extract Air | value in °C (171 -> 17.1 °C) | 102 | | 275 | CN_INT16 | Temperature: Exhaust Air | value in °C (86 -> 8.6 °C) | 103 | | 276 | CN_INT16 | Temperature: Outdoor Air | value in °C (60 -> 6.0 °C) | 104 | | 277 | CN_INT16 | ?? Temperature: Outdoor Air (Preheated?) | value in °C (60 -> 6.0 °C) | 105 | | 278 | CN_INT16 | ?? Temperature: Supply Air (Preheated?) | value in °C (184 -> 18.4 °C) | 106 | | 290 | CN_UINT8 | Humidity: Extract Air | value in % (49%) | 107 | | 291 | CN_UINT8 | Humidity: Exhaust Air | value in % (87%) | 108 | | 292 | CN_UINT8 | Humidity: Outdoor Air | value in % (67%) | 109 | | 293 | CN_UINT8 | Humidity: Preheated Outdoor Air | value in % (67%) | 110 | | 294 | CN_UINT8 | Humidity: Supply Air | value in % (35%) | 111 | | 321 | CN_UINT16 | | 3 | 112 | | 325 | CN_UINT16 | ?? COMFOCOOLOFF | 0, 1, 3 | 113 | | 328 | CN_UINT16 | ?? MANUALMODE | | 114 | | 330 | CN_UINT16 | | 0, 1 | 115 | | 337 | CN_UINT32 | ?? PRESET | 0, 2, 32, 34 | 116 | | 338 | CN_UINT32 | Bypass Override | 0=auto, 2=overriden | 117 | | 341 | CN_UINT32 | ?? COMFOCOOLOFF | 00000000, 02000000 | 118 | | 342 | CN_UINT32 | Supply Fan Mode | 0 = balanced, 2 = supply only | 119 | | 343 | CN_UINT32 | Exhaust Fan Mode | 0 = balanced, 2 = exhaust only | 120 | | 344 | CN_UINT32 | ?? MANUAL MODE | | 121 | | 345 | CN_UINT32 | | 0 | 122 | | 346 | CN_UINT32 | | 0 | 123 | | 369 | CN_UINT8 | Analog Input 0-10V 1 | 0 | 124 | | 370 | CN_UINT8 | Analog Input 0-10V 2 | 0 | 125 | | 371 | CN_UINT8 | Analog Input 0-10V 3 | 0 | 126 | | 372 | CN_UINT8 | Analog Input 0-10V 4 | 0 | 127 | | 384 | CN_INT16 | | 0.0 | 128 | | 386 | CN_BOOL | | 0 | 129 | | 400 | CN_INT16 | | 0.0 | 130 | | 401 | CN_UINT8 | | 0 | 131 | | 402 | CN_BOOL | ?? PostHeaterPresent | 0 | 132 | | 416 | CN_INT16 | ComfoFond Outdoor Air Temperature | -40.0 | 133 | | 417 | CN_INT16 | ComfoFond Ground Temperature | 10.0 | 134 | | 418 | CN_UINT8 | ComfoFond GHE State Percentage | 0 | 135 | | 419 | CN_BOOL | ComfoFond GHE Present | 0=absent, 1=present | 136 | | 513 | CN_UINT16 | | | 137 | | 514 | CN_UINT16 | | | 138 | | 515 | CN_UINT16 | | | 139 | | 516 | CN_UINT16 | | | 140 | | 517 | CN_UINT8 | | | 141 | | 518 | CN_UINT8 | | | 142 | | 519 | CN_UINT8 | | | 143 | | 520 | CN_UINT8 | | | 144 | | 521 | CN_INT16 | | | 145 | | 522 | CN_INT16 | | | 146 | | 523 | CN_INT16 | | | 147 | | 524 | CN_UINT8 | | | 148 | | 784 | CN_UINT8 | ComfoCool State | 0=off, 1=on (0) | 149 | | 785 | CN_BOOL | ?? ComfoCoolCompressor State | 0 | 150 | | 801 | CN_INT16 | ?? T10ROOMTEMPERATURE | 0.0 | 151 | | 802 | CN_INT16 | ComfoCool Condensor Temperature | 0.0 | 152 | | 803 | CN_INT16 | ?? T23SUPPLYAIRTEMPERATURE | 0.0 | 153 | | 1024 | CN_UINT16 | | | 154 | | 1025 | CN_UINT16 | | | 155 | | 1026 | CN_UINT16 | | | 156 | | 1027 | CN_UINT16 | | | 157 | | 1028 | CN_UINT16 | | | 158 | | 1029 | CN_UINT16 | | | 159 | | 1030 | CN_UINT16 | | | 160 | | 1031 | CN_UINT16 | | | 161 | | 1056 | CN_INT16 | | | 162 | | 1124 | CN_INT16 | | | 163 | | 1125 | CN_INT16 | | | 164 | | 1126 | CN_INT16 | | | 165 | | 1127 | CN_INT16 | | | 166 | | 1281 | CN_UINT16 | | | 167 | | 1282 | CN_UINT16 | | | 168 | | 1283 | CN_UINT16 | | | 169 | | 1284 | CN_UINT16 | | | 170 | | 1285 | CN_UINT16 | | | 171 | | 1286 | CN_UINT16 | | | 172 | | 1287 | CN_UINT16 | | | 173 | | 1288 | CN_UINT16 | | | 174 | | 1297 | CN_BOOL | | | 175 | | 1298 | CN_BOOL | | | 176 | | 1299 | CN_BOOL | | | 177 | | 1300 | CN_BOOL | | | 178 | | 1301 | CN_BOOL | | | 179 | | 1302 | CN_BOOL | | | 180 | | 1303 | CN_BOOL | | | 181 | | 1304 | CN_BOOL | | | 182 | -------------------------------------------------------------------------------- /docs/PROTOCOL.md: -------------------------------------------------------------------------------- 1 | # ComfoControl Protocol 2 | 3 | This document tries to explain the ComfoControl Protocol used by Zehnder ventilation units. You need a 4 | *ComfoConnect LAN C* device to interface with the unit. 5 | 6 | **Warning: This documentation is incomplete. If you have more information, don't hesitate to contribute by opening an 7 | issue or making a PR.** 8 | 9 | ## General packet structure 10 | 11 | The messages are send in a TCP-connection to port 56747, and are prepended with a header that resembles the 12 | "Length-prefix protocol buffers" format. 13 | 14 | This file [zehnder.proto](/protobuf/zehnder.proto) contains a Protocol Buffers definition of the protocol. 15 | 16 | ## Manually decoding a packet 17 | 18 | The `protoc` utility can be used to manually decode a message based on the hex representation of the payload. You need 19 | to strip the headers and extract the `cmd` and `msg` before passing it to `protoc`. See the sections below to find out 20 | what to strip. 21 | 22 | Example: 23 | 24 | ```markdown 25 | 0000004 ea886190220044d68a07d85a2e3866fce 0000000000251010800170b3d54264b4 0004 08022002 26 | 0a10a886190220044d68a07d85a2e3866fce10001a126950686f6e652076616e2044657374696e79 27 | length_ src______________________________ dst_____________________________ cmd# cmd_____ 28 | msg_____________________________________________________________________________ 29 | ``` 30 | 31 | Command: 32 | 33 | ```sh 34 | $ echo "08022002" | xxd -r -p | protoc --decode=GatewayOperation zehnder.proto 35 | ``` 36 | 37 | ```javascript 38 | type: RegisterAppRequestType 39 | reference: 2 40 | ``` 41 | 42 | Message: 43 | 44 | ```sh 45 | $ echo "0a10a886190220044d68a07d85a2e3866fce10001a126950686f6e652076616e2044657374696e79" | xxd -r -p | protoc --decode=RegisterAppRequest zehnder.proto 46 | ``` 47 | 48 | ```javascript 49 | uuid: "\250\206\031\002 \004Mh\240}\205\242\343\206o\316" 50 | pin: 0 51 | devicename: "iPhone van Destiny" 52 | ``` 53 | 54 | ## Device discovery (`DiscoveryOperation`) 55 | 56 | The bridge can be discovered by sending an UDP packet containing `0x0a00` to your network's broadcast address on port 56747. 57 | It will respond with the following connection information, also in a UDP packet. The last 6 bytes of the 58 | identifier seems to be the bridge's MAC address. This packet contains no header and can be fully passed to Protocol 59 | Buffers. 60 | 61 | Raw response data: 62 | 63 | ``` 64 | 12230a0d3139322e3136382e312e32313312100000000000251010800170b3d54264b41801 65 | ``` 66 | 67 | ```javascript 68 | searchGatewayResponse 69 | { 70 | ipaddress: "192.168.1.213" 71 | uuid: "\000\000\000\000\000%\020\020\200\001p\263\325Bd\264" 72 | version: 1 73 | } 74 | ``` 75 | 76 | You now have the bridge's `ipaddress` and `uuid`. You need these for further communication. 77 | 78 | ## Bridge communication (`GatewayOperation`) 79 | 80 | Further communication with the bridge happens by opening a TCP connection to the bridge on port 56747. Every message 81 | will start with the `length` of the message, excluding this `length`-field itself. 82 | 83 | Note that only `op` and `msg` is valid Protocol Buffers data. The other fields should not be parsed. 84 | 85 | ### Header 86 | 87 | The header then consist of a `src`, `dst` and `op_length`, followed by the `op` and the `msg`. The `src` seems to be 88 | generated by the client, the `dst` has to be obtained from the discovery packet (`uuid`). The `op_length` fields 89 | indicates the length of the `op` field, the rest of the data contains the `msg`. 90 | 91 | | Field | Data | Remark | 92 | |-----------------------|--------------------------------------|-------------------------------------------------------| 93 | | length (32 bit) | `0x0000004f` | Length of the whole message excluding this field | 94 | | src (16 bytes) | `0xaf154804169043898d2da77148f886be` | | 95 | | dst (16 bytes) | `0x0000000000251010800170b3d54264b4` | | 96 | | op_length (16 bit | `0x0004` | Length of the `op` message | 97 | | op (variable length) | `0x08342002` | Message with type `GatewayOperation` | 98 | | msg (variable length) | `...` | Message with type that is stated in `op.type` | 99 | 100 | ### Commands 101 | 102 | A message consists of a command block (`GatewayOperation`) and a message block (variable type). The command block 103 | contains a `type` and `reference` field. The `type` field indicates the type of the message block. The `reference` field 104 | will contain a incremental number that can be used to link a request with a reply. 105 | 106 | This is a list of the commands. 107 | 108 | | Request | Confirm | Notification / Response | Description | 109 | |-------------------------------|-------------------------------|-------------------------|----------------------------------------------------| 110 | | NoOperation | | | | 111 | | SetAddressRequestType | SetAddressConfirmType | | | 112 | | RegisterAppRequestType | RegisterAppConfirmType | | Adds a device in the registration list | 113 | | StartSessionRequestType | StartSessionConfirmType | | Start as session with the bridge | 114 | | CloseSessionRequestType | CloseSessionConfirmType | | Terminate your session | 115 | | ListRegisteredAppsRequestType | ListRegisteredAppsConfirmType | | Returns a list of registered apps on the bridge | 116 | | DeregisterAppRequestType | DeregisterAppConfirmType | | Remove a UUID from the registration list | 117 | | ChangePinRequestType | ChangePinConfirmType | | Change the PIN code | 118 | | GetRemoteAccessIdRequestType | GetRemoteAccessIdConfirmType | | | 119 | | SetRemoteAccessIdRequestType | SetRemoteAccessIdConfirmType | | | 120 | | GetSupportIdRequestType | GetSupportIdConfirmType | | | 121 | | GetWebIdRequestType | GetWebIdConfirmType | | | 122 | | SetWebIdRequestType | SetWebIdConfirmType | | | 123 | | SetPushIdRequestType | SetPushIdConfirmType | | | 124 | | DebugRequestType | DebugConfirmType | | | 125 | | UpgradeRequestType | UpgradeConfirmType | | | 126 | | SetDeviceSettingsRequestType | SetDeviceSettingsConfirmType | | | 127 | | VersionRequestType | VersionConfirmType | | | 128 | | | | GatewayNotificationType | | 129 | | | | KeepAliveType | You should send these to keep the connection open. | 130 | | FactoryResetType | | | | 131 | | CnTimeRequestType | CnTimeConfirmType | | Returns the seconds since 2000-01-01 00:00:00. | 132 | | CnNodeRequestType | | CnNodeNotificationType | | 133 | | CnRmiRequestType | CnRmiResponseType | | | 134 | | CnRmiAsyncRequestType | CnRmiAsyncConfirmType | CnRmiAsyncResponseType | | 135 | | CnRpdoRequestType | CnRpdoConfirmType | CnRpdoNotificationType | | 136 | | | | CnAlarmNotificationType | | 137 | | CnFupReadRegisterRequestType | CnFupReadRegisterConfirmType | | | 138 | | CnFupProgramBeginRequestType | CnFupProgramBeginConfirmType | | | 139 | | CnFupProgramRequestType | CnFupProgramConfirmType | | | 140 | | CnFupProgramEndRequestType | CnFupProgramEndConfirmType | | | 141 | | CnFupReadRequestType | CnFupReadConfirmType | | | 142 | | CnFupResetRequestType | CnFupResetConfirmType | | | 143 | 144 | #### RegisterApp (`RegisterAppRequestType` and `RegisterAppConfirmType`) 145 | 146 | Before you can login, you need to register your device, by sending a `type: RegisterAppRequestType`. 147 | 148 | ```javascript 149 | type: RegisterAppRequestType 150 | reference: 15 151 | 152 | uuid: "\251\226\031\002 \004Mh\240}\205\242\343\206o\312" 153 | pin: 0 154 | devicename: "Computer" 155 | ``` 156 | 157 | The bridge will respond with a `type: RegisterAppConfirmType`. 158 | In case of success, it will respond like this: 159 | 160 | ```javascript 161 | type: RegisterAppConfirmType 162 | reference: 15 163 | ``` 164 | 165 | In case of a failure (invalid PIN), it will respond with a `result: NOT_ALLOWED`. 166 | 167 | ```javascript 168 | type: RegisterAppConfirmType 169 | result: NOT_ALLOWED 170 | reference: 15 171 | ``` 172 | 173 | #### StartSession (`StartSessionRequestType` and `StartSessionConfirmType`) 174 | 175 | The client logs in by sending a `type: StartSessionRequestType`. Only one client can be logged in at the same time. 176 | 177 | ```javascript 178 | type: StartSessionRequestType 179 | reference: 16 180 | ``` 181 | 182 | In case of a success, it will respond with a `type: StartSessionRequestType` with `result: OK`. 183 | 184 | ```javascript 185 | type: StartSessionConfirmType 186 | result: OK 187 | reference: 16 188 | ``` 189 | 190 | If another client is already logged in, the bridge will respond with a `result` of `OTHER_SESSION`. The name of the 191 | other device will be in `devicename`. 192 | 193 | ```javascript 194 | type: StartSessionConfirmType 195 | result: OTHER_SESSION 196 | reference: 16 197 | 198 | devicename: "Google Nexus 5X" 199 | ``` 200 | 201 | You can force the takeover of this session by specifying a `takeover: 1`. 202 | 203 | ```javascript 204 | type: StartSessionConfirmType 205 | result: OK 206 | reference: 17 207 | 208 | takeover: 1 209 | ``` 210 | 211 | Next, we see a few notifications. They seem to be messages to let the client know what nodes are available. We only 212 | send messages to `nodeId: 1`. 213 | 214 | ```javascript 215 | type: CnNodeNotificationType 216 | 217 | nodeId: 1 218 | productId: 1 219 | zoneId: 1 220 | mode: NODE_NORMAL 221 | ``` 222 | 223 | ```javascript 224 | type: CnNodeNotificationType 225 | 226 | nodeId: 48 227 | productId: 5 228 | zoneId: 255 229 | mode: NODE_NORMAL 230 | ``` 231 | 232 | These are the known `productId`s. 233 | 234 | | productId | type | description | 235 | |-----------|----------------|--------------------------------| 236 | | 1 | ComfoAirQ | The ComfoAirQ ventilation unit | 237 | | 2 | ComfoSense | ComfoSense C | 238 | | 3 | ComfoSwitch | ComfoSwitch C | 239 | | 4 | OptionBox | | 240 | | 5 | ZehnderGateway | ComfoConnect LAN C | 241 | | 6 | ComfoCool | ComfoCool Q600 | 242 | | 7 | KNXGateway | ComfoConnect KNX C | 243 | | 8 | Service Tool | | 244 | | 9 | PT Tool | Production test tool | 245 | | 10 | DVT Tool | Design verification test tool | 246 | 247 | #### CloseSession (`CloseSessionRequestType`) 248 | 249 | The client logs out by sending a `type: CloseSessionRequestType`. The bridge doesn't seem to send a response on 250 | this. 251 | 252 | ```javascript 253 | type: CloseSessionRequestType 254 | ``` 255 | 256 | When the session is closed by the bridge (because another client connects with `takeover: 1`), the bridge will also send 257 | a `type: CloseSessionRequestType` message to the client. 258 | 259 | #### CnRpdoRequest (`CnRpdoRequestType`, `CnRpdoConfirmType` and `CnRpdoNotificationType`) 260 | 261 | To receive status updates, you need to register to a `pdid` by sending a `type: CnRpdoRequestType`. You also need 262 | to specify a `pdid`, `zone`, `type` and `timeout`. The zone always seems to be `1`. The `type` seems to depend on the 263 | `pdid`. 264 | 265 | ```javascript 266 | type: CnRpdoRequestType 267 | reference: 104 268 | 269 | pdid: 176 270 | zone: 1 271 | type: 1 272 | timeout: 4294967295 273 | ``` 274 | 275 | The bridge will reply with a `type: CnRpdoConfirmType`. 276 | 277 | ```javascript 278 | type: CnRpdoConfirmType 279 | result: OK 280 | reference: 104 281 | ``` 282 | 283 | Next, when an update is available, the bridge will send a `type: CnRpdoNotificationType`. 284 | 285 | ```javascript 286 | type: CnRpdoNotificationType 287 | 288 | pdid: 176 289 | data: "\000" 290 | ``` 291 | 292 | For known PDOs, check [PROTOCOL-PDO.md](PROTOCOL-PDO.md) 293 | 294 | #### CnRmiRequest (`CnRmiRequestType` and `CnRmiResponseType`) 295 | 296 | You can execute a function on the device by invoking a `type: CnRmiRequestType`. You need to specify the `nodeId` and a 297 | `message`. This can make a configuration change, or request data. 298 | 299 | ```javascript 300 | type: CnRmiRequestType 301 | reference: 122 302 | 303 | nodeId: 1 304 | message: "\001\001\001\020\010" 305 | ``` 306 | 307 | The bridge will respond with a `type: CnRmiResponseType`. 308 | 309 | ```javascript 310 | type: CnRmiResponseType 311 | reference: 122 312 | 313 | message: "ComfoAir Q450 B R RF ST Quality\000" 314 | ``` 315 | 316 | For an overview about the known RMI Requests, check [PROTOCOL-RMI.md](PROTOCOL-RMI.md) 317 | 318 | # ComfoControl CAN/RMI Protocol 319 | 320 | This document tries to outline the protocol as used by the newer "Q"-Models. Please note that some stuff might be version dependent, especially ranges. My 321 | findings are based on ~R1.6.2 322 | 323 | Two basic assumptions: 324 | 325 | - Your airflow unit is m³/h 326 | - Your temperature is measured in °C 327 | 328 | If your ventilation is set to something else you need to try it out 329 | 330 | ## Quick Overview of the network: 331 | 332 | Speed is 100kb/s, no CAN-FD but extended ID's are used (one exception: firmware uploading) 333 | Each device has an unique node-id smaller than 0x3F or 64. If a device detects that another device is writing using "his" id, the device will change its id 334 | 335 | Nodes send a periodic message to 0x10000000 + Node-ID with DLC 0 or 4 336 | 337 | All PDO's (= sensors, regular data to be transferred, PUSH-Model) are sent with the following ID: 338 | `PDO-ID << 14 + 0x40 + Node-ID` 339 | 340 | Firmware-Updates are sent using 11-bit IDs or 1F800000 341 | 342 | RMI-Commands are sent and received using extended-IDs: 343 | 344 | ``` 345 | 1F000000 346 | + SrcAddr << 0 6 bits source Node-Id 347 | + DstAddr << 6 6 bits destination Node-Id 348 | + AnotherCounter <<12 2 bits we dont know what this is, set it to 0, everything else wont work 349 | + MultiMsg <<14 1 bit if this is a message composed of multiple CAN-frames 350 | + ErrorOccured <<15 1 bit When Response: If an error occured 351 | + IsRequest <<16 1 bit If the message is a request 352 | + SeqNr <<17 2 bits, request counter (should be the same for each frame in a multimsg), copied over to the response 353 | ``` 354 | 355 | Some Examples: 356 | 357 | ``` 358 | 1F015057: 11111 0000 0001 0101 00 0001 010111 multi-msg request with SeqNr = 0 359 | 1F011074: 11111 0000 0001 0001 00 0001 110100 single-msg request with SeqNr = 0 360 | 1F071074: 11111 0000 0111 0001 00 0001 110100 single-msg request with SeqNr = 3 361 | 362 | 1F005D01: 11111 0000 0000 0101 11 0100 000001 no-error multi-msg response, seqnr = 0 363 | 1F001D01: 11111 0000 0000 0001 11 0100 000001 no-error single-msg response, seqnr = 0 364 | 1F009D01: 11111 0000 0000 1001 11 0100 000001 error, seqnr = 0 365 | ``` 366 | 367 | ## Encoding an RMI command/message into CAN-Messages 368 | 369 | There are two ways of messaging them: 370 | 371 | 1. If the message is less or equal than 8 bytes, it will be sent unfragmented. 372 | CAN-Data = RMI-Data 373 | MultiMsg = 0 374 | 375 | Example: 376 | Request: `01 1D 01 10 0A` (Get (`0x01`) from Unit `1D 01` (`TEMPHUMCONTROL 01`) exact value (`10`) variable `0A` (`Target Temperature Warm`)) 377 | Response: `E6 00` little-endian encoded, `0x00E6 = 230 = 23.0°C` 378 | ``` 379 | can1 1F011074 [5] 01 1D 01 10 0A 380 | can1 1F001D01 [2] E6 00 381 | ``` 382 | 383 | 2. If the message is longer than 8 bytes, it will be fragmented into 7-byte blocks 384 | Each block is prepended with the index (starting at 0) of the block. If the block is the last to come, 0x80 is added to the first byte. 385 | 386 | Example: 387 | CMI-Request: `80 03 01`, answer `00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00` 388 | ``` 389 | can1 1F011074 [3] 80 03 01 # I send 390 | can1 1F005D01 [8] 00 00 00 00 00 00 00 00 # Answer 391 | can1 1F005D01 [8] 01 00 00 00 00 00 00 00 392 | can1 1F005D01 [8] 02 00 00 00 00 00 00 00 393 | can1 1F005D01 [8] 03 00 00 00 00 00 00 00 394 | can1 1F005D01 [5] 84 00 00 00 00 395 | ``` 396 | 397 | -------------------------------------------------------------------------------- /protobuf/nanopb.proto: -------------------------------------------------------------------------------- 1 | // Custom options for defining: 2 | // - Maximum size of string/bytes 3 | // - Maximum number of elements in array 4 | // 5 | // These are used by nanopb to generate statically allocable structures 6 | // for memory-limited environments. 7 | 8 | syntax = "proto2"; 9 | import "google/protobuf/descriptor.proto"; 10 | 11 | option java_package = "fi.kapsi.koti.jpa.nanopb"; 12 | 13 | enum FieldType { 14 | FT_DEFAULT = 0; // Automatically decide field type, generate static field if possible. 15 | FT_CALLBACK = 1; // Always generate a callback field. 16 | FT_POINTER = 4; // Always generate a dynamically allocated field. 17 | FT_STATIC = 2; // Generate a static field or raise an exception if not possible. 18 | FT_IGNORE = 3; // Ignore the field completely. 19 | } 20 | 21 | enum IntSize { 22 | IS_DEFAULT = 0; // Default, 32/64bit based on type in .proto 23 | IS_8 = 8; 24 | IS_16 = 16; 25 | IS_32 = 32; 26 | IS_64 = 64; 27 | } 28 | 29 | // This is the inner options message, which basically defines options for 30 | // a field. When it is used in message or file scope, it applies to all 31 | // fields. 32 | message NanoPBOptions { 33 | // Allocated size for 'bytes' and 'string' fields. 34 | optional int32 max_size = 1; 35 | 36 | // Allocated number of entries in arrays ('repeated' fields) 37 | optional int32 max_count = 2; 38 | 39 | // Size of integer fields. Can save some memory if you don't need 40 | // full 32 bits for the value. 41 | optional IntSize int_size = 7 [default = IS_DEFAULT]; 42 | 43 | // Force type of field (callback or static allocation) 44 | optional FieldType type = 3 [default = FT_DEFAULT]; 45 | 46 | // Use long names for enums, i.e. EnumName_EnumValue. 47 | optional bool long_names = 4 [default = true]; 48 | 49 | // Add 'packed' attribute to generated structs. 50 | // Note: this cannot be used on CPUs that break on unaligned 51 | // accesses to variables. 52 | optional bool packed_struct = 5 [default = false]; 53 | 54 | // Skip this message 55 | optional bool skip_message = 6 [default = false]; 56 | 57 | // Generate oneof fields as normal optional fields instead of union. 58 | optional bool no_unions = 8 [default = false]; 59 | 60 | // integer type tag for a message 61 | optional uint32 msgid = 9; 62 | } 63 | 64 | // Extensions to protoc 'Descriptor' type in order to define options 65 | // inside a .proto file. 66 | // 67 | // Protocol Buffers extension number registry 68 | // -------------------------------- 69 | // Project: Nanopb 70 | // Contact: Petteri Aimonen 71 | // Web site: http://kapsi.fi/~jpa/nanopb 72 | // Extensions: 1010 (all types) 73 | // -------------------------------- 74 | 75 | extend google.protobuf.FileOptions { 76 | optional NanoPBOptions nanopb_fileopt = 1010; 77 | } 78 | 79 | extend google.protobuf.MessageOptions { 80 | optional NanoPBOptions nanopb_msgopt = 1010; 81 | } 82 | 83 | extend google.protobuf.EnumOptions { 84 | optional NanoPBOptions nanopb_enumopt = 1010; 85 | } 86 | 87 | extend google.protobuf.FieldOptions { 88 | optional NanoPBOptions nanopb = 1010; 89 | } 90 | 91 | 92 | -------------------------------------------------------------------------------- /protobuf/zehnder.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto2"; 2 | option java_package = "com.zehnder.proto"; 3 | option optimize_for = SPEED; 4 | 5 | import "nanopb.proto"; 6 | 7 | /////////////////////////////////////////////////////////////////////////////////////////////////// 8 | // Discovery messages 9 | /////////////////////////////////////////////////////////////////////////////////////////////////// 10 | message DiscoveryOperation { 11 | optional SearchGatewayRequest searchGatewayRequest = 1; 12 | optional SearchGatewayResponse searchGatewayResponse = 2; 13 | } 14 | 15 | message SearchGatewayRequest { 16 | } 17 | 18 | message SearchGatewayResponse { 19 | enum GatewayType { 20 | lanc = 0; 21 | season = 1; 22 | } 23 | required string ipaddress = 1 [(nanopb).max_size = 16]; 24 | required bytes uuid = 2 [(nanopb).max_size = 16]; 25 | required uint32 version = 3; 26 | optional GatewayType type = 4 [default = lanc]; 27 | } 28 | 29 | /////////////////////////////////////////////////////////////////////////////////////////////////// 30 | // Message-type 31 | // Raw value is encoded as 16-bit network-order on the wire (not default enum encoding, which is varint) 32 | /////////////////////////////////////////////////////////////////////////////////////////////////// 33 | message GatewayOperation { 34 | enum OperationType { 35 | NoOperation = 0; 36 | 37 | SetAddressRequestType = 1; 38 | RegisterAppRequestType = 2; 39 | StartSessionRequestType = 3; 40 | CloseSessionRequestType = 4; 41 | ListRegisteredAppsRequestType = 5; 42 | DeregisterAppRequestType = 6; 43 | ChangePinRequestType = 7; 44 | GetRemoteAccessIdRequestType = 8; 45 | SetRemoteAccessIdRequestType = 9; 46 | GetSupportIdRequestType = 10; 47 | SetSupportIdRequestType = 11; 48 | GetWebIdRequestType = 12; 49 | SetWebIdRequestType = 13; 50 | SetPushIdRequestType = 14; 51 | DebugRequestType = 15; 52 | UpgradeRequestType = 16; 53 | SetDeviceSettingsRequestType = 17; 54 | VersionRequestType = 18; 55 | 56 | SetAddressConfirmType = 51; 57 | RegisterAppConfirmType = 52; 58 | StartSessionConfirmType = 53; 59 | CloseSessionConfirmType = 54; 60 | ListRegisteredAppsConfirmType = 55; 61 | DeregisterAppConfirmType = 56; 62 | ChangePinConfirmType = 57; 63 | GetRemoteAccessIdConfirmType = 58; 64 | SetRemoteAccessIdConfirmType = 59; 65 | GetSupportIdConfirmType = 60; 66 | SetSupportIdConfirmType = 61; 67 | GetWebIdConfirmType = 62; 68 | SetWebIdConfirmType = 63; 69 | SetPushIdConfirmType = 64; 70 | DebugConfirmType = 65; 71 | UpgradeConfirmType = 66; 72 | SetDeviceSettingsConfirmType = 67; 73 | VersionConfirmType = 68; 74 | 75 | GatewayNotificationType = 100; 76 | KeepAliveType = 101; 77 | FactoryResetType = 102; //REMOVE IN PRODUCTION! FOR AUTOMATIC TEST 78 | 79 | 80 | // ComfoNetII-specific messages 81 | CnTimeRequestType = 30; 82 | CnTimeConfirmType = 31; 83 | CnNodeRequestType = 42; 84 | CnNodeNotificationType = 32; 85 | 86 | CnRmiRequestType = 33; 87 | CnRmiResponseType = 34; 88 | CnRmiAsyncRequestType = 35; 89 | CnRmiAsyncConfirmType = 36; 90 | CnRmiAsyncResponseType = 37; 91 | 92 | CnRpdoRequestType = 38; 93 | CnRpdoConfirmType = 39; 94 | CnRpdoNotificationType = 40; 95 | 96 | CnAlarmNotificationType = 41; 97 | 98 | CnFupReadRegisterRequestType = 70; 99 | CnFupReadRegisterConfirmType = 71; 100 | CnFupProgramBeginRequestType = 72; 101 | CnFupProgramBeginConfirmType = 73; 102 | CnFupProgramRequestType = 74; 103 | CnFupProgramConfirmType = 75; 104 | CnFupProgramEndRequestType = 76; 105 | CnFupProgramEndConfirmType = 77; 106 | CnFupReadRequestType = 78; 107 | CnFupReadConfirmType = 79; 108 | CnFupResetRequestType = 80; 109 | CnFupResetConfirmType = 81; 110 | 111 | CnWhoAmIRequestType = 82; 112 | CnWhoAmIConfirmType = 83; 113 | 114 | //WiFi setup 115 | WiFiSettingsRequestType = 120; 116 | WiFiSettingsConfirmType = 121; 117 | WiFiNetworksRequestType = 122; 118 | WiFiNetworksConfirmType = 123; 119 | WiFiJoinNetworkRequestType = 124; 120 | WiFiJoinNetworkConfirmType = 125; 121 | } 122 | optional OperationType type = 1 [default = NoOperation]; 123 | 124 | enum GatewayResult { 125 | OK = 0; // The request was successful 126 | BAD_REQUEST = 1; // Something was wrong with the request 127 | INTERNAL_ERROR = 2; // The request was ok, but the handling of the request failed 128 | NOT_REACHABLE = 3; // The backend cannot route the request 129 | OTHER_SESSION = 4; // The gateway already has an active session with another client 130 | NOT_ALLOWED = 5; // Request is not allowed 131 | NO_RESOURCES = 6; // Not enough resources, e.g., memory, to complete request 132 | NOT_EXIST = 7; // ComfoNet node or property does not exist 133 | RMI_ERROR = 8; // The RMI failed, the message contains the error response 134 | } 135 | optional GatewayResult result = 2 [default = OK]; 136 | optional string resultDescription = 3; 137 | optional uint32 reference = 4; // App-supplied reference for a request. If set in the request, it must be echoed in the confirm. If used, must be non-zero. 138 | } 139 | 140 | /////////////////////////////////////////////////////////////////////////////////////////////////// 141 | // This is the base-message for all notifications. 142 | // The Gateway sends this message to the Backend and the connected App (if any). 143 | // The Gateway specifies the list of currently registered PushUUIDs in this message. 144 | // The Backend uses this list to send out the push-notifications if needed (depending on the contents of the push-message). 145 | // TODO: Tell Gateway which notifications to forward to the Backend and which to forward to the App only 146 | /////////////////////////////////////////////////////////////////////////////////////////////////// 147 | message GatewayNotification { 148 | repeated bytes pushUUIDs = 1; 149 | optional CnAlarmNotification alarm = 2; 150 | } 151 | 152 | /////////////////////////////////////////////////////////////////////////////////////////////////// 153 | // KeepAlive message 154 | // This message is used to keep the session alive and to check for half-open TCP connections. 155 | /////////////////////////////////////////////////////////////////////////////////////////////////// 156 | message KeepAlive { 157 | } 158 | 159 | /////////////////////////////////////////////////////////////////////////////////////////////////// 160 | // FactoryReset message 161 | // This message is used to reset the Gateway to Factory defaults 162 | /////////////////////////////////////////////////////////////////////////////////////////////////// 163 | message FactoryReset { 164 | required bytes resetKey = 1 [(nanopb).max_size = 16]; // should contain the GatewayUUID 165 | } 166 | 167 | /////////////////////////////////////////////////////////////////////////////////////////////////// 168 | // SetDeviceSettings message 169 | // This message is used to set the MAC address and serial number of the device 170 | /////////////////////////////////////////////////////////////////////////////////////////////////// 171 | message SetDeviceSettingsRequest { 172 | required bytes macAddress = 1 [(nanopb).max_size = 16]; 173 | required string serialNumber = 2 [(nanopb).max_size = 16]; 174 | } 175 | 176 | message SetDeviceSettingsConfirm { 177 | } 178 | 179 | /////////////////////////////////////////////////////////////////////////////////////////////////// 180 | // Associates a GatewayUUID or AppUUID with a Backend-connection. 181 | // This way the Backend knows on which TCP-connection the specified UUID can be reached. 182 | // 183 | // Authentication: 184 | // The uuid in this message is the same as the srcuuid in the message-header. 185 | // This message is not authenticated. 186 | /////////////////////////////////////////////////////////////////////////////////////////////////// 187 | message SetAddressRequest { 188 | required bytes uuid = 1 [(nanopb).max_size = 16]; 189 | } 190 | 191 | message SetAddressConfirm { 192 | } 193 | 194 | /////////////////////////////////////////////////////////////////////////////////////////////////// 195 | // Used to register an AppUUID at the Gateway. It is only handled on the local connection! 196 | // The Gateway keeps a list of registered AppUUIDs and devicenames in eeprom/flash. 197 | // When too many apps are registered, NO_RESOURCES is returned. 198 | // 199 | // Authentication: 200 | // This is the only request that is not authenticated by srcuuid (since it is not yet known at the Gateway). 201 | // This request is authenticated by Pin-code in the message. 202 | // The uuid in this message is the same as the srcuuid in the message-header. 203 | // Note: this message is only allowed on the local connection!! 204 | //////////////////////////////////////////////////////////////////////////////////////////////// 205 | message RegisterAppRequest { 206 | required bytes uuid = 1 [(nanopb).max_size = 16]; 207 | required uint32 pin = 2; // 0..9999 208 | required string devicename = 3 [(nanopb).max_size = 32]; 209 | } 210 | 211 | message RegisterAppConfirm { 212 | } 213 | 214 | /////////////////////////////////////////////////////////////////////////////////////////////////// 215 | // Starts a new session at the Gateway. If the Gateway currently already has an active session 216 | // it responds with result OTHER_SESSION, and fill in the devicename. 217 | // The client asks the user if it want to close the old session and start a new one. In this case 218 | // the takeover-flag is set to True 219 | // 220 | // Authentication: 221 | // This message is authenticated by srcuuid 222 | /////////////////////////////////////////////////////////////////////////////////////////////////// 223 | message StartSessionRequest { 224 | optional bool takeover = 1; 225 | } 226 | 227 | message StartSessionConfirm { 228 | optional string devicename = 1 [(nanopb).max_size = 32]; 229 | optional bool resumed = 2; 230 | } 231 | 232 | /////////////////////////////////////////////////////////////////////////////////////////////////// 233 | // Inform other side that the session is closed. CloseSessionConfirm is only sent back when 234 | // an error occurred. 235 | // 236 | // Authentication: 237 | // None 238 | /////////////////////////////////////////////////////////////////////////////////////////////////// 239 | message CloseSessionRequest { 240 | } 241 | 242 | message CloseSessionConfirm { 243 | } 244 | 245 | 246 | /////////////////////////////////////////////////////////////////////////////////////////////////// 247 | // Used to get a list of currently registered AppUUIDs at the Gateway. 248 | // 249 | // Authentication: 250 | // This message is authenticated by srcuuid (must be a registered AppUUID or WebUUID). 251 | // SupportUUID is not allowed. 252 | /////////////////////////////////////////////////////////////////////////////////////////////////// 253 | message ListRegisteredAppsRequest { 254 | } 255 | 256 | message ListRegisteredAppsConfirm { 257 | message App { 258 | required bytes uuid = 1 [(nanopb).max_size = 16]; 259 | required string devicename = 2 [(nanopb).max_size = 32]; 260 | } 261 | repeated App apps = 1; 262 | } 263 | 264 | /////////////////////////////////////////////////////////////////////////////////////////////////// 265 | // Used to deregister a mobile app from the Gateway. 266 | // The Gateway removes the specified uuid from the list of registered AppUUIDs. 267 | // The devicename for this uuid is also removed. 268 | // 269 | // Authentication: 270 | // This message is authenticated by srcuuid (must be a registered AppUUID or WebUUID). 271 | // SupportUUID is not allowed. 272 | /////////////////////////////////////////////////////////////////////////////////////////////////// 273 | message DeregisterAppRequest { 274 | required bytes uuid = 1 [(nanopb).max_size = 16]; 275 | } 276 | 277 | message DeregisterAppConfirm { 278 | } 279 | 280 | /////////////////////////////////////////////////////////////////////////////////////////////////// 281 | // Used to change the Pin-code of the Gateway. 282 | // The default Pin-code is 0000. The Pin-code is also reset to 0000 when a factory reset is performed. 283 | // 284 | // Authentication: 285 | // This message is authenticated by srcuuid (must be a registered AppUUID or WebUUID). 286 | // The old Pin-code is also verified 287 | /////////////////////////////////////////////////////////////////////////////////////////////////// 288 | message ChangePinRequest { 289 | required uint32 oldpin = 1; 290 | required uint32 newpin = 2; 291 | } 292 | 293 | message ChangePinConfirm { 294 | } 295 | 296 | /////////////////////////////////////////////////////////////////////////////////////////////////// 297 | // This returns the currently set RemoteUUID at the Gateway or nothing if not set. 298 | // 299 | // Authentication: 300 | // This message is authenticated by srcuuid (must be a registered AppUUID). 301 | // SupportUUID/WebUUID are not allowed. 302 | /////////////////////////////////////////////////////////////////////////////////////////////////// 303 | message GetRemoteAccessIdRequest { 304 | } 305 | 306 | message GetRemoteAccessIdConfirm { 307 | optional bytes uuid = 1 [(nanopb).max_size = 16]; //the currently set RemoteUUID 308 | } 309 | 310 | /////////////////////////////////////////////////////////////////////////////////////////////////// 311 | // The RemoteUUID is set at the Gateway when the user enables remote access from the App. 312 | // The Gateway stores the RemoteUUID in eeprom/flash and uses it to set its address at the Backend. 313 | // When the Gateway has not received a RemoteUUID yet, it cannot set the address at the Backend and 314 | // therefore there is no need to setup the connection to the Backend. 315 | // The App uses this RemoteUUID to gain access to the Gateway via the Backend. 316 | // The Backend routes messages from the App to the Gateway that registered this id with SetAddress. 317 | // Setting the RemoteUUID to a new value requires other Apps that still use the old value (or no value) 318 | // to get an updated RemoteAccessId. 319 | // Remote access can be disabled by keeping the uuid in the request empty. In this case the Gateway 320 | // will remove the current RemoteUUID (if any) and close the connection to the Backend. 321 | // 322 | // Authentication: 323 | // This message is authenticated by srcuuid (must be a registered AppUUID). 324 | // Note: this message is only allowed on the local connection!! 325 | /////////////////////////////////////////////////////////////////////////////////////////////////// 326 | message SetRemoteAccessIdRequest { 327 | optional bytes uuid = 1 [(nanopb).max_size = 16]; 328 | } 329 | 330 | message SetRemoteAccessIdConfirm { 331 | } 332 | 333 | /////////////////////////////////////////////////////////////////////////////////////////////////// 334 | // This returns the currently set SupportUUID at the Gateway or nothing if not set. 335 | // 336 | // Authentication: 337 | // This message is authenticated by srcuuid (must be a registered AppUUID or WebUUID). 338 | /////////////////////////////////////////////////////////////////////////////////////////////////// 339 | message GetSupportIdRequest { 340 | } 341 | 342 | message GetSupportIdConfirm { 343 | optional bytes uuid = 1 [(nanopb).max_size = 16]; //the currently set SupportUUID 344 | optional uint32 remainingTime = 2; 345 | } 346 | 347 | /////////////////////////////////////////////////////////////////////////////////////////////////// 348 | // The SupportUUID is set at the Gateway when the user enables remote support from the App. 349 | // The Gateway stores the SupportUUID in eeprom/flash and uses it to authenticate incoming 350 | // requests (same way as AppUUID, WebUUID). 351 | // The SupportUUID is only valid for a limited time (to be determined). 352 | // The Gateway is responsible from removing the SupportUUID when the time is expired. 353 | // The SupportUUID can be removed from the Gateway by keeping the uuid in the request empty. 354 | // 355 | // Authentication: 356 | // This message is authenticated by srcuuid (must be a registered AppUUID). 357 | /////////////////////////////////////////////////////////////////////////////////////////////////// 358 | message SetSupportIdRequest { 359 | optional bytes uuid = 1 [(nanopb).max_size = 16]; 360 | optional uint32 validTime = 2; //number of seconds the SupportUUID is valid 361 | } 362 | 363 | message SetSupportIdConfirm { 364 | } 365 | 366 | /////////////////////////////////////////////////////////////////////////////////////////////////// 367 | // This returns the currently set WebUUID at the Gateway or nothing if not set. 368 | // 369 | // Authentication: 370 | // This message is authenticated by srcuuid (must be a registered AppUUID). 371 | /////////////////////////////////////////////////////////////////////////////////////////////////// 372 | message GetWebIdRequest { 373 | } 374 | 375 | message GetWebIdConfirm { 376 | optional bytes uuid = 1 [(nanopb).max_size = 16]; //the currently set WebUUID 377 | } 378 | 379 | /////////////////////////////////////////////////////////////////////////////////////////////////// 380 | // The WebUUID is set at the Gateway when the user enables remote login for the Web-portal (from a registered App). 381 | // The Gateway stores the WebUUID in eeprom/flash and uses it to authenticate incoming requests (same way as AppUUID). 382 | // The WebUUID can be removed from the Gateway by keeping the uuid in the request empty. 383 | // 384 | // Authentication: 385 | // This message is authenticated by srcuuid (must be a registered AppUUID). 386 | /////////////////////////////////////////////////////////////////////////////////////////////////// 387 | message SetWebIdRequest { 388 | optional bytes uuid = 1 [(nanopb).max_size = 16]; 389 | } 390 | 391 | message SetWebIdConfirm { 392 | } 393 | 394 | /////////////////////////////////////////////////////////////////////////////////////////////////// 395 | // The PushUUID is set at the Gateway when the user enables push-support in the App. 396 | // Multiple PushUUIDs can be set at the Gateway (one per AppUUID). 397 | // The Gateway stores the PushUUIDs in eeprom/flash and uses them when a notification is received 398 | // from the ComfoNet-side. The notification is forwarded to the Backend and the connected App (if any) 399 | // using the GatewayNotification (see TODO) messages. 400 | // The PushUUID can be removed from the Gateway by keeping the uuid in the request empty 401 | // (for the AppUUID indicated by the srcuuid). 402 | // 403 | // Authentication: 404 | // This message is authenticated by srcuuid (must be a registered AppUUID). 405 | /////////////////////////////////////////////////////////////////////////////////////////////////// 406 | message SetPushIdRequest { 407 | optional bytes uuid = 1 [(nanopb).max_size = 16]; 408 | } 409 | 410 | message SetPushIdConfirm { 411 | } 412 | 413 | /////////////////////////////////////////////////////////////////////////////////////////////////// 414 | // Firmware upgrade of the Gateway. 415 | // The chunks must be the consecutive bytes of the binary. 416 | // When the upgrade finishes, the Gateway reboots. So, when successful, the UPGRADE_FINISH is 417 | // not confirmed. 418 | // 419 | // Authentication: 420 | // This message is authenticated by srcuuid; must be a registered AppUUID via LAN or SupportUUID. 421 | /////////////////////////////////////////////////////////////////////////////////////////////////// 422 | 423 | message UpgradeRequest { 424 | enum UpgradeRequestCommand { 425 | UPGRADE_START = 0; 426 | UPGRADE_CONTINUE = 1; 427 | UPGRADE_FINISH = 2; 428 | UPGRADE_ABORT = 3; 429 | } 430 | optional UpgradeRequestCommand command = 1 [ default = UPGRADE_CONTINUE ]; 431 | optional bytes chunk = 2; // at most 256 bytes 432 | } 433 | 434 | message UpgradeConfirm { 435 | } 436 | 437 | /////////////////////////////////////////////////////////////////////////////////////////////////// 438 | // Gateway-specific debugging operations 439 | /////////////////////////////////////////////////////////////////////////////////////////////////// 440 | message DebugRequest { 441 | enum DebugRequestCommand { 442 | DBG_ECHO = 0; 443 | DBG_SLEEP = 1; 444 | DBG_SESSION_ECHO = 2; 445 | DBG_PRINT_SETTINGS = 3; 446 | DBG_ALARM = 4; 447 | DBG_LED = 5; 448 | DBG_GPI = 6; 449 | DBG_GPO = 7; 450 | DBG_RS232_WRITE = 8; 451 | DBG_RS232_READ = 9; 452 | DBG_CAN_WRITE = 10; 453 | DBG_CAN_READ = 11; 454 | DBG_KNX_WRITE = 12; 455 | DBG_KNX_READ = 13; 456 | DBG_TOGGLE = 14; 457 | DBG_REBOOT = 15; 458 | DBG_CLOUD = 16; 459 | DBG_EEPROM_READ = 17; 460 | DBG_EEPROM_WRITE = 18; 461 | } 462 | required DebugRequestCommand command = 1; 463 | optional int32 argument = 2; 464 | } 465 | 466 | message DebugConfirm { 467 | required int32 result = 1; 468 | } 469 | 470 | 471 | /////////////////////////////////////////////////////////////////////////////////////////////////// 472 | // ComfoNetII messages 473 | // 474 | // All messages below shall only be used within a session. 475 | /////////////////////////////////////////////////////////////////////////////////////////////////// 476 | 477 | message VersionRequest { 478 | } 479 | 480 | message VersionConfirm { 481 | required uint32 gatewayVersion = 1; // current software version of the Gateway 482 | required string serialNumber = 2 [(nanopb).max_size = 16]; // Gateway's serial number 483 | required uint32 comfoNetVersion = 3; // current version implemented by the Gateway 484 | } 485 | 486 | // Wrapper for CN_clkSetTime() 487 | message CnTimeRequest { 488 | optional uint32 setTime = 1; 489 | } 490 | 491 | // Wrapper for CN_clkGetTime() 492 | message CnTimeConfirm { 493 | required uint32 currentTime = 1; 494 | } 495 | 496 | // Wrapper for CN_nmtFindNode() / CN_nmtRegisterCallback() 497 | // When a session is started, every existing node is notified to the app as soon as possible. 498 | // This request (re)triggers discovery of the nodes. There is no confirm to this request, but the Gateway will respond quickly 499 | // with (at least) the discovery of the Gateway itself using a CnNodeNotification. 500 | message CnNodeRequest { 501 | } 502 | 503 | message CnNodeNotification { 504 | required uint32 nodeId = 1 [(nanopb).int_size = IS_8]; 505 | optional uint32 productId = 2 [(nanopb).int_size = IS_8, default = 0]; 506 | optional uint32 zoneId = 3 [(nanopb).int_size = IS_8]; 507 | 508 | enum NodeModeType { 509 | NODE_LEGACY = 0; // mode is NODE_OFFLINE when productId = 0, otherwise mode is NODE_NORMAL 510 | NODE_OFFLINE = 1; 511 | NODE_NORMAL = 2; 512 | NODE_UPDATE = 3; 513 | } 514 | optional NodeModeType mode = 4 [default = NODE_LEGACY ]; 515 | } 516 | 517 | // Blocking wrapper for CN_rmiSend() 518 | // The Gateway stalls till the RMI operation completes. 519 | message CnRmiRequest { 520 | required uint32 nodeId = 1 [(nanopb).int_size = IS_8]; 521 | required bytes message = 2; 522 | } 523 | 524 | // In-order wrapper for CN_rmiRecv() 525 | message CnRmiResponse { 526 | optional uint32 result = 1 [ default = 0 ]; // ComfoNet error code 527 | optional bytes message = 2; 528 | } 529 | 530 | // Non-blocking wrapper for CN_rmiSend() 531 | // The response to this request is CnRmiAsyncConfirm. 532 | // Overlapped RMI requests can be achieved by issuing multiple CnRmiAsyncRequest before CnRmiAsyncResponses are received. 533 | message CnRmiAsyncRequest { 534 | required uint32 nodeId = 1 [(nanopb).int_size = IS_8]; 535 | required bytes message = 2; 536 | } 537 | 538 | // Confirm of receipt of CnRmiAsyncRequest 539 | // After this confirmation, the actual RMI response is sent using CnRmiAsyncResponse. 540 | message CnRmiAsyncConfirm { 541 | optional uint32 result = 1 [ default = 0 ]; // ComfoNet error code 542 | } 543 | 544 | // Out-of-order wrapper for CN_rmiRecv() 545 | // When there multiple outstanding async RMI requests, these requests will finish in the same order as the requests were issued. 546 | // However, the time between request/response may be large. Moreover, other protobuf messages can be processed meanwhile. 547 | message CnRmiAsyncResponse { 548 | optional uint32 result = 1 [ default = 0 ]; // ComfoNet error code 549 | optional bytes message = 2; 550 | } 551 | 552 | // Wrapper for CN_rpdoCreate(). 553 | // All RPDOs will be deleted when a session is closed. 554 | message CnRpdoRequest { 555 | required uint32 pdid = 1 [(nanopb).int_size = IS_16]; 556 | optional uint32 zone = 2 [(nanopb).int_size = IS_8, default = 0xff]; 557 | optional uint32 type = 3; // when no type is specified, a previously registered RPDO with given PDID is deleted 558 | optional uint32 timeout = 4 [default = 0xffffffff]; 559 | optional uint32 interval = 5 [default = 0]; // interval of RPDO notification 560 | } 561 | 562 | message CnRpdoConfirm { 563 | } 564 | 565 | message CnRpdoNotification { 566 | required uint32 pdid = 1 [(nanopb).int_size = IS_16]; 567 | required bytes data = 2 [(nanopb).max_size = 8]; // the data may (always) be padded to 8 bytes 568 | optional uint32 zone = 3 [(nanopb).int_size = IS_8, default = 0x01]; 569 | } 570 | 571 | message CnAlarmNotification { 572 | optional uint32 zone = 1 [(nanopb).int_size = IS_8]; 573 | optional uint32 productId = 2 [(nanopb).int_size = IS_8]; 574 | optional uint32 productVariant = 3 [(nanopb).int_size = IS_8]; 575 | optional string serialNumber = 4 [(nanopb).max_size = 32]; 576 | optional uint32 swProgramVersion = 5; 577 | optional bytes errors = 6 [(nanopb).max_size = 32]; 578 | optional uint32 errorId = 7 [(nanopb).int_size = IS_8]; 579 | optional uint32 nodeId = 8 [(nanopb).int_size = IS_8]; 580 | } 581 | 582 | /////////////////////////////////////////////////////////////////////////////////////////////////// 583 | // FUP messages 584 | // 585 | // All messages below shall only be used within a session, connected either locally or using 586 | // the supportUUID. 587 | /////////////////////////////////////////////////////////////////////////////////////////////////// 588 | 589 | // Wrapper for CN_fupReadNormalRegister() and CN_fupReadIndexedRegister() 590 | message CnFupReadRegisterRequest { 591 | required uint32 node = 1 [(nanopb).int_size = IS_8]; 592 | required uint32 registerId = 2 [(nanopb).int_size = IS_8]; 593 | optional uint32 index = 3 [(nanopb).int_size = IS_8]; 594 | } 595 | 596 | message CnFupReadRegisterConfirm { 597 | required uint32 value = 1; 598 | } 599 | 600 | // Wrapper for CN_fupEraseMemoryBlock() and CN_fupProgramMemoryBlockBegin() 601 | // Since the flash is erased upon a begin request, it may take several minutes before the request is confirmed. 602 | message CnFupProgramBeginRequest { 603 | repeated uint32 node = 1 [(nanopb).int_size = IS_8, (nanopb).max_count = 32]; 604 | optional uint32 block = 2 [(nanopb).int_size = IS_8, default = 0]; 605 | } 606 | 607 | message CnFupProgramBeginConfirm { 608 | } 609 | 610 | // Wrapper for CN_fupWriteData() and CN_fupRequestToSend() 611 | message CnFupProgramRequest { 612 | required bytes chunk = 1; // at most 256 bytes 613 | } 614 | 615 | message CnFupProgramConfirm { 616 | } 617 | 618 | // Wrapper for CN_fupProgramMemoryBlockEnd() and CN_fupVerifyMemoryBlock() 619 | message CnFupProgramEndRequest { 620 | } 621 | 622 | message CnFupProgramEndConfirm { 623 | } 624 | 625 | // Wrapper for CN_fupReadBegin(), CN_fupReadData(), CN_fupClearToSend(), and CN_fupReadEnd() 626 | message CnFupReadRequest { 627 | required uint32 node = 1 [(nanopb).int_size = IS_8]; 628 | optional uint32 block = 2 [(nanopb).int_size = IS_8, default = 0]; 629 | } 630 | 631 | // One or more confirms to one CnFupReadRequest, but always ends with the last bool set to true. 632 | // In case of an error, the last CnFupReadConfirm has both a GatewayOperation.result and CnFupReadConfirm.last (possibly without chunk) set. 633 | message CnFupReadConfirm { 634 | optional bytes chunk = 1; // at most 256 bytes at a time 635 | optional bool last = 2 [default = false]; 636 | } 637 | 638 | // Wrapper for CN_fupReset() 639 | message CnFupResetRequest { 640 | required uint32 node = 1 [(nanopb).int_size = IS_8]; 641 | } 642 | 643 | message CnFupResetConfirm { 644 | } 645 | 646 | // Wrapper for ComfoNet WhoAmI 647 | message CnWhoAmIRequest { 648 | optional uint32 nodeId = 1 [(nanopb).int_size = IS_8]; 649 | optional uint32 zone = 2 [(nanopb).int_size = IS_8]; 650 | } 651 | 652 | message CnWhoAmIConfirm { 653 | } 654 | 655 | // WiFi config 656 | enum WiFiSecurity { 657 | UNKNOWN = 0; 658 | OPEN = 1; 659 | WPA_WPA2 = 2; 660 | WEP = 3; //do not use 661 | IEEE_802_1X = 4; //do not use 662 | } 663 | 664 | enum WiFiMode { 665 | AP = 0; 666 | STA = 1; 667 | } 668 | 669 | message WiFiNetwork { 670 | required string ssid = 1 [(nanopb).max_size = 32]; 671 | required WiFiSecurity security = 2 [default = WPA_WPA2]; 672 | required int32 rssi = 3 [(nanopb).int_size = IS_8]; // Wi-FI Signal Strength; 673 | } 674 | 675 | message WiFiSettingsRequest { 676 | } 677 | 678 | message WiFiSettingsConfirm { 679 | enum WiFiJoinResult { 680 | OK = 0; 681 | SCAN_FAIL = 1; 682 | JOIN_FAIL = 2; 683 | AUTH_FAIL = 3; 684 | ASSOC_FAIL = 4; 685 | CONN_INPROGRESS = 5; 686 | } 687 | required WiFiMode mode = 1; 688 | optional WiFiNetwork current = 2; 689 | optional WiFiJoinResult joinResult = 3 [default = OK]; //result of last join-request 690 | } 691 | 692 | message WiFiNetworksRequest { 693 | optional bool forceScan = 1 [default = false]; 694 | } 695 | 696 | message WiFiNetworksConfirm { 697 | repeated WiFiNetwork networks = 1; //visible networks from last scan (only in AP mode) 698 | optional uint32 scanAge = 2; //number of seconds since last scan (only in AP mode) 699 | } 700 | 701 | message WiFiJoinNetworkRequest { 702 | required WiFiMode mode = 1; 703 | optional string ssid = 2 [(nanopb).max_size = 32]; 704 | optional string password = 3 [(nanopb).max_size = 64]; 705 | optional WiFiSecurity security = 4 [default = WPA_WPA2]; 706 | } 707 | 708 | message WiFiJoinNetworkConfirm { 709 | } 710 | 711 | 712 | 713 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aiocomfoconnect" 3 | version = "0.1.15" 4 | description = "aiocomfoconnect is an asyncio Python 3 library for communicating with a Zehnder ComfoAir Q350/450/600 ventilation system" 5 | authors = ["Michaël Arnauts "] 6 | readme = "README.md" 7 | homepage = "https://github.com/michaelarnauts/aiocomfoconnect" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | aiohttp = "^3.8.0" 12 | protobuf = "^6.31" 13 | 14 | [tool.poetry.group.dev.dependencies] 15 | grpcio-tools = "^1.73.0" 16 | dpkt = "^1.9.8" 17 | black = "^24.4.0" 18 | isort = "^5.10.1" 19 | pylint = "^3.2" 20 | pytest = "^8.2.0" 21 | 22 | [build-system] 23 | requires = ["poetry-core"] 24 | build-backend = "poetry.core.masonry.api" 25 | 26 | [tool.isort] 27 | profile = "black" 28 | 29 | [tool.black] 30 | target-version = ["py310"] 31 | line-length = 180 32 | 33 | [tool.pylint.MAIN] 34 | max-line-length = 180 35 | 36 | [tool.pylint."MESSAGES CONTROL"] 37 | # Reasons disabled: 38 | # format - handled by black 39 | # locally-disabled - it spams too much 40 | # duplicate-code - unavoidable 41 | # cyclic-import - doesn't test if both import on load 42 | # abstract-class-little-used - prevents from setting right foundation 43 | # unused-argument - generic callbacks and setup methods create a lot of warnings 44 | # too-many-* - are not enforced for the sake of readability 45 | # too-few-* - same as too-many-* 46 | # abstract-method - with intro of async there are always methods missing 47 | # inconsistent-return-statements - doesn't handle raise 48 | # too-many-ancestors - it's too strict. 49 | # wrong-import-order - isort guards this 50 | # consider-using-f-string - str.format sometimes more readable 51 | # --- 52 | # Enable once current issues are fixed: 53 | # consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) 54 | # consider-using-assignment-expr (Pylint CodeStyle extension) 55 | disable = [ 56 | "format", 57 | "abstract-method", 58 | "cyclic-import", 59 | "duplicate-code", 60 | "inconsistent-return-statements", 61 | "locally-disabled", 62 | "not-context-manager", 63 | "too-few-public-methods", 64 | "too-many-ancestors", 65 | "too-many-arguments", 66 | "too-many-branches", 67 | "too-many-instance-attributes", 68 | "too-many-lines", 69 | "too-many-locals", 70 | "too-many-positional-arguments", 71 | "too-many-public-methods", 72 | "too-many-return-statements", 73 | "too-many-statements", 74 | "too-many-boolean-expressions", 75 | "unused-argument", 76 | "wrong-import-order", 77 | ] 78 | enable = [ 79 | #"useless-suppression", # temporarily every now and then to clean them up 80 | "use-symbolic-message-instead", 81 | ] 82 | -------------------------------------------------------------------------------- /script/decode_pcap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import logging 4 | 5 | import dpkt 6 | from google.protobuf.message import DecodeError 7 | from tcpsession.tcpsession import TCPSessions 8 | 9 | from aiocomfoconnect import Bridge 10 | from aiocomfoconnect.bridge import Message 11 | from aiocomfoconnect.protobuf import zehnder_pb2 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | FOUND_RMI = {} 16 | FOUND_PDO = {} 17 | 18 | 19 | def main(args): 20 | # Load pcap 21 | tcpsessions = TCPSessions(args.filename) 22 | tcpsessions.process_pcap() 23 | 24 | sessions = tcpsessions.get_all_sessions() 25 | if len(sessions) < args.stream: 26 | raise Exception("Session not found") 27 | session = sessions[args.stream - 1] 28 | 29 | buffer_in = bytearray() 30 | buffer_out = bytearray() 31 | 32 | for info, packet in session: 33 | i = info[1] # Packet number 34 | 35 | # Decode ethernet layer 36 | eth = dpkt.ethernet.Ethernet(packet) 37 | if not isinstance(eth.data, dpkt.ip.IP): 38 | continue 39 | 40 | # Decode IP layer 41 | ip = eth.data 42 | if not isinstance(ip.data, dpkt.tcp.TCP): 43 | continue 44 | 45 | # Decode TCP layer 46 | tcp = ip.data 47 | 48 | # Skip when we have no data 49 | if not tcp.data: 50 | continue 51 | 52 | if tcp.dport == Bridge.PORT: 53 | _LOGGER.debug('RX %d %s', i, tcp.data.hex()) 54 | buffer_in.extend(tcp.data) 55 | 56 | # Process inbound messages 57 | while msg := read_message(buffer_in): 58 | _LOGGER.debug('MSG IN %s', msg.hex()) 59 | decode_message(msg) 60 | 61 | elif tcp.sport == Bridge.PORT: 62 | _LOGGER.debug('TX %d %s', i, tcp.data.hex()) 63 | buffer_out.extend(tcp.data) 64 | 65 | # Process outbound messages 66 | while msg := read_message(buffer_out): 67 | _LOGGER.debug('MSG OUT %s', msg.hex()) 68 | decode_message(msg) 69 | 70 | print("CnRpdoRequestType") 71 | for pdo in FOUND_PDO: 72 | print(pdo, FOUND_PDO[pdo]) 73 | print() 74 | 75 | print("CnRmiRequestType") 76 | for rmi in FOUND_RMI: 77 | print(rmi, FOUND_RMI[rmi]) 78 | 79 | 80 | def read_message(buffer: bytearray): 81 | """ Try to read a message from the passed buffer. """ 82 | if len(buffer) < 4: 83 | return None 84 | 85 | msg_len_buf = buffer[:4] 86 | msg_len = int.from_bytes(msg_len_buf, byteorder='big') 87 | 88 | if len(buffer) - 4 < msg_len: 89 | _LOGGER.debug("Not enough data to read %s bytes (%s bytes total): %s", msg_len, len(buffer) - 4, buffer.hex()) 90 | return None 91 | 92 | msg_buf = buffer[4:msg_len + 4] 93 | 94 | # Remove the full message 95 | del (buffer[:msg_len + 4]) 96 | 97 | # Try again, we still have data left 98 | return msg_buf 99 | 100 | 101 | def decode_message(msg: bytearray): 102 | """ Decode a message. """ 103 | try: 104 | message = Message.decode(bytes(msg)) 105 | except DecodeError: 106 | _LOGGER.error("Failed to decode message: %s", msg.hex()) 107 | raise 108 | 109 | _LOGGER.debug(message) 110 | 111 | if message.cmd.type == zehnder_pb2.GatewayOperation.CnRmiRequestType: 112 | if not message.cmd.reference in FOUND_RMI: 113 | FOUND_RMI[message.cmd.reference] = {} 114 | FOUND_RMI[message.cmd.reference]['tx'] = [message.msg.nodeId, message.msg.message.hex()] 115 | 116 | if message.cmd.type == zehnder_pb2.GatewayOperation.CnRmiResponseType: 117 | if not message.cmd.reference in FOUND_RMI: 118 | FOUND_RMI[message.cmd.reference] = {} 119 | FOUND_RMI[message.cmd.reference]['rx'] = message.msg.message 120 | 121 | if message.cmd.type == zehnder_pb2.GatewayOperation.CnRpdoRequestType: 122 | if not message.msg.pdid in FOUND_PDO: 123 | FOUND_PDO[message.msg.pdid] = {} 124 | try: 125 | FOUND_PDO[message.msg.pdid]['tx'].append(message.msg.type) 126 | except KeyError: 127 | FOUND_PDO[message.msg.pdid]['tx'] = [message.msg.type] 128 | 129 | if message.cmd.type == zehnder_pb2.GatewayOperation.CnRpdoConfirmType: 130 | pass 131 | 132 | if message.cmd.type == zehnder_pb2.GatewayOperation.CnRpdoNotificationType: 133 | if not message.msg.pdid in FOUND_PDO: 134 | FOUND_PDO[message.msg.pdid] = {} 135 | try: 136 | FOUND_PDO[message.msg.pdid]['rx'].append(message.msg.data.hex()) 137 | except KeyError: 138 | FOUND_PDO[message.msg.pdid]['rx'] = [message.msg.data.hex()] 139 | 140 | 141 | if __name__ == '__main__': 142 | parser = argparse.ArgumentParser() 143 | parser.add_argument('--debug', '-d', help='Enable debug logging', default=False, action='store_true') 144 | parser.add_argument('filename', help='Filename to open') 145 | parser.add_argument('--stream', type=int, help='TCP stream to use', default=1) 146 | 147 | args = parser.parse_args() 148 | 149 | if args.debug: 150 | logging.basicConfig(level=logging.DEBUG) 151 | else: 152 | logging.basicConfig(level=logging.INFO) 153 | logging.getLogger('tcpsession.tcpsession').setLevel(logging.WARNING) 154 | 155 | main(args) 156 | -------------------------------------------------------------------------------- /script/tcpsession/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelarnauts/aiocomfoconnect/b5c95707daf5094a913cb82b04af6c3d326c5af4/script/tcpsession/__init__.py -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelarnauts/aiocomfoconnect/b5c95707daf5094a913cb82b04af6c3d326c5af4/tests/__init__.py --------------------------------------------------------------------------------