├── custom_components ├── __init__.py └── dirigera_platform │ ├── services.yaml │ ├── requirements.txt │ ├── icons.json │ ├── const.py │ ├── manifest.json │ ├── cover.py │ ├── fan.py │ ├── binary_sensor.py │ ├── mocks │ ├── ikea_motion_sensor_mock.py │ ├── ikea_open_close_mock.py │ ├── ikea_outlet_mock.py │ ├── ikea_controller_mock.py │ ├── ikea_vindstyrka_mock.py │ ├── ikea_blinds_mock.py │ ├── ikea_bulb_mock.py │ └── ikea_air_purifier_mock.py │ ├── strings.json │ ├── switch.py │ ├── translations │ ├── en.json │ ├── de.json │ └── pt.json │ ├── scene.py │ ├── icons.py │ ├── device_trigger.py │ ├── dirigera_lib_patch.py │ ├── ikea_gateway.py │ ├── sensor.py │ ├── config_flow.py │ ├── __init__.py │ ├── hub_event_listener.py │ ├── light.py │ └── base_classes.py ├── hacs.json ├── screenshots ├── config-mock.png ├── mock-lights.png ├── mock-outlets.png ├── system-logs.png ├── settings-system.png ├── calling-dump-data.png ├── config-ip-details.png ├── enable-debugging.png ├── config-press-action.png ├── developertools-services.png ├── dirigera-integration-locate.png ├── settings-deviceandservices.png └── config-hub-setup-complete-mock.png ├── .github ├── workflows │ ├── hassfest.yaml │ ├── action.yaml │ └── validate.yaml └── pull_request_template.md ├── LICENSE ├── .gitignore └── README.md /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/services.yaml: -------------------------------------------------------------------------------- 1 | dump_data: -------------------------------------------------------------------------------- /custom_components/dirigera_platform/requirements.txt: -------------------------------------------------------------------------------- 1 | dirigera==1.2.1 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "IKEA Dirigera Hub Integration", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /screenshots/config-mock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/config-mock.png -------------------------------------------------------------------------------- /screenshots/mock-lights.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/mock-lights.png -------------------------------------------------------------------------------- /screenshots/mock-outlets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/mock-outlets.png -------------------------------------------------------------------------------- /screenshots/system-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/system-logs.png -------------------------------------------------------------------------------- /screenshots/settings-system.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/settings-system.png -------------------------------------------------------------------------------- /custom_components/dirigera_platform/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "dump_data": "mdi:dump-truck" 4 | } 5 | } -------------------------------------------------------------------------------- /screenshots/calling-dump-data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/calling-dump-data.png -------------------------------------------------------------------------------- /screenshots/config-ip-details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/config-ip-details.png -------------------------------------------------------------------------------- /screenshots/enable-debugging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/enable-debugging.png -------------------------------------------------------------------------------- /screenshots/config-press-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/config-press-action.png -------------------------------------------------------------------------------- /screenshots/developertools-services.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/developertools-services.png -------------------------------------------------------------------------------- /screenshots/dirigera-integration-locate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/dirigera-integration-locate.png -------------------------------------------------------------------------------- /screenshots/settings-deviceandservices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/settings-deviceandservices.png -------------------------------------------------------------------------------- /screenshots/config-hub-setup-complete-mock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanjoyg/dirigera_platform/HEAD/screenshots/config-hub-setup-complete-mock.png -------------------------------------------------------------------------------- /custom_components/dirigera_platform/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "dirigera_platform" 2 | PLATFORM="dirigera_platform" 3 | CONF_HIDE_DEVICE_SET_BULBS="hide_device_set_bulbs" 4 | -------------------------------------------------------------------------------- /.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/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Describe your changes 2 | Briefly describe your changes 3 | 4 | 5 | ## Checklist before submitting a pull request 6 | - [ ] Updated base lib and added the required version in the `requirements.txt` and `manifest.json` 7 | 8 | ## Checklist for reviewer 9 | - [ ] Select "Squash and merge" to keep commit timeline clean 10 | -------------------------------------------------------------------------------- /.github/workflows/action.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: dirigera_platform 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 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "dirigera_platform", 3 | "name": "IKEA Dirigera Hub Integration", 4 | "after_dependencies": ["http"], 5 | "codeowners": ["@sanjoyg"], 6 | "config_flow": true, 7 | "documentation": "https://github.com/sanjoyg/dirigera_platform", 8 | "iot_class": "local_polling", 9 | "issue_tracker": "https://github.com/sanjoyg/dirigera_platform", 10 | "loggers": ["custom_components.dirigera_platform"], 11 | "requirements": ["dirigera==1.2.1"], 12 | "version": "0.0.1" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/cover.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant import config_entries, core 4 | 5 | from .const import DOMAIN, PLATFORM 6 | from .base_classes import ikea_blinds_sensor 7 | 8 | logger = logging.getLogger("custom_components.dirigera_platform") 9 | 10 | async def async_setup_entry( 11 | hass: core.HomeAssistant, 12 | config_entry: config_entries.ConfigEntry, 13 | async_add_entities, 14 | ): 15 | logger.debug("BLINDS Starting async_setup_entry") 16 | """Setup sensors from a config entry created in the integrations UI.""" 17 | 18 | devices = hass.data[DOMAIN][PLATFORM].blinds 19 | 20 | blinds_sensors = [ikea_blinds_sensor(x) for x in devices] 21 | logger.debug(f"Found {len(blinds_sensors)} blinds sensors to setup...") 22 | 23 | async_add_entities(blinds_sensors) 24 | logger.debug("BLINDS Complete async_setup_entry") -------------------------------------------------------------------------------- /custom_components/dirigera_platform/fan.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from dirigera import Hub 4 | 5 | from homeassistant import config_entries, core 6 | 7 | from .const import DOMAIN, PLATFORM 8 | from .base_classes import ikea_starkvind_air_purifier_fan 9 | 10 | logger = logging.getLogger("custom_components.dirigera_platform") 11 | 12 | async def async_setup_entry( 13 | hass: core.HomeAssistant, 14 | config_entry: config_entries.ConfigEntry, 15 | async_add_entities,): 16 | 17 | logger.debug("FAN/AirPurifier Starting async_setup_entry") 18 | 19 | air_purifier_devices = hass.data[DOMAIN][PLATFORM].air_purifiers 20 | fan_sensors = [ikea_starkvind_air_purifier_fan(x) for x in air_purifier_devices] 21 | logger.debug(f"Found {len(fan_sensors)} air purifier fan sensors to add...") 22 | 23 | async_add_entities(fan_sensors) 24 | logger.debug("FAN/AirPurifier Complete async_setup_entry") 25 | return -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 sanjoyg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/binary_sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant import config_entries, core 4 | from homeassistant.components.binary_sensor import BinarySensorDeviceClass 5 | 6 | from .const import DOMAIN, PLATFORM 7 | from .base_classes import ikea_starkvind_air_purifier_binary_sensor, ikea_motion_sensor, ikea_open_close_sensor, ikea_water_sensor 8 | from .ikea_gateway import ikea_gateway 9 | 10 | logger = logging.getLogger("custom_components.dirigera_platform") 11 | 12 | async def async_setup_entry( 13 | hass: core.HomeAssistant, 14 | config_entry: config_entries.ConfigEntry, 15 | async_add_entities, 16 | ): 17 | logger.debug("Binary Sensor Starting async_setup_entry") 18 | """Setup sensors from a config entry created in the integrations UI.""" 19 | platform : ikea_gateway = hass.data[DOMAIN][PLATFORM] 20 | 21 | async_add_entities([ikea_motion_sensor(x) for x in platform.motion_sensors]) 22 | async_add_entities([ikea_open_close_sensor(x) for x in platform.open_close_sensors]) 23 | async_add_entities([ikea_water_sensor(x) for x in platform.water_sensors]) 24 | 25 | async_add_entities([ 26 | ikea_starkvind_air_purifier_binary_sensor( 27 | device, 28 | BinarySensorDeviceClass.PROBLEM, 29 | "Filter Alarm Status", 30 | "filter_alarm_status", 31 | "mdi:alarm-light-outline") 32 | for device in platform.air_purifiers]) 33 | 34 | logger.debug("Binary Sensor Complete async_setup_entry") -------------------------------------------------------------------------------- /custom_components/dirigera_platform/mocks/ikea_motion_sensor_mock.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components.binary_sensor import BinarySensorEntity 4 | from homeassistant.helpers.entity import DeviceInfo 5 | 6 | logger = logging.getLogger("custom_components.dirigera_platform") 7 | 8 | 9 | class ikea_motion_sensor_mock(BinarySensorEntity): 10 | counter = 0 11 | 12 | def __init__(self): 13 | ikea_motion_sensor_mock.counter = ikea_motion_sensor_mock.counter + 1 14 | 15 | self._manufacturer = "IKEA of Sweden" 16 | self._unique_id = "MS1907151129080101_" + str(ikea_motion_sensor_mock.counter) 17 | self._model = "mock motion sensor" 18 | self._sw_version = "mock sw" 19 | self._name = "mock" 20 | 21 | self._name = "Mock Motion Sensor {}".format(ikea_motion_sensor_mock.counter) 22 | self._is_on = False 23 | 24 | @property 25 | def unique_id(self): 26 | return self._unique_id 27 | 28 | @property 29 | def device_info(self) -> DeviceInfo: 30 | return DeviceInfo( 31 | identifiers={("dirigera_platform", self._unique_id)}, 32 | name=self._name, 33 | manufacturer=self._manufacturer, 34 | model=self._model, 35 | sw_version=self._sw_version, 36 | ) 37 | 38 | @property 39 | def name(self) -> str: 40 | return self._name 41 | 42 | @property 43 | def is_on(self): 44 | return self._is_on 45 | 46 | def update(self): 47 | pass 48 | 49 | async def async_will_remove_from_hass(self) -> None: 50 | ikea_motion_sensor_mock.counter = ikea_motion_sensor_mock.counter - 1 51 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "hub_connection_fail": "Could not connect to IKEA dirigera hub with these values, check IP & Token" 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "ip_address": "Hub IP", 10 | "token": "Token" 11 | }, 12 | "description": "Enter IKEA Dirigera Hub Details", 13 | "title": "IKEA Dirigera Hub" 14 | } 15 | } 16 | }, 17 | "options": { 18 | "error": { 19 | "hub_connection_fail": "Could not connect to IKEA dirigera hub with these values, check IP & Token" 20 | }, 21 | "step": { 22 | "init": { 23 | "title": "IKEA Dirigera Hub Platform", 24 | "data": { 25 | "ip_address": "Hub IP", 26 | "token": "token" 27 | }, 28 | "description": "Update IKEA Dirigera Hub Setting..." 29 | } 30 | } 31 | }, 32 | "device_automation": { 33 | "trigger_type": { 34 | "single_click": "{entity_name} Single Click", 35 | "long_press": "{entity_name} Long Press", 36 | "double_click": "{entity_name} Double Click", 37 | "button1_single_click": "{entity_name} Button 1 Single Click", 38 | "button1_long_press": "{entity_name} Button 1 Long Press", 39 | "button1_double_click": "{entity_name} Button 1 Double Click", 40 | "button2_single_click": "{entity_name} Button 2 Single Click", 41 | "button2_long_press": "{entity_name} Button 2 Long Press", 42 | "button2_double_click": "{entity_name} Button 2 Double Click" 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /custom_components/dirigera_platform/mocks/ikea_open_close_mock.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components.binary_sensor import BinarySensorEntity 4 | from homeassistant.helpers.entity import DeviceInfo 5 | 6 | logger = logging.getLogger("custom_components.dirigera_platform") 7 | 8 | 9 | class ikea_open_close_mock(BinarySensorEntity): 10 | counter = 0 11 | 12 | def __init__(self): 13 | ikea_open_close_mock.counter = ikea_open_close_mock.counter + 1 14 | 15 | self._manufacturer = "IKEA of Sweden" 16 | self._unique_id = "OC1907151129080101_" + str(ikea_open_close_mock.counter) 17 | self._model = "mock open/close sensor" 18 | self._sw_version = "mock sw" 19 | self._name = "mock" 20 | 21 | self._name = "Mock Open Close Sensor {}".format(ikea_open_close_mock.counter) 22 | self._is_on = False 23 | 24 | @property 25 | def unique_id(self): 26 | return self._unique_id 27 | 28 | @property 29 | def device_info(self) -> DeviceInfo: 30 | return DeviceInfo( 31 | identifiers={("dirigera_platform", self._unique_id)}, 32 | name=self._name, 33 | manufacturer=self._manufacturer, 34 | model=self._model, 35 | sw_version=self._sw_version, 36 | suggested_area="Living room", 37 | ) 38 | 39 | @property 40 | def name(self) -> str: 41 | return self._name 42 | 43 | @property 44 | def is_on(self): 45 | return self._is_on 46 | 47 | def update(self): 48 | pass 49 | 50 | async def async_will_remove_from_hass(self) -> None: 51 | ikea_open_close_mock.counter = ikea_open_close_mock.counter - 1 52 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/mocks/ikea_outlet_mock.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components.switch import SwitchEntity 4 | from homeassistant.helpers.entity import DeviceInfo 5 | 6 | logger = logging.getLogger("custom_components.dirigera_platform") 7 | 8 | 9 | class ikea_outlet_mock(SwitchEntity): 10 | counter = 0 11 | 12 | def __init__(self, hub, hub_outlet): 13 | self._hub = hub 14 | self._hub_outlet = hub_outlet 15 | ikea_outlet_mock.counter = ikea_outlet_mock.counter + 1 16 | 17 | self._manufacturer = "IKEA of Sweden" 18 | self._unique_id = "O1907151129080101_" + str(ikea_outlet_mock.counter) 19 | self._model = "mock outlet" 20 | self._sw_version = "mock sw" 21 | self._name = "mock" 22 | 23 | self._name = "Mock Outlet {}".format(ikea_outlet_mock.counter) 24 | self._is_on = False 25 | 26 | @property 27 | def unique_id(self): 28 | return self._unique_id 29 | 30 | @property 31 | def device_info(self) -> DeviceInfo: 32 | return DeviceInfo( 33 | identifiers={("dirigera_platform", self._unique_id)}, 34 | name=self._name, 35 | manufacturer=self._manufacturer, 36 | model=self._model, 37 | sw_version=self._sw_version, 38 | ) 39 | 40 | @property 41 | def name(self) -> str: 42 | return self._name 43 | 44 | @property 45 | def is_on(self): 46 | return self._is_on 47 | 48 | def turn_on(self): 49 | self._is_on = True 50 | 51 | def turn_off(self): 52 | self._is_on = False 53 | 54 | def update(self): 55 | pass 56 | 57 | async def async_will_remove_from_hass(self) -> None: 58 | ikea_outlet_mock.counter = ikea_outlet_mock.counter - 1 59 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/switch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant import config_entries, core 4 | 5 | from .const import DOMAIN, PLATFORM 6 | from .base_classes import ikea_outlet_switch_sensor 7 | from .ikea_gateway import ikea_gateway 8 | from .base_classes import ikea_starkvind_air_purifier_switch_sensor 9 | 10 | logger = logging.getLogger("custom_components.dirigera_platform") 11 | 12 | async def async_setup_entry( 13 | hass: core.HomeAssistant, 14 | config_entry: config_entries.ConfigEntry, 15 | async_add_entities, 16 | ): 17 | logger.debug("SWITCH Starting async_setup_entry") 18 | """Setup sensors from a config entry created in the integrations UI.""" 19 | platform : ikea_gateway = hass.data[DOMAIN][PLATFORM] 20 | 21 | async_add_entities([ikea_outlet_switch_sensor(x) for x in platform.outlets]) 22 | 23 | # Add the air_purifier switches 24 | air_purifier_entities = [] 25 | for air_purifier in platform.air_purifiers: 26 | air_purifier_entities.append( 27 | ikea_starkvind_air_purifier_switch_sensor( 28 | air_purifier, 29 | "Child Lock", 30 | "child_lock", 31 | "async_set_child_lock", 32 | "mdi:account-lock-outline", 33 | ) 34 | ) 35 | air_purifier_entities.append( 36 | ikea_starkvind_air_purifier_switch_sensor( 37 | air_purifier, 38 | "Status Light", 39 | "status_light", 40 | "async_set_status_light", 41 | "mdi:lightbulb", 42 | ) 43 | ) 44 | 45 | logger.debug(f"Found {len(air_purifier_entities)} air_purifier switch sensors...") 46 | async_add_entities(air_purifier_entities) 47 | 48 | logger.debug("SWITCH Complete async_setup_entry") -------------------------------------------------------------------------------- /custom_components/dirigera_platform/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "hub_connection_fail": "Could not connect to IKEA dirigera hub with these values, check IP & Token" 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "ip_address": "Hub IP", 10 | "hide_device_set_bulbs": "Hide Device Set Bulbs" 11 | }, 12 | "description": "Enter IKEA Dirigera Hub Details", 13 | "title": "IKEA Dirigera Hub Setup" 14 | }, 15 | "action": { 16 | "data": { 17 | "ip_address": "Hub IP", 18 | "hide_device_set_bulbs": "Hide Device Set Bulbs" 19 | }, 20 | "description": "Press the action button the dirigera hub and press Submit.", 21 | "title": "IKEA Dirigera Hub Setup" 22 | } 23 | } 24 | }, 25 | "options": { 26 | "error": { 27 | "hub_connection_fail": "Could not connect to IKEA dirigera hub with these values, check IP & Token" 28 | }, 29 | "step": { 30 | "init": { 31 | "title": "IKEA Dirigera Hub Platform", 32 | "data": { 33 | "ip_address": "Hub IP", 34 | "token": "token", 35 | "hide_device_set_bulbs": "Hide Device Set Bulbs" 36 | }, 37 | "description": "Update IKEA Dirigera Hub Setting..." 38 | }, 39 | "action": { 40 | "data": { 41 | "ip_address": "Hub IP", 42 | "hide_device_set_bulbs": "Hide Device Set Bulbs" 43 | }, 44 | "description": "Press the action button the dirigera hub and press Submit.", 45 | "title": "IKEA Dirigera Hub Setup" 46 | } 47 | } 48 | }, 49 | "exceptions": { 50 | "hub_exception": { 51 | "message" : "Exception raised by Hub..." 52 | } 53 | }, 54 | "device_automation": { 55 | "trigger_type": { 56 | "single_click": "{entity_name} Single Click", 57 | "long_press": "{entity_name} Long Press", 58 | "double_click": "{entity_name} Double Click", 59 | "button1_single_click": "{entity_name} Button 1 Single Click", 60 | "button1_long_press": "{entity_name} Button 1 Long Press", 61 | "button1_double_click": "{entity_name} Button 1 Double Click", 62 | "button2_single_click": "{entity_name} Button 2 Single Click", 63 | "button2_long_press": "{entity_name} Button 2 Long Press", 64 | "button2_double_click": "{entity_name} Button 2 Double Click" 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /custom_components/dirigera_platform/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "hub_connection_fail": "Could not connect to IKEA dirigera hub with these values, check IP & Token" 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "ip_address": "Hub IP", 10 | "hide_device_set_bulbs": "Hide Device Set Bulbs" 11 | }, 12 | "description": "Enter IKEA Dirigera Hub Details", 13 | "title": "IKEA Dirigera Hub Setup" 14 | }, 15 | "action": { 16 | "data": { 17 | "ip_address": "Hub IP", 18 | "hide_device_set_bulbs": "Hide Device Set Bulbs" 19 | }, 20 | "description": "Press the action button the dirigera hub and press Submit.", 21 | "title": "IKEA Dirigera Hub Setup" 22 | } 23 | } 24 | }, 25 | "options": { 26 | "error": { 27 | "hub_connection_fail": "Could not connect to IKEA dirigera hub with these values, check IP & Token" 28 | }, 29 | "step": { 30 | "init": { 31 | "title": "IKEA Dirigera Hub Platform", 32 | "data": { 33 | "ip_address": "Hub IP", 34 | "token": "token", 35 | "hide_device_set_bulbs": "Hide Device Set Bulbs" 36 | }, 37 | "description": "Update IKEA Dirigera Hub Setting..." 38 | }, 39 | "action": { 40 | "data": { 41 | "ip_address": "Hub IP", 42 | "hide_device_set_bulbs": "Hide Device Set Bulbs" 43 | }, 44 | "description": "Press the action button the dirigera hub and press Submit.", 45 | "title": "IKEA Dirigera Hub Setup" 46 | } 47 | } 48 | }, 49 | "exceptions": { 50 | "hub_exception": { 51 | "message" : "Exception raised by Hub..." 52 | } 53 | }, 54 | "device_automation": { 55 | "trigger_type": { 56 | "single_click": "{entity_name} Single Click", 57 | "long_press": "{entity_name} Long Press", 58 | "double_click": "{entity_name} Double Click", 59 | "button1_single_click": "{entity_name} Button 1 Single Click", 60 | "button1_long_press": "{entity_name} Button 1 Long Press", 61 | "button1_double_click": "{entity_name} Button 1 Double Click", 62 | "button2_single_click": "{entity_name} Button 2 Single Click", 63 | "button2_long_press": "{entity_name} Button 2 Long Press", 64 | "button2_double_click": "{entity_name} Button 2 Double Click" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/mocks/ikea_controller_mock.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from enum import Enum 3 | import logging 4 | 5 | from homeassistant import config_entries, core 6 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 7 | from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN 8 | from homeassistant.core import HomeAssistantError 9 | from homeassistant.helpers.entity import DeviceInfo 10 | 11 | from ..const import DOMAIN 12 | 13 | logger = logging.getLogger("custom_components.dirigera_platform") 14 | 15 | 16 | class ikea_controller_mock(SensorEntity): 17 | counter = 0 18 | 19 | def __init__(self) -> None: 20 | logger.debug("ikea_controller_mock ctor") 21 | ikea_controller_mock.counter = ikea_controller_mock.counter + 1 22 | self._unique_id = "CT1907151129080101_" + str(ikea_controller_mock.counter) 23 | self._name = "Mock Controller " + str(ikea_controller_mock.counter) 24 | 25 | def update(self): 26 | logger.debug("update called on ikea_controller_mock") 27 | pass 28 | 29 | @property 30 | def device_info(self) -> DeviceInfo: 31 | return DeviceInfo( 32 | identifiers={("dirigera_platform", self._unique_id)}, 33 | name=self._name, 34 | manufacturer="IKEA of Sweden", 35 | model="Mock Controler", 36 | sw_version="mock sw", 37 | ) 38 | 39 | @property 40 | def name(self) -> str: 41 | logger.debug("name() called on ikea_controller_mock: {}".format(self._name)) 42 | return self._name 43 | 44 | @property 45 | def unique_id(self): 46 | logger.debug( 47 | "unique_id() called on ikea_controller_mock : {}".format(self._unique_id) 48 | ) 49 | return self._unique_id 50 | 51 | @property 52 | def available(self): 53 | logger.debug("available() called on ikea_controller_mock") 54 | return True 55 | 56 | @property 57 | def is_on(self): 58 | return True 59 | 60 | @property 61 | def device_class(self): 62 | logger.debug("device_class() called on ikea_controller_mock") 63 | return SensorDeviceClass.BATTERY 64 | 65 | @property 66 | def native_value(self): 67 | logger.debug("native_value() called on ikea_controller_mock") 68 | return 20 69 | 70 | @property 71 | def native_unit_of_measurement(self) -> str: 72 | return "%" 73 | 74 | async def async_will_remove_from_hass(self) -> None: 75 | ikea_controller_mock.counter = ikea_controller_mock.counter - 1 76 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "hub_connection_fail": "Não foi possível ligar ao hub IKEA dirigera com esses valores, verifique o IP e o Token" 5 | }, 6 | "step": { 7 | "user": { 8 | "data": { 9 | "ip_address": "IP do Hub", 10 | "hide_device_set_bulbs": "Ocultar Lâmpadas Definidas do Dispositivo" 11 | }, 12 | "description": "Introduza os Detalhes do Hub IKEA Dirigera", 13 | "title": "Configuração do Hub IKEA Dirigera" 14 | }, 15 | "action": { 16 | "data": { 17 | "ip_address": "IP do Hub", 18 | "hide_device_set_bulbs": "Ocultar Lâmpadas Definidas do Dispositivo" 19 | }, 20 | "description": "Pressione o botão de ação no hub dirigera e prima Submeter.", 21 | "title": "Configuração do Hub IKEA Dirigera" 22 | } 23 | } 24 | }, 25 | "options": { 26 | "error": { 27 | "hub_connection_fail": "Não foi possível ligar ao hub IKEA dirigera com esses valores, verifique o IP e o Token" 28 | }, 29 | "step": { 30 | "init": { 31 | "title": "Plataforma do Hub IKEA Dirigera", 32 | "data": { 33 | "ip_address": "IP do Hub", 34 | "token": "Token", 35 | "hide_device_set_bulbs": "Ocultar Lâmpadas Definidas do Dispositivo" 36 | }, 37 | "description": "Atualizar Definições do Hub IKEA Dirigera..." 38 | }, 39 | "action": { 40 | "data": { 41 | "ip_address": "IP do Hub", 42 | "hide_device_set_bulbs": "Ocultar Lâmpadas Definidas do Dispositivo" 43 | }, 44 | "description": "Pressione o botão de ação no hub dirigera e prima Submeter.", 45 | "title": "Configuração do Hub IKEA Dirigera" 46 | } 47 | } 48 | }, 49 | "exceptions": { 50 | "hub_exception": { 51 | "message": "Exceção gerada pelo Hub..." 52 | } 53 | }, 54 | "device_automation": { 55 | "trigger_type": { 56 | "single_click": "{entity_name} Clique Único", 57 | "long_press": "{entity_name} Pressão Longa", 58 | "double_click": "{entity_name} Duplo Clique", 59 | "button1_single_click": "{entity_name} Botão 1 Clique Único", 60 | "button1_long_press": "{entity_name} Botão 1 Pressão Longa", 61 | "button1_double_click": "{entity_name} Botão 1 Duplo Clique", 62 | "button2_single_click": "{entity_name} Botão 2 Clique Único", 63 | "button2_long_press": "{entity_name} Botão 2 Pressão Longa", 64 | "button2_double_click": "{entity_name} Botão 2 Duplo Clique" 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/mocks/ikea_vindstyrka_mock.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import random 4 | 5 | from homeassistant.core import HomeAssistantError 6 | from homeassistant.helpers.entity import DeviceInfo 7 | 8 | from ..const import DOMAIN 9 | from ..sensor import ikea_vindstyrka_device 10 | 11 | logger = logging.getLogger("custom_components.dirigera_platform") 12 | 13 | 14 | class ikea_vindstyrka_device_mock(ikea_vindstyrka_device): 15 | counter = 0 16 | 17 | def __init__(self) -> None: 18 | logger.debug("ikea_vindstyrka_device_mock ctor") 19 | ikea_vindstyrka_device_mock.counter = ikea_vindstyrka_device_mock.counter + 1 20 | self._unique_id = "E1907151129080101_" + str( 21 | ikea_vindstyrka_device_mock.counter 22 | ) 23 | self._name = "Mock Env Sensor " + str(ikea_vindstyrka_device_mock.counter) 24 | self._updated_at = None 25 | 26 | def update(self): 27 | if ( 28 | self._updated_at is None 29 | or (datetime.datetime.now() - self._updated_at).total_seconds() > 30 30 | ): 31 | try: 32 | logger.debug("Updated environment sensor...") 33 | self._updated_at = datetime.datetime.now() 34 | except Exception as ex: 35 | logger.error("error encountered running update on : {}".format(self.name)) 36 | logger.error(ex) 37 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 38 | else: 39 | logger.debug("Not updating environment sensor...") 40 | 41 | def get_current_temperature(self): 42 | return random.randint(-10, 50) 43 | 44 | def get_current_r_h(self): 45 | return random.randint(10, 90) 46 | 47 | def get_current_p_m25(self): 48 | return random.randint(50, 500) 49 | 50 | def get_max_measured_p_m25(self): 51 | return random.randint(202, 500) 52 | 53 | def get_min_measured_p_m25(self): 54 | return random.randint(50, 200) 55 | 56 | def get_voc_index(self): 57 | return random.randint(50, 500) 58 | 59 | @property 60 | def available(self): 61 | return True 62 | 63 | @property 64 | def device_info(self) -> DeviceInfo: 65 | return DeviceInfo( 66 | identifiers={("dirigera_platform", self._unique_id)}, 67 | name=self._name, 68 | manufacturer="IKEA of Sweden", 69 | model="Mock Env Sensor", 70 | sw_version="mock sw", 71 | ) 72 | 73 | @property 74 | def name(self) -> str: 75 | return self._name 76 | 77 | @property 78 | def unique_id(self): 79 | return self._unique_id 80 | 81 | async def async_will_remove_from_hass(self) -> None: 82 | ikea_vindstyrka_device_mock.counter = ikea_vindstyrka_device_mock.counter - 1 83 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/scene.py: -------------------------------------------------------------------------------- 1 | """Imports scenes from Dirigera as scene entities in HA.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | #from dirigera import Hub 7 | from .dirigera_lib_patch import HubX, HackScene 8 | #from dirigera.devices.scene import Scene as DirigeraScene 9 | #from dirigera.devices.scene import Trigger, TriggerDetails, EndTriggerEvent 10 | 11 | from homeassistant.components.scene import Scene 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.exceptions import HomeAssistantError 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | 17 | from .const import DOMAIN, PLATFORM 18 | from .icons import to_hass_icon, ikea_to_hass_icon 19 | 20 | logger = logging.getLogger("custom_components.dirigera_platform") 21 | 22 | async def async_setup_entry( 23 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 24 | ) -> None: 25 | """Create scene entities from Dirigera scene.""" 26 | logger.debug("async_setup_entry for scenes...") 27 | 28 | #Commented as hack for pydantic scene issue 29 | #Trigger.update_forward_refs() 30 | #TriggerDetails.update_forward_refs() 31 | #EndTriggerEvent.update_forward_refs() 32 | 33 | async_add_entities(hass.data[DOMAIN][PLATFORM].scenes) 34 | logger.debug("async_setup_entry complete for scenes...") 35 | 36 | class ikea_scene(Scene): 37 | """Implements scene entity for a Dirigera scene.""" 38 | 39 | _attr_has_entity_name = True 40 | 41 | def __init__(self, hub: HubX, scene: HackScene) -> None: 42 | """Initialize.""" 43 | self._hub = hub 44 | self._scene = scene 45 | 46 | @property 47 | def unique_id(self): 48 | return self._scene.id 49 | 50 | @property 51 | def name(self) -> str: 52 | """Return name from Dirigera.""" 53 | #return self._dirigera_scene.info.name 54 | return self._scene.name 55 | 56 | @property 57 | def icon(self) -> str: 58 | """Return suitable replacement icon.""" 59 | #return to_hass_icon(self._dirigera_scene.info.icon) 60 | return ikea_to_hass_icon(self._scene.icon) 61 | 62 | async def async_activate(self, **kwargs: Any) -> None: 63 | """Trigger Dirigera Scene.""" 64 | logger.debug("Activating scene '%s' (%s)", self.name, self.unique_id) 65 | await self.hass.async_add_executor_job(self._scene.trigger) 66 | 67 | async def async_update(self) -> None: 68 | """Fetch updated scene definition from Dirigera.""" 69 | logger.debug("Updating scene '%s' (%s)", self.name, self.unique_id) 70 | try: 71 | self._dirigera_scene = await self.hass.async_add_executor_job(self._hub.get_scene_by_id, self.unique_id) 72 | except Exception as ex: 73 | logger.error("Error encountered on update of '%s' (%s)", self.name, self.unique_id) 74 | logger.error(ex) 75 | raise HomeAssistantError from ex -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | Pipfile 6 | Pipfile.lock 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | .idea/ 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## IKEA Dirigera Hub Integration 2 | This custom components help integrating HomeAssistant with the new IKEA Dirigera hub. This integration is a scaffolding on the great work done by Nicolas Hilberg at https://github.com/Leggin/dirigera 3 | 4 | Supports 5 | * Lights 6 | * Outlets 7 | * Open/Close Sensors 8 | * Motion Sensor 9 | * Environment Sensor 10 | * FYRTUR Blinds 11 | * STYRBAR Remotes 12 | * AirPurifier 13 | * STARKVIND AirPurifier 14 | * VALLHORN Motion Sensors 15 | * Scenes 16 | * BADRING Water Leak sensor: 17 | * SOMRIG Controllers - Included Events for Automation 18 | 19 | Buy Me A Coffee 20 | 21 | Donation to above will got to [Samriddhi Foundation](https://www.samriddhifoundation.net/) an iniative by my teenage daughter to help the less fortunate. 22 | 23 | ## Pre-requisite 24 | 1. Identify the IP of the gateway - Usually looking at the client list in your home router interface will give that. 25 | 26 | ## Installing 27 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=sanjoyg&repository=dirigera_platform&category=integration) 28 | 29 | - Like all add-on installation goto the "HACS" option in the left menu bar in home assistant 30 | - Select Integration and add custom repository and enter this repositoy 31 | 32 | ## Using the integration 33 | 1. One you get to add integration and get to the configuration screen, the IP of the gateway will be requested. 34 | **IMPORTANT** 35 | Before hitting enter be near the IKEA Dirigera hub as post entering IP a request to press the action button on the hub 36 | 37 | 2. Once you get the screen requesting to press the action button, physically press the button once and then click on submit 38 | 39 | 3. If the IP is right and action button has been pressed, then the integration will be added and all devices registered will be shown. At this time the following device types are supported 40 | 41 | In addition you'll find the scenes added as individual entities. Go to the "Entities" to find them as they're not part of any device. Use the "Activate" button to trigger a scene. 42 | 43 | ## Testing installation with mock 44 | 1. If you enter the IP as "mock" then mock bulbs and outlet will be added. 45 | 2. Once you verify that the bulbs and outlets are added feel free to delete the integration 46 | 47 | Here is how it looks 48 | 49 | 1. After you have downloaded the integration from HACS and go to Setting -> Integration -> ADD INTEGRATION to add the dirigera integration, the following screen will come up 50 | 51 | ![](https://github.com/sanjoyg/dirigera_platform/blob/main/screenshots/config-ip-details.png) 52 | 53 | To test the integration, enter the IP as "mock". The check-box indicates if the bulbs/lights associated with a device-set should be visible as entities or not 54 | 55 | ![](https://github.com/sanjoyg/dirigera_platform/blob/main/screenshots/config-mock.png) 56 | 57 | The integration would prompt to press the action button on the hub 58 | 59 | ![](https://github.com/sanjoyg/dirigera_platform/blob/main/screenshots/config-press-action.png) 60 | 61 | Since this is mock, we would get a success message 62 | 63 | ![](https://github.com/sanjoyg/dirigera_platform/blob/main/screenshots/config-hub-setup-complete-mock.png) 64 | 65 | Once this is complete you would see two bulbs and two outlets appearing. 66 | 67 | ![](https://github.com/sanjoyg/dirigera_platform/blob/main/screenshots/mock-lights.png) 68 | ![](https://github.com/sanjoyg/dirigera_platform/blob/main/screenshots/mock-outlets.png) 69 | 70 | ## Raising Issue 71 | 72 | Now I dont have access to all sensors, hence what will be useful is when you raise an issue also supply to the JSON that the hub returns. 73 | To get the JSON do the following 74 | 75 | * Go to Developer -> Service and invoke dirigera_platform.dump_data without any parameters 76 | * Look at the HASS log which would have the JSON. 77 | * If you see any platform errors include that as well 78 | 79 | [Detailed Instructions](https://github.com/sanjoyg/dirigera_platform/wiki/Calling-dump_data-to-dump-the-JSON) 80 | 81 | 82 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/icons.py: -------------------------------------------------------------------------------- 1 | """Mapping icons from Dirigera to similar icons in Home Assistant.""" 2 | 3 | import logging 4 | 5 | from dirigera.devices.scene import Icon 6 | 7 | logger = logging.getLogger("custom_components.dirigera_platform") 8 | 9 | # Mapping is mostly AI generated - some suggestions can likely be improved 10 | # HASS icons: https://pictogrammers.com/library/mdi/ 11 | # Dirigera icons: https://github.com/Leggin/dirigera/blob/main/src/dirigera/devices/scene.py 12 | icon_mapping: dict[Icon, str] = { 13 | Icon.SCENES_ARRIVE_HOME: "mdi:home-import-outline", 14 | Icon.SCENES_BOOK: "mdi:book", 15 | Icon.SCENES_BRIEFCASE: "mdi:briefcase", 16 | Icon.SCENES_BRIGHTNESS_UP: "mdi:lightbulb", 17 | Icon.SCENES_BROOM: "mdi:broom", 18 | Icon.SCENES_CAKE: "mdi:cake", 19 | Icon.SCENES_CLAPPER: "mdi:movie-open", 20 | Icon.SCENES_CLEAN_SPARKLES: "mdi:creation", 21 | Icon.SCENES_CUTLERY: "mdi:silverware-fork-knife", 22 | Icon.SCENES_DISCO_BALL: "mdi:ceiling-light", 23 | Icon.SCENES_GAME_PAD: "mdi:gamepad", 24 | Icon.SCENES_GIFT_BAG: "mdi:shopping", 25 | Icon.SCENES_GIFT_BOX: "mdi:gift", 26 | Icon.SCENES_HEADPHONES: "mdi:headphones", 27 | Icon.SCENES_HEART: "mdi:heart", 28 | Icon.SCENES_HOME_FILLED: "mdi:home", 29 | Icon.SCENES_HOT_DRINK: "mdi:cup", 30 | Icon.SCENES_LADLE: "mdi:silverware-spoon", 31 | Icon.SCENES_LEAF: "mdi:leaf", 32 | Icon.SCENES_LEAVE_HOME: "mdi:home-export-outline", 33 | Icon.SCENES_MOON: "mdi:moon-waning-crescent", 34 | Icon.SCENES_MUSIC_NOTE: "mdi:music-note", 35 | Icon.SCENES_PAINTING: "mdi:palette", 36 | Icon.SCENES_POPCORN: "mdi:popcorn", 37 | Icon.SCENES_POT_WITH_LID: "mdi:pot", 38 | Icon.SCENES_SPEAKER_GENERIC: "mdi:speaker", 39 | Icon.SCENES_SPRAY_BOTTLE: "mdi:spray", 40 | Icon.SCENES_SUITCASE: "mdi:suitcase", 41 | Icon.SCENES_SUITCASE_2: "mdi:briefcase", 42 | Icon.SCENES_SUN_HORIZON: "mdi:weather-sunset", 43 | Icon.SCENES_TREE: "mdi:pine-tree", 44 | Icon.SCENES_TROPHY: "mdi:trophy", 45 | Icon.SCENES_WAKE_UP: "mdi:alarm", 46 | Icon.SCENES_WEIGHTS: "mdi:dumbbell", 47 | Icon.SCENES_YOGA: "mdi:yoga", 48 | } 49 | 50 | ikea_to_hass_mapping: dict[str, str] = { 51 | "scenes_arrive_home": "mdi:home-import-outline", 52 | "scenes_book": "mdi:book", 53 | "scenes_briefcase": "mdi:briefcase", 54 | "scenes_brightness_up": "mdi:lightbulb", 55 | "scenes_broom": "mdi:broom", 56 | "scenes_cake": "mdi:cake", 57 | "scenes_clapper": "mdi:movie-open", 58 | "scenes_clean_sparkles": "mdi:creation", 59 | "scenes_cutlery": "mdi:silverware-fork-knife", 60 | "scenes_disco_ball": "mdi:ceiling-light", 61 | "scenes_game_pad": "mdi:gamepad", 62 | "scenes_gift_bag": "mdi:shopping", 63 | "scenes_gift_box": "mdi:gift", 64 | "scenes_headphones": "mdi:headphones", 65 | "scenes_heart": "mdi:heart", 66 | "scenes_home_filled": "mdi:home", 67 | "scenes_hot_drink": "mdi:cup", 68 | "scenes_ladle": "mdi:silverware-spoon", 69 | "scenes_leaf": "mdi:leaf", 70 | "scenes_leave_home": "mdi:home-export-outline", 71 | "scenes_moon": "mdi:moon-waning-crescent", 72 | "scenes_music_note": "mdi:music-note", 73 | "scenes_painting": "mdi:palette", 74 | "scenes_popcorn": "mdi:popcorn", 75 | "scenes_pot_with_lid": "mdi:pot", 76 | "scenes_speaker_generic": "mdi:speaker", 77 | "scenes_spray_bottle": "mdi:spray", 78 | "scenes_suitcase": "mdi:suitcase", 79 | "scenes_suitcase_2": "mdi:briefcase", 80 | "scenes_sun_horizon": "mdi:weather-sunset", 81 | "scenes_tree": "mdi:pine-tree", 82 | "scenes_trophy": "mdi:trophy", 83 | "scenes_wake_up": "mdi:alarm", 84 | "scenes_weights": "mdi:dumbbell", 85 | "scenes_yoga": "mdi:yoga" 86 | } 87 | 88 | def ikea_to_hass_icon(ikea_icon) -> str: 89 | if ikea_icon in ikea_to_hass_mapping: 90 | return ikea_to_hass_mapping[ikea_icon] 91 | 92 | logger.warning(f"Unknown icon {ikea_icon}") 93 | return "mdi:help" 94 | 95 | def to_hass_icon(dirigera_icon: Icon) -> str: 96 | """Return suitable replacement icon.""" 97 | hass_icon: str = icon_mapping[dirigera_icon] 98 | if hass_icon is None: 99 | logger.warning("Unknown icon %s", str(dirigera_icon)) 100 | return "mdi:help" 101 | return hass_icon 102 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/device_trigger.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | import voluptuous as vol 4 | from typing import Any 5 | 6 | from homeassistant.core import HomeAssistant 7 | import homeassistant.helpers.config_validation as cv 8 | from homeassistant.helpers import entity_registry as er 9 | 10 | from homeassistant.const import CONF_TYPE, CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, ATTR_ENTITY_ID 11 | from homeassistant.components.homeassistant.triggers import event as event_trigger 12 | from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA 13 | 14 | from .const import DOMAIN 15 | from .hub_event_listener import hub_event_listener 16 | 17 | logger = logging.getLogger("custom_components.dirigera_platform") 18 | 19 | TRIGGER_TYPES = ["single_click", "long_press","double_click"] 20 | TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend({vol.Required(CONF_TYPE): cv.string, vol.Required(ATTR_ENTITY_ID): cv.string}) 21 | 22 | async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict[str, Any]]: 23 | logger.debug(f"Got to async get triggers device_id: {device_id}") 24 | 25 | triggers = [] 26 | entity_registry = er.async_get(hass) 27 | 28 | for entity_entry in er.async_entries_for_device(entity_registry,device_id): 29 | logger.debug(f"Iterated to : {entity_entry}") 30 | entity_id = entity_entry.unique_id 31 | entity_name = entity_entry.entity_id 32 | 33 | registry_entry = hub_event_listener.get_registry_entry(entity_id) 34 | if registry_entry is None: 35 | logger.warning(f"entity_id: {entity_id}, not found in dirigera_platform registry. Not associating triggers") 36 | continue 37 | 38 | if registry_entry.__class__.__name__ != "registry_entry": 39 | logger.warning(f"entity_id: {entity_id} corresponding in dirigera_platform not a registry_entry") 40 | continue 41 | 42 | registry_entity = registry_entry.entity 43 | 44 | if "identifiers" not in registry_entity.device_info or len(list(registry_entity.device_info["identifiers"])[0]) < 2: 45 | logger.warning(f"entity_id: {entity_id} corresponding in dirigera_platform entity doesnt have identifiers or isnt 2 entries long, device_info : {registry_entity.device_info}") 46 | logger.info(registry_entity.device_info["identifiers"]) 47 | registry_entity_id = list(registry_entity.device_info["identifiers"])[0][1] 48 | 49 | if registry_entity_id != entity_id: 50 | logger.error(f"Found controller with entity id : {registry_entity_id} but doesnt match requested entity id: {entity_id}") 51 | continue 52 | 53 | logger.debug(f"Found controller to tag events to entity : {entity_name}") 54 | 55 | # Now we have an ikea_controller 56 | use_prefx : bool = False 57 | if registry_entity.number_of_buttons > 1: 58 | logger.debug("More than one button will use prefix") 59 | use_prefx =True 60 | 61 | for btn_idx in range(registry_entity.number_of_buttons): 62 | for trigger_type in TRIGGER_TYPES: 63 | if use_prefx: 64 | trigger_name = f"button{btn_idx+1}_{trigger_type}" 65 | else: 66 | trigger_name = trigger_type 67 | 68 | triggers.append( 69 | { 70 | CONF_DEVICE_ID: device_id, 71 | CONF_DOMAIN: DOMAIN, 72 | CONF_PLATFORM: "device", 73 | CONF_TYPE: trigger_name, 74 | ATTR_ENTITY_ID: entity_name 75 | }) 76 | 77 | break 78 | 79 | logger.debug(f"Returning triggers : {triggers}") 80 | return triggers 81 | 82 | async def async_attach_trigger(hass, config, action, trigger_info): 83 | logger.debug(f"Got to async_attach_trigger config: {config}, action: {action}, trigger_info: {trigger_info}") 84 | 85 | event_config = event_trigger.TRIGGER_SCHEMA( 86 | { 87 | event_trigger.CONF_PLATFORM: "event", 88 | event_trigger.CONF_EVENT_TYPE: f"{DOMAIN}_event", 89 | event_trigger.CONF_EVENT_DATA: { 90 | CONF_DEVICE_ID: config[CONF_DEVICE_ID], 91 | CONF_TYPE: config[CONF_TYPE], 92 | ATTR_ENTITY_ID: config[ATTR_ENTITY_ID] 93 | }, 94 | } 95 | ) 96 | 97 | return await event_trigger.async_attach_trigger( 98 | hass, event_config, action, trigger_info, platform_type="device" 99 | ) -------------------------------------------------------------------------------- /custom_components/dirigera_platform/mocks/ikea_blinds_mock.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components.cover import ( 4 | CoverDeviceClass, 5 | CoverEntity, 6 | CoverEntityFeature, 7 | ) 8 | from homeassistant.helpers.entity import DeviceInfo 9 | 10 | logger = logging.getLogger("custom_components.dirigera_platform") 11 | 12 | 13 | class ikea_blinds_mock(CoverEntity): 14 | counter = 0 15 | 16 | def __init__(self, hub, hub_blinds) -> None: 17 | logger.debug("IkeaBlinds mock ctor...") 18 | self._hub = hub 19 | ikea_blinds_mock.counter = ikea_blinds_mock.counter + 1 20 | 21 | self._manufacturer = "IKEA of Sweden" 22 | self._unique_id = "B1907151129080101_" + str(ikea_blinds_mock.counter) 23 | self._model = "mock blind" 24 | self._sw_version = "mock sw" 25 | self._name = "mock" 26 | 27 | self._name = "Mock Blind {}".format(ikea_blinds_mock.counter) 28 | self._supported_feature = ( 29 | CoverEntityFeature.OPEN 30 | | CoverEntityFeature.CLOSE 31 | | CoverEntityFeature.SET_POSITION 32 | ) 33 | self._is_on = False 34 | self._current_level = 100 35 | self._target_level = 100 36 | 37 | @property 38 | def unique_id(self): 39 | return self._unique_id 40 | 41 | @property 42 | def device_info(self) -> DeviceInfo: 43 | return DeviceInfo( 44 | identifiers={("dirigera_platform", self._unique_id)}, 45 | name=self._name, 46 | manufacturer=self._manufacturer, 47 | model=self._model, 48 | sw_version=self._sw_version, 49 | suggested_area="Bedroom", 50 | ) 51 | 52 | @property 53 | def supported_features(self): 54 | logger.debug("blinds supported_features called") 55 | return self._supported_feature 56 | 57 | def update(self): 58 | logger.debug("mock update for {}...".format(self._name)) 59 | pass 60 | 61 | @property 62 | def name(self) -> str: 63 | return self._name 64 | 65 | @property 66 | def is_on(self): 67 | return self._is_on 68 | 69 | @property 70 | def device_class(self) -> str: 71 | return CoverDeviceClass.BLIND 72 | 73 | @property 74 | def current_cover_position(self): 75 | logger.debug("blinds current_cover_position called") 76 | logger.debug( 77 | "Current: {}, Target: {}".format(self._current_level, self._target_level) 78 | ) 79 | return self._current_level 80 | 81 | @property 82 | def is_closed(self): 83 | logger.debug("blinds is_closed called") 84 | logger.debug( 85 | "Current: {}, Target: {}".format(self._current_level, self._target_level) 86 | ) 87 | 88 | return self._current_level == 0 89 | 90 | @property 91 | def is_closing(self): 92 | logger.debug("blinds is_closing called") 93 | logger.debug( 94 | "Current: {}, Target: {}".format(self._current_level, self._target_level) 95 | ) 96 | 97 | if self._current_level != 0 and self._target_level == 0: 98 | return True 99 | return False 100 | 101 | @property 102 | def is_opening(self): 103 | logger.debug("blinds is_opening called") 104 | logger.debug( 105 | "Current: {}, Target: {}".format(self._current_level, self._target_level) 106 | ) 107 | 108 | if self._current_level != 100 and self._target_level == 100: 109 | return True 110 | return False 111 | 112 | def open_cover(self, **kwargs): 113 | logger.debug("blinds open_cover called") 114 | logger.debug( 115 | "Current: {}, Target: {}".format(self._current_level, self._target_level) 116 | ) 117 | 118 | self._current_level = 100 119 | self._target_level = 100 120 | 121 | def close_cover(self, **kwargs): 122 | logger.debug("blinds close_cover called") 123 | logger.debug( 124 | "Current: {}, Target: {}".format(self._current_level, self._target_level) 125 | ) 126 | 127 | self._current_level = 0 128 | self._target_level = 0 129 | 130 | def set_cover_position(self, **kwargs): 131 | logger.debug("blinds set_cover_position {}".format(kwargs)) 132 | logger.debug(kwargs["position"]) 133 | logger.debug( 134 | "Current: {}, Target: {}".format(self._current_level, self._target_level) 135 | ) 136 | 137 | if "position" in kwargs: 138 | position = kwargs["position"] 139 | if position >= 0 and position <= 100: 140 | self._target_level = position 141 | self._current_level = position 142 | logger.debug( 143 | "Now: Current: {}, Target: {}".format( 144 | self._current_level, self._target_level 145 | ) 146 | ) 147 | 148 | async def async_will_remove_from_hass(self) -> None: 149 | ikea_blinds_mock.counter = ikea_blinds_mock.counter - 1 150 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/mocks/ikea_bulb_mock.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components.light import ( 4 | ATTR_BRIGHTNESS, 5 | ATTR_COLOR_TEMP_KELVIN, 6 | ATTR_HS_COLOR, 7 | ColorMode, 8 | LightEntity, 9 | ) 10 | from homeassistant.helpers.entity import DeviceInfo 11 | 12 | logger = logging.getLogger("custom_components.dirigera_platform") 13 | 14 | 15 | class ikea_bulb_mock(LightEntity): 16 | counter = 0 17 | 18 | def __init__(self) -> None: 19 | logger.debug("ikea_bulb mock ctor...") 20 | ikea_bulb_mock.counter = ikea_bulb_mock.counter + 1 21 | 22 | self._manufacturer = "IKEA of Sweden" 23 | self._unique_id = "L1907151129080101_" + str(ikea_bulb_mock.counter) 24 | self._model = "mock bulb" 25 | self._sw_version = "mock sw" 26 | self._name = "mock" 27 | 28 | self._name = "Mock Light {}".format(ikea_bulb_mock.counter) 29 | self._supported_color_modes = [ 30 | ColorMode.BRIGHTNESS, 31 | ColorMode.COLOR_TEMP, 32 | ColorMode.HS, 33 | ] 34 | 35 | if len(self._supported_color_modes) > 1: 36 | # If there are more color modes which means we have either temperature 37 | # or HueSaturation. then lets make sure BRIGHTNESS is not part of it 38 | # as per above documentation 39 | self._supported_color_modes.remove(ColorMode.BRIGHTNESS) 40 | 41 | if len(self._supported_color_modes) == 0: 42 | logger.debug("Color modes array is zero, setting to UNKNOWN") 43 | self._supported_color_modes = [ColorMode.UNKNOWN] 44 | else: 45 | if ColorMode.HS in self._supported_color_modes: 46 | self._color_mode = ColorMode.HS 47 | elif ColorMode.COLOR_TEMP in self._supported_color_modes: 48 | self._color_mode = ColorMode.COLOR_TEMP 49 | elif ColorMode.BRIGHTNESS in self._supported_color_modesor_modes: 50 | self._color_mode = ColorMode.BRIGHTNESS 51 | 52 | self._color_temp = 3000 53 | self._min_color_temp = 2202 54 | self._max_color_temp = 4000 55 | self._color_hue = 0.0 56 | self._color_saturation = 0.0 57 | self._brightness = 100 58 | self._is_on = False 59 | 60 | @property 61 | def unique_id(self): 62 | return self._unique_id 63 | 64 | @property 65 | def device_info(self) -> DeviceInfo: 66 | return DeviceInfo( 67 | identifiers={("dirigera_platform", self._unique_id)}, 68 | name=self._name, 69 | manufacturer=self._manufacturer, 70 | model=self._model, 71 | sw_version=self._sw_version, 72 | ) 73 | 74 | def set_state(self): 75 | pass 76 | 77 | @property 78 | def name(self) -> str: 79 | return self._name 80 | 81 | @property 82 | def brightness(self): 83 | return int((self._brightness / 100) * 255) 84 | 85 | @property 86 | def max_color_temp_kelvin(self): 87 | return self._max_color_temp 88 | 89 | @property 90 | def min_color_temp_kelvin(self): 91 | return self._min_color_temp 92 | 93 | @property 94 | def color_temp_kevin(self): 95 | return self._color_temp 96 | 97 | @property 98 | def hs_color(self): 99 | return (self._color_hue, self._color_saturation) 100 | 101 | @property 102 | def is_on(self): 103 | return self._is_on 104 | 105 | @property 106 | def supported_color_modes(self): 107 | logger.debug("returning supported colors") 108 | return self._supported_color_modes 109 | 110 | @property 111 | def color_mode(self): 112 | logger.debug("Returning color mode") 113 | return self._color_mode 114 | 115 | def update(self): 116 | logger.debug("mock update for {}...".format(self._name)) 117 | pass 118 | 119 | def turn_on(self, **kwargs): 120 | logger.debug("turn_on...") 121 | logger.debug(kwargs) 122 | 123 | logger.debug("Request to turn on...") 124 | self._is_on = True 125 | logger.debug(kwargs) 126 | if ATTR_BRIGHTNESS in kwargs: 127 | # brightness requested 128 | logger.debug("Request to set brightness...") 129 | brightness = int(kwargs[ATTR_BRIGHTNESS]) 130 | logger.debug("Set brightness : {}".format(brightness)) 131 | self._brightness = int((brightness / 255) * 100) 132 | 133 | if ATTR_COLOR_TEMP_KELVIN in kwargs: 134 | # color temp requested 135 | # If request is white then brightness is passed 136 | logger.debug("Request to set color temp...") 137 | ct = kwargs[ATTR_COLOR_TEMP_KELVIN] 138 | logger.debug("Set CT : {}".format(ct)) 139 | self._color_temp = ct 140 | 141 | if ATTR_HS_COLOR in kwargs: 142 | logger.debug("Request to set color HS") 143 | hs_tuple = kwargs[ATTR_HS_COLOR] 144 | self._color_hue = hs_tuple[0] 145 | self._color_saturation = hs_tuple[1] 146 | 147 | def turn_off(self, **kwargs): 148 | logger.debug("turn_off...") 149 | self._is_on = False 150 | 151 | async def async_will_remove_from_hass(self) -> None: 152 | ikea_bulb_mock.counter = ikea_bulb_mock.counter - 1 153 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/dirigera_lib_patch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any, Dict, List, Optional 3 | from typing import Any, Optional, Dict 4 | 5 | from dirigera import Hub 6 | 7 | from dirigera.devices.device import Attributes, Device 8 | from dirigera.hub.abstract_smart_home_hub import AbstractSmartHomeHub 9 | from dirigera.devices.scene import Info, Icon, SceneType, Trigger, TriggerDetails, ControllerType 10 | import logging 11 | 12 | logger = logging.getLogger("custom_components.dirigera_platform") 13 | 14 | # Patch to fix issues with motion sensor 15 | class HubX(Hub): 16 | def __init__( 17 | self, token: str, ip_address: str, port: str = "8443", api_version: str = "v1" 18 | ) -> None: 19 | super().__init__(token, ip_address, port, api_version) 20 | 21 | def get_controllers(self) -> List[ControllerX]: 22 | """ 23 | Fetches all controllers registered in the Hub 24 | """ 25 | devices = self.get("/devices") 26 | controllers = list(filter(lambda x: x["type"] == "controller", devices)) 27 | return [dict_to_controller(controller, self) for controller in controllers] 28 | 29 | # Scenes are a problem so making a hack 30 | def get_scenes(self): 31 | """ 32 | Fetches all controllers registered in the Hub 33 | """ 34 | scenes = self.get("/scenes") 35 | #scenes = list(filter(lambda x: x["type"] == "scene", devices)) 36 | 37 | return [HackScene.make_scene(self, scene) for scene in scenes] 38 | 39 | def get_scene_by_id(self, scene_id: str): 40 | """ 41 | Fetches a specific scene by a given id 42 | """ 43 | data = self.get(f"/scenes/{scene_id}") 44 | return HackScene.make_scene(self, data) 45 | 46 | def create_empty_scene(self, controller_id: str, clicks_supported:list): 47 | logging.debug(f"Creating empty scene for controller : {controller_id} with clicks : {clicks_supported}") 48 | for click in clicks_supported: 49 | scene_name = f'dirigera_integration_empty_scene_{controller_id}_{click}' 50 | info = Info(name=f'dirigera_integration_empty_scene_{controller_id}_{click}', icon=Icon.SCENES_CAKE) 51 | device_trigger = Trigger(type="controller", disabled=False, 52 | trigger=TriggerDetails(clickPattern=click, buttonIndex=0, deviceId=controller_id, controllerType=ControllerType.SHORTCUT_CONTROLLER)) 53 | 54 | logger.debug(f"Creating empty scene : {info.name}") 55 | #self.create_scene(info=info, scene_type=SceneType.USER_SCENE,triggers=[device_trigger]) 56 | data = { 57 | "info": {"name" : scene_name, "icon" : "scenes_cake"}, 58 | "type": "customScene", 59 | "triggers":[ 60 | { 61 | "type": "controller", 62 | "disabled": False, 63 | "trigger": 64 | { 65 | "controllerType": "shortcutController", 66 | "clickPattern": click, 67 | "buttonIndex": 0, 68 | "deviceId": controller_id 69 | } 70 | } 71 | ], 72 | "actions": [] 73 | } 74 | 75 | self.post("/scenes/", data=data) 76 | 77 | def delete_empty_scenes(self): 78 | scenes = self.get_scenes() 79 | for scene in scenes: 80 | if scene.name.startswith("dirigera_integration_empty_scene_"): 81 | logging.debug(f"Deleting Scene id: {scene.id} name: {scene.name}...") 82 | self.delete_scene(scene.id) 83 | 84 | class ControllerAttributesX(Attributes): 85 | is_on: Optional[bool] = None 86 | battery_percentage: Optional[int] = None 87 | switch_label: Optional[str] = None 88 | 89 | class ControllerX(Device): 90 | dirigera_client: AbstractSmartHomeHub 91 | attributes: ControllerAttributesX 92 | 93 | def reload(self) -> ControllerX: 94 | data = self.dirigera_client.get(route=f"/devices/{self.id}") 95 | return ControllerX(dirigeraClient=self.dirigera_client, **data) 96 | 97 | def set_name(self, name: str) -> None: 98 | if "customName" not in self.capabilities.can_receive: 99 | raise AssertionError( 100 | "This controller does not support the set_name function" 101 | ) 102 | 103 | data = [{"attributes": {"customName": name}}] 104 | self.dirigera_client.patch(route=f"/devices/{self.id}", data=data) 105 | self.attributes.custom_name = name 106 | 107 | def dict_to_controller( 108 | data: Dict[str, Any], dirigera_client: AbstractSmartHomeHub 109 | ) -> ControllerX: 110 | return ControllerX(dirigeraClient=dirigera_client, **data) 111 | 112 | class HackScene(): 113 | 114 | def __init__(self, hub, id, name, icon): 115 | self.hub = hub 116 | self.id = id 117 | self.name = name 118 | self.icon = icon 119 | 120 | def parse_scene_json(json_data): 121 | id = json_data["id"] 122 | name = json_data["info"]["name"] 123 | icon = json_data["info"]["icon"] 124 | return id, name, icon 125 | 126 | def make_scene(dirigera_client, json_data): 127 | id, name, icon = HackScene.parse_scene_json(json_data) 128 | return HackScene(dirigera_client, id, name, icon) 129 | 130 | def reload(self) -> HackScene: 131 | data = self.dirigera_client.get(route=f"/scenes/{self.id}") 132 | return HackScene.make_scene(self, data) 133 | #return Scene(dirigeraClient=self.dirigera_client, **data) 134 | 135 | def trigger(self) -> HackScene: 136 | self.hub.post(route=f"/scenes/{self.id}/trigger") 137 | 138 | def undo(self) -> HackScene: 139 | self.hub.post(route=f"/scenes/{self.id}/undo") -------------------------------------------------------------------------------- /custom_components/dirigera_platform/ikea_gateway.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | 4 | from .dirigera_lib_patch import HubX 5 | from .scene import ikea_scene 6 | from .light import ikea_bulb 7 | from .base_classes import ( 8 | ikea_blinds_device, 9 | ikea_starkvind_air_purifier_device, 10 | ikea_outlet_device, 11 | ikea_vindstyrka_device, 12 | ikea_controller_device, 13 | ikea_open_close_device, 14 | ikea_motion_sensor_device, 15 | ikea_water_sensor_device 16 | ) 17 | 18 | from dirigera.devices.scene import Trigger, TriggerDetails, EndTriggerEvent 19 | 20 | logger = logging.getLogger("custom_components.dirigera_platform") 21 | 22 | class HubDeviceType(Enum): 23 | EMPTY_SCENE = "empty_scene" 24 | SCENE = "scene" 25 | LIGHT = "light" 26 | BLIND = "blinds" 27 | AIR_PURIFIER = "air_purifier" 28 | OUTLET = "outlet" 29 | ENVIRONMENT_SENSOR = "environment_sensor" 30 | CONTROLLER = "controller" 31 | OPEN_CLOSE_SENSOR = "open_close" 32 | MOTION_SENSOR = "motion_sensor" 33 | WATER_SENSOR = "water_sensor" 34 | 35 | class ikea_gateway: 36 | def __init__(self): 37 | Trigger.update_forward_refs() 38 | TriggerDetails.update_forward_refs() 39 | EndTriggerEvent.update_forward_refs() 40 | 41 | logger.debug("dirigera_platform init...") 42 | self.devices = {} 43 | 44 | async def make_devices(self, hass, ip, token): 45 | hub: HubX = HubX(token, ip) 46 | 47 | #Scenes 48 | scenes = await hass.async_add_executor_job(hub.get_scenes) 49 | logger.debug(f"Found {len(scenes)} scenes...") 50 | empty_scenes = [] 51 | non_empty_scenes = [] 52 | for scene in scenes: 53 | if scene.name.startswith("dirigera_integration_empty_scene_"): 54 | empty_scenes.append(ikea_scene(hub,scene)) 55 | else: 56 | non_empty_scenes.append(ikea_scene(hub,scene)) 57 | 58 | self.devices[HubDeviceType.EMPTY_SCENE] = empty_scenes 59 | self.devices[HubDeviceType.SCENE] = non_empty_scenes 60 | 61 | #Light 62 | lights = await hass.async_add_executor_job(hub.get_lights) 63 | logger.debug(f"Found {len(lights)} total of all light devices to setup...") 64 | self.devices[HubDeviceType.LIGHT] = [ikea_bulb(hub, light) for light in lights] 65 | 66 | #Cover 67 | blinds = await hass.async_add_executor_job(hub.get_blinds) 68 | logger.debug(f"Found {len(lights)} total of all blinds devices to setup...") 69 | self.devices[HubDeviceType.BLIND] = [ikea_blinds_device(hass, hub, b) for b in blinds] 70 | 71 | #Air Purifier 72 | air_purifiers = await hass.async_add_executor_job(hub.get_air_purifiers) 73 | logger.debug(f"Found {len(air_purifiers)} total of all air purifiers devices to setup...") 74 | self.devices[HubDeviceType.AIR_PURIFIER] = [ikea_starkvind_air_purifier_device(hass, hub, a) for a in air_purifiers] 75 | 76 | #Outlets 77 | outlets = await hass.async_add_executor_job(hub.get_outlets) 78 | logger.debug(f"Found {len(outlets)} total of all outlets devices to setup...") 79 | self.devices[HubDeviceType.OUTLET] = [ ikea_outlet_device(hass, hub, x) for x in outlets ] 80 | 81 | #Environment Sensor 82 | environment_sensors = await hass.async_add_executor_job(hub.get_environment_sensors) 83 | logger.debug(f"Found {len(environment_sensors)} total of all environment devices entities to setup...") 84 | self.devices[HubDeviceType.ENVIRONMENT_SENSOR] = [ikea_vindstyrka_device(hass, hub, env_device) for env_device in environment_sensors] 85 | 86 | #Controllers 87 | controllers = await hass.async_add_executor_job(hub.get_controllers) 88 | logger.debug(f"Found {len(controllers)} total of all controllers devices to setup...") 89 | self.devices[HubDeviceType.CONTROLLER] = [ikea_controller_device(hass, hub, x) for x in controllers] 90 | 91 | #Open Close Sensors 92 | open_close_sensors = await hass.async_add_executor_job(hub.get_open_close_sensors) 93 | logger.debug(f"Found {len(open_close_sensors)} total of all open_close devices to setup...") 94 | self.devices[HubDeviceType.OPEN_CLOSE_SENSOR] = [ikea_open_close_device(hass, hub, x) for x in open_close_sensors] 95 | 96 | #Motion Sensors 97 | motion_sensors = await hass.async_add_executor_job(hub.get_motion_sensors) 98 | logger.debug(f"Found {len(motion_sensors)} total of all motion_sensors devices to setup...") 99 | self.devices[HubDeviceType.MOTION_SENSOR] = [ikea_motion_sensor_device(hass, hub, x) for x in motion_sensors] 100 | 101 | #Water Sensors 102 | water_sensors = await hass.async_add_executor_job(hub.get_water_sensors) 103 | logger.debug(f"Found {len(water_sensors)} total of all water_sensors devices to setup...") 104 | self.devices[HubDeviceType.WATER_SENSOR] = [ikea_water_sensor_device(hass, hub, x) for x in water_sensors] 105 | 106 | def get_devices(self, key): 107 | if key not in self.devices: 108 | self.devices[key]=[] 109 | return self.devices[key] 110 | 111 | @property 112 | def empty_scenes(self): 113 | return self.get_devices(HubDeviceType.EMPTY_SCENE) 114 | 115 | @property 116 | def scenes(self): 117 | return self.get_devices(HubDeviceType.SCENE) 118 | 119 | @property 120 | def lights(self): 121 | return self.get_devices(HubDeviceType.LIGHT) 122 | 123 | @property 124 | def blinds(self): 125 | return self.get_devices(HubDeviceType.BLIND) 126 | 127 | @property 128 | def air_purifiers(self): 129 | return self.get_devices(HubDeviceType.AIR_PURIFIER) 130 | 131 | @property 132 | def outlets(self): 133 | return self.get_devices(HubDeviceType.OUTLET) 134 | 135 | @property 136 | def environment_sensors(self): 137 | return self.get_devices(HubDeviceType.ENVIRONMENT_SENSOR) 138 | 139 | @property 140 | def controllers(self): 141 | return self.get_devices(HubDeviceType.CONTROLLER) 142 | 143 | @property 144 | def open_close_sensors(self): 145 | return self.get_devices(HubDeviceType.OPEN_CLOSE_SENSOR) 146 | 147 | @property 148 | def motion_sensors(self): 149 | return self.get_devices(HubDeviceType.MOTION_SENSOR) 150 | 151 | @property 152 | def water_sensors(self): 153 | return self.get_devices(HubDeviceType.WATER_SENSOR) -------------------------------------------------------------------------------- /custom_components/dirigera_platform/mocks/ikea_air_purifier_mock.py: -------------------------------------------------------------------------------- 1 | from homeassistant.helpers.entity import DeviceInfo 2 | from homeassistant.core import HomeAssistantError 3 | from homeassistant.components.fan import FanEntity, FanEntityFeature 4 | 5 | import logging 6 | import datetime 7 | import math 8 | from dirigera.devices.air_purifier import FanModeEnum 9 | from ..sensor import ikea_vindstyrka_device 10 | from ..const import DOMAIN 11 | 12 | logger = logging.getLogger("custom_components.dirigera_platform") 13 | 14 | 15 | class ikea_starkvind_air_purifier_mock_device: 16 | counter = 0 17 | 18 | def __init__(self) -> None: 19 | ikea_starkvind_air_purifier_mock_device.counter = ( 20 | ikea_starkvind_air_purifier_mock_device.counter + 1 21 | ) 22 | self._name = "MOCK Air Purifier " + str( 23 | ikea_starkvind_air_purifier_mock_device.counter 24 | ) 25 | self._unique_id = "AP1907151129080101_" + str( 26 | ikea_starkvind_air_purifier_mock_device.counter 27 | ) 28 | self._updated_at = None 29 | self._motor_state = 20 30 | self._status_light = True 31 | self._child_lock = True 32 | self._fan_mode = "auto" 33 | logger.debug("Air purifer Mock Device ctor complete...") 34 | 35 | def update(self): 36 | if ( 37 | self._updated_at is None 38 | or (datetime.datetime.now() - self._updated_at).total_seconds() > 10 39 | ): 40 | try: 41 | logger.info("AirPurifier Mock Update called...") 42 | self._updated_at = datetime.datetime.now() 43 | except Exception as ex: 44 | logger.error("error encountered running update on : {}".format(self.name)) 45 | logger.error(ex) 46 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 47 | 48 | @property 49 | def available(self): 50 | return True 51 | 52 | @property 53 | def is_on(self): 54 | logger.debug("ikea_starkvind_air_purifier_mock_device is_on called...") 55 | if self.available and self.motor_state > 0: 56 | return True 57 | return False 58 | 59 | @property 60 | def device_info(self) -> DeviceInfo: 61 | logger.info("Got device_info call on airpurifier mock...") 62 | return DeviceInfo( 63 | identifiers={("dirigera_platform", self._unique_id)}, 64 | name=self._name, 65 | manufacturer="MOCK", 66 | model="Mock 1.0", 67 | sw_version="Mock SW 1.0", 68 | suggested_area="Kitchen", 69 | ) 70 | 71 | @property 72 | def name(self) -> str: 73 | logger.info("Returning name as {} airpurifier mock...".format(self._name)) 74 | return self._name 75 | 76 | @property 77 | def unique_id(self): 78 | logger.info( 79 | "Returning unique_id as {} airpurifier mock...".format(self._unique_id) 80 | ) 81 | return self._unique_id 82 | 83 | @property 84 | def supported_features(self): 85 | logger.debug("AirPurifier supported features called...") 86 | return FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED 87 | 88 | @property 89 | def motor_state(self) -> int: 90 | return self._motor_state 91 | 92 | @property 93 | def percentage(self) -> int: 94 | # Scale the 1-50 into 95 | return math.ceil(self.motor_state * 100 / 50) 96 | 97 | @property 98 | def fan_mode_sequence(self) -> str: 99 | return "lowMediumHighAuto" 100 | 101 | @property 102 | def preset_modes(self): 103 | return [e.value for e in FanModeEnum] 104 | 105 | @property 106 | def preset_mode(self) -> str: 107 | return self._fan_mode 108 | 109 | @property 110 | def speed_count(self): 111 | return 50 112 | 113 | @property 114 | def motor_runtime(self): 115 | return 30 116 | 117 | @property 118 | def filter_alarm_status(self) -> bool: 119 | return False 120 | 121 | @property 122 | def filter_elapsed_time(self) -> int: 123 | return 60 124 | 125 | @property 126 | def filter_lifetime(self) -> int: 127 | return 90 128 | 129 | @property 130 | def current_p_m25(self) -> int: 131 | return 241 132 | 133 | @property 134 | def status_light(self) -> bool: 135 | logger.debug("air purifier mock status_light : {}".format(self._status_light)) 136 | return self._status_light 137 | 138 | @property 139 | def child_lock(self) -> bool: 140 | logger.debug("air purifier mock child_lock : {}".format(self._child_lock)) 141 | return self._child_lock 142 | 143 | def set_percentage(self, percentage: int) -> None: 144 | # Convert percent to speed 145 | desired_speed = math.ceil(percentage * 50 / 100) 146 | logger.debug( 147 | "set_percentage got : {}, scaled to : {}".format(percentage, desired_speed) 148 | ) 149 | self._motor_state = desired_speed 150 | 151 | def set_status_light(self, status: bool) -> None: 152 | logger.debug("set_status_light : {}".format(status)) 153 | self._status_light = status 154 | 155 | def set_child_lock(self, status: bool) -> None: 156 | logger.debug("set_child_lock : {}".format(status)) 157 | self._child_lock = status 158 | 159 | def set_fan_mode(self, preset_mode: FanModeEnum) -> None: 160 | logger.debug("set_fan_mode : {}".format(preset_mode.value)) 161 | self._fan_mode = str(preset_mode.value) 162 | if preset_mode == FanModeEnum.AUTO: 163 | self._motor_state = 1 164 | elif preset_mode == FanModeEnum.HIGH: 165 | self._motor_state = 50 166 | elif preset_mode == FanModeEnum.MEDIUM: 167 | self._motor_state = 25 168 | elif preset_mode == FanModeEnum.LOW: 169 | self._motor_state = 10 170 | else: 171 | logger.debug("Unknown fan_mode called...") 172 | 173 | def set_preset_mode(self, preset_mode: str): 174 | logger.debug("set_preset_mode : {}".format(preset_mode)) 175 | mode_to_set = None 176 | if preset_mode == FanModeEnum.AUTO.value: 177 | mode_to_set = FanModeEnum.AUTO 178 | elif preset_mode == FanModeEnum.HIGH.value: 179 | mode_to_set = FanModeEnum.HIGH 180 | elif preset_mode == FanModeEnum.MEDIUM.value: 181 | mode_to_set = FanModeEnum.MEDIUM 182 | elif preset_mode == FanModeEnum.LOW.value: 183 | mode_to_set = FanModeEnum.LOW 184 | 185 | if mode_to_set is None: 186 | logger.error("Non defined preset used to set : {}".format(preset_mode)) 187 | return 188 | 189 | logger.debug("set_preset_mode equated to : {}".format(mode_to_set.value)) 190 | self.set_fan_mode(mode_to_set) 191 | 192 | def turn_on(self, percentage=None, preset_mode=None, **kwargs) -> None: 193 | logger.debug( 194 | "Airpurifier call to turn_on with percentage: {}, preset_mode: {}".format( 195 | percentage, preset_mode 196 | ) 197 | ) 198 | if preset_mode is not None: 199 | self.set_preset_mode(preset_mode) 200 | elif percentage is not None: 201 | self.set_percentage(percentage) 202 | else: 203 | logger.debug( 204 | "We were asked to be turned on but percentage and preset were not set, using last know" 205 | ) 206 | if self.preset_mode is not None: 207 | self.set_preset_mode(self.preset_mode) 208 | elif self.percentage is not None: 209 | self.set_percentage(self.percentage) 210 | else: 211 | logger.debug("No last known value, setting to auto") 212 | self.set_preset_mode("auto") 213 | 214 | def turn_off(self, **kwargs) -> None: 215 | self.set_percentage(0) 216 | 217 | async def async_will_remove_from_hass(self) -> None: 218 | ikea_starkvind_air_purifier_mock_device.counter = ( 219 | ikea_starkvind_air_purifier_mock_device.counter - 1 220 | ) 221 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .dirigera_lib_patch import HubX 4 | 5 | from .base_classes import ( 6 | battery_percentage_sensor, 7 | ikea_vindstyrka_temperature, 8 | ikea_vindstyrka_humidity, 9 | ikea_vindstyrka_pm25, 10 | ikea_vindstyrka_voc_index, 11 | WhichPM25, 12 | ikea_starkvind_air_purifier_sensor, 13 | current_amps_sensor , 14 | current_active_power_sensor, 15 | current_voltage_sensor, 16 | total_energy_consumed_sensor, 17 | energy_consumed_at_last_reset_sensor , 18 | total_energy_consumed_last_updated_sensor, 19 | total_energy_consumed_sensor, 20 | time_of_last_energy_reset_sensor 21 | ) 22 | from .ikea_gateway import ikea_gateway 23 | 24 | from homeassistant import config_entries, core 25 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 26 | from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN 27 | from homeassistant.core import HomeAssistantError 28 | from homeassistant.helpers.entity import EntityCategory 29 | 30 | from .const import DOMAIN, PLATFORM 31 | 32 | logger = logging.getLogger("custom_components.dirigera_platform") 33 | 34 | async def async_setup_entry( 35 | hass: core.HomeAssistant, 36 | config_entry: config_entries.ConfigEntry, 37 | async_add_entities, 38 | ): 39 | """Setup sensors from a config entry created in the integrations UI.""" 40 | logger.debug("Staring async_setup_entry in SENSOR...") 41 | 42 | config = hass.data[DOMAIN][config_entry.entry_id] 43 | 44 | hub = HubX(config[CONF_TOKEN], config[CONF_IP_ADDRESS]) 45 | 46 | platform: ikea_gateway = hass.data[DOMAIN][PLATFORM] 47 | 48 | # Precuationary delete all empty scenes 49 | if len(platform.empty_scenes) > 0: 50 | await hass.async_add_executor_job(hub.delete_empty_scenes) 51 | 52 | await add_controllers_sensors(hass, async_add_entities, hub, platform.controllers) 53 | await add_environment_sensors(async_add_entities, platform.environment_sensors) 54 | await add_outlet_power_attrs(async_add_entities, platform.outlets) 55 | 56 | # Add battery sensors 57 | battery_sensors = [] 58 | battery_sensors.extend([battery_percentage_sensor(x) for x in platform.motion_sensors]) 59 | battery_sensors.extend([battery_percentage_sensor(x) for x in platform.open_close_sensors]) 60 | battery_sensors.extend([battery_percentage_sensor(x) for x in platform.water_sensors]) 61 | battery_sensors.extend([battery_percentage_sensor(x) for x in platform.environment_sensors if getattr(x,"battery_percentage",None) is not None]) 62 | battery_sensors.extend([battery_percentage_sensor(x) for x in platform.blinds if getattr(x,"battery_percentage",None) is not None]) 63 | 64 | logger.debug(f"Found {len(battery_sensors)} battery sensors...") 65 | async_add_entities(battery_sensors) 66 | 67 | await add_air_purifier_sensors(async_add_entities, platform.air_purifiers) 68 | logger.debug("sensor Complete async_setup_entry") 69 | 70 | async def add_environment_sensors(async_add_entities, env_devices): 71 | env_sensors = [] 72 | for env_device in env_devices: 73 | # For each device setup up multiple entities 74 | # Some non IKEA environment sensors only have some of the attributes 75 | # hence check if it exists and then add 76 | if getattr(env_device,"current_temperature") is not None: 77 | env_sensors.append(ikea_vindstyrka_temperature(env_device)) 78 | if getattr(env_device,"current_r_h") is not None: 79 | env_sensors.append(ikea_vindstyrka_humidity(env_device)) 80 | if getattr(env_device,"current_p_m25") is not None: 81 | env_sensors.append(ikea_vindstyrka_pm25(env_device, WhichPM25.CURRENT)) 82 | if getattr(env_device,"max_measured_p_m25") is not None: 83 | env_sensors.append(ikea_vindstyrka_pm25(env_device, WhichPM25.MAX)) 84 | if getattr(env_device,"min_measured_p_m25") is not None: 85 | env_sensors.append(ikea_vindstyrka_pm25(env_device, WhichPM25.MIN)) 86 | if getattr(env_device,"voc_index") is not None: 87 | env_sensors.append(ikea_vindstyrka_voc_index(env_device)) 88 | 89 | logger.debug("Found {} env entities to setup...".format(len(env_sensors))) 90 | 91 | async_add_entities(env_sensors) 92 | 93 | async def add_outlet_power_attrs(async_add_entities, outlets): 94 | # Add sensors for the outlets 95 | power_entities = [] 96 | power_attrs=["current_amps","current_active_power","current_voltage","total_energy_consumed","energy_consumed_at_last_reset","time_of_last_energy_reset","total_energy_consumed_last_updated"] 97 | # Some outlets like INSPELNING Smart plug have ability to report power, so add those as well 98 | logger.debug("Looking for extra attributes of power/current/voltage in outlet....") 99 | for outlet in outlets: 100 | for attr in power_attrs: 101 | if hasattr(outlet._json_data.attributes, attr) and getattr(outlet._json_data.attributes, attr, None) is not None: 102 | power_entities.append(eval(f"{attr}_sensor(outlet)")) 103 | 104 | logger.debug(f"Found {len(power_entities)}, power attribute sensors for outlets") 105 | async_add_entities(power_entities) 106 | 107 | async def add_air_purifier_sensors(async_add_entities, air_purifiers): 108 | #Now Air Purifier Sensors 109 | air_purifier_entities = [] 110 | for air_purifier in air_purifiers: 111 | air_purifier_entities.append( 112 | ikea_starkvind_air_purifier_sensor( 113 | device=air_purifier, 114 | prefix="Filter Lifetime", 115 | device_class=SensorDeviceClass.DURATION, 116 | native_value_prop="filter_lifetime", 117 | native_uom="min", 118 | icon_name="mdi:clock-time-eleven-outline", 119 | ) 120 | ) 121 | 122 | air_purifier_entities.append( 123 | ikea_starkvind_air_purifier_sensor( 124 | device=air_purifier, 125 | prefix="Filter Elapsed Time", 126 | device_class=SensorDeviceClass.DURATION, 127 | native_value_prop="filter_elapsed_time", 128 | native_uom="min", 129 | icon_name="mdi:timelapse", 130 | ) 131 | ) 132 | 133 | air_purifier_entities.append( 134 | ikea_starkvind_air_purifier_sensor( 135 | device=air_purifier, 136 | prefix="Current pm25", 137 | device_class=SensorDeviceClass.PM25, 138 | native_value_prop="current_p_m25", 139 | native_uom="µg/m³", 140 | icon_name="mdi:molecule", 141 | ) 142 | ) 143 | 144 | air_purifier_entities.append( 145 | ikea_starkvind_air_purifier_sensor( 146 | device=air_purifier, 147 | prefix="Motor Runtime", 148 | device_class=SensorDeviceClass.DURATION, 149 | native_value_prop="motor_runtime", 150 | native_uom="min", 151 | icon_name="mdi:run-fast", 152 | ) 153 | ) 154 | 155 | async_add_entities(air_purifier_entities) 156 | 157 | async def add_controllers_sensors(hass, async_add_entities, hub, controllers): 158 | logger.debug("Starting to add controller sensors...") 159 | # Controllers with more one button are returned as spearate controllers 160 | # their uniqueid has _1, _2 suffixes. Only the primary controller has 161 | # battery % attribute which we shall use to identify 162 | controller_entities = [] 163 | for controller in controllers: 164 | # Hack to create empty scene so that we can associate it the controller 165 | # so that click of buttons on the controller can generate events on the hub 166 | clicks_supported = controller._json_data.capabilities.can_send 167 | clicks_supported = [ x for x in clicks_supported if x.endswith("Press") ] 168 | 169 | if len(clicks_supported) == 0: 170 | logger.debug(f"Ignoring controller for scene creation : {controller._json_data.id} as no press event supported : {controller._json_data.capabilities.can_send}") 171 | else: 172 | logger.debug(f"Will be creating empty scene for {controller._json_data.id}") 173 | await hass.async_add_executor_job(hub.create_empty_scene,controller._json_data.id, clicks_supported) 174 | 175 | if getattr(controller._json_data.attributes,"battery_percentage",None) is not None: 176 | controller_entities.append(controller) 177 | 178 | logger.debug("Found {} controller devices to setup...".format(len(controller_entities))) 179 | async_add_entities(controller_entities) -------------------------------------------------------------------------------- /custom_components/dirigera_platform/config_flow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import string 3 | from typing import Any, Dict 4 | 5 | from dirigera.hub.auth import get_token, random_code, send_challenge 6 | import voluptuous as vol 7 | 8 | from homeassistant import config_entries, core 9 | from homeassistant.components.light import PLATFORM_SCHEMA 10 | from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN 11 | from homeassistant.core import callback 12 | import homeassistant.helpers.config_validation as cv 13 | 14 | from .const import DOMAIN, CONF_HIDE_DEVICE_SET_BULBS 15 | 16 | logger = logging.getLogger("custom_components.dirigera_platform") 17 | 18 | HUB_SCHEMA = vol.Schema({ 19 | vol.Required(CONF_IP_ADDRESS): cv.string, 20 | vol.Optional(CONF_HIDE_DEVICE_SET_BULBS, default=True): cv.boolean 21 | }) 22 | 23 | NULL_SCHEMA = vol.Schema({}) 24 | 25 | 26 | def get_dirigera_token_step_one(ip_address): 27 | logger.debug("In generate token step one ") 28 | ALPHABET = f"_-~.{string.ascii_letters}{string.digits}" 29 | CODE_LENGTH = 128 30 | code_verifier = random_code(ALPHABET, CODE_LENGTH) 31 | code = send_challenge(ip_address, code_verifier) 32 | logger.debug("returning from generate token step one") 33 | return code, code_verifier 34 | 35 | 36 | def get_dirigera_token_step_two(ip_address, code, code_verifier): 37 | logger.debug("in generated token step two ") 38 | token = get_token(ip_address, code, code_verifier) 39 | logger.debug("returning from generate token step two") 40 | return token 41 | 42 | 43 | class dirigera_platform_config_flow(config_entries.ConfigFlow, domain=DOMAIN): 44 | VERSION = 1 45 | 46 | def __init__(self): 47 | self.ip = None 48 | self.code = None 49 | self.hide_device_set_bulbs = True 50 | self.code_verifier = None 51 | 52 | async def async_step_user( 53 | self, user_input: Dict[str, Any] = None 54 | ) -> Dict[str, Any]: 55 | logger.debug("CONFIG async_step_user called....") 56 | 57 | errors: Dict[str, str] = {} 58 | 59 | if user_input is not None and CONF_IP_ADDRESS in user_input: 60 | logger.debug("async step init user input is not none...") 61 | logger.debug("user_input is ") 62 | logger.debug(user_input) 63 | 64 | self.ip = user_input[CONF_IP_ADDRESS] 65 | self.hide_device_set_bulbs = user_input[CONF_HIDE_DEVICE_SET_BULBS] 66 | 67 | if self.ip is None or len(self.ip.strip()) == 0: 68 | logger.debug("IP specified is blank...") 69 | errors["base"] = "ip_not_specified" 70 | else: 71 | try: 72 | logger.debug("Moving to second step....") 73 | if self.ip == "mock": 74 | logger.warning( 75 | "Using mock ip, skipping token generation step 1" 76 | ) 77 | else: 78 | ( 79 | self.code, 80 | self.code_verifier, 81 | ) = await core.async_get_hass().async_add_executor_job( 82 | get_dirigera_token_step_one, self.ip 83 | ) 84 | return self.async_show_form( 85 | step_id="action", data_schema=NULL_SCHEMA, errors=errors 86 | ) 87 | except Exception as ex: 88 | logger.error("Failed to connect to dirigera hub") 89 | logger.error(ex) 90 | errors["base"] = "hub_connection_fail" 91 | 92 | return self.async_show_form( 93 | step_id="user", data_schema=HUB_SCHEMA, errors=errors 94 | ) 95 | 96 | async def async_step_action( 97 | self, user_input: Dict[str, Any] = None 98 | ) -> Dict[str, Any]: 99 | logger.debug("CONFIG async_step_action called....") 100 | # Since IP is specified we will try and get the auth token an set that up in 101 | # the config for use at a later time 102 | errors: Dict[str, str] = {} 103 | logger.debug("ip {}".format(self.ip)) 104 | 105 | # Try and get the token step_2 106 | try: 107 | if self.ip == "mock": 108 | logger.warning("Using mock ip, skipping token generation step 2") 109 | token = "mock" 110 | else: 111 | token = await core.async_get_hass().async_add_executor_job( 112 | get_dirigera_token_step_two, self.ip, self.code, self.code_verifier 113 | ) 114 | logger.info("Successful generating token") 115 | 116 | user_input[CONF_IP_ADDRESS] = self.ip 117 | user_input[CONF_TOKEN] = token 118 | user_input[CONF_HIDE_DEVICE_SET_BULBS] = self.hide_device_set_bulbs 119 | 120 | return self.async_create_entry( 121 | title="IKEA Dirigera Hub : {}".format(user_input[CONF_IP_ADDRESS]), 122 | data=user_input, 123 | ) 124 | except Exception as ex: 125 | logger.error("Failed to connect to dirigera hub") 126 | logger.error(ex) 127 | errors["base"] = "hub_connection_fail" 128 | 129 | return self.async_show_form( 130 | step_id="user", data_schema=NULL_SCHEMA, errors=errors 131 | ) 132 | 133 | @staticmethod 134 | @callback 135 | def async_get_options_flow(config_entry): 136 | """Get the options flow for this handler.""" 137 | return OptionsFlowHandler(config_entry) 138 | 139 | 140 | class OptionsFlowHandler(config_entries.OptionsFlow): 141 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 142 | logger.debug("OPTIONS flow handler init...") 143 | logger.debug(config_entry.data) 144 | 145 | self.config_entry = config_entry 146 | 147 | async def async_step_init( 148 | self, user_input: Dict[str, Any] = None 149 | ) -> Dict[str, Any]: 150 | # Called when configure is called from an existing configured integration 151 | # The first screen that is shown, asl called after IP when submitted 152 | 153 | logger.error("OPTIONS async_step_init called....") 154 | logger.error(user_input) 155 | 156 | errors: Dict[str, str] = {} 157 | 158 | if user_input is not None: 159 | logger.error("async step init user input is not none...") 160 | logger.error("user_input is ") 161 | logger.error(user_input) 162 | 163 | self.ip = user_input[CONF_IP_ADDRESS] 164 | self.hide_device_set_bulbs = user_input[CONF_HIDE_DEVICE_SET_BULBS] 165 | logger.debug(f"IN THIS STEP hide.. set {self.hide_device_set_bulbs}") 166 | 167 | if self.ip is None or len(self.ip.strip()) == 0: 168 | logger.debug("IP specified is blank...") 169 | errors["base"] = "ip_not_specified" 170 | else: 171 | try: 172 | logger.error("Moving to second step....") 173 | if self.ip == "mock": 174 | logger.warning( 175 | "Using mock ip, skipping token generation step 1" 176 | ) 177 | else: 178 | ( 179 | self.code, 180 | self.code_verifier, 181 | ) = await core.async_get_hass().async_add_executor_job( 182 | get_dirigera_token_step_one, self.ip 183 | ) 184 | return self.async_show_form( 185 | step_id="action", data_schema=NULL_SCHEMA, errors=errors 186 | ) 187 | except Exception as ex: 188 | logger.error("Failed to connect to dirigera hub") 189 | logger.error(ex) 190 | errors["base"] = "hub_connection_fail" 191 | 192 | return self.async_show_form( 193 | step_id="init", data_schema=HUB_SCHEMA, errors=errors 194 | ) 195 | 196 | async def async_step_action( 197 | self, user_input: Dict[str, Any] = None 198 | ) -> Dict[str, Any]: 199 | logger.debug("CONFIG async_step_action called....") 200 | logger.debug(user_input) 201 | # Since IP is specified we will try and get the auth token an set that up in 202 | # the config for use at a later time 203 | errors: Dict[str, str] = {} 204 | logger.error("ip {}".format(self.ip)) 205 | logger.error(f"hide device set bulbs {self.hide_device_set_bulbs}") 206 | # Try and get the token step_2 207 | try: 208 | if self.ip == "mock": 209 | logger.warning("Using mock ip, skipping token generation step 2") 210 | token = "mock" 211 | else: 212 | token = await core.async_get_hass().async_add_executor_job( 213 | get_dirigera_token_step_two, self.ip, self.code, self.code_verifier 214 | ) 215 | logger.info("Successful generating token") 216 | logger.error(token) 217 | 218 | user_input[CONF_IP_ADDRESS] = self.ip 219 | user_input[CONF_TOKEN] = token 220 | user_input[CONF_HIDE_DEVICE_SET_BULBS] = self.hide_device_set_bulbs 221 | logger.error("before create entry...") 222 | logger.error(user_input) 223 | 224 | self.hass.config_entries.async_update_entry(self.config_entry, data=user_input, 225 | title="IKEA Dirigera Hub : {}".format(user_input[CONF_IP_ADDRESS]),) 226 | #return self.async_create_entry(title=None, data=None) 227 | #return self.config_entry.async_update_entry(user_input) 228 | return self.async_create_entry( 229 | title="IKEA Dirigera Hub : {}".format(user_input[CONF_IP_ADDRESS]), 230 | data=user_input, 231 | ) 232 | except Exception as ex: 233 | logger.error("Failed to connect to dirigera hub") 234 | logger.error(ex) 235 | errors["base"] = "hub_connection_fail" 236 | 237 | return self.async_show_form( 238 | step_id="init", data_schema=NULL_SCHEMA, errors=errors 239 | ) 240 | -------------------------------------------------------------------------------- /custom_components/dirigera_platform/__init__.py: -------------------------------------------------------------------------------- 1 | """Platform for IKEA dirigera hub integration.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import logging 6 | 7 | from dirigera import Hub 8 | from .dirigera_lib_patch import HubX 9 | 10 | from .ikea_gateway import ikea_gateway 11 | 12 | import voluptuous as vol 13 | 14 | from homeassistant import config_entries, core 15 | from homeassistant.components.light import PLATFORM_SCHEMA 16 | from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN, Platform 17 | 18 | # Import the device class from the component that you want to support 19 | from homeassistant.core import HomeAssistant 20 | import homeassistant.helpers.config_validation as cv 21 | 22 | from .const import DOMAIN, CONF_HIDE_DEVICE_SET_BULBS, PLATFORM 23 | from .hub_event_listener import hub_event_listener 24 | 25 | PLATFORMS_TO_SETUP = [ Platform.SWITCH, 26 | Platform.BINARY_SENSOR, 27 | Platform.LIGHT, 28 | Platform.SENSOR, 29 | Platform.COVER, 30 | Platform.FAN, 31 | Platform.SCENE] 32 | 33 | logger = logging.getLogger("custom_components.dirigera_platform") 34 | 35 | # Validation of the user's configuration 36 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 37 | { 38 | vol.Required(CONF_IP_ADDRESS): cv.string, 39 | vol.Required(CONF_TOKEN): cv.string, 40 | vol.Optional(CONF_HIDE_DEVICE_SET_BULBS, default=True): cv.boolean 41 | } 42 | ) 43 | 44 | hub_events = None 45 | 46 | async def async_setup(hass: HomeAssistant, config: dict) -> bool: 47 | logger.debug("Starting async_setup...") 48 | #for k in config.keys(): 49 | # logger.debug(f"config key: {k} value: {config[k]}") 50 | logger.debug("Complete async_setup...") 51 | 52 | def handle_dump_data(call): 53 | import dirigera 54 | 55 | logger.info("=== START Devices JSON ===") 56 | key = list(hass.data[DOMAIN].keys())[0] 57 | 58 | config_data = hass.data[DOMAIN][key] 59 | ip = config_data[CONF_IP_ADDRESS] 60 | token = config_data[CONF_TOKEN] 61 | 62 | logger.info("--------------") 63 | if ip == "mock": 64 | logger.info("{ MOCK JSON }") 65 | else: 66 | hub = dirigera.Hub(token, ip) 67 | json_resp = hub.get("/devices") 68 | logger.debug(f"TYPE IS {type(json_resp)}") 69 | #import json 70 | #devices_json = json.loads(json_resp) 71 | # Sanitize the dump 72 | 73 | master_id_map = {} 74 | id_counter = 1 75 | for device_json in json_resp: 76 | if "id" in device_json: 77 | id_value = device_json["id"] 78 | id_to_replace = id_counter 79 | 80 | if id_value in master_id_map: 81 | id_to_replace = master_id_map[id_value] 82 | else: 83 | id_counter = id_counter + 1 84 | master_id_map[id_value] = id_to_replace 85 | 86 | device_json["id"] = id_to_replace 87 | 88 | if "relationId" in device_json: 89 | id_value = device_json["relationId"] 90 | id_to_replace = id_counter 91 | 92 | if id_value in master_id_map: 93 | id_to_replace = master_id_map[id_value] 94 | else: 95 | id_counter = id_counter + 1 96 | master_id_map[id_value] = id_to_replace 97 | 98 | device_json["id"] = id_to_replace 99 | 100 | if "attributes" in device_json and "serialNumber" in device_json["attributes"]: 101 | id_value = device_json["attributes"]["serialNumber"] 102 | id_to_replace = id_counter 103 | 104 | if id_value in master_id_map: 105 | id_to_replace = master_id_map[id_value] 106 | else: 107 | id_counter = id_counter + 1 108 | master_id_map[id_value] = id_to_replace 109 | 110 | device_json["attributes"]["serialNumber"] = id_to_replace 111 | 112 | if "room" in device_json and "id" in device_json["room"]: 113 | id_value = device_json["room"]["id"] 114 | id_to_replace = id_counter 115 | 116 | if id_value in master_id_map: 117 | id_to_replace = master_id_map[id_value] 118 | else: 119 | id_counter = id_counter + 1 120 | master_id_map[id_value] = id_to_replace 121 | 122 | device_json["room"]["id"] = id_to_replace 123 | 124 | if "deviceSet" in device_json: 125 | for device_set in device_json["deviceSet"]: 126 | if "id" in device_set: 127 | id_value = device_set["id"] 128 | id_to_replace = id_counter 129 | 130 | if id_value in master_id_map: 131 | id_to_replace = master_id_map[id_value] 132 | else: 133 | id_counter = id_counter + 1 134 | master_id_map[id_value] = id_to_replace 135 | 136 | device_set["id"]= id_to_replace 137 | 138 | if "remote_link" in device_json["remoteLinks"]: 139 | for remote_link in device_json["remoteLinks"]: 140 | id_value = device_set["id"] 141 | id_to_replace = id_counter 142 | 143 | if id_value in master_id_map: 144 | id_to_replace = master_id_map[id_value] 145 | else: 146 | id_counter = id_counter + 1 147 | master_id_map[id_value] = id_to_replace 148 | 149 | remote_link["id"]= id_to_replace 150 | 151 | logger.info(json_resp) 152 | logger.info("--------------") 153 | 154 | 155 | hass.services.async_register(DOMAIN, "dump_data", handle_dump_data) 156 | return True 157 | 158 | 159 | async def async_setup_entry( 160 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 161 | ) -> bool: 162 | global hub_events 163 | """Set up platform from a ConfigEntry.""" 164 | logger.info("Staring async_setup_entry in init...") 165 | 166 | hass.data.setdefault(DOMAIN, {}) 167 | hass_data = dict(entry.data) 168 | 169 | # for backward compatibility 170 | hide_device_set_bulbs : bool = True 171 | if CONF_HIDE_DEVICE_SET_BULBS in hass_data: 172 | logger.debug("Found HIDE_DEVICE_SET ***** ") 173 | #logger.debug(hass_data) 174 | hide_device_set_bulbs = hass_data[CONF_HIDE_DEVICE_SET_BULBS] 175 | else: 176 | logger.debug("Not found HIDE_DEVICE_SET ***** ") 177 | # If its not with HASS update it 178 | hass_data[CONF_HIDE_DEVICE_SET_BULBS] = hide_device_set_bulbs 179 | 180 | ip = hass_data[CONF_IP_ADDRESS] 181 | # Registers update listener to update config entry when options are updated. 182 | unsub_options_update_listener = entry.add_update_listener(options_update_listener) 183 | entry.async_on_unload(entry.add_update_listener(options_update_listener)) 184 | 185 | # Store a reference to the unsubscribe function to cleanup if an entry is unloaded. 186 | hass_data["unsub_options_update_listener"] = unsub_options_update_listener 187 | hass.data[DOMAIN][entry.entry_id] = hass_data 188 | 189 | hass_data = dict(entry.data) 190 | hub = HubX(hass_data[CONF_TOKEN], hass_data[CONF_IP_ADDRESS]) 191 | 192 | # Lets get all kinds that we are interested in one go and create the devices 193 | # such that the platform can go ahead and add the associated sensors 194 | platform = ikea_gateway() 195 | hass.data[DOMAIN][PLATFORM] = platform 196 | logger.debug("Starting make_devices...") 197 | await platform.make_devices(hass,hass_data[CONF_IP_ADDRESS], hass_data[CONF_TOKEN]) 198 | 199 | #await hass.async_add_executor_job(platform.make_devices,hass, hass_data[CONF_IP_ADDRESS], hass_data[CONF_TOKEN]) 200 | 201 | # Setup the entities 202 | #setup_domains = ["switch", "binary_sensor", "light", "sensor", "cover", "fan", "scene"] 203 | #hass.async_create_task( 204 | # hass.config_entries.async_forward_entry_setups(entry, setup_domains) 205 | #) 206 | #for setup_domain in setup_domains: 207 | # await hass.config_entries.async_forward_entry_setup(entry,setup_domain) 208 | await hass.config_entries.async_forward_entry_setups (entry, PLATFORMS_TO_SETUP) 209 | 210 | # Now lets start the event listender too 211 | hub = Hub(hass_data[CONF_TOKEN], hass_data[CONF_IP_ADDRESS]) 212 | 213 | if hass_data[CONF_IP_ADDRESS] != "mock": 214 | hub_events = hub_event_listener(hub, hass) 215 | hub_events.start() 216 | 217 | logger.debug("Complete async_setup_entry...") 218 | 219 | return True 220 | 221 | async def options_update_listener( 222 | hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry 223 | ): 224 | logger.debug("**********In options_update_listener") 225 | logger.debug(config_entry) 226 | """Handle options update.""" 227 | await hass.config_entries.async_reload(config_entry.entry_id) 228 | 229 | async def async_unload_entry( 230 | hass: core.HomeAssistant, entry: config_entries.ConfigEntry 231 | ) -> bool: 232 | global hub_events 233 | # Called during re-load and delete 234 | logger.debug("Starting async_unload_entry") 235 | 236 | #Stop the listener 237 | if hub_events is not None: 238 | hub_events.stop() 239 | hub_events = None 240 | 241 | hass_data = dict(entry.data) 242 | hub = HubX(hass_data[CONF_TOKEN], hass_data[CONF_IP_ADDRESS]) 243 | 244 | # For each controller if there is an empty scene delete it 245 | logger.debug("In unload so forcing delete of scenes...") 246 | await hass.async_add_executor_job(hub.delete_empty_scenes) 247 | logger.debug("Done deleting empty scenes....") 248 | 249 | """Unload a config entry.""" 250 | unload_ok = all( 251 | [ 252 | await asyncio.gather( 253 | *[ 254 | hass.config_entries.async_forward_entry_unload(entry, "light"), 255 | hass.config_entries.async_forward_entry_unload(entry, "switch"), 256 | hass.config_entries.async_forward_entry_unload(entry, "binary_sensor"), 257 | hass.config_entries.async_forward_entry_unload(entry, "sensor"), 258 | hass.config_entries.async_forward_entry_unload(entry, "cover"), 259 | hass.config_entries.async_forward_entry_unload(entry, "fan"), 260 | hass.config_entries.async_forward_entry_unload(entry, "scene"), 261 | ] 262 | ) 263 | ] 264 | ) 265 | 266 | hass.data[DOMAIN][entry.entry_id]["unsub_options_update_listener"]() 267 | hass.data[DOMAIN].pop(entry.entry_id) 268 | logger.debug("Successfully popped entry") 269 | logger.debug("Complete async_unload_entry") 270 | 271 | return unload_ok 272 | 273 | 274 | async def async_remove_config_entry_device( 275 | hass: HomeAssistant, 276 | config_entry: config_entries.ConfigEntry, 277 | device_entry: config_entries.DeviceEntry, 278 | ) -> bool: 279 | 280 | logger.info("Got request to remove device") 281 | logger.info(config_entry) 282 | logger.info(device_entry) 283 | return True -------------------------------------------------------------------------------- /custom_components/dirigera_platform/hub_event_listener.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import logging 3 | import time 4 | import json 5 | import re 6 | import websocket 7 | import ssl 8 | import re 9 | from typing import Any 10 | import datetime 11 | from dateutil import parser 12 | from dirigera import Hub 13 | 14 | from homeassistant.const import ATTR_ENTITY_ID 15 | 16 | logger = logging.getLogger("custom_components.dirigera_platform.hub_event_listener") 17 | 18 | DATE_TIME_FORMAT:str = "%Y-%m-%dT%H:%M:%S.%fZ" 19 | 20 | process_events_from = { 21 | "motionSensor" : ["isDetected","isOn","batteryPercentage"], 22 | "outlet" : [ "isOn", 23 | "currentAmps", 24 | "currentActivePower", 25 | "currentVoltage", 26 | "totalEnergyConsumed", 27 | "energyConsumedAtLastReset", 28 | "timeOfLastEnergyReset", 29 | "totalEnergyConsumedLastUpdated"], 30 | "light" : ["isOn", "lightLevel", "colorTemperature"], 31 | "openCloseSensor" : ["isOpen","batteryPercentage"], 32 | "waterSensor" : ["waterLeakDetected","batteryPercentage"], 33 | "blinds" : ["blindsCurrentLevel","batteryPercentage"], 34 | "environmentSensor": [ "currentTemperature", 35 | "currentRH", 36 | "currentPM25", 37 | "vocIndex", 38 | "batteryPercentage"] 39 | } 40 | 41 | controller_trigger_last_time_map = {} 42 | 43 | def to_snake_case(name:str) -> str: 44 | return re.sub(r'(? registry_entry: 80 | if id not in hub_event_listener.device_registry: 81 | return None 82 | return hub_event_listener.device_registry[id] 83 | 84 | def __init__(self, hub : Hub, hass): 85 | super().__init__() 86 | self._hub : Hub = hub 87 | self._request_to_stop = False 88 | self._hass = hass 89 | 90 | def on_error(self, ws:Any, ws_msg:str): 91 | logger.debug(f"on_error hub event listener {ws_msg}") 92 | 93 | def parse_scene_update(self, msg): 94 | global controller_trigger_last_time_map 95 | # Verify that this is controller initiated 96 | if "data" not in msg: 97 | logger.warning(f"discarding message as key 'data' not found: {msg}") 98 | return 99 | 100 | if "triggers" not in msg["data"]: 101 | logger.warning(f"discarding message as key 'data/triggers'") 102 | return 103 | 104 | triggers = msg["data"]["triggers"] 105 | 106 | for trigger in triggers: 107 | if "type" not in trigger: 108 | logger.warning(f"key 'type' not in trigger json : {trigger}") 109 | continue 110 | 111 | if trigger["type"] != "controller": 112 | logger.debug(f"Trigger type : {trigger['type']} not controller ignoring...") 113 | continue 114 | 115 | if "trigger" not in trigger: 116 | logger.warning(f"key 'trigger' not found in trigger json: {trigger}") 117 | continue 118 | 119 | details = trigger["trigger"] 120 | 121 | if "controllerType" not in details or "clickPattern" not in details or "deviceId" not in details: 122 | logger.debug(f"Required key controllerType/clickPattern/deviceId not in trigger json : {trigger}") 123 | continue 124 | 125 | controller_type = details["controllerType"] 126 | click_pattern = details["clickPattern"] 127 | device_id = details["deviceId"] 128 | 129 | if controller_type != "shortcutController": 130 | logger.debug(f"controller type on message not compatible: {controller_type}, ignoring...") 131 | continue 132 | 133 | if click_pattern == "singlePress": 134 | trigger_type = "single_click" 135 | elif click_pattern == "longPress": 136 | trigger_type = "long_press" 137 | elif click_pattern == "doublePress": 138 | trigger_type = "double_click" 139 | else: 140 | logger.debug(f"click_pattern : {click_pattern} not in list of types...ignoring") 141 | continue 142 | 143 | device_id_for_registry = device_id 144 | 145 | button_idx = 0 146 | pattern = '(([0-9]|[a-z]|-)*)_([0-9])+' 147 | match = re.search(pattern, device_id) 148 | if match is not None: 149 | device_id_for_registry = f"{match.groups()[0]}_1" 150 | button_idx = int(match.groups()[2]) 151 | logger.debug(f"Multi button controller, device_id effective : {device_id_for_registry} with buttons : {button_idx}") 152 | 153 | if button_idx != 0: 154 | trigger_type =f"button{button_idx}_{trigger_type}" 155 | 156 | # Now look up the associated entity in our own registry 157 | registry_value = hub_event_listener.get_registry_entry(device_id_for_registry) 158 | 159 | if registry_value.__class__.__name__ != "registry_entry": 160 | logger.debug(f"id : {device_id_for_registry} listener registry is not correct : {registry_value.__class__.__name__}...") 161 | continue 162 | 163 | entity = registry_value.entity 164 | 165 | unique_key = f"{entity.registry_entry.device_id}_{trigger_type}" 166 | last_fired = datetime.datetime.now() 167 | if unique_key in controller_trigger_last_time_map: 168 | last_fired = controller_trigger_last_time_map[unique_key] 169 | logger.debug(f"Found date/time in map for controller : {last_fired}") 170 | 171 | controller_trigger_last_time_map[unique_key] = datetime.datetime.now() 172 | 173 | if "lastTriggered" in msg["data"]: 174 | current_triggered_str = msg["data"]["lastTriggered"] 175 | try: 176 | current_triggered = parser.parse(current_triggered_str) 177 | one_second_delta = datetime.timedelta(seconds=1) 178 | controller_trigger_last_time_map[unique_key] = current_triggered 179 | logger.debug(f"Updated date/time in map for controller with : {current_triggered}") 180 | if last_fired is not None and one_second_delta > current_triggered - last_fired: 181 | logger.debug("Will not let this event be fired, this is to get over bug IKEA bug of firing event twice for controller") 182 | return 183 | 184 | except Exception as ex: 185 | logger.warning(f"Failed to parse date/time for last_triggered from event : {current_triggered_str}, wont affect functionality...") 186 | logger.warning(ex) 187 | # Ignore and let event be fired 188 | 189 | # Now raise the bus event 190 | event_data = { 191 | "type": trigger_type, 192 | "device_id": entity.registry_entry.device_id, 193 | ATTR_ENTITY_ID: entity.registry_entry.entity_id 194 | } 195 | 196 | self._hass.bus.fire(event_type="dirigera_platform_event",event_data=event_data) 197 | logger.debug(f"Event fired.. {event_data}") 198 | 199 | def on_message(self, ws:Any, ws_msg:str): 200 | 201 | try: 202 | logger.debug(f"rcvd message : {ws_msg}") 203 | msg = json.loads(ws_msg) 204 | if "type" not in msg: 205 | logger.debug(f"'type' not found in incoming message, discarding : {msg}") 206 | return 207 | 208 | if msg['type'] == "sceneUpdated": 209 | logger.debug(f"Found sceneUpdated message... ") 210 | return self.parse_scene_update(msg) 211 | 212 | if msg['type'] != "deviceStateChanged": 213 | logger.debug(f"discarding non state message: {msg}") 214 | return 215 | 216 | if "data" not in msg or "id" not in msg['data']: 217 | logger.info(f"discarding message as key 'data' or 'data/id' not found: {msg}") 218 | return 219 | 220 | info = msg['data'] 221 | id = info['id'] 222 | 223 | device_type = None 224 | if "deviceType" in info: 225 | device_type = info["deviceType"] 226 | elif "type" in info: 227 | device_type = info["type"] 228 | else: 229 | logger.warn("expected type or deviceType in JSON, none found, ignoring...") 230 | return 231 | 232 | logger.debug(f"device type of message {device_type}") 233 | if device_type not in process_events_from: 234 | # To avoid issues been reported. If we dont have it in our list 235 | # then best to not process this event 236 | return 237 | 238 | if id not in hub_event_listener.device_registry: 239 | logger.info(f"discarding message as device for id: {id} not found for msg: {msg}") 240 | return 241 | 242 | registry_value = hub_event_listener.get_registry_entry(id) 243 | entity = registry_value.entity 244 | 245 | if "isReachable" in info: 246 | try: 247 | logger.debug(f"Setting {id} reachable as {info['isReachable']}") 248 | entity._json_data.is_reachable=info["isReachable"] 249 | except Exception as ex: 250 | logger.error(f"Failed to setattr is_reachable on device: {id} for msg: {msg}") 251 | logger.error(ex) 252 | 253 | to_process_attr = process_events_from[device_type] 254 | turn_on_off = False 255 | 256 | if "attributes" in info and info["attributes"] is not None: 257 | attributes = info["attributes"] 258 | 259 | for key in attributes: 260 | if key not in to_process_attr: 261 | logger.debug(f"attribute {key} with value {attributes[key]} not in list of device type {device_type}, ignoring update...") 262 | continue 263 | try: 264 | key_attr = to_snake_case(key) 265 | # This is a hack need a better impl 266 | if key_attr == "is_on": 267 | turn_on_off = True 268 | logger.debug(f"setting {key_attr} to {attributes[key]}") 269 | logger.debug(f"Entity before setting: {entity._json_data}") 270 | 271 | value_to_set = attributes[key] 272 | #Need a hack for outlet with date/time entities 273 | if key in ["timeOfLastEnergyReset","totalEnergyConsumedLastUpdated"]: 274 | logger.debug(f"Got into date/time so will set the value accordingly...") 275 | try : 276 | value_to_set = parser.parse(attributes[key]) 277 | except: 278 | #Ignore the exception 279 | logger.warning(f"Failed to convert {attributes[key]} to date/time...") 280 | 281 | setattr(entity._json_data.attributes,key_attr, value_to_set) 282 | logger.debug(f"Entity after setting: {entity._json_data}") 283 | except Exception as ex: 284 | logger.warn(f"Failed to set attribute key: {key} converted to {key_attr} on device: {id}") 285 | logger.warn(ex) 286 | 287 | # Lights behave odd with hubs when setting attribute one event is generated which 288 | # causes brightness or other to toggle so put in a hack to fix that 289 | # if its is_on attribute then ignore this routine 290 | if device_type == "light" and entity.should_ignore_update and not turn_on_off: 291 | entity.reset_ignore_update() 292 | logger.debug("Ignoring calling update_ha_state as ignore_update is set") 293 | return 294 | 295 | entity.schedule_update_ha_state(False) 296 | 297 | if registry_value.cascade_entity is not None: 298 | # Cascade the update 299 | logger.debug(f"Cascading to cascade entity : {registry_value.cascade_entity.unique_id}") 300 | registry_value.cascade_entity.schedule_update_ha_state(False) 301 | 302 | 303 | except Exception as ex: 304 | # Temp solution to not log entries 305 | logger.debug("error processing hub event") 306 | logger.debug(f"{ws_msg}") 307 | logger.debug(ex) 308 | 309 | def create_listener(self): 310 | try: 311 | logger.info("Starting dirigera hub event listener") 312 | self._wsapp = websocket.WebSocketApp( 313 | self._hub.websocket_base_url, 314 | header={"Authorization": f"Bearer {self._hub.token}"}, 315 | on_message=self.on_message) 316 | self._wsapp.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE}) 317 | #self._hub.create_event_listener(on_message=self.on_message, on_error=self.on_error) 318 | except Exception as ex: 319 | logger.error("Error creating event listener...") 320 | logger.error(ex) 321 | 322 | def stop(self): 323 | logger.info("Listener request for stop..") 324 | 325 | self._request_to_stop = True 326 | try: 327 | #self._hub.stop_event_listener() 328 | if self._wsapp is not None: 329 | self._wsapp.close() 330 | except: 331 | pass 332 | self.join() 333 | hub_event_listener.device_registry.clear() 334 | logger.info("Listener stopped..") 335 | 336 | def run(self): 337 | while True: 338 | # Blocking call 339 | self.create_listener() 340 | logger.debug("Listener thread complete...") 341 | if self._request_to_stop: 342 | break 343 | logger.warn("Failed to create listener or listener exited, will sleep 10 seconds before retrying") 344 | time.sleep(10) -------------------------------------------------------------------------------- /custom_components/dirigera_platform/light.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | 4 | from dirigera import Hub 5 | from dirigera.devices.device import Room 6 | from dirigera.devices.light import Light 7 | 8 | from homeassistant import config_entries, core 9 | from homeassistant.components.light import ( 10 | ATTR_BRIGHTNESS, 11 | ATTR_COLOR_TEMP_KELVIN, 12 | ATTR_HS_COLOR, 13 | ColorMode, 14 | LightEntity, 15 | ) 16 | 17 | from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN 18 | from homeassistant.core import HomeAssistantError 19 | from homeassistant.helpers.entity import DeviceInfo 20 | 21 | from .const import DOMAIN, CONF_HIDE_DEVICE_SET_BULBS, PLATFORM 22 | from .hub_event_listener import hub_event_listener, registry_entry 23 | 24 | logger = logging.getLogger("custom_components.dirigera_platform") 25 | 26 | async def async_setup_entry( 27 | hass: core.HomeAssistant, 28 | config_entry: config_entries.ConfigEntry, 29 | async_add_entities, 30 | ): 31 | logger.debug("LIGHT Starting async_setup_entry") 32 | """Setup sensors from a config entry created in the integrations UI.""" 33 | config = hass.data[DOMAIN][config_entry.entry_id] 34 | logger.debug(config) 35 | 36 | # hub = dirigera.Hub(config[CONF_TOKEN], config[CONF_IP_ADDRESS]) 37 | hub = Hub(config[CONF_TOKEN], config[CONF_IP_ADDRESS]) 38 | 39 | #Backward compatibility 40 | hide_device_set_bulbs = True 41 | if CONF_HIDE_DEVICE_SET_BULBS in config: 42 | hide_device_set_bulbs = config[CONF_HIDE_DEVICE_SET_BULBS] 43 | 44 | logger.debug(f"found setting hide_device_set_bulbs : {hide_device_set_bulbs}") 45 | 46 | all_lights = hass.data[DOMAIN][PLATFORM].lights 47 | logger.debug("Found {} total of all light entities to setup...".format(len(all_lights))) 48 | 49 | device_sets = {} 50 | lights = [] 51 | for light in all_lights: 52 | if len(light._json_data.device_set) > 0: 53 | for one_set in light._json_data.device_set: 54 | id = one_set['id'] 55 | name = one_set['name'] 56 | # Use the room of the first light encountered in the set as the 'suggested area' for HA 57 | suggested_room = light._json_data.room 58 | 59 | target_device_set = None 60 | 61 | if id not in device_sets: 62 | logger.debug(f"Found new device set {name}") 63 | device_sets[id] = device_set_model(id, name, suggested_room) 64 | 65 | target_device_set = device_sets[id] 66 | target_device_set.add_light(light) 67 | 68 | #if not hide_device_set_bulbs: 69 | lights.append(light) 70 | else: 71 | lights.append(light) 72 | 73 | logger.debug(f"Found {len(device_sets.keys())} device_sets") 74 | logger.debug(f"Found {len(lights)} lights to setup...") 75 | async_add_entities([ikea_bulb_device_set(hub, device_sets[key], device_sets[key].get_lights()[0] ) for key in device_sets]) 76 | 77 | async_add_entities(lights) 78 | logger.debug("LIGHT Complete async_setup_entry") 79 | 80 | class device_set_model: 81 | def __init__(self, id, name, suggested_room: Optional[Room]): 82 | logger.debug(f"device_set ctor {id} : {name}") 83 | self._lights = [] 84 | self._name = name 85 | self._id = id 86 | self._suggested_room = suggested_room 87 | 88 | @property 89 | def id(self): 90 | return self._id 91 | 92 | @property 93 | def name(self): 94 | return self._name 95 | 96 | @property 97 | def suggested_room(self) -> Optional[Room]: 98 | return self._suggested_room 99 | 100 | def get_lights(self) -> list: 101 | return self._lights 102 | 103 | def add_light(self, bulb): 104 | logger.debug(f"Adding {bulb.name} to device_set : {self.name}") 105 | self._lights.append(bulb) 106 | 107 | class ikea_bulb(LightEntity): 108 | 109 | def __init__(self, hub, json_data : Light) -> None: 110 | logger.debug("ikea_bulb ctor...") 111 | self._hub = hub 112 | self._json_data = json_data 113 | self._ignore_update = False 114 | 115 | # Register the device for updates 116 | hub_event_listener.register(self._json_data.id, registry_entry(self)) 117 | 118 | self.set_state() 119 | 120 | # When changing brightness a random update is sent by hub with brightness level before 121 | # the actual value is sent. So this is a hack to ignore that random update 122 | @property 123 | def should_ignore_update(self): 124 | return self._ignore_update 125 | 126 | def reset_ignore_update(self): 127 | self._ignore_update = False 128 | 129 | def set_state(self): 130 | # Set Color capabilities 131 | logger.debug("Set State of bulb..") 132 | color_modes = [] 133 | can_receive = self._json_data.capabilities.can_receive 134 | #logger.debug("Got can_receive in state") 135 | #logger.debug(can_receive) 136 | self._color_mode = ColorMode.ONOFF 137 | for cap in can_receive: 138 | if cap == "lightLevel": 139 | color_modes.append(ColorMode.BRIGHTNESS) 140 | elif cap == "colorTemperature": 141 | color_modes.append(ColorMode.COLOR_TEMP) 142 | elif cap == "colorHue" or cap == "colorSaturation": 143 | color_modes.append(ColorMode.HS) 144 | 145 | # Based on documentation here 146 | # https://developers.home-assistant.io/docs/core/entity/light#color-modes 147 | if len(color_modes) > 1: 148 | # If there are more color modes which means we have either temperature 149 | # or HueSaturation. then lets make sure BRIGHTNESS is not part of it 150 | # as per above documentation 151 | color_modes.remove(ColorMode.BRIGHTNESS) 152 | 153 | if len(color_modes) == 0: 154 | logger.debug("Color modes array is zero, setting to UNKNOWN") 155 | self._supported_color_modes = [ColorMode.ONOFF] 156 | else: 157 | self._supported_color_modes = color_modes 158 | if ColorMode.HS in self._supported_color_modes: 159 | self._color_mode = ColorMode.HS 160 | elif ColorMode.COLOR_TEMP in self._supported_color_modes: 161 | self._color_mode = ColorMode.COLOR_TEMP 162 | elif ColorMode.BRIGHTNESS in self._supported_color_modes: 163 | self._color_mode = ColorMode.BRIGHTNESS 164 | 165 | #logger.debug("supported color mode set to:") 166 | #logger.debug(self._supported_color_modes) 167 | #logger.debug("color mode set to:") 168 | #logger.debug(self._color_mode) 169 | 170 | @property 171 | def should_poll(self) -> bool: 172 | return False 173 | 174 | @property 175 | def unique_id(self): 176 | return self._json_data.id 177 | 178 | @property 179 | def available(self): 180 | return self._json_data.is_reachable 181 | 182 | @property 183 | def device_info(self) -> DeviceInfo: 184 | 185 | return DeviceInfo( 186 | identifiers={("dirigera_platform", self._json_data.id)}, 187 | name=self.name, 188 | manufacturer=self._json_data.attributes.manufacturer, 189 | model=self._json_data.attributes.model, 190 | sw_version=self._json_data.attributes.firmware_version, 191 | suggested_area=self._json_data.room.name if self._json_data.room is not None else None, 192 | ) 193 | 194 | @property 195 | def name(self): 196 | if self._json_data.attributes.custom_name is None or len(self._json_data.attributes.custom_name) == 0: 197 | return self.unique_id 198 | return self._json_data.attributes.custom_name 199 | 200 | @property 201 | def brightness(self): 202 | # This is called by HASS so should be in the range 203 | # of 0-100 204 | scaled = int((self.light_level/ 100) * 255) 205 | return scaled 206 | 207 | @property 208 | def light_level(self): 209 | # This is the state of the HUB so in 1-255 range 210 | return self._json_data.attributes.light_level 211 | 212 | @light_level.setter 213 | def light_level(self, value): 214 | scaled = int((value/255)*100) 215 | if scaled < 1: 216 | scaled = 1 217 | elif scaled > 100: 218 | scaled = 100 219 | self._json_data.attributes.light_level = scaled 220 | 221 | @property 222 | def max_color_temp_kelvin(self): 223 | return self._json_data.attributes.color_temperature_min 224 | 225 | @property 226 | def min_color_temp_kelvin(self): 227 | return self._json_data.attributes.color_temperature_max 228 | 229 | @property 230 | def color_temp_kelvin(self): 231 | return self._json_data.attributes.color_temperature 232 | 233 | @property 234 | def color_temperature(self): 235 | return self._json_data.attributes.color_temperature 236 | 237 | @color_temperature.setter 238 | def color_temperature(self, value): 239 | self._json_data.attributes.color_temperature = value 240 | 241 | @property 242 | def hs_color(self): 243 | return ( self.color_hue, self.color_saturation * 100) 244 | 245 | @property 246 | def color_hue(self): 247 | return self._json_data.attributes.color_hue 248 | 249 | @color_hue.setter 250 | def color_hue(self, value) : 251 | self.json_data.attributes.color_hue = value 252 | 253 | @property 254 | def color_saturation(self): 255 | return self._json_data.attributes.color_saturation 256 | 257 | @color_saturation.setter 258 | def color_saturation(self, value): 259 | self._json_data.attributes.color_saturation = value 260 | 261 | @property 262 | def is_on(self): 263 | return self._json_data.attributes.is_on 264 | 265 | @property 266 | def supported_color_modes(self): 267 | return self._supported_color_modes 268 | 269 | @property 270 | def color_mode(self): 271 | return self._color_mode 272 | 273 | @color_mode.setter 274 | def color_mode(self, value): 275 | self._color_mode = value 276 | 277 | async def async_update(self): 278 | try: 279 | logger.debug("async update called on bulb..") 280 | self._json_data = await self.hass.async_add_executor_job(self._hub.get_light_by_id, self._json_data.id) 281 | self.set_state() 282 | except Exception as ex: 283 | logger.error("error encountered running update on : {}".format(self.name)) 284 | logger.error(ex) 285 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 286 | 287 | async def async_turn_on(self, **kwargs): 288 | logger.debug("light turn_on...") 289 | logger.debug(kwargs) 290 | 291 | try: 292 | # Probably change 293 | self.reset_ignore_update() 294 | await self.hass.async_add_executor_job(self._json_data.set_light,True) 295 | 296 | if ATTR_BRIGHTNESS in kwargs: 297 | # brightness requested 298 | # The setter will move the HASS value of 0-100 to 1-255 299 | self.light_level = int(kwargs[ATTR_BRIGHTNESS]) 300 | logger.debug("scaled brightness : {}".format(self.light_level)) 301 | # update 302 | await self.hass.async_add_executor_job(self._json_data.set_light_level,self.light_level) 303 | self._ignore_update = True 304 | 305 | if ATTR_COLOR_TEMP_KELVIN in kwargs: 306 | # color temp requested 307 | # If request is white then brightness is passed 308 | logger.debug("Request to set color temp...") 309 | ct = kwargs[ATTR_COLOR_TEMP_KELVIN] 310 | logger.debug("Set CT : {}".format(ct)) 311 | await self.hass.async_add_executor_job(self._json_data.set_color_temperature,ct) 312 | self._ignore_update = True 313 | 314 | if ATTR_HS_COLOR in kwargs: 315 | logger.debug("Request to set color HS") 316 | hs_tuple = kwargs[ATTR_HS_COLOR] 317 | self._color_hue = hs_tuple[0] 318 | self._color_saturation = hs_tuple[1] / 100 319 | # Saturation is 0 - 1 at IKEA 320 | 321 | await self.hass.async_add_executor_job(self._json_data.set_light_color,self._color_hue, self._color_saturation) 322 | self._ignore_update = True 323 | self.async_schedule_update_ha_state(False) 324 | except Exception as ex: 325 | logger.error("error encountered turning on : {}".format(self.name)) 326 | logger.error(ex) 327 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 328 | 329 | async def async_turn_off(self, **kwargs): 330 | logger.debug("light turn_off...") 331 | try: 332 | self.reset_ignore_update() 333 | await self.hass.async_add_executor_job(self._json_data.set_light,False) 334 | self.async_schedule_update_ha_state(False) 335 | except Exception as ex: 336 | logger.error("error encountered turning off : {}".format(self.name)) 337 | logger.error(ex) 338 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 339 | 340 | # IKEA Device Set - Works wierdly 341 | # There is a post available to set the state but the GET for state 342 | # is not available, it would need to be derived from whichever is bulb/light 343 | # is available. Also the way the app behaves is replicated. The first bulb 344 | # in the set is what the state of the group is thus, that is what we also 345 | # replicate 346 | 347 | class ikea_bulb_device_set(LightEntity): 348 | def __init__(self, hub, device_set: device_set_model, first_bulb: ikea_bulb) -> None: 349 | logger.debug("ikea_bulb device_set ctor...") 350 | logger.debug(f"Setting up device_set {device_set.id} with first bulb {first_bulb.unique_id}") 351 | self._hub = hub 352 | self._controller = first_bulb 353 | self._device_set = device_set 354 | self._patch_url = f"/devices/set/{device_set.id}?deviceType=light" 355 | 356 | # Update cascade entity 357 | registry_entry_of_bulb = hub_event_listener.get_registry_entry(first_bulb.unique_id) 358 | registry_entry_of_bulb.cascade_entity = self 359 | 360 | @property 361 | def should_poll(self): 362 | return False 363 | 364 | @property 365 | def unique_id(self): 366 | return self._device_set.id 367 | 368 | @property 369 | def available(self): 370 | return self._controller.available 371 | 372 | @property 373 | def device_info(self) -> DeviceInfo: 374 | 375 | # Register the device for updates 376 | hub_event_listener.register(self.unique_id, self) 377 | 378 | return DeviceInfo( 379 | identifiers={("dirigera_platform", self._device_set.id)}, 380 | name=self._device_set.name , 381 | manufacturer="IKEA", 382 | model="Device Set", 383 | sw_version="1.0", 384 | suggested_area=self._device_set.suggested_room.name if self._device_set.suggested_room is not None else None, 385 | ) 386 | 387 | @property 388 | def name(self): 389 | return self._device_set.name 390 | 391 | @property 392 | def brightness(self): 393 | return self._controller.brightness 394 | 395 | @property 396 | def max_color_temp_kelvin(self): 397 | return self._controller.max_color_temp_kelvin 398 | 399 | @property 400 | def min_color_temp_kelvin(self): 401 | return self._controller.min_color_temp_kelvin 402 | 403 | @property 404 | def color_temp_kelvin(self): 405 | return self._controller.color_temp_kelvin 406 | 407 | @property 408 | def hs_color(self): 409 | return self._controller.hs_color 410 | 411 | @property 412 | def is_on(self): 413 | return self._controller.is_on 414 | 415 | @property 416 | def supported_color_modes(self): 417 | return self._controller.supported_color_modes 418 | 419 | @property 420 | def color_mode(self): 421 | return self._controller.color_mode 422 | 423 | async def async_update(self): 424 | try: 425 | logger.debug("Update of device_set....") 426 | #for light in self._device_set.get_lights(): 427 | # await light.async_update() 428 | 429 | except Exception as ex: 430 | logger.error("error encountered running update on device_set : {}".format(self.name)) 431 | logger.error(ex) 432 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 433 | 434 | def patch_command(self, key_val : dict): 435 | data = [{"attributes" : key_val}] 436 | try: 437 | self._hub.patch(self._patch_url, data=data) 438 | except Exception as ex: 439 | logger.error("error encountered running update on : {}".format(self.name)) 440 | logger.error(ex) 441 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 442 | 443 | # As indicated the turn on / brightness / color etc can be set by a patch command 444 | # the corresponding get doesnt work 445 | async def async_turn_on(self, **kwargs): 446 | logger.debug("light device_set turn_on...") 447 | logger.debug(kwargs) 448 | 449 | try: 450 | self._controller.reset_ignore_update() 451 | await self.hass.async_add_executor_job(self.patch_command,{"isOn": True}) 452 | 453 | if ATTR_BRIGHTNESS in kwargs: 454 | # brightness requested 455 | # the brightness sent by HASS will be in the range of 0-100 which has to be scaled 456 | # to 1-255 457 | 458 | logger.debug("Request to device_set set brightness...") 459 | level = int(kwargs[ATTR_BRIGHTNESS]) 460 | 461 | # This is in the 1-100 level so scale it 462 | logger.debug("Set brightness : {}".format(level)) 463 | logger.debug("Set scaled brightness : {}".format(int((level / 255) * 100))) 464 | await self.hass.async_add_executor_job(self.patch_command, {"lightLevel" : int((level / 255) * 100)}) 465 | self._controller._ignore_update = True 466 | 467 | if ATTR_COLOR_TEMP_KELVIN in kwargs: 468 | # color temp requested 469 | # If request is white then brightness is passed 470 | logger.debug("Request to device_set set color temp...") 471 | ct = kwargs[ATTR_COLOR_TEMP_KELVIN] 472 | logger.debug("Set CT : {}".format(ct)) 473 | await self.hass.async_add_executor_job(self.patch_command, {"colorTemperature" : ct}) 474 | self._controller._ignore_update = True 475 | 476 | if ATTR_HS_COLOR in kwargs: 477 | logger.debug("Request to set color HS device_set") 478 | hs_tuple = kwargs[ATTR_HS_COLOR] 479 | self._color_hue = hs_tuple[0] 480 | self._color_saturation = hs_tuple[1] / 100 481 | # Saturation is 0 - 1 at IKEA 482 | self._controller._ignore_update = True 483 | 484 | await self.hass.async_add_executor_job(self.patch_command,{ "colorHue" : self._color_hue, "colorSaturation" : self._color_saturation}) 485 | 486 | except Exception as ex: 487 | logger.error("error encountered turning on device_set : {}".format(self.name)) 488 | logger.error(ex) 489 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 490 | 491 | async def async_turn_off(self, **kwargs): 492 | self._controller.reset_ignore_update() 493 | logger.debug("light device_set turn_off...") 494 | try: 495 | await self.hass.async_add_executor_job(self.patch_command, {"isOn": False}) 496 | except Exception as ex: 497 | logger.error("error encountered turning off device_set : {}".format(self.name)) 498 | logger.error(ex) 499 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") -------------------------------------------------------------------------------- /custom_components/dirigera_platform/base_classes.py: -------------------------------------------------------------------------------- 1 | from homeassistant import core 2 | from homeassistant.core import HomeAssistantError 3 | 4 | from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass, SensorEntity 5 | from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity 6 | from homeassistant.components.cover import CoverDeviceClass, CoverEntity,CoverEntityFeature 7 | from homeassistant.components.datetime import DateTimeEntity 8 | from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory 9 | from homeassistant.components.fan import FanEntity, FanEntityFeature 10 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 11 | from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity 12 | from homeassistant.const import ( 13 | EVENT_HOMEASSISTANT_STOP, 14 | PERCENTAGE, 15 | SIGNAL_STRENGTH_DECIBELS, 16 | EntityCategory, 17 | UnitOfElectricCurrent, 18 | UnitOfElectricPotential, 19 | UnitOfEnergy, 20 | UnitOfPower, 21 | UnitOfTemperature, 22 | CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, 23 | ) 24 | 25 | from dirigera import Hub 26 | from dirigera.devices.blinds import Blind 27 | from dirigera.devices.environment_sensor import EnvironmentSensor 28 | from dirigera.devices.controller import Controller 29 | from dirigera.devices.air_purifier import FanModeEnum 30 | 31 | from .hub_event_listener import hub_event_listener, registry_entry 32 | from .const import DOMAIN 33 | 34 | from enum import Enum 35 | import logging 36 | import math 37 | import datetime 38 | 39 | logger = logging.getLogger("custom_components.dirigera_platform") 40 | 41 | DATE_TIME_FORMAT:str = "%Y-%m-%dT%H:%M:%S.%fZ" 42 | 43 | def induce_properties(class_to_induce, attr): 44 | for key in attr.keys(): 45 | logger.debug(f"Inducing class {class_to_induce.__name__} property {key} : value {attr[key]}") 46 | make_property(class_to_induce, key, attr[key]) 47 | 48 | def make_property(class_to_induce, name, value): 49 | setattr(class_to_induce, name, property(lambda self: getattr(self._json_data.attributes,name))) 50 | 51 | class ikea_base_device: 52 | def __init__(self, hass, hub, json_data, get_by_id_fx) -> None: 53 | logger.debug("ikea_base_device ctor...") 54 | self._hass = hass 55 | self._hub = hub 56 | self._json_data = json_data 57 | self._get_by_id_fx = get_by_id_fx 58 | self._listeners : list[Entity] = [] 59 | self._skip_update = False 60 | 61 | # inject properties based on attr 62 | induce_properties(ikea_base_device, self._json_data.attributes.dict()) 63 | 64 | # Register the device for updates 65 | if self.should_register_with_listener: 66 | hub_event_listener.register(self._json_data.id, registry_entry(self)) 67 | 68 | @property 69 | def skip_update(self)->bool: 70 | return self._skip_update 71 | 72 | @skip_update.setter 73 | def skip_update(self, value:bool): 74 | self._skip_update = value 75 | 76 | def add_listener(self, entity : Entity) -> None: 77 | self._listeners.append(entity) 78 | 79 | @property 80 | def unique_id(self): 81 | return self._json_data.id 82 | 83 | @property 84 | def available(self): 85 | return self._json_data.is_reachable 86 | 87 | @property 88 | def should_register_with_listener(self): 89 | return True 90 | 91 | @property 92 | def device_info(self) -> DeviceInfo: 93 | 94 | return DeviceInfo( 95 | identifiers={("dirigera_platform", self._json_data.id)}, 96 | name=self.name, 97 | manufacturer=self._json_data.attributes.manufacturer, 98 | model=self._json_data.attributes.model, 99 | sw_version=self._json_data.attributes.firmware_version, 100 | suggested_area=self._json_data.room.name if self._json_data.room is not None else None, 101 | ) 102 | 103 | @property 104 | def name(self): 105 | if self._json_data.attributes.custom_name is None or len(self._json_data.attributes.custom_name) == 0: 106 | return self.unique_id 107 | return self._json_data.attributes.custom_name 108 | 109 | async def async_update(self): 110 | if self.skip_update: 111 | logger.debug(f"update skipped for {self.name} as marked to skip...") 112 | return 113 | 114 | logger.debug(f"update called {self.name}") 115 | try: 116 | self._json_data = await self._hass.async_add_executor_job(self._get_by_id_fx, self._json_data.id) 117 | except Exception as ex: 118 | logger.error("error encountered running update on : {}".format(self.name)) 119 | logger.error(ex) 120 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 121 | 122 | # To ensure state update of hass is cascaded 123 | def async_schedule_update_ha_state(self, force_refresh:bool = False) -> None: 124 | for listener in self._listeners: 125 | listener.schedule_update_ha_state(force_refresh) 126 | 127 | # To ensure state update of hass is cascaded 128 | def schedule_update_ha_state(self, force_refresh:bool = False) -> None: 129 | for listener in self._listeners: 130 | listener.schedule_update_ha_state(force_refresh) 131 | 132 | class ikea_base_device_sensor(): 133 | def __init__(self, device, id_suffix:str = "", name:str = "", native_unit_of_measurement="", icon="", device_class=None, entity_category=None, state_class=None): 134 | self._device = device 135 | self._name = name 136 | self._id_suffix = id_suffix 137 | self._native_unit_of_measurement = native_unit_of_measurement 138 | self._device_class = device_class 139 | self._entity_category = entity_category 140 | self._state_class = state_class 141 | self._device.add_listener(self) 142 | self._icon = icon 143 | self.printed = False 144 | 145 | @property 146 | def unique_id(self): 147 | return self._device.unique_id + self._id_suffix 148 | 149 | @property 150 | def available(self): 151 | return self._device.available 152 | 153 | @property 154 | def device_info(self) -> DeviceInfo: 155 | return self._device.device_info 156 | 157 | @property 158 | def name(self): 159 | if self._name is None or len(self._name) == 0: 160 | return self._device.name 161 | if self._device.name.lower() == self._name.lower(): 162 | # Dont duplication , Bug Fix #109 163 | return self._device.name 164 | return f"{self._device.name} {self._name}" 165 | 166 | @property 167 | def entity_category(self): 168 | return self._entity_category 169 | 170 | @property 171 | def device_class(self) -> str: 172 | return self._device_class 173 | 174 | @property 175 | def state_class(self): 176 | return self._state_class 177 | 178 | @property 179 | def icon(self): 180 | return self._icon 181 | 182 | @property 183 | def native_unit_of_measurement(self) -> str: 184 | return self._native_unit_of_measurement 185 | 186 | async def async_update(self): 187 | await self._device.async_update() 188 | 189 | class ikea_outlet_device(ikea_base_device): 190 | def __init__(self, hass, hub, json_data): 191 | super().__init__(hass, hub, json_data, hub.get_outlet_by_id) 192 | self.skip_update = True 193 | 194 | async def async_turn_on(self): 195 | logger.debug("outlet turn_on") 196 | try: 197 | await self._hass.async_add_executor_job(self._json_data.set_on, True) 198 | except Exception as ex: 199 | logger.error("error encountered turning on : {}".format(self.name)) 200 | logger.error(ex) 201 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 202 | 203 | async def async_turn_off(self): 204 | logger.debug("outlet turn_off") 205 | try: 206 | await self._hass.async_add_executor_job(self._json_data.set_on, False) 207 | except Exception as ex: 208 | logger.error("error encountered turning off : {}".format(self.name)) 209 | logger.error(ex) 210 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 211 | 212 | class ikea_outlet_switch_sensor(ikea_base_device_sensor, SwitchEntity): 213 | def __init__(self, device): 214 | super().__init__(device = device, name = device.name ) 215 | 216 | @property 217 | def is_on(self): 218 | return self._device.is_on 219 | 220 | async def async_turn_on(self): 221 | logger.debug("sensor: outlet turn_on") 222 | await self._device.async_turn_on() 223 | 224 | async def async_turn_off(self): 225 | logger.debug("sensor: outlet turn_off") 226 | await self._device.async_turn_off() 227 | 228 | class ikea_motion_sensor_device(ikea_base_device): 229 | def __init__(self,hass, hub, json_data): 230 | logger.debug("ikea_motion_sensor_device ctor...") 231 | super().__init__(hass, hub, json_data, hub.get_motion_sensor_by_id) 232 | self.skip_update = True 233 | 234 | class ikea_motion_sensor(ikea_base_device_sensor, BinarySensorEntity): 235 | def __init__(self, device: ikea_motion_sensor_device): 236 | logger.debug("ikea_motion_sensor ctor...") 237 | # No suffix or name prefix for backward compatibility 238 | super().__init__(device) 239 | 240 | @property 241 | def is_on(self): 242 | return self._device.is_on or self._device.is_detected 243 | 244 | class ikea_open_close_device(ikea_base_device): 245 | def __init__(self, hass, hub, json_data): 246 | logger.debug("ikea_motion_sensor_device ctor...") 247 | super().__init__(hass, hub, json_data, hub.get_open_close_by_id) 248 | self.skip_update = True 249 | 250 | class ikea_open_close_sensor(ikea_base_device_sensor, BinarySensorEntity): 251 | def __init__(self, device: ikea_open_close_device): 252 | logger.debug("ikea_motion_sensor ctor...") 253 | # No suffix or name prefix for backward compatibility 254 | super().__init__(device) 255 | 256 | @property 257 | def device_class(self) -> str: 258 | return BinarySensorDeviceClass.WINDOW 259 | 260 | @property 261 | def is_on(self): 262 | return self._device.is_open 263 | 264 | class ikea_water_sensor_device(ikea_base_device): 265 | def __init__(self, hass, hub, json_data): 266 | super().__init__(hass, hub, json_data, hub.get_water_sensor_by_id) 267 | self.skip_update = True 268 | 269 | class ikea_water_sensor(ikea_base_device_sensor, BinarySensorEntity): 270 | def __init__(self, device : ikea_water_sensor_device): 271 | logger.debug("ikea_water_sensor ctor...") 272 | super().__init__(device) 273 | 274 | @property 275 | def is_on(self): 276 | return self._device.water_leak_detected 277 | 278 | class ikea_blinds_device(ikea_base_device): 279 | def __init__(self, hass:core.HomeAssistant, hub:Hub, blind:Blind): 280 | logger.debug("IkeaBlinds ctor...") 281 | super().__init__(hass, hub, blind, hub.get_blinds_by_id) 282 | 283 | @property 284 | def device_class(self) -> str: 285 | return CoverDeviceClass.BLIND 286 | 287 | async def async_open_cover(self): 288 | await self._hass.async_add_executor_job(self._json_data.set_target_level, 0) 289 | 290 | async def async_close_cover(self): 291 | await self._hass.async_add_executor_job(self._json_data.set_target_level, 100) 292 | 293 | async def async_set_cover_position(self, position:int): 294 | if position >= 0 and position <= 100: 295 | await self._hass.async_add_executor_job(self._json_data.set_target_level,100 - position) 296 | 297 | class ikea_blinds_sensor(ikea_base_device_sensor, CoverEntity): 298 | def __init__(self, device:ikea_blinds_device): 299 | logger.debug("IkeaBlinds ctor...") 300 | super().__init__(device) 301 | 302 | @property 303 | def device_class(self) -> str: 304 | return CoverDeviceClass.BLIND 305 | 306 | @property 307 | def supported_features(self): 308 | return ( 309 | CoverEntityFeature.OPEN 310 | | CoverEntityFeature.CLOSE 311 | | CoverEntityFeature.SET_POSITION 312 | ) 313 | 314 | @property 315 | def current_cover_position(self): 316 | return 100 - self._device.blinds_current_level 317 | 318 | @property 319 | def target_cover_position(self): 320 | return 100 - self._device.blinds_target_level 321 | 322 | @property 323 | def is_closed(self): 324 | if self.current_cover_position is None: 325 | return False 326 | return self.current_cover_position == 0 327 | 328 | @property 329 | def is_closing(self): 330 | if self.current_cover_position is None or self.target_cover_position is False: 331 | return False 332 | 333 | if self.current_cover_position != 0 and self.target_cover_position == 0: 334 | return True 335 | 336 | return False 337 | 338 | @property 339 | def is_opening(self): 340 | if self.current_cover_position is None or self.target_cover_position is False: 341 | return False 342 | 343 | if self.current_cover_position != 100 and self.target_cover_position == 100: 344 | return True 345 | 346 | return False 347 | 348 | async def async_open_cover(self, **kwargs): 349 | await self._device.async_open_cover() 350 | 351 | async def async_close_cover(self, **kwargs): 352 | await self._device.async_close_cover() 353 | 354 | async def async_set_cover_position(self, **kwargs): 355 | position = int(kwargs["position"]) 356 | await self._device.async_set_cover_position(position) 357 | 358 | class ikea_vindstyrka_device(ikea_base_device): 359 | def __init__(self, hass:core.HomeAssistant, hub:Hub , json_data:EnvironmentSensor) -> None: 360 | super().__init__(hass, hub, json_data, hub.get_environment_sensor_by_id) 361 | self._updated_at = None 362 | 363 | async def async_update(self): 364 | if self._updated_at is None or (datetime.datetime.now() - self._updated_at).total_seconds() > 30: 365 | try: 366 | logger.debug("env sensor update called...") 367 | self._json_data = await self._hass.async_add_executor_job(self._hub.get_environment_sensor_by_id, self._json_data.id) 368 | self._updated_at = datetime.datetime.now() 369 | except Exception as ex: 370 | logger.error(f"error encountered running update on : {self.name}") 371 | logger.error(ex) 372 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 373 | 374 | class ikea_vindstyrka_temperature(ikea_base_device_sensor, SensorEntity): 375 | def __init__(self, device: ikea_vindstyrka_device) -> None: 376 | super().__init__( 377 | device, 378 | id_suffix="TEMP", 379 | name="Temperature", 380 | device_class=SensorDeviceClass.TEMPERATURE, 381 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 382 | state_class="measurement") 383 | logger.debug("ikea_vindstyrka_temperature ctor...") 384 | 385 | @property 386 | def native_value(self) -> float: 387 | return self._device.current_temperature 388 | 389 | class ikea_vindstyrka_humidity(ikea_base_device_sensor, SensorEntity): 390 | def __init__(self, device: ikea_vindstyrka_device) -> None: 391 | logger.debug("ikea_vindstyrka_humidity ctor...") 392 | super().__init__( 393 | device, 394 | id_suffix="HUM", 395 | name="Humidity", 396 | device_class=SensorDeviceClass.HUMIDITY, 397 | native_unit_of_measurement=PERCENTAGE) 398 | 399 | @property 400 | def native_value(self) -> int: 401 | return self._device.current_r_h 402 | 403 | class WhichPM25(Enum): 404 | CURRENT = 0 405 | MIN = 1 406 | MAX = 2 407 | 408 | class ikea_vindstyrka_pm25(ikea_base_device_sensor, SensorEntity): 409 | def __init__( 410 | self, device: ikea_vindstyrka_device, pm25_type: WhichPM25 411 | ) -> None: 412 | logger.debug("ikea_vindstyrka_pm25 ctor...") 413 | self._pm25_type = pm25_type 414 | id_suffix = " " 415 | name_suffix = " " 416 | if self._pm25_type == WhichPM25.CURRENT: 417 | id_suffix = "CURPM25" 418 | name_suffix = "Current PM2.5" 419 | if self._pm25_type == WhichPM25.MAX: 420 | id_suffix = "MAXPM25" 421 | name_suffix = "Max Measured PM2.5" 422 | if self._pm25_type == WhichPM25.MIN: 423 | id_suffix = "MINPM25" 424 | name_suffix = "Min Measured PM2.5" 425 | 426 | super().__init__(device, 427 | id_suffix=id_suffix, 428 | name=name_suffix, 429 | device_class=SensorDeviceClass.PM25, 430 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER) 431 | 432 | @property 433 | def native_value(self) -> int: 434 | if self._pm25_type == WhichPM25.CURRENT: 435 | return self._device.current_p_m25 436 | elif self._pm25_type == WhichPM25.MAX: 437 | return self._device.max_measured_p_m25 438 | elif self._pm25_type == WhichPM25.MIN: 439 | return self._device.min_measured_p_m25 440 | logger.debug("ikea_vindstyrka_pm25.native_value() shouldnt be here") 441 | return None 442 | 443 | class ikea_vindstyrka_voc_index(ikea_base_device_sensor, SensorEntity): 444 | def __init__(self, device: ikea_vindstyrka_device) -> None: 445 | logger.debug("ikea_vindstyrka_voc_index ctor...") 446 | super().__init__( 447 | device, 448 | id_suffix="VOC", 449 | name="VOC Index", 450 | device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, 451 | native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER) 452 | 453 | @property 454 | def native_value(self) -> int: 455 | return self._device.voc_index 456 | 457 | # SOMRIG Controllers act differently in the gateway Hub 458 | # While its one device but two id's are sent back each 459 | # representing the two buttons on the controler. The id is 460 | # all same except _1 and _2 suffix. The serial number on the 461 | # controllers is same. 462 | 463 | CONTROLLER_BUTTON_MAP = { "SOMRIG shortcut button" : 2 } 464 | 465 | class ikea_controller_device(ikea_base_device, SensorEntity): 466 | def __init__(self,hass:core.HomeAssistant, hub:Hub, json_data:Controller): 467 | logger.debug("ikea_controller ctor...") 468 | self._buttons = 1 469 | if json_data.attributes.model in CONTROLLER_BUTTON_MAP: 470 | self._buttons = CONTROLLER_BUTTON_MAP[json_data.attributes.model] 471 | logger.debug(f"Set #buttons to {self._buttons} as controller model is : {json_data.attributes.model}") 472 | 473 | super().__init__(hass , hub, json_data, hub.get_controller_by_id) 474 | self.skip_update = True 475 | 476 | @property 477 | def entity_category(self): 478 | return EntityCategory.DIAGNOSTIC 479 | 480 | @property 481 | def icon(self): 482 | return "mdi:battery" 483 | 484 | @property 485 | def native_value(self): 486 | return self.battery_percentage 487 | 488 | @property 489 | def native_unit_of_measurement(self) -> str: 490 | return "%" 491 | 492 | @property 493 | def device_class(self) -> str: 494 | return SensorDeviceClass.BATTERY 495 | 496 | @property 497 | def number_of_buttons(self) -> int: 498 | return self._buttons 499 | 500 | async def async_update(self): 501 | pass 502 | 503 | class ikea_starkvind_air_purifier_device(ikea_base_device): 504 | def __init__(self, hass, hub, json_data) -> None: 505 | logger.debug("Air purifer Fan device ctor ...") 506 | super().__init__(hass, hub, json_data, hub.get_air_purifier_by_id) 507 | self._updated_at = None 508 | 509 | @property 510 | def supported_features(self) -> FanEntityFeature: 511 | return FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED|FanEntityFeature.TURN_OFF|FanEntityFeature.TURN_ON 512 | 513 | @property 514 | def percentage(self) -> int: 515 | # Scale the 1-50 into 516 | return math.ceil(self.motor_state * 100 / 50) 517 | 518 | @property 519 | def preset_modes(self) -> list[str]: 520 | return [e.value for e in FanModeEnum] 521 | 522 | @property 523 | def preset_mode(self) -> str: 524 | if self.fan_mode == FanModeEnum.OFF: 525 | return "off" 526 | if self.fan_mode == FanModeEnum.LOW: 527 | return "low" 528 | if self.fan_mode == FanModeEnum.MEDIUM: 529 | return "medium" 530 | if self.fan_mode == FanModeEnum.HIGH: 531 | return "high" 532 | return "auto" 533 | #return self.fan_mode 534 | 535 | async def async_update(self): 536 | if ( 537 | self._updated_at is None 538 | or (datetime.datetime.now() - self._updated_at).total_seconds() > 30 539 | ): 540 | try: 541 | self._json_data = await self._hass.async_add_executor_job(self._hub.get_air_purifier_by_id, self._json_data.id) 542 | self._updated_at = datetime.datetime.now() 543 | except Exception as ex: 544 | logger.error("error encountered running update on : {}".format(self.name)) 545 | logger.error(ex) 546 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 547 | 548 | async def async_set_percentage(self, percentage: int) -> None: 549 | # Convert percent to speed 550 | desired_speed = math.ceil(percentage * 50 / 100) 551 | logger.debug("set_percentage got : {}, scaled to : {}".format(percentage, desired_speed)) 552 | await self._hass.async_add_executor_job(self._json_data.set_motor_state, desired_speed) 553 | 554 | async def async_set_status_light(self, status: bool) -> None: 555 | logger.debug("set_status_light : {}".format(status)) 556 | await self._hass.async_add_executor_job(self._json_data.set_status_light, status) 557 | 558 | async def async_set_child_lock(self, status: bool) -> None: 559 | logger.debug("set_child_lock : {}".format(status)) 560 | await self._hass.async_add_executor_job(self._json_data.set_child_lock, status) 561 | 562 | async def async_set_fan_mode(self, preset_mode: FanModeEnum) -> None: 563 | logger.debug("set_fan_mode : {}".format(preset_mode.value)) 564 | await self._hass.async_add_executor_job(self._json_data.set_fan_mode, preset_mode) 565 | 566 | async def async_set_preset_mode(self, preset_mode: str): 567 | logger.debug("set_preset_mode : {}".format(preset_mode)) 568 | mode_to_set = None 569 | if preset_mode == "auto": 570 | mode_to_set = FanModeEnum.AUTO 571 | elif preset_mode == "high": 572 | mode_to_set = FanModeEnum.HIGH 573 | elif preset_mode == "medium": 574 | mode_to_set = FanModeEnum.MEDIUM 575 | elif preset_mode == "low": 576 | mode_to_set = FanModeEnum.LOW 577 | elif preset_mode == "off": 578 | mode_to_set = FanModeEnum.OFF 579 | logger.debug(f"Asked to set preset {preset_mode}") 580 | if mode_to_set is None: 581 | logger.error("Non defined preset used to set : {}".format(preset_mode)) 582 | self.preset_mode = mode_to_set 583 | return 584 | 585 | logger.debug("set_preset_mode equated to : {}".format(mode_to_set.value)) 586 | #await self._hass.async_add_executor_job(self.async_set_fan_mode, mode_to_set) 587 | await self._hass.async_add_executor_job(self._json_data.set_fan_mode, mode_to_set) 588 | 589 | async def async_turn_on(self, percentage=None, preset_mode=None) -> None: 590 | logger.debug("Airpurifier call to turn_on with percentage: {}, preset_mode: {}".format(percentage, preset_mode)) 591 | 592 | if preset_mode is not None: 593 | await self.async_set_preset_mode(preset_mode) 594 | elif percentage is not None: 595 | await self.async_set_percentage(percentage) 596 | else: 597 | logger.debug("We were asked to be turned on but percentage and preset were not set, using last known") 598 | if self.preset_mode is not None: 599 | await self.async_set_preset_mode(self.preset_mode) 600 | elif self.percentage is not None: 601 | await self.async_set_percentage(self.percentage) 602 | else: 603 | logger.debug("No last known value, setting to auto") 604 | await self.async_set_preset_mode("auto") 605 | 606 | async def async_turn_off(self, **kwargs) -> None: 607 | await self.async_set_percentage(0) 608 | #await self._hass.async_add_executor_job(self.set_percentage, 0) 609 | 610 | class ikea_starkvind_air_purifier_fan(ikea_base_device_sensor, FanEntity): 611 | def __init__(self, device: ikea_starkvind_air_purifier_device) -> None: 612 | logger.debug("Air purifer Fan sensor ctor ...") 613 | super().__init__(device) 614 | 615 | @property 616 | def percentage(self): 617 | return self._device.percentage 618 | 619 | @property 620 | def preset_modes(self) -> list[str]: 621 | return self._device.preset_modes 622 | 623 | @property 624 | def preset_mode(self): 625 | return self._device.preset_mode 626 | 627 | # Property Doesnt Exist 628 | @property 629 | def speed_count(self): 630 | return 50 631 | 632 | @property 633 | def supported_features(self) -> FanEntityFeature: 634 | return self._device.supported_features 635 | 636 | async def async_set_percentage(self, percentage: int) -> None: 637 | await self._device.async_set_percentage(percentage) 638 | 639 | async def async_set_preset_mode(self, preset_mode: str): 640 | await self._device.async_set_preset_mode(preset_mode) 641 | 642 | async def async_set_fan_mode(self, preset_mode: FanModeEnum) -> None: 643 | await self._device.async_set_fan_mode(preset_mode) 644 | 645 | async def async_turn_on(self, percentage=None, preset_mode=None) -> None: 646 | await self._device.async_turn_on(percentage, preset_mode) 647 | 648 | async def async_turn_off(self, **kwargs) -> None: 649 | await self._device.async_turn_off() 650 | 651 | class ikea_starkvind_air_purifier_sensor(ikea_base_device_sensor, SensorEntity): 652 | def __init__( 653 | self, 654 | device: ikea_starkvind_air_purifier_device, 655 | prefix: str, 656 | device_class: SensorDeviceClass, 657 | native_value_prop: str, 658 | native_unit_of_measurement: str, 659 | icon_name: str, 660 | ): 661 | logger.debug("ikea_starkvind_air_purifier_sensor ctor ...") 662 | super().__init__( 663 | device, 664 | id_suffix=prefix, 665 | name=prefix, 666 | device_class=device_class, 667 | native_unit_of_measurement=native_uom, 668 | icon=icon_name) 669 | 670 | self._native_value_prop = native_value_prop 671 | 672 | @property 673 | def native_value(self): 674 | return getattr(self._device, self._native_value_prop) 675 | 676 | async def async_turn_off(self): 677 | pass 678 | 679 | async def async_turn_on(self): 680 | pass 681 | 682 | class ikea_starkvind_air_purifier_binary_sensor(ikea_base_device_sensor, BinarySensorEntity): 683 | def __init__( 684 | self, 685 | device: ikea_starkvind_air_purifier_device, 686 | device_class: BinarySensorDeviceClass, 687 | prefix: str, 688 | native_value_prop: str, 689 | icon_name: str, 690 | ): 691 | logger.debug("ikea_starkvind_air_purifier_binary_sensor ctor ...") 692 | super().__init__( 693 | device, 694 | id_suffix=prefix, 695 | name=prefix, 696 | device_class=device_class, 697 | icon=icon_name) 698 | 699 | self._native_value_prop = native_value_prop 700 | device.add_listener(self) 701 | 702 | @property 703 | def is_on(self): 704 | return getattr(self._device, self._native_value_prop) 705 | 706 | def async_turn_off(self): 707 | pass 708 | 709 | def async_handle_turn_on_service(self): 710 | pass 711 | 712 | class ikea_starkvind_air_purifier_switch_sensor(ikea_base_device_sensor, SwitchEntity): 713 | def __init__( 714 | self, 715 | device: ikea_starkvind_air_purifier_device, 716 | prefix: str, 717 | is_on_prop: str, 718 | turn_on_off_fx: str, 719 | icon_name: str, 720 | ): 721 | logger.debug("ikea_starkvind_air_purifier_switch_sensor ctor...") 722 | super().__init__( 723 | device, 724 | id_suffix=prefix, 725 | name=prefix, 726 | device_class=SwitchDeviceClass.OUTLET, 727 | icon=icon_name) 728 | self._is_on_prop = is_on_prop 729 | self._turn_on_off = getattr(self._device, turn_on_off_fx) 730 | 731 | @property 732 | def is_on(self): 733 | return getattr(self._device, self._is_on_prop) 734 | 735 | async def async_turn_on(self): 736 | logger.debug("{} turn_on".format(self.name)) 737 | try: 738 | await self._turn_on_off(True) 739 | except Exception as ex: 740 | logger.error("error encountered turning on : {}".format(self.name)) 741 | logger.error(ex) 742 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 743 | 744 | async def async_turn_off(self): 745 | logger.debug("{} turn_off".format(self.name)) 746 | try: 747 | await self._turn_on_off(False) 748 | except Exception as ex: 749 | logger.error("error encountered turning off : {}".format(self.name)) 750 | logger.error(ex) 751 | raise HomeAssistantError(ex, DOMAIN, "hub_exception") 752 | 753 | class battery_percentage_sensor(ikea_base_device_sensor, SensorEntity): 754 | def __init__(self, device): 755 | super().__init__( 756 | device = device, 757 | id_suffix="BP01", 758 | name="Battery Percentage", 759 | native_unit_of_measurement=PERCENTAGE, 760 | state_class=SensorStateClass.MEASUREMENT, 761 | #uom="%", 762 | device_class=SensorDeviceClass.BATTERY, 763 | entity_category=EntityCategory.DIAGNOSTIC) 764 | 765 | @property 766 | def native_value(self): 767 | return getattr(self._device, "battery_percentage") 768 | 769 | class current_amps_sensor(ikea_base_device_sensor, SensorEntity): 770 | def __init__(self, device): 771 | super().__init__( 772 | device = device, 773 | id_suffix="CA01", 774 | name="Current Amps", 775 | #uom="A", 776 | native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, 777 | state_class=SensorStateClass.MEASUREMENT, 778 | icon="mdi:current-ac", 779 | device_class=SensorDeviceClass.CURRENT) 780 | 781 | @property 782 | def native_value(self): 783 | return getattr(self._device, "current_amps") 784 | 785 | class current_active_power_sensor(ikea_base_device_sensor, SensorEntity): 786 | def __init__(self, device): 787 | super().__init__( 788 | device = device, 789 | id_suffix="CAP01", 790 | name="Current Active Power", 791 | native_unit_of_measurement=UnitOfPower.WATT, 792 | icon="mdi:lightning-bolt-outline", 793 | device_class=SensorDeviceClass.POWER) 794 | 795 | @property 796 | def native_value(self): 797 | return getattr(self._device, "current_active_power") 798 | 799 | class current_voltage_sensor(ikea_base_device_sensor, SensorEntity): 800 | 801 | def __init__(self, device): 802 | super().__init__( 803 | device = device, 804 | id_suffix="CV01", 805 | name="Current Voltage", 806 | #uom="V", 807 | native_unit_of_measurement=UnitOfElectricPotential.VOLT, 808 | state_class=SensorStateClass.MEASUREMENT, 809 | icon="mdi:power-plug", 810 | device_class=SensorDeviceClass.VOLTAGE) 811 | 812 | @property 813 | def native_value(self): 814 | return getattr(self._device, "current_voltage") 815 | 816 | class total_energy_consumed_sensor(ikea_base_device_sensor, SensorEntity): 817 | def __init__(self, device): 818 | super().__init__( 819 | device = device, 820 | id_suffix="TEC01", 821 | name="Total Energy Consumed", 822 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 823 | icon="mdi:lightning-bolt-outline", 824 | device_class=SensorDeviceClass.ENERGY, 825 | state_class=SensorStateClass.TOTAL_INCREASING) 826 | 827 | @property 828 | def native_value(self): 829 | return getattr(self._device, "total_energy_consumed") 830 | 831 | class energy_consumed_at_last_reset_sensor(ikea_base_device_sensor, SensorEntity): 832 | def __init__(self, device): 833 | super().__init__( 834 | device = device, 835 | id_suffix="ELAR01", 836 | name="Energy Consumed at Last Reset", 837 | native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, 838 | icon="mdi:lightning-bolt-outline", 839 | device_class=SensorDeviceClass.ENERGY, 840 | state_class=SensorStateClass.TOTAL_INCREASING) 841 | 842 | @property 843 | def native_value(self): 844 | return getattr(self._device, "energy_consumed_at_last_reset") 845 | 846 | class time_of_last_energy_reset_sensor(ikea_base_device_sensor, DateTimeEntity): 847 | def __init__(self, device): 848 | super().__init__( 849 | device = device, 850 | id_suffix="TLER01", 851 | device_class=SensorDeviceClass.TIMESTAMP, 852 | name="Time of Last Energy Reset", 853 | icon="mdi:update") 854 | 855 | @property 856 | def native_value(self): 857 | # Hack 858 | value = getattr(self._device, "time_of_last_energy_reset") 859 | if type(value) == str: 860 | #convert to datetime 861 | logger.debug(f"Found time_of_last_energy_reset as string attempting convert to datetime") 862 | self.time_of_last_energy_reset = value 863 | return getattr(self._device, "time_of_last_energy_reset") 864 | 865 | @property 866 | def time_of_last_energy_reset(self): 867 | return self.native_value() 868 | 869 | @time_of_last_energy_reset.setter 870 | def time_of_last_energy_reset(self, value): 871 | # This is called from hub events where its a str 872 | try: 873 | dt_value = datetime.datetime.strptime(value, DATE_TIME_FORMAT) 874 | setattr(self._device,"time_of_last_energy_reset",dt_value) 875 | except: 876 | logger.warning(f"Failed to set time_of_last_energy_reset in sensor using value : {value}") 877 | 878 | class total_energy_consumed_last_updated_sensor(ikea_base_device_sensor, DateTimeEntity): 879 | def __init__(self, device): 880 | super().__init__( device, 881 | id_suffix="TECLU01", 882 | name="Total Energy Consumed Last Updated", 883 | device_class=SensorDeviceClass.TIMESTAMP, 884 | icon="mdi:update") 885 | 886 | def __init__(self, device): 887 | super().__init__( 888 | device = device, 889 | id_suffix="TECLU01", 890 | device_class=SensorDeviceClass.TIMESTAMP, 891 | name="Time Energy Consumed Last Updated", 892 | icon="mdi:update") 893 | 894 | @property 895 | def native_value(self): 896 | return getattr(self._device, "total_energy_consumed_last_updated") 897 | 898 | @property 899 | def total_energy_consumed_last_updated(self): 900 | return self.native_value() 901 | 902 | @total_energy_consumed_last_updated.setter 903 | def time_of_last_energy_reset(self, value): 904 | # This is called from hub events where its a str 905 | try: 906 | dt_value = datetime.datetime.strptime(value, DATE_TIME_FORMAT) 907 | setattr(self._device,"total_energy_consumed_last_updated",dt_value) 908 | except: 909 | logger.warning(f"Failed to set total_energy_consumed_last_updated in sensor using value : {value}") --------------------------------------------------------------------------------