├── .github └── workflows │ ├── hassfest.yaml │ └── validate.yaml ├── LICENSE.md ├── README.md ├── custom_components └── pax_ble │ ├── __init__.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── coordinator_calima.py │ ├── coordinator_svensa.py │ ├── devices │ ├── base_device.py │ ├── calima.py │ ├── characteristics.py │ └── svensa.py │ ├── entity.py │ ├── enums.py │ ├── helpers.py │ ├── manifest.json │ ├── number.py │ ├── select.py │ ├── sensor.py │ ├── services.yaml │ ├── strings.json │ ├── switch.py │ ├── test_pax.py │ ├── time.py │ └── translations │ ├── en.json │ ├── fi.json │ ├── nb.json │ └── sv.json ├── hacs.json └── svensa.md /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: HACS validation 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home assistant Custom component for Pax Calima 2 | 3 | ## Installation 4 | 5 | Download using HACS (manually add repo) or manually put it in the custom_components folder. 6 | 7 | ## Supported devices 8 | 9 | This integration was originally meant to just support Pax Calima, but as other fans have been made that builds on the same concept, this 10 | integration now supports: 11 | * Pax Calima 12 | * Pax Levante 50 13 | * Vent-Axia Svara (Same as the Calima) 14 | * Vent-Axia Svensa (same as PureAir Sense) 15 | 16 | If you've got other fans of the same'ish type, just give it a go and let me know how it works out :) 17 | 18 | ## Add device 19 | 20 | The integration supports discovery of devices, but only for Calima (until someone tells me how I can add multiple device-types in manifest "local_name", or if there's another way...) 21 | If discovery doesn't work you may try to add it manually through the integration configuration. 22 | If you have issues connecting, try cycling power on the device. It seems that the Bluetooth interface easily hangs if it's messed around with a bit. 23 | 24 | For Svensa-specific instructions, see [here](svensa.md). 25 | 26 | ## PIN code 27 | 28 | A valid PIN code is required to be able to control the fan. You can add the fan without PIN, but then you'll only be able to read values. 29 | * For Calima/Svara you just enter the decimal value printed on the fan motor (remove from base) 30 | * For Svensa, the PIN is not written on the device, but should be requested from it. See [instructions for Svensa](svensa.md). 31 | * For Levante 50, select Calima as model but request the PIN using the steps outlined for Svensa. Enter pairing mode by powercycling the fan using the switch on the side. 32 | 33 | ## Sensor data 34 | 35 | The sensors for temp/humidity/light seem to be a bit inaccurate, or I'm not converting them correctly, so don't expect them to be as accurate as from other dedicated sensors. 36 | The humidity sensor will show 0 when humidity is low! 37 | Airflow is just a conversion of the fan speed based on a linear correlation between those two. This is a bit inaccurate at best, as the true flow will vary greatly depending on how your fan is mounted. 38 | 39 | ## Good to know 40 | 41 | Speed and duration for boostmode are local variables in home assistant, and as such will not influence boostmode from the app. These variables will also be reset to default if you re-add a device. 42 | 43 | Configuration parameters are read only on Home Assistant startup, and subsequently once every day, to get any changes made from elsewhere. 44 | 45 | Fast scan interval refers to the interval after a write has been made. This allows for quick feedback when the fan is controlled and does not disconnect between reads. This fast interval will remain for 10 reads. 46 | 47 | Setting speed to less than 800 RPM might stall the fan, depending on the specific application. I don't know if stalling like this could damage the fan/motor, so do this with care. 48 | 49 | ## Thanks 50 | 51 | - [@PatrickE94](https://github.com/PatrickE94/pycalima) for the Calima driver 52 | - [@MarkoMarjamaa](https://github.com/MarkoMarjamaa/homeassistant-paxcalima) for a good starting point for the HA-implementation 53 | -------------------------------------------------------------------------------- /custom_components/pax_ble/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for Pax fans.""" 2 | 3 | import asyncio 4 | import logging 5 | 6 | from functools import partial 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant, ServiceCall 9 | from homeassistant.helpers.device_registry import DeviceEntry 10 | from homeassistant.helpers import device_registry as dr 11 | from homeassistant.helpers import entity_registry as er 12 | 13 | from homeassistant.const import CONF_DEVICES 14 | from .const import ( 15 | DOMAIN, 16 | PLATFORMS, 17 | CONF_NAME, 18 | CONF_MODEL, 19 | CONF_MAC, 20 | CONF_PIN, 21 | CONF_SCAN_INTERVAL, 22 | CONF_SCAN_INTERVAL_FAST, 23 | ) 24 | from .helpers import getCoordinator 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 30 | """Set up Pax BLE from a config entry.""" 31 | _LOGGER.debug("Setting up configuration for Pax BLE!") 32 | hass.data.setdefault(DOMAIN, {}) 33 | 34 | # Set up per-entry data storage 35 | if entry.entry_id not in hass.data[DOMAIN]: 36 | hass.data[DOMAIN][entry.entry_id] = {} 37 | hass.data[DOMAIN][entry.entry_id][CONF_DEVICES] = {} 38 | 39 | # Create one coordinator for each device 40 | first_iteration = True 41 | for device_id in entry.data[CONF_DEVICES]: 42 | if not first_iteration: 43 | await asyncio.sleep(10) 44 | first_iteration = False 45 | 46 | device_data = entry.data[CONF_DEVICES][device_id] 47 | name = device_data[CONF_NAME] 48 | mac = device_data[CONF_MAC] 49 | 50 | # Register device 51 | device_registry = dr.async_get(hass) 52 | dev = device_registry.async_get_or_create( 53 | config_entry_id=entry.entry_id, 54 | identifiers={(DOMAIN, mac)}, 55 | name=name 56 | ) 57 | 58 | coordinator = getCoordinator(hass, device_data, dev) 59 | await coordinator.async_request_refresh() 60 | hass.data[DOMAIN][entry.entry_id][CONF_DEVICES][device_id] = coordinator 61 | 62 | # Avoid forwarding platforms multiple times 63 | if not hass.data[DOMAIN][entry.entry_id].get("forwarded"): 64 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 65 | hass.data[DOMAIN][entry.entry_id]["forwarded"] = True 66 | else: 67 | _LOGGER.debug("Platforms already forwarded for entry %s", entry.entry_id) 68 | 69 | # Set up update listener 70 | entry.async_on_unload(entry.add_update_listener(update_listener)) 71 | 72 | # Register services 73 | hass.services.async_register(DOMAIN, "request_update", partial(service_request_update, hass)) 74 | 75 | return True 76 | 77 | # Service-call to update values 78 | async def service_request_update(hass, call: ServiceCall): 79 | """Handle the service call to update entities for a specific device.""" 80 | device_id = call.data.get("device_id") 81 | if not device_id: 82 | _LOGGER.error("Device ID is required") 83 | return 84 | 85 | # Get the device entry from the device registry 86 | device_registry = dr.async_get(hass) 87 | device_entry = device_registry.async_get(device_id) 88 | if not device_entry: 89 | _LOGGER.error("No device entry found for device ID %s", device_id) 90 | return 91 | 92 | """Find the coordinator corresponding to the given device ID.""" 93 | coordinators = hass.data[DOMAIN].get(CONF_DEVICES, {}) 94 | 95 | # Iterate through all coordinators and check their device_id property 96 | for coordinator in coordinators.values(): 97 | if getattr(coordinator, "device_id", None) == device_id: 98 | await coordinator._async_update_data() 99 | return 100 | 101 | _LOGGER.warning("No coordinator found for device ID %s", device_id) 102 | 103 | 104 | # Example migration function 105 | async def async_migrate_entry(hass, config_entry: ConfigEntry): 106 | if config_entry.version == 1: 107 | _LOGGER.error( 108 | "You have an old PAX configuration, please remove and add again. Sorry for the inconvenience!" 109 | ) 110 | return False 111 | 112 | return True 113 | 114 | 115 | async def update_listener(hass: HomeAssistant, entry: ConfigEntry): 116 | _LOGGER.debug("Updating Pax BLE entry!") 117 | await hass.config_entries.async_reload(entry.entry_id) 118 | 119 | 120 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 121 | """Unload a config entry.""" 122 | _LOGGER.debug("Unloading Pax BLE entry!") 123 | 124 | # Make sure we are disconnected 125 | devices = hass.data[DOMAIN].get(entry.entry_id, {}).get(CONF_DEVICES, {}) 126 | for dev_id, coordinator in devices.items(): 127 | await coordinator.disconnect() 128 | 129 | # Unload entries 130 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 131 | 132 | return unload_ok 133 | 134 | 135 | async def async_remove_config_entry_device( 136 | hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry 137 | ) -> bool: 138 | """Remove entities and device from HASS""" 139 | device_id = device_entry.id 140 | ent_reg = er.async_get(hass) 141 | reg_entities = {} 142 | for ent in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id): 143 | if device_id == ent.device_id: 144 | reg_entities[ent.unique_id] = ent.entity_id 145 | for entity_id in reg_entities.values(): 146 | ent_reg.async_remove(entity_id) 147 | dev_reg = dr.async_get(hass) 148 | dev_reg.async_remove_device(device_id) 149 | 150 | """Remove from config_entry""" 151 | devices = [] 152 | for dev_id, dev_config in config_entry.data[CONF_DEVICES].items(): 153 | if dev_config[CONF_NAME] == device_entry.name: 154 | devices.append(dev_config[CONF_MAC]) 155 | 156 | new_data = config_entry.data.copy() 157 | for dev in devices: 158 | # Remove device from config entry 159 | new_data[CONF_DEVICES].pop(dev) 160 | hass.config_entries.async_update_entry(config_entry, data=new_data) 161 | hass.config_entries._async_schedule_save() 162 | 163 | return True 164 | -------------------------------------------------------------------------------- /custom_components/pax_ble/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow to configure Pax integration""" 2 | 3 | import logging 4 | import voluptuous as vol 5 | 6 | from homeassistant.components.bluetooth import BluetoothServiceInfoBleak 7 | from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.data_entry_flow import FlowResult 10 | from homeassistant.helpers import config_validation as cv 11 | from homeassistant.helpers import device_registry as dr 12 | from homeassistant.helpers import entity_registry as er 13 | from homeassistant.helpers import selector 14 | from types import SimpleNamespace 15 | from typing import Any 16 | 17 | from .devices.base_device import BaseDevice 18 | 19 | from homeassistant.const import CONF_DEVICES 20 | from .const import ( 21 | CONF_ACTION, 22 | CONF_ADD_DEVICE, 23 | CONF_WRONG_PIN_SELECTOR, 24 | CONF_EDIT_DEVICE, 25 | CONF_REMOVE_DEVICE, 26 | ) 27 | from .const import ( 28 | DOMAIN, 29 | CONF_NAME, 30 | CONF_MODEL, 31 | CONF_MAC, 32 | CONF_PIN, 33 | CONF_SCAN_INTERVAL, 34 | CONF_SCAN_INTERVAL_FAST, 35 | ) 36 | from .const import DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL_FAST 37 | from .const import DeviceModel 38 | from .helpers import getCoordinator 39 | 40 | CONFIG_ENTRY_NAME = "Pax BLE" 41 | SELECTED_DEVICE = "selected_device" 42 | 43 | DEVICE_DATA = { 44 | CONF_NAME: "", 45 | CONF_MODEL: "", 46 | CONF_MAC: "", 47 | CONF_PIN: "", 48 | CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, 49 | CONF_SCAN_INTERVAL_FAST: DEFAULT_SCAN_INTERVAL_FAST, 50 | } 51 | 52 | _LOGGER = logging.getLogger(__name__) 53 | 54 | 55 | class PaxConfigFlowHandler(ConfigFlow, domain=DOMAIN): 56 | VERSION = 2 57 | 58 | def __init__(self): 59 | self.device_data = DEVICE_DATA.copy() # Data of "current" device 60 | self.config_entry = None 61 | self.accept_wrong_pin = False 62 | 63 | def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: 64 | """Get the options flow for this handler.""" 65 | return PaxOptionsFlowHandler() 66 | 67 | def get_pax_config_entry(self, entry_title: str) -> ConfigEntry: 68 | if self.hass is not None: 69 | config_entries = self.hass.config_entries.async_entries(DOMAIN) 70 | for entry in config_entries: 71 | if entry.title == entry_title: 72 | return entry 73 | 74 | def device_exists(self, device_key: str) -> bool: 75 | self.config_entry = self.get_pax_config_entry(CONFIG_ENTRY_NAME) 76 | if self.config_entry is not None: 77 | if CONF_DEVICES in self.config_entry.data: 78 | if device_key in self.config_entry.data[CONF_DEVICES]: 79 | return True 80 | return False 81 | 82 | async def async_step_user( 83 | self, user_input: dict[str, Any] | None = None 84 | ) -> FlowResult: 85 | """Handle a flow initialized by the user, adding the integration.""" 86 | errors = {} 87 | 88 | """Only one instance of the integration is allowed""" 89 | await self.async_set_unique_id(CONFIG_ENTRY_NAME) 90 | self._abort_if_unique_id_configured() 91 | 92 | return self.async_create_entry(title=CONFIG_ENTRY_NAME, data={CONF_DEVICES: {}}) 93 | 94 | async def async_step_bluetooth( 95 | self, discovery_info: BluetoothServiceInfoBleak 96 | ) -> FlowResult: 97 | """Handle a flow initialized by bluetooth discovery.""" 98 | _LOGGER.debug("Discovered device: %s", discovery_info.address) 99 | self.device_data[CONF_MAC] = dr.format_mac(discovery_info.address) 100 | 101 | """Abort if we already have a discovery in process for this device""" 102 | await self.async_set_unique_id(self.device_data[CONF_MAC]) 103 | self._abort_if_unique_id_configured() 104 | 105 | """Abort if this device is already configured""" 106 | if self.device_exists(self.device_data[CONF_MAC]): 107 | _LOGGER.debug("Aborting because device exists!") 108 | return self.async_abort( 109 | reason="device_already_configured", 110 | description_placeholders={"dev_name": self.device_data[CONF_MAC]}, 111 | ) 112 | 113 | return await self.async_step_add_device() 114 | 115 | """################################################## 116 | ##################### ADD DEVICE #################### 117 | ##################################################""" 118 | 119 | async def async_step_add_device(self, user_input=None): 120 | """Handler for adding discovered device.""" 121 | errors = {} 122 | 123 | if user_input is not None: 124 | dev_mac = dr.format_mac(user_input[CONF_MAC]) 125 | if self.device_exists(dev_mac): 126 | return self.async_abort( 127 | reason="device_already_configured", 128 | description_placeholders={"dev_name": dev_mac}, 129 | ) 130 | 131 | fan = BaseDevice(self.hass, dev_mac, user_input[CONF_PIN]) 132 | 133 | if await fan.connect(): 134 | await fan.setAuth(user_input[CONF_PIN]) 135 | 136 | if not self.accept_wrong_pin: 137 | pin_verified = await fan.checkAuth() 138 | else: 139 | pin_verified = True 140 | await fan.disconnect() 141 | 142 | if pin_verified: 143 | """Make sure integration is installed""" 144 | self.config_entry = self.get_pax_config_entry(CONFIG_ENTRY_NAME) 145 | if self.config_entry is None: 146 | # No integration installed, add entry with new device 147 | await self.async_set_unique_id(CONFIG_ENTRY_NAME) 148 | new_data = {CONF_DEVICES: {}} 149 | new_data[CONF_DEVICES][dev_mac] = user_input 150 | _LOGGER.debug("Creating config entry: %s", new_data) 151 | 152 | return self.async_create_entry( 153 | title=CONFIG_ENTRY_NAME, 154 | data=new_data, 155 | description_placeholders={ 156 | "dev_name": new_data[CONF_DEVICES][dev_mac][CONF_NAME] 157 | }, 158 | ) 159 | else: 160 | # Integration found, update with new device 161 | new_data = self.config_entry.data.copy() 162 | new_data[CONF_DEVICES][dev_mac] = user_input 163 | 164 | self.hass.config_entries.async_update_entry( 165 | self.config_entry, data=new_data 166 | ) 167 | self.hass.config_entries._async_schedule_save() 168 | 169 | await self.hass.config_entries.async_reload( 170 | self.config_entry.entry_id 171 | ) 172 | 173 | return self.async_abort( 174 | reason="add_success", 175 | description_placeholders={ 176 | "dev_name": user_input[CONF_NAME] 177 | }, 178 | ) 179 | else: 180 | # Store values for accept / decline wrong pin 181 | self.device_data = user_input 182 | errors["base"] = "wrong_pin" 183 | return await self.async_step_wrong_pin() 184 | else: 185 | errors["base"] = "cannot_connect" 186 | # Store values for new attempt 187 | self.device_data = user_input 188 | 189 | data_schema = getDeviceSchemaAdd(self.device_data) 190 | return self.async_show_form( 191 | step_id="add_device", data_schema=data_schema, errors=errors 192 | ) 193 | 194 | """################################################## 195 | ###################### WRONG PIN #################### 196 | ##################################################""" 197 | 198 | async def async_step_wrong_pin(self, user_input=None): 199 | """Accept or decline wrong pin.""" 200 | errors = {} 201 | 202 | if user_input is not None: 203 | if user_input.get(CONF_WRONG_PIN_SELECTOR) == "accept": 204 | self.accept_wrong_pin = True 205 | return await self.async_step_add_device(self.device_data) 206 | if user_input.get(CONF_WRONG_PIN_SELECTOR) == "decline": 207 | self.accept_wrong_pin = False 208 | return await self.async_step_add_device() 209 | if user_input.get(CONF_WRONG_PIN_SELECTOR) == "pair": 210 | # Use the helper function 211 | result, error = await attempt_pair_device(self.hass, self.device_data) 212 | if result: 213 | self.accept_wrong_pin = False 214 | return await self.async_step_add_device() 215 | errors["base"] = error 216 | 217 | return self.async_show_form( 218 | step_id="wrong_pin", data_schema=MENU_WRONG_PIN_SCHEMA, errors=errors 219 | ) 220 | 221 | 222 | class PaxOptionsFlowHandler(OptionsFlow): 223 | def __init__(self): 224 | self.selected_device = None # Mac address / key 225 | self.device_data = DEVICE_DATA.copy() # Data of "current" device 226 | self.accept_wrong_pin = False 227 | 228 | async def async_step_init(self, user_input: dict[str, Any] | None = None): 229 | # Manage the options for the custom component.""" 230 | 231 | if user_input is not None: 232 | if user_input.get(CONF_ACTION) == CONF_ADD_DEVICE: 233 | return await self.async_step_add_device() 234 | if user_input.get(CONF_ACTION) == CONF_EDIT_DEVICE: 235 | return await self.async_step_select_edit_device() 236 | if user_input.get(CONF_ACTION) == CONF_REMOVE_DEVICE: 237 | return await self.async_step_remove_device() 238 | 239 | return self.async_show_form(step_id="init", data_schema=CONFIGURE_SCHEMA) 240 | 241 | def device_exists(self, device_key) -> bool: 242 | if device_key in self.config_entry.data[CONF_DEVICES]: 243 | return True 244 | return False 245 | 246 | """################################################## 247 | ##################### ADD DEVICE #################### 248 | ##################################################""" 249 | 250 | async def async_step_add_device(self, user_input=None): 251 | """Handler for adding device.""" 252 | errors = {} 253 | 254 | if user_input is not None: 255 | if self.device_exists(dr.format_mac(user_input[CONF_MAC])): 256 | return self.async_abort( 257 | reason="already_configured", 258 | description_placeholders={ 259 | "dev_name": user_input[CONF_MAC], 260 | }, 261 | ) 262 | 263 | fan = BaseDevice(self.hass, user_input[CONF_MAC], user_input[CONF_PIN]) 264 | 265 | if await fan.connect(): 266 | await fan.setAuth(user_input[CONF_PIN]) 267 | 268 | if not self.accept_wrong_pin: 269 | pin_verified = await fan.checkAuth() 270 | else: 271 | pin_verified = True 272 | await fan.disconnect() 273 | 274 | if pin_verified: 275 | # Add device to config entry 276 | new_data = self.config_entry.data.copy() 277 | new_data[CONF_DEVICES][user_input[CONF_MAC]] = user_input 278 | 279 | self.hass.config_entries.async_update_entry( 280 | self.config_entry, data=new_data 281 | ) 282 | self.hass.config_entries._async_schedule_save() 283 | await self.hass.config_entries.async_reload( 284 | self.config_entry.entry_id 285 | ) 286 | 287 | return self.async_abort( 288 | reason="add_success", 289 | description_placeholders={ 290 | "dev_name": new_data[CONF_DEVICES][user_input[CONF_MAC]][ 291 | CONF_NAME 292 | ], 293 | }, 294 | ) 295 | else: 296 | # Store values for accept / decline wrong pin 297 | self.device_data = user_input 298 | errors["base"] = "wrong_pin" 299 | return await self.async_step_wrong_pin() 300 | else: 301 | errors["base"] = "cannot_connect" 302 | # Store values for new attempt 303 | self.device_data = user_input 304 | 305 | data_schema = getDeviceSchemaAdd(self.device_data) 306 | return self.async_show_form( 307 | step_id="add_device", data_schema=data_schema, errors=errors 308 | ) 309 | 310 | """################################################## 311 | #################### PAIR DEVICE #################### 312 | ##################################################""" 313 | 314 | async def async_step_pair_device(self, user_input=None): 315 | """Handler for pairing device.""" 316 | errors = {} 317 | 318 | # Initialize the BaseDevice 319 | fan = BaseDevice( 320 | self.hass, self.device_data[CONF_MAC], self.device_data[CONF_PIN] 321 | ) 322 | 323 | # Attempt to connect to the device 324 | if await fan.connect(): 325 | try: 326 | # Attempt to pair the device 327 | result = await fan.pair() 328 | self.device_data[CONF_PIN] = result 329 | except Exception as e: 330 | # Log the error and add a user-friendly message 331 | _LOGGER.error(f"Error during pairing: {e}") 332 | errors["base"] = "pairing_failed" 333 | else: 334 | # Handle connection failure 335 | errors["base"] = "connection_failed" 336 | 337 | # Return the next step with any errors 338 | return await self.async_step_add_device(errors=errors) 339 | 340 | """################################################## 341 | ###################### WRONG PIN #################### 342 | ##################################################""" 343 | 344 | async def async_step_wrong_pin(self, user_input=None): 345 | """Accept or decline wrong pin.""" 346 | errors = {} 347 | 348 | if user_input is not None: 349 | if user_input.get(CONF_WRONG_PIN_SELECTOR) == "accept": 350 | self.accept_wrong_pin = True 351 | return await self.async_step_add_device(self.device_data) 352 | if user_input.get(CONF_WRONG_PIN_SELECTOR) == "decline": 353 | self.accept_wrong_pin = False 354 | return await self.async_step_add_device() 355 | if user_input.get(CONF_WRONG_PIN_SELECTOR) == "pair": 356 | # Use the helper function 357 | result, error = await attempt_pair_device(self.hass, self.device_data) 358 | if result: 359 | self.accept_wrong_pin = False 360 | return await self.async_step_add_device() 361 | errors["base"] = error 362 | 363 | return self.async_show_form( 364 | step_id="wrong_pin", data_schema=MENU_WRONG_PIN_SCHEMA, errors=errors 365 | ) 366 | 367 | """################################################## 368 | ################# SELECT EDIT DEVICE ################ 369 | ##################################################""" 370 | 371 | async def async_step_select_edit_device(self, user_input=None): 372 | """Handler for selecting device to edit.""" 373 | errors = {} 374 | 375 | if user_input is not None: 376 | self.selected_device = user_input[SELECTED_DEVICE] 377 | self.device_data = self.config_entry.data[CONF_DEVICES][ 378 | self.selected_device 379 | ] 380 | 381 | return await self.async_step_edit_device() 382 | 383 | devices = {} 384 | for dev_id, dev_config in self.config_entry.data[CONF_DEVICES].items(): 385 | devices[dev_id] = dev_config[CONF_NAME] 386 | 387 | return self.async_show_form( 388 | step_id="select_edit_device", 389 | data_schema=getDeviceSchemaSelect(devices), 390 | errors=errors, 391 | ) 392 | 393 | """################################################## 394 | #################### EDIT DEVICE ################### 395 | ##################################################""" 396 | 397 | async def async_step_edit_device(self, user_input=None): 398 | """Handler for inputting new data for device.""" 399 | errors = {} 400 | 401 | if user_input is not None: 402 | # Update device in config entry 403 | new_data = self.config_entry.data.copy() 404 | new_data[CONF_DEVICES][self.selected_device].update(user_input) 405 | self.hass.config_entries.async_update_entry( 406 | self.config_entry, data=new_data 407 | ) 408 | 409 | return self.async_abort( 410 | reason="edit_success", 411 | description_placeholders={ 412 | "dev_name": new_data[CONF_DEVICES][self.selected_device][CONF_NAME] 413 | }, 414 | ) 415 | 416 | return self.async_show_form( 417 | step_id="edit_device", 418 | data_schema=getDeviceSchemaEdit(self.device_data), 419 | errors=errors, 420 | description_placeholders={ 421 | "dev_name": self.config_entry.data[CONF_DEVICES][self.selected_device][ 422 | CONF_NAME 423 | ] 424 | }, 425 | ) 426 | 427 | """################################################## 428 | #################### REMOVE DEVICE ################## 429 | ##################################################""" 430 | 431 | async def async_step_remove_device(self, user_input=None): 432 | """Handler for selecting device to remove.""" 433 | errors = {} 434 | 435 | if user_input is not None: 436 | self.selected_device = user_input[SELECTED_DEVICE] 437 | self.device_data = self.config_entry.data[CONF_DEVICES][ 438 | self.selected_device 439 | ] 440 | 441 | # Remove device from config entry 442 | new_data = self.config_entry.data.copy() 443 | new_data[CONF_DEVICES].pop(self.selected_device) 444 | 445 | await self.async_remove_device( 446 | self.config_entry.entry_id, self.selected_device 447 | ) 448 | self.hass.config_entries.async_update_entry( 449 | self.config_entry, data=new_data 450 | ) 451 | self.hass.config_entries._async_schedule_save() 452 | 453 | return self.async_abort( 454 | reason="remove_success", 455 | description_placeholders={"dev_name": self.device_data[CONF_NAME]}, 456 | ) 457 | 458 | devices = {} 459 | for dev_id, dev_config in self.config_entry.data[CONF_DEVICES].items(): 460 | devices[dev_id] = dev_config[CONF_NAME] 461 | 462 | return self.async_show_form( 463 | step_id="remove_device", 464 | data_schema=getDeviceSchemaSelect(devices), 465 | errors=errors, 466 | ) 467 | 468 | async def async_remove_device(self, entry_id, mac) -> None: 469 | """Remove device""" 470 | device_id = None 471 | dev_reg = dr.async_get(self.hass) 472 | dev_entry = dev_reg.async_get_device({(DOMAIN, mac)}) 473 | if dev_entry is not None: 474 | device_id = dev_entry.id 475 | dev_reg.async_remove_device(device_id) 476 | 477 | """Remove entities""" 478 | ent_reg = er.async_get(self.hass) 479 | reg_entities = {} 480 | for ent in er.async_entries_for_config_entry(ent_reg, entry_id): 481 | if device_id == ent.device_id: 482 | reg_entities[ent.unique_id] = ent.entity_id 483 | for entity_id in reg_entities.values(): 484 | ent_reg.async_remove(entity_id) 485 | 486 | 487 | """ ################################################### """ 488 | """ Static schemas """ 489 | """ ################################################ """ 490 | 491 | CONF_ACTIONS = [CONF_ADD_DEVICE, CONF_EDIT_DEVICE, CONF_REMOVE_DEVICE] 492 | CONFIGURE_SCHEMA = vol.Schema( 493 | { 494 | vol.Required(CONF_ACTION): selector.SelectSelector( 495 | selector.SelectSelectorConfig( 496 | options=CONF_ACTIONS, translation_key=CONF_ACTION 497 | ), 498 | ) 499 | } 500 | ) 501 | 502 | """ ################################################### """ 503 | """ Dynamic schemas """ 504 | """ ################################################### """ 505 | 506 | 507 | # Schema taking device details when adding 508 | def getDeviceSchemaAdd(user_input: dict[str, Any] | None = None) -> vol.Schema: 509 | DEVICE_MODELS = list(DeviceModel) 510 | 511 | data_schema = vol.Schema( 512 | { 513 | vol.Required( 514 | CONF_NAME, description="Name", default=user_input[CONF_NAME] 515 | ): cv.string, 516 | vol.Required( 517 | CONF_MODEL, description="Model", default=user_input[CONF_MODEL] 518 | ): vol.In(DEVICE_MODELS), 519 | vol.Required( 520 | CONF_MAC, description="MAC Address", default=user_input[CONF_MAC] 521 | ): cv.string, 522 | vol.Required( 523 | CONF_PIN, description="Pin Code", default=user_input[CONF_PIN] 524 | ): cv.string, 525 | vol.Optional( 526 | CONF_SCAN_INTERVAL, default=user_input[CONF_SCAN_INTERVAL] 527 | ): vol.All(vol.Coerce(int), vol.Range(min=5, max=999)), 528 | vol.Optional( 529 | CONF_SCAN_INTERVAL_FAST, default=user_input[CONF_SCAN_INTERVAL_FAST] 530 | ): vol.All(vol.Coerce(int), vol.Range(min=5, max=999)), 531 | } 532 | ) 533 | 534 | return data_schema 535 | 536 | 537 | # Schema taking device details when editing 538 | def getDeviceSchemaEdit(user_input: dict[str, Any] | None = None) -> vol.Schema: 539 | data_schema = vol.Schema( 540 | { 541 | vol.Required( 542 | CONF_PIN, description="Pin Code", default=user_input[CONF_PIN] 543 | ): cv.string, 544 | vol.Optional( 545 | CONF_SCAN_INTERVAL, default=user_input[CONF_SCAN_INTERVAL] 546 | ): vol.All(vol.Coerce(int), vol.Range(min=5, max=999)), 547 | vol.Optional( 548 | CONF_SCAN_INTERVAL_FAST, default=user_input[CONF_SCAN_INTERVAL_FAST] 549 | ): vol.All(vol.Coerce(int), vol.Range(min=5, max=999)), 550 | } 551 | ) 552 | 553 | return data_schema 554 | 555 | 556 | # Schema for selecting device to edit 557 | def getDeviceSchemaSelect(devices: dict[str, Any] | None = None) -> vol.Schema: 558 | schema_devices = {} 559 | for dev_key, dev_name in devices.items(): 560 | schema_devices[dev_key] = f"{dev_name} ({dev_key})" 561 | 562 | data_schema = vol.Schema({vol.Required(SELECTED_DEVICE): vol.In(schema_devices)}) 563 | 564 | return data_schema 565 | 566 | 567 | # Schema for accepting wrong pin 568 | MENU_WRONG_PIN_VALUES = ["accept", "decline", "pair"] 569 | MENU_WRONG_PIN_SCHEMA = vol.Schema( 570 | { 571 | vol.Required(CONF_WRONG_PIN_SELECTOR): selector.SelectSelector( 572 | selector.SelectSelectorConfig( 573 | options=MENU_WRONG_PIN_VALUES, translation_key=CONF_WRONG_PIN_SELECTOR 574 | ), 575 | ) 576 | } 577 | ) 578 | 579 | """ ################################################### """ 580 | """ Helper functions """ 581 | """ ################################################### """ 582 | 583 | 584 | async def attempt_pair_device(hass, device_data): 585 | """Helper method to attempt pairing a device.""" 586 | device = SimpleNamespace(name="Config Flow Device") 587 | coordinator = getCoordinator(hass, device_data, device) 588 | 589 | if await coordinator._fan.connect(): 590 | try: 591 | result = await coordinator._fan.pair() 592 | device_data[CONF_PIN] = result 593 | return True, None 594 | except Exception as e: 595 | _LOGGER.error(f"Error during pairing: {e}") 596 | return False, str(e) 597 | else: 598 | return False, "cannot_connect" 599 | -------------------------------------------------------------------------------- /custom_components/pax_ble/const.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from homeassistant.const import Platform 3 | 4 | # Global Constants 5 | DOMAIN: str = "pax_ble" 6 | PLATFORMS = [ 7 | Platform.TIME, 8 | Platform.SENSOR, 9 | Platform.SWITCH, 10 | Platform.NUMBER, 11 | Platform.SELECT, 12 | ] 13 | 14 | # Configuration Constants 15 | CONF_ACTION = "action" 16 | CONF_ADD_DEVICE = "add_device" 17 | CONF_WRONG_PIN_SELECTOR = "wrong_pin_selector" 18 | CONF_EDIT_DEVICE = "edit_device" 19 | CONF_REMOVE_DEVICE = "remove_device" 20 | 21 | # Configuration Device Constants 22 | CONF_NAME: str = "name" 23 | CONF_MODEL: str = "model" 24 | CONF_MAC: str = "mac" 25 | CONF_PIN: str = "pin" 26 | CONF_SCAN_INTERVAL: str = "scan_interval" 27 | CONF_SCAN_INTERVAL_FAST: str = "scan_interval_fast" 28 | 29 | # Defaults 30 | DEFAULT_SCAN_INTERVAL: int = 300 # Seconds 31 | DEFAULT_SCAN_INTERVAL_FAST: int = 5 # Seconds 32 | 33 | 34 | # Device models 35 | class DeviceModel(str, Enum): 36 | CALIMA = "Calima" 37 | SVARA = "Svara" 38 | SVENSA = "Svensa" 39 | -------------------------------------------------------------------------------- /custom_components/pax_ble/coordinator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import async_timeout 3 | import datetime as dt 4 | import logging 5 | 6 | from abc import ABC, abstractmethod 7 | from homeassistant.helpers import device_registry as dr 8 | from homeassistant.helpers.device_registry import DeviceEntry 9 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 10 | from typing import Optional 11 | 12 | from .devices.base_device import BaseDevice 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | class BaseCoordinator(DataUpdateCoordinator, ABC): 18 | _fast_poll_enabled = False 19 | _fast_poll_count = 0 20 | _normal_poll_interval = 60 21 | _fast_poll_interval = 10 22 | 23 | _deviceInfoLoaded = False 24 | _last_config_timestamp = None 25 | 26 | # Should be set by a child class 27 | _fan: Optional[BaseDevice] = None # This is basically a type hint 28 | 29 | def __init__( 30 | self, 31 | hass, 32 | device: DeviceEntry, 33 | model: str, 34 | mac: str, 35 | pin: int, 36 | scan_interval: int, 37 | scan_interval_fast: int, 38 | ): 39 | """Initialize coordinator parent""" 40 | super().__init__( 41 | hass, 42 | _LOGGER, 43 | # Name of the data. For logging purposes. 44 | name=model + ": " + device.name, 45 | # Polling interval. Will only be polled if there are subscribers. 46 | update_interval=dt.timedelta(seconds=scan_interval), 47 | ) 48 | 49 | self._normal_poll_interval = scan_interval 50 | self._fast_poll_interval = scan_interval_fast 51 | 52 | self._device = device 53 | self._model = model 54 | 55 | # Initialize state in case of new integration 56 | self._state = {} 57 | self._state["boostmodespeedwrite"] = 2400 58 | self._state["boostmodesecwrite"] = 600 59 | 60 | @property 61 | def device_id(self): 62 | return self._device.id 63 | 64 | @property 65 | def devicename(self): 66 | return self._device.name 67 | 68 | @property 69 | def identifiers(self): 70 | return self._device.identifiers 71 | 72 | def setFastPollMode(self): 73 | _LOGGER.debug("Enabling fast poll mode") 74 | self._fast_poll_enabled = True 75 | self._fast_poll_count = 0 76 | self.update_interval = dt.timedelta(seconds=self._fast_poll_interval) 77 | self._schedule_refresh() 78 | 79 | def setNormalPollMode(self): 80 | _LOGGER.debug("Enabling normal poll mode") 81 | self._fast_poll_enabled = False 82 | self.update_interval = dt.timedelta(seconds=self._normal_poll_interval) 83 | 84 | async def disconnect(self): 85 | await self._fan.disconnect() 86 | 87 | async def _safe_connect(self) -> bool: 88 | """ 89 | Try up to 5× with exponential backoff. 90 | """ 91 | backoff = 1.0 92 | for attempt in range(1, 6): 93 | if await self._fan.connect(): 94 | return True 95 | _LOGGER.debug("Coordinator connect attempt %d failed", attempt) 96 | if attempt < 5: 97 | await asyncio.sleep(backoff) 98 | backoff *= 2 99 | _LOGGER.warning("Coordinator failed to connect after 5 attempts") 100 | return False 101 | 102 | async def _async_update_data(self): 103 | _LOGGER.debug("Coordinator updating data!!") 104 | 105 | """ Counter for fast polling """ 106 | self._update_poll_counter() 107 | 108 | """ Fetch device info if not already fetched """ 109 | if not self._deviceInfoLoaded: 110 | try: 111 | async with async_timeout.timeout(20): 112 | if await self.read_deviceinfo(disconnect=False): 113 | await self._async_update_device_info() 114 | self._deviceInfoLoaded = True 115 | except Exception as err: 116 | _LOGGER.debug("Failed when loading device information: %s", str(err)) 117 | 118 | """ Fetch config data if we have no/old values """ 119 | if dt.datetime.now().date() != self._last_config_timestamp: 120 | try: 121 | async with async_timeout.timeout(20): 122 | if await self.read_configdata(disconnect=False): 123 | self._last_config_timestamp = dt.datetime.now().date() 124 | except Exception as err: 125 | _LOGGER.debug("Failed when loading config data: %s", str(err)) 126 | 127 | """ Fetch sensor data """ 128 | try: 129 | async with async_timeout.timeout(20): 130 | await self.read_sensordata(disconnect=not self._fast_poll_enabled) 131 | except Exception as err: 132 | _LOGGER.debug("Failed when fetching sensordata: %s", str(err)) 133 | 134 | async def _async_update_device_info(self) -> None: 135 | device_registry = dr.async_get(self.hass) 136 | device_registry.async_update_device( 137 | self.device_id, 138 | manufacturer=self.get_data("manufacturer"), 139 | model=self.get_data("model"), 140 | hw_version=self.get_data("hw_rev"), 141 | sw_version=self.get_data("sw_rev"), 142 | ) 143 | _LOGGER.debug("Updated device data for: %s", self.devicename) 144 | 145 | def _update_poll_counter(self): 146 | if self._fast_poll_enabled: 147 | self._fast_poll_count += 1 148 | if self._fast_poll_count > 10: 149 | self.setNormalPollMode() 150 | 151 | def get_data(self, key): 152 | if key in self._state: 153 | return self._state[key] 154 | return None 155 | 156 | def set_data(self, key, value): 157 | _LOGGER.debug("Set_Data: %s %s", key, value) 158 | self._state[key] = value 159 | 160 | async def read_deviceinfo(self, disconnect=False) -> bool: 161 | _LOGGER.debug("Reading device information") 162 | try: 163 | # Make sure we are connected 164 | if not await self._safe_connect(): 165 | raise Exception("Not connected!") 166 | except Exception as e: 167 | _LOGGER.warning("Error when fetching device info: %s", str(e)) 168 | return False 169 | 170 | # Fetch data. Some data may not be availiable, that's okay. 171 | try: 172 | self._state["manufacturer"] = await self._fan.getManufacturer() 173 | except Exception as err: 174 | _LOGGER.debug("Couldn't read manufacturer! %s", str(err)) 175 | try: 176 | self._state["model"] = await self._fan.getDeviceName() 177 | except Exception as err: 178 | _LOGGER.debug("Couldn't read device name! %s", str(err)) 179 | try: 180 | self._state["fw_rev"] = await self._fan.getFirmwareRevision() 181 | except Exception as err: 182 | _LOGGER.debug("Couldn't read firmware revision! %s", str(err)) 183 | try: 184 | self._state["hw_rev"] = await self._fan.getHardwareRevision() 185 | except Exception as err: 186 | _LOGGER.debug("Couldn't read hardware revision! %s", str(err)) 187 | try: 188 | self._state["sw_rev"] = await self._fan.getSoftwareRevision() 189 | except Exception as err: 190 | _LOGGER.debug("Couldn't read software revision! %s", str(err)) 191 | 192 | if not self._fan.isConnected(): 193 | return False 194 | elif disconnect: 195 | await self._fan.disconnect() 196 | return True 197 | 198 | # Must be overridden by subclass 199 | @abstractmethod 200 | async def read_sensordata(self, disconnect=False) -> bool: 201 | _LOGGER.debug("Reading sensor data") 202 | 203 | # Must be overridden by subclass 204 | @abstractmethod 205 | async def write_data(self, key) -> bool: 206 | _LOGGER.debug("Write_Data: %s", key) 207 | 208 | # Must be overridden by subclass 209 | @abstractmethod 210 | async def read_configdata(self, disconnect=False) -> bool: 211 | _LOGGER.debug("Reading config data") 212 | -------------------------------------------------------------------------------- /custom_components/pax_ble/coordinator_calima.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import logging 3 | 4 | from typing import Optional 5 | 6 | from .coordinator import BaseCoordinator 7 | from .devices.calima import Calima 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class CalimaCoordinator(BaseCoordinator): 13 | _fan: Optional[Calima] = None # This is basically a type hint 14 | 15 | def __init__( 16 | self, hass, device, model, mac, pin, scan_interval, scan_interval_fast 17 | ): 18 | """Initialize coordinator parent""" 19 | super().__init__( 20 | hass, device, model, mac, pin, scan_interval, scan_interval_fast 21 | ) 22 | 23 | # Initialize correct fan 24 | _LOGGER.debug("Initializing Calima!") 25 | self._fan = Calima(hass, mac, pin) 26 | 27 | async def read_sensordata(self, disconnect=False) -> bool: 28 | _LOGGER.debug("Reading sensor data") 29 | try: 30 | # Make sure we are connected 31 | if not await self._safe_connect(): 32 | raise Exception("Not connected!") 33 | except Exception as e: 34 | _LOGGER.warning("Error when fetching config data: %s", str(e)) 35 | return False 36 | 37 | FanState = await self._fan.getState() # Sensors 38 | BoostMode = await self._fan.getBoostMode() # Sensors? 39 | 40 | if FanState is None: 41 | _LOGGER.debug("Could not read data") 42 | return False 43 | else: 44 | self._state["humidity"] = FanState.Humidity 45 | self._state["temperature"] = FanState.Temp 46 | self._state["light"] = FanState.Light 47 | self._state["rpm"] = FanState.RPM 48 | if FanState.RPM > 400: 49 | self._state["flow"] = int(FanState.RPM * 0.05076 - 14) 50 | else: 51 | self._state["flow"] = 0 52 | self._state["state"] = FanState.Mode 53 | 54 | self._state["boostmode"] = BoostMode.OnOff 55 | self._state["boostmodespeedread"] = BoostMode.Speed 56 | self._state["boostmodesecread"] = BoostMode.Seconds 57 | 58 | if disconnect: 59 | await self._fan.disconnect() 60 | return True 61 | 62 | async def write_data(self, key) -> bool: 63 | _LOGGER.debug("Write_Data: %s", key) 64 | try: 65 | # Make sure we are connected 66 | if not await self._safe_connect(): 67 | raise Exception("Not connected!") 68 | except Exception as e: 69 | _LOGGER.warning("Error when writing data: %s", str(e)) 70 | return False 71 | 72 | # Authorize 73 | await self._fan.authorize() 74 | 75 | try: 76 | # Write data 77 | match key: 78 | case "automatic_cycles": 79 | await self._fan.setAutomaticCycles( 80 | int(self._state["automatic_cycles"]) 81 | ) 82 | case "boostmode": 83 | # Use default values if not set up 84 | if int(self._state["boostmodesecwrite"]) == 0: 85 | self._state["boostmodespeedwrite"] = 2400 86 | self._state["boostmodesecwrite"] = 600 87 | await self._fan.setBoostMode( 88 | int(self._state["boostmode"]), 89 | int(self._state["boostmodespeedwrite"]), 90 | int(self._state["boostmodesecwrite"]), 91 | ) 92 | case "fanspeed_humidity" | "fanspeed_light" | "fanspeed_trickle": 93 | await self._fan.setFanSpeedSettings( 94 | int(self._state["fanspeed_humidity"]), 95 | int(self._state["fanspeed_light"]), 96 | int(self._state["fanspeed_trickle"]), 97 | ) 98 | case ( 99 | "lightsensorsettings_delayedstart" 100 | | "lightsensorsettings_runningtime" 101 | ): 102 | await self._fan.setLightSensorSettings( 103 | int(self._state["lightsensorsettings_delayedstart"]), 104 | int(self._state["lightsensorsettings_runningtime"]), 105 | ) 106 | case "sensitivity_humidity" | "sensitivity_light": 107 | await self._fan.setSensorsSensitivity( 108 | int(self._state["sensitivity_humidity"]), 109 | int(self._state["sensitivity_light"]), 110 | ) 111 | case "trickledays_weekdays" | "trickledays_weekends": 112 | await self._fan.setTrickleDays( 113 | int(self._state["trickledays_weekdays"]), 114 | int(self._state["trickledays_weekends"]), 115 | ) 116 | case "silenthours_on" | "silenthours_starttime" | "silenthours_endtime": 117 | await self._fan.setSilentHours( 118 | bool(self._state["silenthours_on"]), 119 | self._state["silenthours_starttime"], 120 | self._state["silenthours_endtime"], 121 | ) 122 | 123 | case _: 124 | return False 125 | 126 | except Exception as e: 127 | _LOGGER.debug("Not able to write command: %s", str(e)) 128 | return False 129 | 130 | self.setFastPollMode() 131 | return True 132 | 133 | async def read_configdata(self, disconnect=False) -> bool: 134 | try: 135 | # Make sure we are connected 136 | if not await self._safe_connect(): 137 | raise Exception("Not connected!") 138 | except Exception as e: 139 | _LOGGER.warning("Error when fetching config data: %s", str(e)) 140 | return False 141 | 142 | AutomaticCycles = await self._fan.getAutomaticCycles() # Configuration 143 | self._state["automatic_cycles"] = AutomaticCycles 144 | 145 | FanMode = await self._fan.getMode() # Configurations 146 | self._state["mode"] = FanMode 147 | 148 | FanSpeeds = await self._fan.getFanSpeedSettings() # Configuration 149 | self._state["fanspeed_humidity"] = FanSpeeds.Humidity 150 | self._state["fanspeed_light"] = FanSpeeds.Light 151 | self._state["fanspeed_trickle"] = FanSpeeds.Trickle 152 | 153 | HeatDistributorSettings = await self._fan.getHeatDistributor() # Configuration 154 | self._state["heatdistributorsettings_temperaturelimit"] = ( 155 | HeatDistributorSettings.TemperatureLimit 156 | ) 157 | self._state["heatdistributorsettings_fanspeedbelow"] = ( 158 | HeatDistributorSettings.FanSpeedBelow 159 | ) 160 | self._state["heatdistributorsettings_fanspeedabove"] = ( 161 | HeatDistributorSettings.FanSpeedAbove 162 | ) 163 | 164 | LightSensorSettings = await self._fan.getLightSensorSettings() # Configuration 165 | self._state["lightsensorsettings_delayedstart"] = ( 166 | LightSensorSettings.DelayedStart 167 | ) 168 | self._state["lightsensorsettings_runningtime"] = LightSensorSettings.RunningTime 169 | 170 | Sensitivity = await self._fan.getSensorsSensitivity() # Configuration 171 | self._state["sensitivity_humidity"] = Sensitivity.Humidity 172 | self._state["sensitivity_light"] = Sensitivity.Light 173 | 174 | SilentHours = await self._fan.getSilentHours() # Configuration 175 | self._state["silenthours_on"] = SilentHours.On 176 | self._state["silenthours_starttime"] = dt.time( 177 | SilentHours.StartingHour, SilentHours.StartingMinute 178 | ) 179 | self._state["silenthours_endtime"] = dt.time( 180 | SilentHours.EndingHour, SilentHours.EndingMinute 181 | ) 182 | 183 | TrickleDays = await self._fan.getTrickleDays() # Configuration 184 | self._state["trickledays_weekdays"] = TrickleDays.Weekdays 185 | self._state["trickledays_weekends"] = TrickleDays.Weekends 186 | 187 | if disconnect: 188 | await self._fan.disconnect() 189 | return True 190 | -------------------------------------------------------------------------------- /custom_components/pax_ble/coordinator_svensa.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from typing import Optional 4 | 5 | from .coordinator import BaseCoordinator 6 | from .devices.svensa import Svensa 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class SvensaCoordinator(BaseCoordinator): 12 | _fan: Optional[Svensa] = None # This is basically a type hint 13 | 14 | def __init__( 15 | self, hass, device, model, mac, pin, scan_interval, scan_interval_fast 16 | ): 17 | """Initialize coordinator parent""" 18 | super().__init__( 19 | hass, device, model, mac, pin, scan_interval, scan_interval_fast 20 | ) 21 | 22 | # Initialize correct fan 23 | _LOGGER.debug("Initializing Svensa!") 24 | self._fan = Svensa(hass, mac, pin) 25 | 26 | async def read_sensordata(self, disconnect=False) -> bool: 27 | _LOGGER.debug("Reading sensor data") 28 | try: 29 | # Make sure we are connected 30 | if not await self._safe_connect(): 31 | raise Exception("Not connected!") 32 | except Exception as e: 33 | _LOGGER.warning("Error when fetching config data: %s", str(e)) 34 | return False 35 | 36 | FanState = await self._fan.getState() # Sensors 37 | BoostMode = await self._fan.getBoostMode() # Sensors? 38 | 39 | if FanState is None: 40 | _LOGGER.debug("Could not read data") 41 | return False 42 | else: 43 | self._state["humidity"] = FanState.Humidity 44 | self._state["airquality"] = FanState.AirQuality 45 | self._state["temperature"] = FanState.Temp 46 | self._state["light"] = FanState.Light 47 | self._state["rpm"] = FanState.RPM 48 | if FanState.RPM > 400: 49 | self._state["flow"] = int(FanState.RPM * 0.05076 - 14) 50 | else: 51 | self._state["flow"] = 0 52 | self._state["state"] = FanState.Mode 53 | 54 | self._state["boostmode"] = BoostMode.OnOff 55 | self._state["boostmodespeedread"] = BoostMode.Speed 56 | self._state["boostmodesecread"] = BoostMode.Seconds 57 | 58 | if disconnect: 59 | await self._fan.disconnect() 60 | return True 61 | 62 | async def write_data(self, key) -> bool: 63 | _LOGGER.debug("Write_Data: %s", key) 64 | try: 65 | # Make sure we are connected 66 | if not await self._safe_connect(): 67 | raise Exception("Not connected!") 68 | except Exception as e: 69 | _LOGGER.warning("Error when writing data: %s", str(e)) 70 | return False 71 | 72 | # Authorize 73 | await self._fan.authorize() 74 | 75 | try: 76 | # Write data 77 | match key: 78 | case "airing" | "fanspeed_airing": 79 | await self._fan.setAutomaticCycles( 80 | 26, 81 | int(self._state["airing"]), 82 | int(self._state["fanspeed_airing"]), 83 | ) 84 | case "boostmode": 85 | # Use default values if not set up 86 | if int(self._state["boostmodesecwrite"]) == 0: 87 | self._state["boostmodespeedwrite"] = 2400 88 | self._state["boostmodesecwrite"] = 600 89 | await self._fan.setBoostMode( 90 | int(self._state["boostmode"]), 91 | int(self._state["boostmodespeedwrite"]), 92 | int(self._state["boostmodesecwrite"]), 93 | ) 94 | case "sensitivity_humidity" | "fanspeed_humidity": 95 | await self._fan.setHumidity( 96 | int(self._state["sensitivity_humidity"]) != 0, 97 | int(self._state["sensitivity_humidity"]), 98 | int(self._state["fanspeed_humidity"]), 99 | ) 100 | case "sensitivity_presence" | "sensitivity_gas": 101 | await self._fan.setPresenceGas( 102 | int(self._state["sensitivity_presence"]) != 0, 103 | int(self._state["sensitivity_presence"]), 104 | int(self._state["sensitivity_gas"]) != 0, 105 | int(self._state["sensitivity_gas"]), 106 | ) 107 | case "timer_runtime" | "timer_delay" | "fanspeed_sensor": 108 | await self._fan.setTimerFunctions( 109 | int(self._state["timer_runtime"]), 110 | int(self._state["timer_delay"]) != 0, 111 | int(self._state["timer_delay"]), 112 | int(self._state["fanspeed_sensor"]), 113 | ) 114 | case "trickle_on" | "fanspeed_trickle": 115 | await self._fan.setConstantOperation( 116 | bool(self._state["trickle_on"]), 117 | int(self._state["fanspeed_trickle"]), 118 | ) 119 | case "sensitivity_light": 120 | # Should we do anything here? 121 | pass 122 | 123 | case _: 124 | return False 125 | 126 | except Exception as e: 127 | _LOGGER.debug("Not able to write command: %s", str(e)) 128 | return False 129 | 130 | self.setFastPollMode() 131 | return True 132 | 133 | async def read_configdata(self, disconnect=False) -> bool: 134 | try: 135 | # Make sure we are connected 136 | if not await self._safe_connect(): 137 | raise Exception("Not connected!") 138 | except Exception as e: 139 | _LOGGER.warning("Error when fetching config data: %s", str(e)) 140 | return False 141 | 142 | AutomaticCycles = await self._fan.getAutomaticCycles() # Configuration 143 | self._state["airing"] = AutomaticCycles.TimeMin 144 | self._state["fanspeed_airing"] = AutomaticCycles.Speed 145 | _LOGGER.debug(f"Automatic cycles: {AutomaticCycles}") 146 | 147 | ConstantOperation = await self._fan.getConstantOperation() # Configuration 148 | self._state["trickle_on"] = ConstantOperation.Active 149 | self._state["fanspeed_trickle"] = ConstantOperation.Speed 150 | _LOGGER.debug(f"Constant Op: {ConstantOperation}") 151 | 152 | FanMode = await self._fan.getMode() # Configurations 153 | self._state["mode"] = FanMode 154 | _LOGGER.debug(f"FanMode: {FanMode}") 155 | 156 | Humidity = await self._fan.getHumidity() # Configuration 157 | self._state["fanspeed_humidity"] = Humidity.Speed 158 | self._state["sensitivity_humidity"] = Humidity.Level 159 | _LOGGER.debug(f"Humidity: {Humidity}") 160 | 161 | PresenceGas = await self._fan.getPresenceGas() # Configuration 162 | self._state["sensitivity_presence"] = PresenceGas.PresenceLevel 163 | self._state["sensitivity_gas"] = PresenceGas.GasLevel 164 | _LOGGER.debug(f"PresenceGas: {PresenceGas}") 165 | 166 | TimeFunctions = await self._fan.getTimerFunctions() # Configuration 167 | self._state["timer_runtime"] = TimeFunctions.PresenceTime 168 | self._state["timer_delay"] = TimeFunctions.TimeMin 169 | self._state["fanspeed_sensor"] = TimeFunctions.Speed 170 | _LOGGER.debug(f"Time Functions: {TimeFunctions}") 171 | 172 | if disconnect: 173 | await self._fan.disconnect() 174 | return True 175 | -------------------------------------------------------------------------------- /custom_components/pax_ble/devices/base_device.py: -------------------------------------------------------------------------------- 1 | from .characteristics import * 2 | 3 | from homeassistant.components import bluetooth 4 | from struct import pack, unpack 5 | import datetime 6 | from bleak import BleakClient 7 | from bleak.exc import BleakError 8 | import binascii 9 | from collections import namedtuple 10 | import logging 11 | import asyncio 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | Time = namedtuple("Time", "DayOfWeek Hour Minute Second") 16 | BoostMode = namedtuple("BoostMode", "OnOff Speed Seconds") 17 | 18 | 19 | class BaseDevice: 20 | def __init__(self, hass, mac, pin): 21 | self._hass = hass 22 | self._mac = mac 23 | self._pin = pin 24 | self._client: BleakClient | None = None 25 | # Characteristic UUIDs (centralized in characteristics.py ideally) 26 | self.chars = { 27 | CHARACTERISTIC_APPEARANCE: "00002a01-0000-1000-8000-00805f9b34fb", # Not used 28 | CHARACTERISTIC_BOOST: "118c949c-28c8-4139-b0b3-36657fd055a9", 29 | CHARACTERISTIC_CLOCK: "6dec478e-ae0b-4186-9d82-13dda03c0682", 30 | CHARACTERISTIC_DEVICE_NAME: "00002a00-0000-1000-8000-00805f9b34fb", # Not used 31 | CHARACTERISTIC_FACTORY_SETTINGS_CHANGED: "63b04af9-24c0-4e5d-a69c-94eb9c5707b4", 32 | CHARACTERISTIC_FAN_DESCRIPTION: "b85fa07a-9382-4838-871c-81d045dcc2ff", 33 | CHARACTERISTIC_FIRMWARE_REVISION: "00002a26-0000-1000-8000-00805f9b34fb", # Not used 34 | CHARACTERISTIC_HARDWARE_REVISION: "00002a27-0000-1000-8000-00805f9b34fb", # Not used 35 | CHARACTERISTIC_SOFTWARE_REVISION: "00002a28-0000-1000-8000-00805f9b34fb", # Not used 36 | CHARACTERISTIC_LED: "8b850c04-dc18-44d2-9501-7662d65ba36e", 37 | CHARACTERISTIC_MANUFACTURER_NAME: "00002a29-0000-1000-8000-00805f9b34fb", # Not used 38 | CHARACTERISTIC_MODE: "90cabcd1-bcda-4167-85d8-16dcd8ab6a6b", 39 | CHARACTERISTIC_MODEL_NUMBER: "00002a24-0000-1000-8000-00805f9b34fb", # Not used 40 | CHARACTERISTIC_PIN_CODE: "4cad343a-209a-40b7-b911-4d9b3df569b2", 41 | CHARACTERISTIC_PIN_CONFIRMATION: "d1ae6b70-ee12-4f6d-b166-d2063dcaffe1", 42 | CHARACTERISTIC_RESET: "ff5f7c4f-2606-4c69-b360-15aaea58ad5f", 43 | CHARACTERISTIC_SENSOR_DATA: "528b80e8-c47a-4c0a-bdf1-916a7748f412", 44 | CHARACTERISTIC_SERIAL_NUMBER: "00002a25-0000-1000-8000-00805f9b34fb", # Not used 45 | CHARACTERISTIC_STATUS: "25a824ad-3021-4de9-9f2f-60cf8d17bded", 46 | } 47 | 48 | async def authorize(self): 49 | await self.setAuth(self._pin) 50 | 51 | async def connect(self) -> bool: 52 | """ 53 | Reuse BleakClient if already connected; otherwise try up to N times 54 | with exponential backoff. 55 | """ 56 | if self._client and self._client.is_connected: 57 | return True 58 | 59 | retries = 3 60 | backoff = 1.0 61 | for attempt in range(1, retries + 1): 62 | try: 63 | d = bluetooth.async_ble_device_from_address( 64 | self._hass, self._mac.upper() 65 | ) 66 | if not d: 67 | raise BleakError(f"Device {self._mac} not found") 68 | if not self._client: 69 | self._client = BleakClient(d) 70 | await self._client.connect() 71 | _LOGGER.debug("Connected to %s on attempt %d", self._mac, attempt) 72 | return True 73 | except Exception as e: 74 | _LOGGER.debug("Connect attempt %d/%d failed: %s", attempt, retries, e) 75 | 76 | # **cleanup** the half‑dead client before retrying 77 | try: 78 | await self._client.disconnect() 79 | except Exception: 80 | # might already be partially torn down 81 | pass 82 | self._client = None 83 | 84 | if attempt < retries: 85 | await asyncio.sleep(backoff) 86 | backoff *= 2 87 | else: 88 | _LOGGER.warning("Failed to connect %s after %d attempts", self._mac, retries) 89 | return False 90 | 91 | async def disconnect(self) -> None: 92 | if self._client: 93 | try: 94 | await self._client.disconnect() 95 | except Exception as e: 96 | _LOGGER.warning("Error disconnecting %s: %s", self._mac, e) 97 | finally: 98 | self._client = None 99 | 100 | async def _with_disconnect_on_error(self, coro): 101 | try: 102 | return await coro 103 | except Exception: 104 | _LOGGER.debug("GATT operation failed; disconnecting", exc_info=True) 105 | await self.disconnect() 106 | raise 107 | 108 | async def pair(self) -> str: 109 | raise NotImplementedError("Pairing not availiable for this device type.") 110 | 111 | def isConnected(self) -> bool: 112 | return self._client is not None and self._client.is_connected 113 | 114 | def _bToStr(self, val) -> str: 115 | return binascii.b2a_hex(val).decode("utf-8") 116 | 117 | async def _readUUID(self, uuid) -> bytearray: 118 | if not self._client: 119 | raise BleakError("Client not initialized") 120 | return await self._with_disconnect_on_error( 121 | self._client.read_gatt_char(uuid) 122 | ) 123 | 124 | async def _readHandle(self, handle) -> bytearray: 125 | if not self._client: 126 | raise BleakError("Client not initialized") 127 | return await self._with_disconnect_on_error( 128 | self._client.read_gatt_char(handle) 129 | ) 130 | 131 | async def _writeUUID(self, uuid, data) -> None: 132 | if not self._client: 133 | raise BleakError("Client not initialized") 134 | return await self._with_disconnect_on_error( 135 | self._client.write_gatt_char(uuid, data, response=True) 136 | ) 137 | 138 | # --- Generic GATT Characteristics 139 | async def getDeviceName(self) -> str: 140 | # return (await self._readHandle(0x2)).decode("ascii") 141 | return (await self._readUUID(self.chars[CHARACTERISTIC_DEVICE_NAME])).decode( 142 | "ascii" 143 | ) 144 | 145 | async def getModelNumber(self) -> str: 146 | # return (await self._readHandle(0xD)).decode("ascii") 147 | return (await self._readUUID(self.chars[CHARACTERISTIC_MODEL_NUMBER])).decode( 148 | "ascii" 149 | ) 150 | 151 | async def getSerialNumber(self) -> str: 152 | # return (await self._readHandle(0xB)).decode("ascii") 153 | return (await self._readUUID(self.chars[CHARACTERISTIC_SERIAL_NUMBER])).decode( 154 | "ascii" 155 | ) 156 | 157 | async def getHardwareRevision(self) -> str: 158 | # return (await self._readHandle(0xF)).decode("ascii") 159 | return ( 160 | await self._readUUID(self.chars[CHARACTERISTIC_HARDWARE_REVISION]) 161 | ).decode("ascii") 162 | 163 | async def getFirmwareRevision(self) -> str: 164 | # return (await self._readHandle(0x11)).decode("ascii") 165 | return ( 166 | await self._readUUID(self.chars[CHARACTERISTIC_FIRMWARE_REVISION]) 167 | ).decode("ascii") 168 | 169 | async def getSoftwareRevision(self) -> str: 170 | # return (await self._readHandle(0x13)).decode("ascii") 171 | return ( 172 | await self._readUUID(self.chars[CHARACTERISTIC_SOFTWARE_REVISION]) 173 | ).decode("ascii") 174 | 175 | async def getManufacturer(self) -> str: 176 | # return (await self._readHandle(0x15)).decode("ascii") 177 | return ( 178 | await self._readUUID(self.chars[CHARACTERISTIC_MANUFACTURER_NAME]) 179 | ).decode("ascii") 180 | 181 | # --- Onwards to PAX characteristics 182 | async def setAuth(self, pin) -> None: 183 | _LOGGER.debug(f"Connecting with pin: {pin}") 184 | await self._writeUUID(self.chars[CHARACTERISTIC_PIN_CODE], pack(" int: 190 | v = unpack(" bool: 194 | v = unpack( 195 | " None: 200 | await self._writeUUID( 201 | self.chars[CHARACTERISTIC_FAN_DESCRIPTION], 202 | pack("20s", bytearray(name, "utf-8")), 203 | ) 204 | 205 | async def getAlias(self) -> str: 206 | return await self._readUUID(self.chars[CHARACTERISTIC_FAN_DESCRIPTION]).decode( 207 | "utf-8" 208 | ) 209 | 210 | async def getIsClockSet(self) -> str: 211 | return self._bToStr(await self._readUUID(self.chars[CHARACTERISTIC_STATUS])) 212 | 213 | async def getFactorySettingsChanged(self) -> bool: 214 | v = unpack( 215 | " str: 221 | return self._bToStr(await self._readUUID(self.chars[CHARACTERISTIC_LED])) 222 | 223 | async def setTime(self, dayofweek, hour, minute, second) -> None: 224 | await self._writeUUID( 225 | self.chars[CHARACTERISTIC_CLOCK], 226 | pack("<4B", dayofweek, hour, minute, second), 227 | ) 228 | 229 | async def getTime(self) -> Time: 230 | return Time._make( 231 | unpack(" None: 235 | now = datetime.datetime.now() 236 | await self.setTime(now.isoweekday(), now.hour, now.minute, now.second) 237 | 238 | async def getReset(self): # Should be write 239 | return await self._readUUID(self.chars[CHARACTERISTIC_RESET]) 240 | 241 | async def resetDevice(self): # Dangerous 242 | await self._writeUUID(self.chars[CHARACTERISTIC_RESET], pack(" BoostMode: 251 | v = unpack(" None: 255 | if speed % 25: 256 | raise ValueError("Speed must be a multiple of 25") 257 | if not on: 258 | speed = 0 259 | seconds = 0 260 | 261 | await self._writeUUID( 262 | self.chars[CHARACTERISTIC_BOOST], pack(" str: 266 | v = unpack(" FanState: 55 | # Short Short Short Short Byte Short Byte 56 | # Hum Temp Light FanSpeed Mode Tbd Tbd 57 | v = unpack( 58 | "<4HBHB", await self._readUUID(self.chars[CHARACTERISTIC_SENSOR_DATA]) 59 | ) 60 | _LOGGER.debug("Read Fan States: %s", v) 61 | 62 | trigger = "No trigger" 63 | if ((v[4] >> 4) & 1) == 1: 64 | trigger = "Boost" 65 | elif ((v[4] >> 6) & 3) == 3: 66 | trigger = "Switch" 67 | elif (v[4] & 3) == 1: 68 | trigger = "Trickle ventilation" 69 | elif (v[4] & 3) == 2: 70 | trigger = "Light ventilation" 71 | elif ( 72 | v[4] & 3 73 | ) == 3: # Note that the trigger might be active, but mode must be enabled to be activated 74 | trigger = "Humidity ventilation" 75 | 76 | return FanState( 77 | round(math.log2(v[0] - 30) * 10, 2) if v[0] > 30 else 0, 78 | v[1] / 4 - 2.6, 79 | v[2], 80 | v[3], 81 | trigger, 82 | ) 83 | 84 | ################################################ 85 | ############ CONFIGURATION FUNCTIONS ########### 86 | ################################################ 87 | async def getAutomaticCycles(self) -> int: 88 | v = unpack( 89 | " None: 94 | if setting < 0 or setting > 3: 95 | raise ValueError("Setting must be between 0-3") 96 | 97 | await self._writeUUID( 98 | self.chars[CHARACTERISTIC_AUTOMATIC_CYCLES], pack(" Fanspeeds: 102 | return Fanspeeds._make( 103 | unpack( 104 | " None: 112 | for val in (humidity, light, trickle): 113 | if val % 25 != 0: 114 | raise ValueError("Speeds should be multiples of 25") 115 | if val > 2500 or val < 0: 116 | raise ValueError("Speeds must be between 0 and 2500 rpm") 117 | 118 | _LOGGER.debug("Calima setFanSpeedSettings: %s %s %s", humidity, light, trickle) 119 | 120 | await self._writeUUID( 121 | self.chars[CHARACTERISTIC_LEVEL_OF_FAN_SPEED], 122 | pack(" HeatDistributorSettings: 126 | return HeatDistributorSettings._make( 127 | unpack( 128 | " SilentHours: 134 | return SilentHours._make( 135 | unpack("<5B", await self._readUUID(self.chars[CHARACTERISTIC_NIGHT_MODE])) 136 | ) 137 | 138 | async def setSilentHours( 139 | self, on: bool, startingTime: datetime.time, endingTime: datetime.time 140 | ) -> None: 141 | _LOGGER.debug( 142 | "Writing silent hours %s %s %s %s", 143 | startingTime.hour, 144 | startingTime.minute, 145 | endingTime.hour, 146 | endingTime.minute, 147 | ) 148 | value = pack( 149 | "<5B", 150 | int(on), 151 | startingTime.hour, 152 | startingTime.minute, 153 | endingTime.hour, 154 | endingTime.minute, 155 | ) 156 | await self._writeUUID(self.chars[CHARACTERISTIC_NIGHT_MODE], value) 157 | 158 | async def getTrickleDays(self) -> TrickleDays: 159 | return TrickleDays._make( 160 | unpack( 161 | "<2B", 162 | await self._readUUID(self.chars[CHARACTERISTIC_BASIC_VENTILATION]), 163 | ) 164 | ) 165 | 166 | async def setTrickleDays(self, weekdays, weekends) -> None: 167 | await self._writeUUID( 168 | self.chars[CHARACTERISTIC_BASIC_VENTILATION], 169 | pack("<2B", weekdays, weekends), 170 | ) 171 | 172 | async def getLightSensorSettings(self) -> LightSensorSettings: 173 | return LightSensorSettings._make( 174 | unpack( 175 | "<2B", await self._readUUID(self.chars[CHARACTERISTIC_TIME_FUNCTIONS]) 176 | ) 177 | ) 178 | 179 | async def setLightSensorSettings(self, delayed, running) -> None: 180 | if delayed not in (0, 5, 10): 181 | raise ValueError("Delayed must be 0, 5 or 10 minutes") 182 | if running not in (5, 10, 15, 30, 60): 183 | raise ValueError("Running time must be 5, 10, 15, 30 or 60 minutes") 184 | 185 | await self._writeUUID( 186 | self.chars[CHARACTERISTIC_TIME_FUNCTIONS], pack("<2B", delayed, running) 187 | ) 188 | 189 | async def getSensorsSensitivity(self) -> Sensitivity: 190 | # Hum Active | Hum Sensitivity | Light Active | Light Sensitivity 191 | # We fix so that Sensitivity = 0 if active = 0 192 | l = Sensitivity._make( 193 | unpack("<4B", await self._readUUID(self.chars[CHARACTERISTIC_SENSITIVITY])) 194 | ) 195 | 196 | return Sensitivity._make( 197 | unpack( 198 | "<4B", 199 | bytearray( 200 | [ 201 | l.HumidityOn, 202 | l.HumidityOn and l.Humidity, 203 | l.LightOn, 204 | l.LightOn and l.Light, 205 | ] 206 | ), 207 | ) 208 | ) 209 | 210 | async def setSensorsSensitivity(self, humidity, light) -> None: 211 | if humidity > 3 or humidity < 0: 212 | raise ValueError("Humidity sensitivity must be between 0-3") 213 | if light > 3 or light < 0: 214 | raise ValueError("Light sensitivity must be between 0-3") 215 | 216 | value = pack("<4B", bool(humidity), humidity, bool(light), light) 217 | await self._writeUUID(self.chars[CHARACTERISTIC_SENSITIVITY], value) 218 | -------------------------------------------------------------------------------- /custom_components/pax_ble/devices/characteristics.py: -------------------------------------------------------------------------------- 1 | CHARACTERISTIC_APPEARANCE = "CHARACTERISTIC_APPEARANCE" 2 | CHARACTERISTIC_AUTOMATIC_CYCLES = "CHARACTERISTIC_AUTOMATIC_CYCLES" 3 | CHARACTERISTIC_BASIC_VENTILATION = "CHARACTERISTIC_BASIC_VENTILATION" 4 | CHARACTERISTIC_BOOST = "CHARACTERISTIC_BOOST" 5 | CHARACTERISTIC_CLOCK = "CHARACTERISTIC_CLOCK" 6 | CHARACTERISTIC_DEVICE_NAME = "CHARACTERISTIC_DEVICE_NAME" 7 | CHARACTERISTIC_FACTORY_SETTINGS_CHANGED = "CHARACTERISTIC_FACTORY_SETTINGS_CHANGED" 8 | CHARACTERISTIC_FAN_DESCRIPTION = "CHARACTERISTIC_FAN_DESCRIPTION" 9 | CHARACTERISTIC_FIRMWARE_REVISION = "CHARACTERISTIC_FIRMWARE_REVISION" 10 | CHARACTERISTIC_HARDWARE_REVISION = "CHARACTERISTIC_HARDWARE_REVISION" 11 | CHARACTERISTIC_SOFTWARE_REVISION = "CHARACTERISTIC_SOFTWARE_REVISION" 12 | CHARACTERISTIC_LED = "CHARACTERISTIC_LED" 13 | CHARACTERISTIC_LEVEL_OF_FAN_SPEED = "CHARACTERISTIC_LEVEL_OF_FAN_SPEED" 14 | CHARACTERISTIC_MANUFACTURER_NAME = "CHARACTERISTIC_MANUFACTURER_NAME" 15 | CHARACTERISTIC_MODE = "CHARACTERISTIC_MODE" 16 | CHARACTERISTIC_MODEL_NUMBER = "CHARACTERISTIC_MODEL_NUMBER" 17 | CHARACTERISTIC_NIGHT_MODE = "CHARACTERISTIC_NIGHT_MODE" 18 | CHARACTERISTIC_PIN_CODE = "CHARACTERISTIC_PIN_CODE" 19 | CHARACTERISTIC_PIN_CONFIRMATION = "CHARACTERISTIC_PIN_CONFIRMATION" 20 | CHARACTERISTIC_RESET = "CHARACTERISTIC_RESET" 21 | CHARACTERISTIC_SENSITIVITY = "CHARACTERISTIC_SENSITIVITY" 22 | CHARACTERISTIC_SENSOR_DATA = "CHARACTERISTIC_SENSOR_DATA" 23 | CHARACTERISTIC_SERIAL_NUMBER = "CHARACTERISTIC_SERIAL_NUMBER" 24 | CHARACTERISTIC_STATUS = "CHARACTERISTIC_STATUS" 25 | CHARACTERISTIC_TEMP_HEAT_DISTRIBUTOR = "CHARACTERISTIC_TEMP_HEAT_DISTRIBUTOR" 26 | CHARACTERISTIC_TIME_FUNCTIONS = "CHARACTERISTIC_TIME_FUNCTIONS" 27 | 28 | # Svensa 29 | CHARACTERISTIC_CONSTANT_OPERATION = "CHARACTERISTIC_CONSTANT_OPERATION" 30 | CHARACTERISTIC_HUMIDITY = "CHARACTERISTIC_HUMIDITY" 31 | CHARACTERISTIC_PRESENCE_GAS = "CHARACTERISTIC_PRESENCE_GAS" 32 | -------------------------------------------------------------------------------- /custom_components/pax_ble/devices/svensa.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import math 4 | 5 | from .characteristics import * 6 | from .base_device import BaseDevice 7 | 8 | from collections import namedtuple 9 | from struct import pack, unpack 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | AutomaticCycles = namedtuple("AutomaticCycles", "Active Hour TimeMin Speed") 14 | ConstantOperation = namedtuple("ConstantOperation", "Active Speed") 15 | FanState = namedtuple("FanState", "Humidity AirQuality Temp Light RPM Mode") 16 | Humidity = namedtuple("Humidity", "Active Level Speed") 17 | TimerFunctions = namedtuple("TimerFunctions", "PresenceTime TimeActive TimeMin Speed") 18 | PresenceGas = namedtuple( 19 | "PresenceGas", "PresenceActive PresenceLevel GasActive GasLevel" 20 | ) 21 | 22 | 23 | class Svensa(BaseDevice): 24 | def __init__(self, hass, mac, pin): 25 | super().__init__(hass, mac, pin) 26 | 27 | # Update characteristics used in base device 28 | self.chars.update( 29 | { 30 | CHARACTERISTIC_BOOST: "7c4adc07-2f33-11e7-93ae-92361f002671", 31 | CHARACTERISTIC_MODE: "7c4adc0d-2f33-11e7-93ae-92361f002671", 32 | CHARACTERISTIC_MODEL_NUMBER: "00002a24-0000-1000-8000-00805f9b34fb", # Not used 33 | } 34 | ) 35 | 36 | # Update charateristics used in this device 37 | self.chars.update( 38 | { 39 | CHARACTERISTIC_AUTOMATIC_CYCLES: "7c4adc05-2f33-11e7-93ae-92361f002671", 40 | CHARACTERISTIC_TIME_FUNCTIONS: "7c4adc04-2f33-11e7-93ae-92361f002671", 41 | } 42 | ) 43 | 44 | # Add characteristics specific for Svensa 45 | self.chars.update( 46 | { 47 | CHARACTERISTIC_HUMIDITY: "7c4adc01-2f33-11e7-93ae-92361f002671", # humActive, humLevel, fanSpeed 48 | CHARACTERISTIC_CONSTANT_OPERATION: "7c4adc03-2f33-11e7-93ae-92361f002671", 49 | CHARACTERISTIC_PRESENCE_GAS: "7c4adc02-2f33-11e7-93ae-92361f002671", 50 | } 51 | ) 52 | 53 | # Override base method, this should return the correct pin 54 | async def pair(self) -> str: 55 | for _ in range(5): 56 | await self.setAuth(0) 57 | if (pin := await self.getAuth()) != 0: 58 | return str(pin) 59 | await asyncio.sleep(5) 60 | 61 | raise RuntimeError("Failed to retrieve a valid PIN after 5 attempts") 62 | 63 | ################################################ 64 | ############## STATE / SENSOR DATA ############# 65 | ################################################ 66 | async def getState(self) -> FanState: 67 | # Byte Byte Short Short Short Short Byte Byte Byte Byte Byte 68 | # Trg1 Trg2 Hum Gas Light FanSpeed Tbd Tbd Tbd Temp? Tbd 69 | v = unpack( 70 | "<2B4H5B", await self._readUUID(self.chars[CHARACTERISTIC_SENSOR_DATA]) 71 | ) 72 | _LOGGER.debug("Read Fan States: %s", v) 73 | 74 | # Found in package com.component.svara.views.calima.SkyModeView 75 | trigger = "No trigger" 76 | 77 | if v[1] & 0x10: 78 | # Check 5th-last bit (and remaining = 0) 79 | trigger = "Constant Speed" 80 | else: 81 | # Check last 4 bits 82 | trg_value = v[1] & 0x0F 83 | if trg_value != 11: 84 | match trg_value: 85 | case 0: 86 | trigger = "Idle" 87 | case 1: 88 | trigger = "Humidity" 89 | case 2: 90 | trigger = "Light" 91 | case 3: 92 | trigger = "Timer" 93 | case 4: 94 | trigger = "Air Quality Sensor" 95 | case 5: 96 | trigger = "Airing" 97 | case 6: 98 | trigger = "Pause" 99 | case 7: 100 | trigger = "Boost" 101 | case _: 102 | trigger = "Timer" 103 | else: 104 | trigger = "Timer" 105 | 106 | # FanState = namedtuple("FanState", "Humidity AirQuality Temp Light RPM Mode") 107 | return FanState( 108 | round(15 * math.log2(v[2]) - 75, 2) if v[2] > 35 else 0, 109 | v[3], 110 | v[9], 111 | v[4], 112 | v[5], 113 | trigger, 114 | ) 115 | 116 | ################################################ 117 | ############ CONFIGURATION FUNCTIONS ########### 118 | ################################################ 119 | async def getAutomaticCycles(self) -> AutomaticCycles: 120 | # Active | Hour | TimeMin | Speed 121 | l = AutomaticCycles._make( 122 | unpack( 123 | "<3BH", 124 | await self._readUUID(self.chars[CHARACTERISTIC_AUTOMATIC_CYCLES]), 125 | ) 126 | ) 127 | 128 | # We fix so that TimeMin = 0 if Active = 0 129 | return AutomaticCycles(l.Active, l.Hour, l.TimeMin if l.Active else 0, l.Speed) 130 | 131 | async def setAutomaticCycles(self, hour: int, timeMin: int, speed: int) -> None: 132 | await self._writeUUID( 133 | self.chars[CHARACTERISTIC_AUTOMATIC_CYCLES], 134 | pack("<3BH", timeMin > 0, hour, timeMin, speed), 135 | ) 136 | 137 | async def getConstantOperation(self) -> ConstantOperation: 138 | v = unpack( 139 | " None: 147 | _LOGGER.debug("Write Constant Operation settings") 148 | 149 | if speed % 25: 150 | raise ValueError("Speed must be a multiple of 25") 151 | if not active: 152 | speed = 0 153 | 154 | await self._writeUUID( 155 | self.chars[CHARACTERISTIC_CONSTANT_OPERATION], pack(" Humidity: 159 | v = unpack(" None: 166 | _LOGGER.debug("Write Fan Humidity settings") 167 | 168 | if speed % 25: 169 | raise ValueError("Speed must be a multiple of 25") 170 | if not active: 171 | level = 0 172 | speed = 0 173 | 174 | await self._writeUUID( 175 | self.chars[CHARACTERISTIC_HUMIDITY], pack(" PresenceGas: 179 | # Pres Active | Pres Sensitivity | Gas Active | Gas Sensitivity 180 | # We fix so that Sensitivity = 0 if active = 0 181 | l = PresenceGas._make( 182 | unpack("<4B", await self._readUUID(self.chars[CHARACTERISTIC_PRESENCE_GAS])) 183 | ) 184 | 185 | return PresenceGas._make( 186 | unpack( 187 | "<4B", 188 | bytearray( 189 | [ 190 | l.PresenceActive, 191 | l.PresenceActive and l.PresenceLevel, 192 | l.GasActive, 193 | l.GasActive and l.GasLevel, 194 | ] 195 | ), 196 | ) 197 | ) 198 | 199 | async def setPresenceGas( 200 | self, 201 | presence_active: bool, 202 | presence_level: int, 203 | gas_active: bool, 204 | gas_level: int, 205 | ) -> None: 206 | _LOGGER.debug("Write Fan Presence/Gas settings") 207 | 208 | if not presence_active: 209 | presence_level = 0 210 | if not gas_active: 211 | gas_level = 0 212 | 213 | await self._writeUUID( 214 | self.chars[CHARACTERISTIC_PRESENCE_GAS], 215 | pack("<4B", presence_active, presence_level, gas_active, gas_level), 216 | ) 217 | 218 | async def getTimerFunctions(self) -> TimerFunctions: 219 | # PresenceTime | TimeActive | TimeMin | Speed, we fix so that TimeMin (Delay Time) = 0 if TimeActive = 0 220 | l = TimerFunctions._make( 221 | unpack( 222 | "<3BH", await self._readUUID(self.chars[CHARACTERISTIC_TIME_FUNCTIONS]) 223 | ) 224 | ) 225 | return TimerFunctions._make( 226 | unpack( 227 | "<3BH", 228 | bytearray( 229 | [l.PresenceTime, l.TimeActive, l.TimeActive and l.TimeMin, l.Speed] 230 | ), 231 | ) 232 | ) 233 | 234 | async def setTimerFunctions( 235 | self, presenceTimeMin, timeActive: bool, timeMin: int, speed: int 236 | ) -> None: 237 | if presenceTimeMin not in (5, 10, 15, 30, 60): 238 | raise ValueError("presenceTime must be 5, 10, 15, 30 or 60 minutes") 239 | if timeMin not in (0, 2, 4): 240 | raise ValueError("timeActive must be 0, 2 or 4 minutes") 241 | if speed % 25: 242 | raise ValueError("Speed must be a multiple of 25") 243 | 244 | await self._writeUUID( 245 | self.chars[CHARACTERISTIC_TIME_FUNCTIONS], 246 | pack("<3BH", presenceTimeMin, timeActive, timeMin, speed), 247 | ) 248 | -------------------------------------------------------------------------------- /custom_components/pax_ble/entity.py: -------------------------------------------------------------------------------- 1 | """Base entity class for Pax Calima integration.""" 2 | 3 | import logging 4 | 5 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 6 | 7 | from .const import DOMAIN 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | class PaxCalimaEntity(CoordinatorEntity): 13 | """Pax Calima base entity class.""" 14 | 15 | def __init__(self, coordinator, paxentity): 16 | """Pass coordinator to CoordinatorEntity.""" 17 | super().__init__(coordinator) 18 | 19 | """Generic Entity properties""" 20 | self._attr_entity_category = paxentity.category 21 | self._attr_icon = paxentity.icon 22 | self._attr_name = "{} {}".format( 23 | self.coordinator.devicename, paxentity.entityName 24 | ) 25 | self._attr_unique_id = "{}-{}".format(self.coordinator.device_id, self.name) 26 | self._attr_device_info = { 27 | "identifiers": self.coordinator.identifiers, 28 | } 29 | self._extra_state_attributes = {} 30 | 31 | """Store this entities key.""" 32 | self._key = paxentity.key 33 | 34 | @property 35 | def extra_state_attributes(self): 36 | """Return the state attributes.""" 37 | return self._extra_state_attributes 38 | -------------------------------------------------------------------------------- /custom_components/pax_ble/enums.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eriknn/ha-pax_ble/aba2561772eb9e62ee5b89d5c0a46b62646e5b26/custom_components/pax_ble/enums.py -------------------------------------------------------------------------------- /custom_components/pax_ble/helpers.py: -------------------------------------------------------------------------------- 1 | from .const import ( 2 | CONF_NAME, 3 | CONF_MODEL, 4 | CONF_MAC, 5 | CONF_PIN, 6 | CONF_SCAN_INTERVAL, 7 | CONF_SCAN_INTERVAL_FAST, 8 | ) 9 | from .const import DeviceModel 10 | from .coordinator_calima import CalimaCoordinator 11 | from .coordinator_svensa import SvensaCoordinator 12 | 13 | 14 | def getCoordinator(hass, device_data, dev): 15 | name = device_data[CONF_NAME] 16 | model = device_data.get(CONF_MODEL, "Calima") 17 | mac = device_data[CONF_MAC] 18 | pin = device_data[CONF_PIN] 19 | scan_interval = device_data[CONF_SCAN_INTERVAL] 20 | scan_interval_fast = device_data[CONF_SCAN_INTERVAL_FAST] 21 | 22 | # Set up coordinator 23 | coordinator = None 24 | match DeviceModel(model): 25 | case DeviceModel.CALIMA | DeviceModel.SVARA: 26 | coordinator = CalimaCoordinator( 27 | hass, dev, model, mac, pin, scan_interval, scan_interval_fast 28 | ) 29 | case DeviceModel.SVENSA: 30 | coordinator = SvensaCoordinator( 31 | hass, dev, model, mac, pin, scan_interval, scan_interval_fast 32 | ) 33 | case _: 34 | _LOGGER.debug("Unknown fan model") 35 | 36 | return coordinator 37 | -------------------------------------------------------------------------------- /custom_components/pax_ble/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "pax_ble", 3 | "name": "Pax Bluetooth", 4 | "bluetooth": [ 5 | {"local_name": "PAX Calima"} 6 | ], 7 | "codeowners": ["@eriknn"], 8 | "config_flow": true, 9 | "dependencies": ["bluetooth"], 10 | "documentation": "https://github.com/eriknn/ha-pax", 11 | "iot_class": "local_polling", 12 | "issue_tracker": "https://github.com/eriknn/ha-pax/issues", 13 | "loggers": ["custom_components.pax_ble"], 14 | "requirements": [], 15 | "version": "1.1.11" 16 | } 17 | -------------------------------------------------------------------------------- /custom_components/pax_ble/number.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from collections import namedtuple 4 | from homeassistant.components.number import NumberDeviceClass, NumberEntity 5 | from homeassistant.const import CONF_DEVICES 6 | from homeassistant.const import UnitOfTemperature, UnitOfTime 7 | from homeassistant.const import REVOLUTIONS_PER_MINUTE 8 | from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN 9 | from homeassistant.helpers.entity import EntityCategory 10 | from homeassistant.helpers.restore_state import RestoreEntity 11 | 12 | from .const import DOMAIN, CONF_NAME 13 | from .const import DeviceModel 14 | from .entity import PaxCalimaEntity 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | OptionsTuple = namedtuple("options", ["min_value", "max_value", "step"]) 19 | OPTIONS = {} 20 | OPTIONS["fanspeed"] = OptionsTuple(800, 2400, 25) 21 | OPTIONS["temperature"] = OptionsTuple(15, 30, 1) 22 | OPTIONS["boostmodesec"] = OptionsTuple(60, 900, 1) 23 | OPTIONS["boostmodespeed"] = OptionsTuple(1000, 2400, 25) 24 | 25 | PaxEntity = namedtuple( 26 | "PaxEntity", 27 | ["key", "entityName", "units", "deviceClass", "category", "icon", "options"], 28 | ) 29 | ENTITIES = [ 30 | PaxEntity( 31 | "fanspeed_humidity", 32 | "Fanspeed Humidity", 33 | REVOLUTIONS_PER_MINUTE, 34 | None, 35 | EntityCategory.CONFIG, 36 | "mdi:engine", 37 | OPTIONS["fanspeed"], 38 | ), 39 | PaxEntity( 40 | "fanspeed_trickle", 41 | "Fanspeed Trickle", 42 | REVOLUTIONS_PER_MINUTE, 43 | None, 44 | EntityCategory.CONFIG, 45 | "mdi:engine", 46 | OPTIONS["fanspeed"], 47 | ), 48 | ] 49 | CALIMA_ENTITIES = [ 50 | PaxEntity( 51 | "heatdistributorsettings_temperaturelimit", 52 | "HeatDistributorSettings TemperatureLimit", 53 | UnitOfTemperature.CELSIUS, 54 | NumberDeviceClass.TEMPERATURE, 55 | EntityCategory.CONFIG, 56 | None, 57 | OPTIONS["temperature"], 58 | ), 59 | PaxEntity( 60 | "heatdistributorsettings_fanspeedbelow", 61 | "HeatDistributorSettings FanSpeedBelow", 62 | REVOLUTIONS_PER_MINUTE, 63 | None, 64 | EntityCategory.CONFIG, 65 | "mdi:engine", 66 | OPTIONS["fanspeed"], 67 | ), 68 | PaxEntity( 69 | "heatdistributorsettings_fanspeedabove", 70 | "HeatDistributorSettings FanSpeedAbove", 71 | REVOLUTIONS_PER_MINUTE, 72 | None, 73 | EntityCategory.CONFIG, 74 | "mdi:engine", 75 | OPTIONS["fanspeed"], 76 | ), 77 | PaxEntity( 78 | "fanspeed_light", 79 | "Fanspeed Light", 80 | REVOLUTIONS_PER_MINUTE, 81 | None, 82 | EntityCategory.CONFIG, 83 | "mdi:engine", 84 | OPTIONS["fanspeed"], 85 | ), 86 | ] 87 | SVENSA_ENTITIES = [ 88 | PaxEntity( 89 | "fanspeed_airing", 90 | "Fanspeed Airing", 91 | REVOLUTIONS_PER_MINUTE, 92 | None, 93 | EntityCategory.CONFIG, 94 | "mdi:engine", 95 | OPTIONS["fanspeed"], 96 | ), 97 | PaxEntity( 98 | "fanspeed_sensor", 99 | "Fanspeed Sensor", 100 | REVOLUTIONS_PER_MINUTE, 101 | None, 102 | EntityCategory.CONFIG, 103 | "mdi:engine", 104 | OPTIONS["fanspeed"], 105 | ), 106 | ] 107 | RESTOREENTITIES = [ 108 | PaxEntity( 109 | "boostmodesecwrite", 110 | "BoostMode Time", 111 | UnitOfTime.SECONDS, 112 | None, 113 | EntityCategory.CONFIG, 114 | "mdi:timer-outline", 115 | OPTIONS["boostmodesec"], 116 | ), 117 | PaxEntity( 118 | "boostmodespeedwrite", 119 | "BoostMode Speed", 120 | REVOLUTIONS_PER_MINUTE, 121 | None, 122 | EntityCategory.CONFIG, 123 | "mdi:engine", 124 | OPTIONS["boostmodespeed"], 125 | ), 126 | ] 127 | 128 | 129 | async def async_setup_entry(hass, config_entry, async_add_devices): 130 | """Setup numbers from a config entry created in the integrations UI.""" 131 | # Create entities 132 | ha_entities = [] 133 | 134 | for device_id in config_entry.data[CONF_DEVICES]: 135 | _LOGGER.debug( 136 | "Starting paxcalima numbers: %s", 137 | config_entry.data[CONF_DEVICES][device_id][CONF_NAME], 138 | ) 139 | 140 | # Find coordinator for this device 141 | coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_DEVICES][device_id] 142 | 143 | # Create entities for this device 144 | for paxentity in ENTITIES: 145 | ha_entities.append(PaxCalimaNumberEntity(coordinator, paxentity)) 146 | 147 | # Device specific entities 148 | match coordinator._model: 149 | case DeviceModel.CALIMA.value | DeviceModel.SVARA.value: 150 | for paxentity in CALIMA_ENTITIES: 151 | ha_entities.append(PaxCalimaNumberEntity(coordinator, paxentity)) 152 | case DeviceModel.SVENSA.value: 153 | for paxentity in SVENSA_ENTITIES: 154 | ha_entities.append(PaxCalimaNumberEntity(coordinator, paxentity)) 155 | 156 | # Entities with local datas 157 | for paxentity in RESTOREENTITIES: 158 | ha_entities.append(PaxCalimaRestoreNumberEntity(coordinator, paxentity)) 159 | 160 | async_add_devices(ha_entities, True) 161 | 162 | 163 | class PaxCalimaNumberEntity(PaxCalimaEntity, NumberEntity): 164 | """Representation of a Number.""" 165 | 166 | def __init__(self, coordinator, paxentity): 167 | """Pass coordinator to PaxCalimaEntity.""" 168 | super().__init__(coordinator, paxentity) 169 | 170 | """Number Entity properties""" 171 | self._attr_device_class = paxentity.deviceClass 172 | self._attr_mode = "box" 173 | self._attr_native_min_value = paxentity.options.min_value 174 | self._attr_native_max_value = paxentity.options.max_value 175 | self._attr_native_step = paxentity.options.step 176 | self._attr_native_unit_of_measurement = paxentity.units 177 | 178 | @property 179 | def native_value(self) -> float | None: 180 | """Return number value.""" 181 | try: 182 | return int(self.coordinator.get_data(self._key)) 183 | except: 184 | return None 185 | 186 | async def async_set_native_value(self, value): 187 | """Save old value""" 188 | old_value = self.coordinator.get_data(self._key) 189 | 190 | """ Write new value to our storage """ 191 | self.coordinator.set_data(self._key, int(value)) 192 | 193 | """ Write value to device """ 194 | if not await self.coordinator.write_data(self._key): 195 | """Restore value""" 196 | self.coordinator.set_data(self._key, old_value) 197 | 198 | self.async_schedule_update_ha_state(force_refresh=False) 199 | 200 | 201 | class PaxCalimaRestoreNumberEntity(PaxCalimaNumberEntity, RestoreEntity): 202 | """Representation of a Number with restore ability (and only local storage).""" 203 | 204 | def __init__(self, coordinator, paxentity): 205 | super().__init__(coordinator, paxentity) 206 | 207 | async def async_added_to_hass(self) -> None: 208 | """Restore last state.""" 209 | await super().async_added_to_hass() 210 | last_state = await self.async_get_last_state() 211 | 212 | if (last_state is not None) and ( 213 | last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) 214 | ): 215 | self.coordinator.set_data(self._key, last_state.state) 216 | 217 | async def async_set_native_value(self, value): 218 | self.coordinator.set_data(self._key, int(value)) 219 | self.async_schedule_update_ha_state(force_refresh=False) 220 | -------------------------------------------------------------------------------- /custom_components/pax_ble/select.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from collections import namedtuple 4 | from homeassistant.components.select import SelectEntity 5 | from homeassistant.const import CONF_DEVICES 6 | from homeassistant.helpers.entity import EntityCategory 7 | 8 | from .const import DOMAIN, CONF_NAME 9 | from .const import DeviceModel 10 | from .entity import PaxCalimaEntity 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | # Creating nested dictionary of key/pairs 15 | OPTIONS = { 16 | "airing": { 17 | "0": "Off", 18 | "30": "30 min", 19 | "60": "60 min", 20 | "90": "90 min", 21 | "120": "120 min", 22 | }, 23 | "automatic_cycles": {"0": "Off", "1": "30 min", "2": "60 min", "3": "90 min"}, 24 | "lightsensorsettings_delayedstart": {"0": "No delay", "5": "5 min", "10": "10 min"}, 25 | "lightsensorsettings_runningtime": { 26 | "5": "5 min", 27 | "10": "10 min", 28 | "15": "15 min", 29 | "30": "30 min", 30 | "60": "60 min", 31 | }, 32 | "sensitivity": { 33 | "0": "Off", 34 | "1": "Low sensitivity", 35 | "2": "Medium sensitivity", 36 | "3": "High sensitivity", 37 | }, 38 | "timer_delay": { 39 | "0": "Off", 40 | "2": "2 min", 41 | "4": "4 min", 42 | }, 43 | } 44 | 45 | PaxEntity = namedtuple( 46 | "PaxEntity", ["key", "entityName", "category", "icon", "options"] 47 | ) 48 | ENTITIES = [ 49 | PaxEntity( 50 | "sensitivity_humidity", 51 | "Sensitivity Humidity", 52 | EntityCategory.CONFIG, 53 | "mdi:water-percent", 54 | OPTIONS["sensitivity"], 55 | ), 56 | ] 57 | CALIMA_ENTITIES = [ 58 | PaxEntity( 59 | "automatic_cycles", 60 | "Automatic Cycles", 61 | EntityCategory.CONFIG, 62 | "mdi:fan-auto", 63 | OPTIONS["automatic_cycles"], 64 | ), 65 | PaxEntity( 66 | "sensitivity_light", 67 | "Sensitivity Light", 68 | EntityCategory.CONFIG, 69 | "mdi:brightness-5", 70 | OPTIONS["sensitivity"], 71 | ), 72 | PaxEntity( 73 | "lightsensorsettings_delayedstart", 74 | "LightSensorSettings DelayedStart", 75 | EntityCategory.CONFIG, 76 | "mdi:timer-outline", 77 | OPTIONS["lightsensorsettings_delayedstart"], 78 | ), 79 | PaxEntity( 80 | "lightsensorsettings_runningtime", 81 | "LightSensorSettings Runningtime", 82 | EntityCategory.CONFIG, 83 | "mdi:timer-outline", 84 | OPTIONS["lightsensorsettings_runningtime"], 85 | ), 86 | ] 87 | SVENSA_ENTITIES = [ 88 | PaxEntity( 89 | "airing", "Airing", EntityCategory.CONFIG, "mdi:fan-auto", OPTIONS["airing"] 90 | ), 91 | PaxEntity( 92 | "sensitivity_presence", 93 | "Sensitivity Presence", 94 | EntityCategory.CONFIG, 95 | "mdi:brightness-5", 96 | OPTIONS["sensitivity"], 97 | ), 98 | PaxEntity( 99 | "sensitivity_gas", 100 | "Sensitivity Gas", 101 | EntityCategory.CONFIG, 102 | "mdi:molecule", 103 | OPTIONS["sensitivity"], 104 | ), 105 | PaxEntity( 106 | "timer_runtime", 107 | "Timer Runtime", 108 | EntityCategory.CONFIG, 109 | "mdi:timer-outline", 110 | OPTIONS["lightsensorsettings_runningtime"], 111 | ), 112 | PaxEntity( 113 | "timer_delay", 114 | "Timer Delay", 115 | EntityCategory.CONFIG, 116 | "mdi:timer-outline", 117 | OPTIONS["timer_delay"], 118 | ), 119 | ] 120 | 121 | 122 | async def async_setup_entry(hass, config_entry, async_add_devices): 123 | """Setup selects from a config entry created in the integrations UI.""" 124 | # Create entities 125 | ha_entities = [] 126 | 127 | for device_id in config_entry.data[CONF_DEVICES]: 128 | _LOGGER.debug( 129 | "Starting paxcalima selects: %s", 130 | config_entry.data[CONF_DEVICES][device_id][CONF_NAME], 131 | ) 132 | 133 | # Find coordinator for this device 134 | coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_DEVICES][device_id] 135 | 136 | # Create entities for this device 137 | for paxentity in ENTITIES: 138 | ha_entities.append(PaxCalimaSelectEntity(coordinator, paxentity)) 139 | 140 | # Device specific entities 141 | match coordinator._model: 142 | case DeviceModel.CALIMA.value: 143 | for paxentity in CALIMA_ENTITIES: 144 | ha_entities.append(PaxCalimaSelectEntity(coordinator, paxentity)) 145 | case DeviceModel.SVENSA.value: 146 | for paxentity in SVENSA_ENTITIES: 147 | ha_entities.append(PaxCalimaSelectEntity(coordinator, paxentity)) 148 | 149 | async_add_devices(ha_entities, True) 150 | 151 | 152 | class PaxCalimaSelectEntity(PaxCalimaEntity, SelectEntity): 153 | """Representation of a Select.""" 154 | 155 | def __init__(self, coordinator, paxentity): 156 | """Pass coordinator to PaxCalimaEntity.""" 157 | super().__init__(coordinator, paxentity) 158 | 159 | """Select Entity properties""" 160 | self._options = paxentity.options 161 | 162 | @property 163 | def current_option(self): 164 | try: 165 | optionIndexStr = self.coordinator.get_data(self._key) 166 | option = self._options[str(optionIndexStr)] 167 | except Exception as e: 168 | option = "Unknown" 169 | return option 170 | 171 | @property 172 | def options(self): 173 | return list(self._options.values()) 174 | 175 | async def async_select_option(self, option): 176 | """Save old value""" 177 | old_value = self.coordinator.get_data(self._key) 178 | 179 | """ Find new value """ 180 | value = None 181 | for key, val in self._options.items(): 182 | if val == option: 183 | value = key 184 | break 185 | 186 | if value is None: 187 | return 188 | 189 | """ Write new value to our storage """ 190 | self.coordinator.set_data(self._key, value) 191 | 192 | """ Write value to device """ 193 | ret = await self.coordinator.write_data(self._key) 194 | 195 | """ Update HA value """ 196 | if not ret: 197 | """Restore value""" 198 | self.coordinator.set_data(self._key, old_value) 199 | self.async_schedule_update_ha_state(force_refresh=False) 200 | 201 | 202 | # type: ignore 203 | -------------------------------------------------------------------------------- /custom_components/pax_ble/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from collections import namedtuple 4 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 5 | from homeassistant.helpers.entity import EntityCategory 6 | from homeassistant.const import CONF_DEVICES 7 | from homeassistant.const import UnitOfVolumeFlowRate, UnitOfTemperature 8 | from homeassistant.const import ( 9 | LIGHT_LUX, 10 | PERCENTAGE, 11 | REVOLUTIONS_PER_MINUTE, 12 | CONCENTRATION_PARTS_PER_MILLION, 13 | ) 14 | 15 | from .const import DOMAIN, CONF_NAME 16 | from .const import DeviceModel 17 | from .entity import PaxCalimaEntity 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | PaxEntity = namedtuple( 22 | "PaxEntity", ["key", "entityName", "units", "deviceClass", "category", "icon"] 23 | ) 24 | ENTITIES = [ 25 | PaxEntity( 26 | "humidity", "Humidity", PERCENTAGE, SensorDeviceClass.HUMIDITY, None, None 27 | ), 28 | PaxEntity( 29 | "temperature", 30 | "Temperature", 31 | UnitOfTemperature.CELSIUS, 32 | SensorDeviceClass.TEMPERATURE, 33 | None, 34 | None, 35 | ), 36 | PaxEntity("light", "Light", LIGHT_LUX, SensorDeviceClass.ILLUMINANCE, None, None), 37 | PaxEntity("rpm", "RPM", REVOLUTIONS_PER_MINUTE, None, None, "mdi:engine"), 38 | PaxEntity( 39 | "flow", 40 | "Flow", 41 | UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, 42 | None, 43 | None, 44 | "mdi:weather-windy", 45 | ), 46 | PaxEntity("state", "State", None, None, None, None), 47 | PaxEntity("mode", "Mode", None, None, EntityCategory.DIAGNOSTIC, None), 48 | ] 49 | SVENSA_ENTITIES = [ 50 | PaxEntity( 51 | "airquality", 52 | "Air Quality", 53 | CONCENTRATION_PARTS_PER_MILLION, 54 | SensorDeviceClass.GAS, 55 | None, 56 | None, 57 | ), 58 | ] 59 | 60 | 61 | async def async_setup_entry(hass, config_entry, async_add_devices): 62 | """Setup sensors from a config entry created in the integrations UI.""" 63 | # Create entities 64 | ha_entities = [] 65 | 66 | for device_id in config_entry.data[CONF_DEVICES]: 67 | _LOGGER.debug( 68 | "Starting paxcalima sensors: %s", 69 | config_entry.data[CONF_DEVICES][device_id][CONF_NAME], 70 | ) 71 | 72 | # Find coordinator for this device 73 | coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_DEVICES][device_id] 74 | 75 | # Create entities for this device 76 | for paxentity in ENTITIES: 77 | ha_entities.append(PaxCalimaSensorEntity(coordinator, paxentity)) 78 | 79 | # Device specific entities 80 | match coordinator._model: 81 | case DeviceModel.SVENSA.value: 82 | for paxentity in SVENSA_ENTITIES: 83 | ha_entities.append(PaxCalimaSensorEntity(coordinator, paxentity)) 84 | 85 | async_add_devices(ha_entities, True) 86 | 87 | 88 | class PaxCalimaSensorEntity(PaxCalimaEntity, SensorEntity): 89 | """Representation of a Sensor.""" 90 | 91 | def __init__(self, coordinator, paxentity): 92 | """Pass coordinator to PaxCalimaEntity.""" 93 | super().__init__(coordinator, paxentity) 94 | 95 | """Sensor Entity properties""" 96 | self._attr_device_class = paxentity.deviceClass 97 | self._attr_native_unit_of_measurement = paxentity.units 98 | 99 | @property 100 | def native_value(self): 101 | """Return the value of the sensor.""" 102 | return self.coordinator.get_data(self._key) 103 | -------------------------------------------------------------------------------- /custom_components/pax_ble/services.yaml: -------------------------------------------------------------------------------- 1 | request_update: 2 | name: "Request value update" 3 | description: "Triggers an update of data associated with a specific device." 4 | fields: 5 | device_id: 6 | name: "Device ID" 7 | description: "The device for which to update values." 8 | selector: 9 | device: 10 | integration: pax_ble -------------------------------------------------------------------------------- /custom_components/pax_ble/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Pax", 3 | "config": { 4 | "step": { 5 | "add_device": { 6 | "title": "Pax: Add device", 7 | "description": "Enter your device details.", 8 | "data": { 9 | "name": "Name of device", 10 | "model": "Device model", 11 | "mac": "MAC Address (aa:bb:cc:dd:ee:ff)", 12 | "pin": "PIN Code", 13 | "scan_interval": "Scan Interval in seconds", 14 | "scan_interval_fast": "Fast Scan Interval in seconds" 15 | } 16 | }, 17 | "wrong_pin": { 18 | "title": "Pax: Wrong PIN", 19 | "data": { 20 | "wrong_pin_selector": "Select action." 21 | } 22 | } 23 | }, 24 | "error": { 25 | "cannot_connect": "Failed to connect", 26 | "cannot_pair": "Failed to pair", 27 | "wrong_pin": "Wrong PIN" 28 | }, 29 | "abort": { 30 | "add_success": "Device {dev_name} successfully added", 31 | "already_configured": "Integration already exists", 32 | "device_already_configured": "Device {dev_name} is already configured" 33 | } 34 | }, 35 | "options": { 36 | "step": { 37 | "init": { 38 | "title": "Pax BLE Configuration", 39 | "data": { 40 | "action": "Please select the desired action." 41 | } 42 | }, 43 | "add_device": { 44 | "title": "Pax BLE: Add device", 45 | "description": "Enter your device details.", 46 | "data": { 47 | "name": "Name of device", 48 | "model": "Device model", 49 | "mac": "MAC Address (aa:bb:cc:dd:ee:ff)", 50 | "pin": "PIN Code", 51 | "scan_interval": "Scan Interval in seconds", 52 | "scan_interval_fast": "Fast Scan Interval in seconds" 53 | } 54 | }, 55 | "wrong_pin": { 56 | "title": "Pax: Wrong PIN", 57 | "data": { 58 | "wrong_pin_selector": "Select action." 59 | } 60 | }, 61 | "edit_device": { 62 | "title": "Pax BLE: Edit device", 63 | "description": "Enter new device details for {dev_name}", 64 | "data": { 65 | "name": "Name of device", 66 | "model": "Device model", 67 | "mac": "MAC Address (aa:bb:cc:dd:ee:ff)", 68 | "pin": "PIN Code", 69 | "scan_interval": "Scan Interval in seconds", 70 | "scan_interval_fast": "Fast Scan Interval in seconds" 71 | } 72 | }, 73 | "remove_device": { 74 | "title": "Pax BLE: Remove device", 75 | "data": { 76 | "selected_device": "Select device to remove." 77 | } 78 | }, 79 | "select_edit_device": { 80 | "title": "Pax BLE: Edit Device", 81 | "data": { 82 | "selected_device": "Select device to edit." 83 | } 84 | } 85 | }, 86 | "error": { 87 | "cannot_connect": "Failed to connect", 88 | "cannot_pair": "Failed to pair", 89 | "wrong_pin": "Wrong PIN" 90 | }, 91 | "abort": { 92 | "add_success": "Device {dev_name} successfully added", 93 | "already_configured": "Device {dev_name} is already configured", 94 | "edit_success": "Device {dev_name} edited", 95 | "remove_success": "Device {dev_name} removed" 96 | } 97 | }, 98 | "selector": { 99 | "action": { 100 | "options": { 101 | "add_device": "Add device", 102 | "edit_device": "Edit device", 103 | "remove_device": "Remove device" 104 | } 105 | }, 106 | "wrong_pin_selector": { 107 | "options": { 108 | "accept": "Accept wrong pin", 109 | "decline": "Try new pin", 110 | "pair": "Start pairing" 111 | } 112 | } 113 | }, 114 | "services": { 115 | "request_update": { 116 | "name": "Request value update", 117 | "description": "Triggers an update of data associated with a specific device.", 118 | "fields": { 119 | "device_id": { 120 | "name": "Device ID", 121 | "description": "The device for which to update values." 122 | } 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /custom_components/pax_ble/switch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from collections import namedtuple 4 | from homeassistant.components.switch import SwitchEntity 5 | from homeassistant.const import CONF_DEVICES 6 | from homeassistant.helpers.entity import EntityCategory 7 | 8 | from .const import DOMAIN, CONF_NAME 9 | from .const import DeviceModel 10 | from .entity import PaxCalimaEntity 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | PaxAttribute = namedtuple("PaxAttribute", ["key", "descriptor", "unit"]) 15 | boostmode_attribute = PaxAttribute("boostmodesecread", "Boost time remaining", " s") 16 | 17 | PaxEntity = namedtuple( 18 | "PaxEntity", ["key", "entityName", "category", "icon", "attributes"] 19 | ) 20 | ENTITIES = [ 21 | PaxEntity("boostmode", "BoostMode", None, "mdi:wind-power", boostmode_attribute), 22 | ] 23 | CALIMA_ENTITIES = [ 24 | PaxEntity( 25 | "trickledays_weekdays", 26 | "TrickleDays Weekdays", 27 | EntityCategory.CONFIG, 28 | "mdi:calendar", 29 | None, 30 | ), 31 | PaxEntity( 32 | "trickledays_weekends", 33 | "TrickleDays Weekends", 34 | EntityCategory.CONFIG, 35 | "mdi:calendar", 36 | None, 37 | ), 38 | PaxEntity( 39 | "silenthours_on", 40 | "SilentHours On", 41 | EntityCategory.CONFIG, 42 | "mdi:volume-off", 43 | None, 44 | ), 45 | ] 46 | SVENSA_ENTITIES = [ 47 | PaxEntity( 48 | "trickle_on", "Trickle On", EntityCategory.CONFIG, "mdi:volume-off", None 49 | ), 50 | ] 51 | 52 | 53 | async def async_setup_entry(hass, config_entry, async_add_devices): 54 | """Setup switch from a config entry created in the integrations UI.""" 55 | # Create entities 56 | ha_entities = [] 57 | 58 | for device_id in config_entry.data[CONF_DEVICES]: 59 | _LOGGER.debug( 60 | "Starting paxcalima switches: %s", 61 | config_entry.data[CONF_DEVICES][device_id][CONF_NAME], 62 | ) 63 | 64 | # Find coordinator for this device 65 | coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_DEVICES][device_id] 66 | 67 | # Create entities for this device 68 | for paxentity in ENTITIES: 69 | ha_entities.append(PaxCalimaSwitchEntity(coordinator, paxentity)) 70 | 71 | # Device specific entities 72 | match coordinator._model: 73 | case DeviceModel.CALIMA.value | DeviceModel.SVARA.value: 74 | for paxentity in CALIMA_ENTITIES: 75 | ha_entities.append(PaxCalimaSwitchEntity(coordinator, paxentity)) 76 | case DeviceModel.SVENSA.value: 77 | for paxentity in SVENSA_ENTITIES: 78 | ha_entities.append(PaxCalimaSwitchEntity(coordinator, paxentity)) 79 | 80 | async_add_devices(ha_entities, True) 81 | 82 | 83 | class PaxCalimaSwitchEntity(PaxCalimaEntity, SwitchEntity): 84 | """Representation of a Switch.""" 85 | 86 | def __init__(self, coordinator, paxentity): 87 | """Pass coordinator to PaxCalimaEntity.""" 88 | super().__init__(coordinator, paxentity) 89 | self._paxattr = paxentity.attributes 90 | 91 | @property 92 | def is_on(self): 93 | """Return the state of the switch.""" 94 | return self.coordinator.get_data(self._key) 95 | 96 | @property 97 | def extra_state_attributes(self): 98 | if self._paxattr is not None: 99 | """Return entity specific state attributes.""" 100 | descriptor = self._paxattr.descriptor 101 | key = self.coordinator.get_data(self._paxattr.key) 102 | unit = self._paxattr.unit 103 | attrs = {descriptor: str(key) + unit} 104 | attrs.update(super().extra_state_attributes) 105 | return attrs 106 | else: 107 | return None 108 | 109 | async def async_turn_on(self, **kwargs): 110 | _LOGGER.debug("Enabling Boost Mode") 111 | await self.writeVal(1) 112 | 113 | async def async_turn_off(self, **kwargs): 114 | _LOGGER.debug("Disabling Boost Mode") 115 | await self.writeVal(0) 116 | 117 | async def writeVal(self, val): 118 | """Save old value""" 119 | old_value = self.coordinator.get_data(self._key) 120 | 121 | """Write new value to our storage""" 122 | self.coordinator.set_data(self._key, val) 123 | 124 | """ Write value to device """ 125 | ret = await self.coordinator.write_data(self._key) 126 | 127 | """ Update HA value """ 128 | if not ret: 129 | """Restore value""" 130 | self.coordinator.set_data(self._key, old_value) 131 | self.async_schedule_update_ha_state(force_refresh=False) 132 | -------------------------------------------------------------------------------- /custom_components/pax_ble/test_pax.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import platform 3 | import sys 4 | import logging 5 | from struct import pack, unpack 6 | from bleak import BleakClient 7 | 8 | CHARACTERISTIC_DEVICE_NAME = "00002a00-0000-1000-8000-00805f9b34fb" 9 | CHARACTERISTIC_FAN_DESCRIPTION = "b85fa07a-9382-4838-871c-81d045dcc2ff" 10 | CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb" 11 | CHARACTERISTIC_HARDWARE_REVISION = "00002a27-0000-1000-8000-00805f9b34fb" 12 | CHARACTERISTIC_SOFTWARE_REVISION = "00002a28-0000-1000-8000-00805f9b34fb" 13 | CHARACTERISTIC_MODEL_NAME = "00002a00-0000-1000-8000-00805f9b34fb" 14 | CHARACTERISTIC_MODEL_NUMBER = "00002a24-0000-1000-8000-00805f9b34fb" 15 | CHARACTERISTIC_SENSOR_DATA = "528b80e8-c47a-4c0a-bdf1-916a7748f412" 16 | 17 | logger = logging.getLogger(__name__) 18 | ADDRESS = "58:2b:db:c8:f3:8a" 19 | 20 | 21 | async def main(): 22 | async with BleakClient(ADDRESS) as client: 23 | result = bytes(await client.read_gatt_char(CHARACTERISTIC_SENSOR_DATA)) 24 | print(len(result)) 25 | v = unpack("<" + str(len(result)) + "B", result) 26 | print(v) 27 | v = unpack("<4HBHB", result) 28 | print(v) 29 | 30 | 31 | if __name__ == "__main__": 32 | logging.basicConfig(level=logging.INFO) 33 | asyncio.run(main()) 34 | -------------------------------------------------------------------------------- /custom_components/pax_ble/time.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from collections import namedtuple 4 | from datetime import time 5 | from homeassistant.components.time import TimeEntity 6 | from homeassistant.const import CONF_DEVICES 7 | from homeassistant.helpers.entity import EntityCategory 8 | 9 | from .const import DOMAIN, CONF_NAME 10 | from .const import DeviceModel 11 | from .entity import PaxCalimaEntity 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | PaxEntity = namedtuple("PaxEntity", ["key", "entityName", "category", "icon"]) 16 | CALIMA_ENTITIES = [ 17 | PaxEntity( 18 | "silenthours_starttime", 19 | "SilentHours Start Time", 20 | EntityCategory.CONFIG, 21 | "mdi:clock-outline", 22 | ), 23 | PaxEntity( 24 | "silenthours_endtime", 25 | "SilentHours End Time", 26 | EntityCategory.CONFIG, 27 | "mdi:clock-outline", 28 | ), 29 | ] 30 | 31 | 32 | async def async_setup_entry(hass, config_entry, async_add_devices): 33 | """Setup switch from a config entry created in the integrations UI.""" 34 | # Create entities 35 | ha_entities = [] 36 | 37 | for device_id in config_entry.data[CONF_DEVICES]: 38 | _LOGGER.debug( 39 | "Starting paxcalima times: %s", 40 | config_entry.data[CONF_DEVICES][device_id][CONF_NAME], 41 | ) 42 | 43 | # Find coordinator for this device 44 | coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_DEVICES][device_id] 45 | 46 | # Device specific entities 47 | match coordinator._model: 48 | case DeviceModel.CALIMA.value | DeviceModel.SVARA.value: 49 | for paxentity in CALIMA_ENTITIES: 50 | ha_entities.append(PaxCalimaTimeEntity(coordinator, paxentity)) 51 | case DeviceModel.SVENSA.value: 52 | # Svensa does not support these entities 53 | pass 54 | 55 | async_add_devices(ha_entities, True) 56 | 57 | 58 | class PaxCalimaTimeEntity(PaxCalimaEntity, TimeEntity): 59 | """Representation of a Time.""" 60 | 61 | def __init__(self, coordinator, paxentity): 62 | """Pass coordinator to PaxCalimaEntity.""" 63 | super().__init__(coordinator, paxentity) 64 | 65 | @property 66 | def native_value(self) -> time | None: 67 | """Return time value.""" 68 | try: 69 | return self.coordinator.get_data(self._key) 70 | except: 71 | return None 72 | 73 | async def async_set_value(self, value: time) -> None: 74 | """Save old value""" 75 | old_value = self.coordinator.get_data(self._key) 76 | 77 | """Create new value""" 78 | new_value = value 79 | 80 | """ Write new value to our storage """ 81 | self.coordinator.set_data(self._key, new_value) 82 | 83 | """ Write value to device """ 84 | if not await self.coordinator.write_data(self._key): 85 | """Restore value""" 86 | self.coordinator.set_data(self._key, old_value) 87 | 88 | self.async_schedule_update_ha_state(force_refresh=False) 89 | -------------------------------------------------------------------------------- /custom_components/pax_ble/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Pax", 3 | "config": { 4 | "step": { 5 | "add_device": { 6 | "title": "Pax: Add device", 7 | "description": "Enter your device details.", 8 | "data": { 9 | "name": "Name of device", 10 | "model": "Device model", 11 | "mac": "MAC Address (aa:bb:cc:dd:ee:ff)", 12 | "pin": "PIN Code", 13 | "scan_interval": "Scan Interval in seconds", 14 | "scan_interval_fast": "Fast Scan Interval in seconds" 15 | } 16 | }, 17 | "wrong_pin": { 18 | "title": "Pax: Wrong PIN", 19 | "data": { 20 | "wrong_pin_selector": "Select action." 21 | } 22 | } 23 | }, 24 | "error": { 25 | "cannot_connect": "Failed to connect", 26 | "cannot_pair": "Failed to pair", 27 | "wrong_pin": "Wrong PIN" 28 | }, 29 | "abort": { 30 | "add_success": "Device {dev_name} successfully added", 31 | "already_configured": "Integration already exists", 32 | "device_already_configured": "Device {dev_name} is already configured" 33 | } 34 | }, 35 | "options": { 36 | "step": { 37 | "init": { 38 | "title": "Pax BLE Configuration", 39 | "data": { 40 | "action": "Please select the desired action." 41 | } 42 | }, 43 | "add_device": { 44 | "title": "Pax BLE: Add device", 45 | "description": "Enter your device details.", 46 | "data": { 47 | "name": "Name of device", 48 | "model": "Device model", 49 | "mac": "MAC Address (aa:bb:cc:dd:ee:ff)", 50 | "pin": "PIN Code", 51 | "scan_interval": "Scan Interval in seconds", 52 | "scan_interval_fast": "Fast Scan Interval in seconds" 53 | } 54 | }, 55 | "wrong_pin": { 56 | "title": "Pax: Wrong PIN", 57 | "data": { 58 | "wrong_pin_selector": "Select action." 59 | } 60 | }, 61 | "edit_device": { 62 | "title": "Pax BLE: Edit device", 63 | "description": "Enter new device details for {dev_name}", 64 | "data": { 65 | "name": "Name of device", 66 | "model": "Device model", 67 | "mac": "MAC Address (aa:bb:cc:dd:ee:ff)", 68 | "pin": "PIN Code", 69 | "scan_interval": "Scan Interval in seconds", 70 | "scan_interval_fast": "Fast Scan Interval in seconds" 71 | } 72 | }, 73 | "remove_device": { 74 | "title": "Pax BLE: Remove device", 75 | "data": { 76 | "selected_device": "Select device to remove." 77 | } 78 | }, 79 | "select_edit_device": { 80 | "title": "Pax BLE: Edit Device", 81 | "data": { 82 | "selected_device": "Select device to edit." 83 | } 84 | } 85 | }, 86 | "error": { 87 | "cannot_connect": "Failed to connect", 88 | "cannot_pair": "Failed to pair", 89 | "wrong_pin": "Wrong PIN" 90 | }, 91 | "abort": { 92 | "add_success": "Device {dev_name} successfully added", 93 | "already_configured": "Device {dev_name} is already configured", 94 | "edit_success": "Device {dev_name} edited", 95 | "remove_success": "Device {dev_name} removed" 96 | } 97 | }, 98 | "selector": { 99 | "action": { 100 | "options": { 101 | "add_device": "Add device", 102 | "edit_device": "Edit device", 103 | "remove_device": "Remove device" 104 | } 105 | }, 106 | "wrong_pin_selector": { 107 | "options": { 108 | "accept": "Accept wrong pin", 109 | "decline": "Try new pin", 110 | "pair": "Start pairing" 111 | } 112 | } 113 | }, 114 | "services": { 115 | "request_update": { 116 | "name": "Request value update", 117 | "description": "Triggers an update of data associated with a specific device.", 118 | "fields": { 119 | "device_id": { 120 | "name": "Device ID", 121 | "description": "The device for which to update values." 122 | } 123 | } 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /custom_components/pax_ble/translations/fi.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Pax", 3 | "config": { 4 | "step": { 5 | "add_device": { 6 | "title": "Pax: Lisää laite", 7 | "description": "Anna laitteesi tiedot.", 8 | "data": { 9 | "name": "Laitteen nimi", 10 | "model": "Device model", 11 | "mac": "MAC-osoite (aa:bb:cc:dd:ee:ff)", 12 | "pin": "PIN-koodi", 13 | "scan_interval": "Päivitysväli sekunneissa", 14 | "scan_interval_fast": "Fast Scan Interval in seconds" 15 | } 16 | }, 17 | "wrong_pin": { 18 | "title": "Pax: Wrong PIN", 19 | "data": { 20 | "wrong_pin_selector": "Select action." 21 | } 22 | } 23 | }, 24 | "error": { 25 | "cannot_connect": "Yhdistäminen epäonnistui", 26 | "cannot_pair": "Failed to pair", 27 | "wrong_pin": "Väärä PIN" 28 | }, 29 | "abort": { 30 | "add_success": "Device {dev_name} successfully added", 31 | "already_configured": "Integration already exists", 32 | "device_already_configured": "Device {dev_name} is already configured" 33 | } 34 | }, 35 | "options": { 36 | "step": { 37 | "init": { 38 | "title": "Pax BLE Configuration", 39 | "data": { 40 | "action": "Please select the desired action." 41 | } 42 | }, 43 | "add_device": { 44 | "title": "Pax BLE: Add device", 45 | "description": "Enter your device details.", 46 | "data": { 47 | "name": "Laitteen nimi", 48 | "model": "Device model", 49 | "mac": "MAC-osoite (aa:bb:cc:dd:ee:ff)", 50 | "pin": "PIN-koodi", 51 | "scan_interval": "Päivitysväli sekunneissa", 52 | "scan_interval_fast": "Fast Scan Interval in seconds" 53 | } 54 | }, 55 | "wrong_pin": { 56 | "title": "Pax: Wrong PIN", 57 | "data": { 58 | "wrong_pin_selector": "Select action." 59 | } 60 | }, 61 | "edit_device": { 62 | "title": "Pax BLE: Edit device", 63 | "description": "Enter new device details.", 64 | "data": { 65 | "name": "Laitteen nimi", 66 | "model": "Device model", 67 | "mac": "MAC-osoite (aa:bb:cc:dd:ee:ff)", 68 | "pin": "PIN-koodi", 69 | "scan_interval": "Päivitysväli sekunneissa", 70 | "scan_interval_fast": "Fast Scan Interval in seconds" 71 | } 72 | }, 73 | "remove_device": { 74 | "title": "Pax BLE: Remove device", 75 | "data": { 76 | "selected_device": "Select device to remove." 77 | } 78 | }, 79 | "select_edit_device": { 80 | "title": "Pax BLE: Edit Device", 81 | "data": { 82 | "selected_device": "Select device to edit." 83 | } 84 | } 85 | }, 86 | "error": { 87 | "cannot_connect": "Failed to connect", 88 | "cannot_pair": "Failed to pair", 89 | "wrong_pin": "Wrong PIN" 90 | }, 91 | "abort": { 92 | "add_success": "Successfully added device", 93 | "already_configured": "Device is already configured", 94 | "edit_success": "Device edited", 95 | "remove_success": "Device removed" 96 | } 97 | }, 98 | "selector": { 99 | "action": { 100 | "options": { 101 | "add_device": "Add device", 102 | "edit_device": "Edit device", 103 | "remove_device": "Remove device" 104 | } 105 | }, 106 | "wrong_pin_selector": { 107 | "options": { 108 | "accept": "Accept wrong pin", 109 | "decline": "Try new pin", 110 | "pair": "Start pairing" 111 | } 112 | } 113 | }, 114 | "services": { 115 | "request_update": { 116 | "name": "Request value update", 117 | "description": "Triggers an update of data associated with a specific device.", 118 | "fields": { 119 | "device_id": { 120 | "name": "Device ID", 121 | "description": "The device for which to update values." 122 | } 123 | } 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /custom_components/pax_ble/translations/nb.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Pax", 3 | "config": { 4 | "step": { 5 | "add_device": { 6 | "title": "Pax: Legg til enhet", 7 | "description": "Angi informasjon om enhet.", 8 | "data": { 9 | "name": "Navn på enhet", 10 | "model": "Modell", 11 | "mac": "MAC-adresse (aa:bb:cc:dd:ee:ff)", 12 | "pin": "PIN-kode", 13 | "scan_interval": "Pollinterval i sekunder", 14 | "scan_interval_fast": "Hurtig Scan Interval i sekunder" 15 | } 16 | }, 17 | "wrong_pin": { 18 | "title": "Pax: Feil PIN-kode", 19 | "data": { 20 | "wrong_pin_selector": "Velg aksjon." 21 | } 22 | } 23 | }, 24 | "error": { 25 | "cannot_connect": "Tilkobling feilet", 26 | "cannot_pair": "Paring feilet", 27 | "wrong_pin": "Feil PIN" 28 | }, 29 | "abort": { 30 | "add_success": "Enheten {dev_name} ble lagt til", 31 | "already_configured": "Integrasjonen eksisterer allerede", 32 | "device_already_configured": "Enheten {dev_name} er allerede konfigurert" 33 | } 34 | }, 35 | "options": { 36 | "step": { 37 | "init": { 38 | "title": "Pax BLE Konfigurasjon", 39 | "data": { 40 | "action": "Vennligst velg aksjon." 41 | } 42 | }, 43 | "add_device": { 44 | "title": "Pax BLE: Legg til enhet", 45 | "description": "Angi informasjon om enhet.", 46 | "data": { 47 | "name": "Navn på enhet", 48 | "model": "Modell", 49 | "mac": "MAC-adresse (aa:bb:cc:dd:ee:ff)", 50 | "pin": "PIN-kode", 51 | "scan_interval": "Pollinterval i sekunder", 52 | "scan_interval_fast": "Hurtig Scan Interval i sekunder" 53 | } 54 | }, 55 | "wrong_pin": { 56 | "title": "Pax: Feil PIN-kode", 57 | "data": { 58 | "wrong_pin_selector": "Velg aksjon." 59 | } 60 | }, 61 | "edit_device": { 62 | "title": "Pax BLE: Rediger enhet", 63 | "description": "Angi ny informasjon for {dev_name}", 64 | "data": { 65 | "name": "Navn på enhet", 66 | "model": "Modell", 67 | "mac": "MAC-adresse (aa:bb:cc:dd:ee:ff)", 68 | "pin": "PIN-kode", 69 | "scan_interval": "Pollinterval i sekunder", 70 | "scan_interval_fast": "Hurtig Scan Interval i sekunder" 71 | } 72 | }, 73 | "remove_device": { 74 | "title": "Pax BLE: Fjern enhet", 75 | "data": { 76 | "selected_device": "Velg enhet som skal fjernes" 77 | } 78 | }, 79 | "select_edit_device": { 80 | "title": "Pax BLE: Rediger enhet", 81 | "data": { 82 | "selected_device": "Velg enhet som skal redigeres" 83 | } 84 | } 85 | }, 86 | "error": { 87 | "cannot_connect": "Tilkobling feilet", 88 | "cannot_pair": "Paring feilet", 89 | "wrong_pin": "Feil PIN" 90 | }, 91 | "abort": { 92 | "add_success": "Enheten {dev_name} ble lagt til", 93 | "already_configured": "Enheten {dev_name} er allerede konfigurert", 94 | "edit_success": "Enhet {dev_name} redigert", 95 | "remove_success": "Enhet {dev_name} fjernet" 96 | } 97 | }, 98 | "selector": { 99 | "action": { 100 | "options": { 101 | "add_device": "Legg til enhet", 102 | "edit_device": "Rediger enhet", 103 | "remove_device": "Fjern enhet" 104 | } 105 | }, 106 | "wrong_pin_selector": { 107 | "options": { 108 | "accept": "Godta feil PIN-kode", 109 | "decline": "Prøv ny PIN-kode", 110 | "pair": "Start paring" 111 | } 112 | } 113 | }, 114 | "services": { 115 | "request_update": { 116 | "name": "Be om verdioppdatering", 117 | "description": "Starter en oppdatering av data knyttet til en spesifikk enhet.", 118 | "fields": { 119 | "device_id": { 120 | "name": "Enhets ID", 121 | "description": "Enheten som skal oppdateres." 122 | } 123 | } 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /custom_components/pax_ble/translations/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Pax", 3 | "config": { 4 | "step": { 5 | "add_device": { 6 | "title": "Pax: Lägg till enhet", 7 | "description": "Ange information om enhet.", 8 | "data": { 9 | "name": "Namn på enhet", 10 | "model": "Device model", 11 | "mac": "MAC-adress (aa:bb:cc:dd:ee:ff)", 12 | "pin": "PIN-kod", 13 | "scan_interval": "Sökintervall i sekunder", 14 | "scan_interval_fast": "Snabbt skanningsintervall i sekunder" 15 | } 16 | }, 17 | "wrong_pin": { 18 | "title": "Pax: Wrong PIN", 19 | "data": { 20 | "wrong_pin_selector": "Select action." 21 | } 22 | } 23 | }, 24 | "error": { 25 | "cannot_connect": "Anslutning misslyckades", 26 | "cannot_pair": "Failed to pair", 27 | "wrong_pin": "Fel PIN-kod" 28 | }, 29 | "abort": { 30 | "add_success": "enhet {dev_name} lades till", 31 | "already_configured": "Integrationen existerar redan", 32 | "device_already_configured": "Enheten {dev_name} är redan konfigurerad" 33 | } 34 | }, 35 | "options": { 36 | "step": { 37 | "init": { 38 | "title": "Pax BLE Konfiguration", 39 | "data": { 40 | "action": "Var god välj önskad åtgärd." 41 | } 42 | }, 43 | "add_device": { 44 | "title": "Pax BLE: Lägg till enhet", 45 | "description": "Ange uppgifter om din enhet.", 46 | "data": { 47 | "name": "Namn på enhet", 48 | "model": "Device model", 49 | "mac": "MAC-adress (aa:bb:cc:dd:ee:ff)", 50 | "pin": "PIN-kod", 51 | "scan_interval": "Sökintervall i sekunder", 52 | "scan_interval_fast": "Snabbt skanningsintervall i sekunder" 53 | } 54 | }, 55 | "wrong_pin": { 56 | "title": "Pax: Wrong PIN", 57 | "data": { 58 | "wrong_pin_selector": "Select action." 59 | } 60 | }, 61 | "edit_device": { 62 | "title": "Pax BLE: Redigera enhet", 63 | "description": "Ange nya enhetsdetaljer för {dev_name}", 64 | "data": { 65 | "name": "Namn på enhet", 66 | "model": "Device model", 67 | "mac": "MAC-adress (aa:bb:cc:dd:ee:ff)", 68 | "pin": "PIN-kod", 69 | "scan_interval": "Sökintervall i sekunder", 70 | "scan_interval_fast": "Snabbt skanningsintervall i sekunder" 71 | } 72 | }, 73 | "remove_device": { 74 | "title": "Pax BLE: Ta bort enhet", 75 | "data": { 76 | "selected_device": "Ange enhet att ta bort." 77 | } 78 | }, 79 | "select_edit_device": { 80 | "title": "Pax BLE: Redigera enhet", 81 | "data": { 82 | "selected_device": "Ange enhet att redigera." 83 | } 84 | } 85 | }, 86 | "error": { 87 | "cannot_connect": "Anslutning misslyckades", 88 | "cannot_pair": "Failed to pair", 89 | "wrong_pin": "Fel PIN-kod" 90 | }, 91 | "abort": { 92 | "add_success": "Enhet {dev_name} lades till", 93 | "already_configured": "Enhet {dev_name} är redan konfigurerad", 94 | "edit_success": "Enhet {dev_name} redigerad", 95 | "remove_success": "Enhet {dev_name} borttagen" 96 | } 97 | }, 98 | "selector": { 99 | "action": { 100 | "options": { 101 | "add_device": "Lägg till enhet", 102 | "edit_device": "Redigera enhet", 103 | "remove_device": "Ta bort enhet" 104 | } 105 | }, 106 | "wrong_pin_selector": { 107 | "options": { 108 | "accept": "Accept wrong pin", 109 | "decline": "Try new pin", 110 | "pair": "Start pairing" 111 | } 112 | } 113 | }, 114 | "services": { 115 | "request_update": { 116 | "name": "Request value update", 117 | "description": "Triggers an update of data associated with a specific device.", 118 | "fields": { 119 | "device_id": { 120 | "name": "Device ID", 121 | "description": "The device for which to update values." 122 | } 123 | } 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pax BLE", 3 | "render_readme": true, 4 | "country": ["NO"] 5 | } 6 | -------------------------------------------------------------------------------- /svensa.md: -------------------------------------------------------------------------------- 1 | # How to connect Vent-Axia Svensa (PureAire Sense) fan 2 | 3 | This integration requires two fan-specific parameters to connect and be able to control Svensa: MAC address and PIN code. 4 | Unlike some other models, Svensa is not automatically connectable (as of Dec 2024). 5 | 6 | Below is one of the ways to make it work. 7 | 8 | ### MAC address 9 | Use a Bluetooth scanner app to list all nearby BLE devices. 10 | These instructions were tested with "BLE Scanner". Probably other apps offer similar functionality. 11 | 12 | NOTE: Apple devices (tested with iPhone and Macbook Pro) do not support listing MAC address. 13 | You'll have to use an Android tablet/smartphone or a Linux/Windows computer. 14 | 15 | The fan will be listed with the name "Svensa", and you'll be able to see the MAC address. 16 | It looks similar to AA:BB:CC:DD:EE:FF. Use it in the integration connection screen. 17 | 18 | ### PIN code 19 | * Once the device is found, connect to it with BLE Scanner. 20 | The app should show a directory-like structure of data items, each with a unique ID. 21 | Look for the one with ID 4cad343a-209a-40b7-b911-4d9b3df569b2. 22 | This item can be read and written. 23 | 24 | * Read it (press R in the app). It should obtain the hex value 00000000. 25 | 26 | * Put the fan in pairing mode: 27 | 1. Remove the front magnetic cover. 28 | 2. Make sure the fan is switched on (spins). 29 | 3. Press the "power" touch-button on the front right. The other buttons should illuminate. 30 | 4. Press the lowest button (looks like a WiFi symbol) for a few seconds, until it starts flashing. 31 | 32 | * Perform write operation on that data item 4cad...69b2. 33 | It will send the previously obtained zero value to the fan, and the fan will respond with the value of the PIN, in hex format (similar to aa:bb:cc:dd). 34 | * Finally, flip the bytes of the PIN (dd:cc:bb:aa), convert this value to decimal, and use the result in the integration PIN field. 35 | 36 | If the above doesn't work, power cycle the fan. It looses Bluetooth connection now and then (also with the official app). 37 | --------------------------------------------------------------------------------