├── hacs.json ├── .github ├── workflows │ └── validate.yaml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── custom_components └── smartthings │ ├── services.yaml │ ├── manifest.json │ ├── scene.py │ ├── const.py │ ├── strings.json │ ├── lock.py │ ├── binary_sensor.py │ ├── button.py │ ├── cover.py │ ├── fan.py │ ├── switch.py │ ├── config_flow.py │ ├── light.py │ ├── number.py │ ├── capability.py │ ├── smartapp.py │ ├── __init__.py │ ├── climate.py │ └── sensor.py ├── README.md └── LICENSE.md /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SmartThings", 3 | "render_readme": true, 4 | "homeassistant": "2023.3.0" 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 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/smartthings/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format for available remote services 2 | 3 | 4 | send_command: 5 | target: 6 | entity: 7 | domain: sensor 8 | fields: 9 | command: 10 | required: true 11 | example: "setMachineState" 12 | selector: 13 | object: 14 | capability: 15 | required: true 16 | example: "ovenOperatingState" 17 | selector: 18 | object: 19 | action: 20 | required: false 21 | example: "stop" 22 | selector: 23 | object: 24 | 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Device information (manufacturer, model):** 27 | - Device: [e.g. Samsung Family Hub Model: xyz] 28 | - Go to https://my.smartthings.com/advanced and go to Control and View Your Devices. 29 | - Select the device that is having the issue. 30 | - Use the "Export Data" button for the Attributes and save as a CSV. 31 | - Attach the CSV file to the issue. 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /custom_components/smartthings/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.5", 3 | "domain": "smartthings", 4 | "name": "SmartThings", 5 | "after_dependencies": ["cloud"], 6 | "codeowners": ["@bakernigel"], 7 | "config_flow": true, 8 | "dependencies": ["webhook"], 9 | "dhcp": [ 10 | { 11 | "hostname": "st*", 12 | "macaddress": "24FD5B*" 13 | }, 14 | { 15 | "hostname": "smartthings*", 16 | "macaddress": "24FD5B*" 17 | }, 18 | { 19 | "hostname": "hub*", 20 | "macaddress": "24FD5B*" 21 | }, 22 | { 23 | "hostname": "hub*", 24 | "macaddress": "D052A8*" 25 | }, 26 | { 27 | "hostname": "hub*", 28 | "macaddress": "286D97*" 29 | } 30 | ], 31 | "documentation": "https://www.home-assistant.io/integrations/smartthings", 32 | "issue_tracker": "https://github.com/bakernigel/smartthings/issues", 33 | "iot_class": "cloud_push", 34 | "loggers": ["httpsig", "pysmartapp", "pysmartthings"], 35 | "requirements": ["pysmartapp==0.3.5", "pysmartthings==0.7.8"] 36 | } 37 | -------------------------------------------------------------------------------- /custom_components/smartthings/scene.py: -------------------------------------------------------------------------------- 1 | """Support for scenes through the SmartThings cloud API.""" 2 | from typing import Any 3 | 4 | from homeassistant.components.scene import Scene 5 | from homeassistant.config_entries import ConfigEntry 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 8 | 9 | from .const import DATA_BROKERS, DOMAIN 10 | 11 | 12 | async def async_setup_entry( 13 | hass: HomeAssistant, 14 | config_entry: ConfigEntry, 15 | async_add_entities: AddEntitiesCallback, 16 | ) -> None: 17 | """Add switches for a config entry.""" 18 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 19 | async_add_entities([SmartThingsScene(scene) for scene in broker.scenes.values()]) 20 | 21 | 22 | class SmartThingsScene(Scene): 23 | """Define a SmartThings scene.""" 24 | 25 | def __init__(self, scene): 26 | """Init the scene class.""" 27 | self._scene = scene 28 | self._attr_name = scene.name 29 | self._attr_unique_id = scene.scene_id 30 | 31 | async def async_activate(self, **kwargs: Any) -> None: 32 | """Activate scene.""" 33 | await self._scene.execute() 34 | 35 | @property 36 | def extra_state_attributes(self): 37 | """Get attributes about the state.""" 38 | return { 39 | "icon": self._scene.icon, 40 | "color": self._scene.color, 41 | "location_id": self._scene.location_id, 42 | } 43 | -------------------------------------------------------------------------------- /custom_components/smartthings/const.py: -------------------------------------------------------------------------------- 1 | """Constants used by the SmartThings component and platforms.""" 2 | from datetime import timedelta 3 | import re 4 | 5 | from homeassistant.const import Platform 6 | 7 | DOMAIN = "smartthings" 8 | 9 | APP_OAUTH_CLIENT_NAME = "Home Assistant" 10 | APP_OAUTH_SCOPES = ["r:devices:*"] 11 | APP_NAME_PREFIX = "homeassistant." 12 | 13 | CONF_APP_ID = "app_id" 14 | CONF_CLOUDHOOK_URL = "cloudhook_url" 15 | CONF_INSTALLED_APP_ID = "installed_app_id" 16 | CONF_INSTANCE_ID = "instance_id" 17 | CONF_LOCATION_ID = "location_id" 18 | CONF_REFRESH_TOKEN = "refresh_token" 19 | 20 | DATA_MANAGER = "manager" 21 | DATA_BROKERS = "brokers" 22 | EVENT_BUTTON = "smartthings.button" 23 | 24 | SIGNAL_SMARTTHINGS_UPDATE = "smartthings_update" 25 | SIGNAL_SMARTAPP_PREFIX = "smartthings_smartap_" 26 | 27 | SETTINGS_INSTANCE_ID = "hassInstanceId" 28 | 29 | SUBSCRIPTION_WARNING_LIMIT = 40 30 | 31 | STORAGE_KEY = DOMAIN 32 | STORAGE_VERSION = 1 33 | 34 | # Ordered 'specific to least-specific platform' in order for capabilities 35 | # to be drawn-down and represented by the most appropriate platform. 36 | PLATFORMS = [ 37 | Platform.CLIMATE, 38 | Platform.FAN, 39 | Platform.LIGHT, 40 | Platform.LOCK, 41 | Platform.COVER, 42 | # Platform.BUTTON, Remove button because it causes all waterFilter attributes to show under button 43 | Platform.SWITCH, 44 | Platform.NUMBER, 45 | Platform.BINARY_SENSOR, 46 | Platform.SENSOR, 47 | Platform.SCENE, 48 | ] 49 | 50 | IGNORED_CAPABILITIES = [ 51 | "execute", 52 | "healthCheck", 53 | "ocf", 54 | ] 55 | 56 | TOKEN_REFRESH_INTERVAL = timedelta(days=14) 57 | 58 | VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" 59 | VAL_UID_MATCHER = re.compile(VAL_UID) 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](https://brands.home-assistant.io/_/smartthings/logo@2x.png) 2 | 3 | __A Home Assistant custom Integration for SmartThings.__ 4 | 5 | __This integration has been replaced by https://github.com/bakernigel/smartthings2. It is no longer supported.__ 6 | 7 | [![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/?category=integration&repository=smartthings&owner=bakernigel) 8 | 9 | ## __Installation Using HACS__ 10 | - Backup your existing HA 11 | - Delete any existing Smarthings integration 12 | - Delete any custom Smarthings installations from HACS 13 | - Restart Home Assistant 14 | - Download the custom SmartThings integration from the HACS custom repository using the button above 15 | - Restart Home Assistant 16 | - Install the Smartthings integration using Settings -> Devices and Services -> Add Integration 17 | - Configure the Smartthings integration the same as for the core integration. You will need a PAT from https://account.smartthings.com/tokens. 18 | - See https://www.home-assistant.io/integrations/smartthings for full instructions. 19 | 20 | ## __𝐅𝐞𝐚𝐭𝐮𝐫𝐞𝐬__ 21 | - Added some missing sensors & controls 22 | - More capabilities are available than core Smartthings integration 23 | - Added a "Smartthings:send_command" action to send a command to the API 24 | - Only tested with Samsung Fridge Family Hub Model 24K_REF_LCD_FHUB9.0, Samsung Dishwasher Model DA_DW_TP1_21_COMMON and Samsung Wall Oven Model LCD_S905D3_OV_P_US_23K. May not work with other devices. 25 | - Integration may add unwanted sensors/controls for your device. Simply disable the unwanted ones in Home Assistant. 26 | - Integration reports the raw state for sensors from Samsung. Adjust the values using HA templates. e.g Family Hub Power {{(states('sensor.fridge_family_hub_power')) | int /10}} 27 | 28 | Based on: https://github.com/contemplator1998/smartthings 29 | -------------------------------------------------------------------------------- /custom_components/smartthings/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Confirm Callback URL", 6 | "description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again." 7 | }, 8 | "pat": { 9 | "title": "Enter Personal Access Token", 10 | "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", 11 | "data": { 12 | "access_token": "[%key:common::config_flow::data::access_token%]" 13 | } 14 | }, 15 | "select_location": { 16 | "title": "Select Location", 17 | "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", 18 | "data": { "location_id": "[%key:common::config_flow::data::location%]" } 19 | }, 20 | "authorize": { "title": "Authorize Home Assistant" } 21 | }, 22 | "abort": { 23 | "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", 24 | "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant." 25 | }, 26 | "error": { 27 | "token_invalid_format": "The token must be in the UID/GUID format", 28 | "token_unauthorized": "The token is invalid or no longer authorized.", 29 | "token_forbidden": "The token does not have the required OAuth scopes.", 30 | "app_setup_error": "Unable to set up the SmartApp. Please try again.", 31 | "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again." 32 | } 33 | } 34 | "services": { 35 | "send_command": { 36 | "name": "Send Smartthings command", 37 | "description": "Sends a command to the Smartthings API ", 38 | "fields": { 39 | "command": { 40 | "name": "Command", 41 | "description": "The command to send." 42 | "capability": { 43 | "name": "Capability", 44 | "description": "The capability for command to act upon." 45 | "action": { 46 | "name": "Action", 47 | "description": "The action to take." 48 | } 49 | }, 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /custom_components/smartthings/lock.py: -------------------------------------------------------------------------------- 1 | """Support for locks through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Sequence 5 | from typing import Any 6 | 7 | from pysmartthings import Attribute, Capability 8 | 9 | from homeassistant.components.lock import LockEntity 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 13 | 14 | from . import SmartThingsEntity 15 | from .const import DATA_BROKERS, DOMAIN 16 | 17 | ST_STATE_LOCKED = "locked" 18 | ST_LOCK_ATTR_MAP = { 19 | "codeId": "code_id", 20 | "codeName": "code_name", 21 | "lockName": "lock_name", 22 | "method": "method", 23 | "timeout": "timeout", 24 | "usedCode": "used_code", 25 | } 26 | 27 | 28 | async def async_setup_entry( 29 | hass: HomeAssistant, 30 | config_entry: ConfigEntry, 31 | async_add_entities: AddEntitiesCallback, 32 | ) -> None: 33 | """Add locks for a config entry.""" 34 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 35 | async_add_entities( 36 | [ 37 | SmartThingsLock(device) 38 | for device in broker.devices.values() 39 | if broker.any_assigned(device.device_id, "lock") 40 | ] 41 | ) 42 | 43 | 44 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 45 | """Return all capabilities supported if minimum required are present.""" 46 | if Capability.lock in capabilities: 47 | return [Capability.lock] 48 | return None 49 | 50 | 51 | class SmartThingsLock(SmartThingsEntity, LockEntity): 52 | """Define a SmartThings lock.""" 53 | 54 | async def async_lock(self, **kwargs: Any) -> None: 55 | """Lock the device.""" 56 | await self._device.lock(set_status=True) 57 | self.async_write_ha_state() 58 | 59 | async def async_unlock(self, **kwargs: Any) -> None: 60 | """Unlock the device.""" 61 | await self._device.unlock(set_status=True) 62 | self.async_write_ha_state() 63 | 64 | @property 65 | def is_locked(self) -> bool: 66 | """Return true if lock is locked.""" 67 | return self._device.status.lock == ST_STATE_LOCKED 68 | 69 | @property 70 | def extra_state_attributes(self) -> dict[str, Any]: 71 | """Return device specific state attributes.""" 72 | state_attrs = {} 73 | status = self._device.status.attributes[Attribute.lock] 74 | if status.value: 75 | state_attrs["lock_state"] = status.value 76 | if isinstance(status.data, dict): 77 | for st_attr, ha_attr in ST_LOCK_ATTR_MAP.items(): 78 | if (data_val := status.data.get(st_attr)) is not None: 79 | state_attrs[ha_attr] = data_val 80 | return state_attrs 81 | -------------------------------------------------------------------------------- /custom_components/smartthings/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Support for binary sensors through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Sequence 5 | 6 | from pysmartthings import Attribute, Capability 7 | 8 | from homeassistant.components.binary_sensor import ( 9 | BinarySensorDeviceClass, 10 | BinarySensorEntity, 11 | ) 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.const import EntityCategory 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | 17 | from . import SmartThingsEntity 18 | from .const import DATA_BROKERS, DOMAIN 19 | 20 | CAPABILITY_TO_ATTRIB = { 21 | Capability.acceleration_sensor: Attribute.acceleration, 22 | Capability.contact_sensor: Attribute.contact, 23 | Capability.filter_status: Attribute.filter_status, 24 | Capability.motion_sensor: Attribute.motion, 25 | Capability.presence_sensor: Attribute.presence, 26 | Capability.sound_sensor: Attribute.sound, 27 | Capability.tamper_alert: Attribute.tamper, 28 | Capability.valve: Attribute.valve, 29 | Capability.water_sensor: Attribute.water, 30 | } 31 | ATTRIB_TO_CLASS = { 32 | Attribute.acceleration: BinarySensorDeviceClass.MOVING, 33 | Attribute.contact: BinarySensorDeviceClass.OPENING, 34 | Attribute.filter_status: BinarySensorDeviceClass.PROBLEM, 35 | Attribute.motion: BinarySensorDeviceClass.MOTION, 36 | Attribute.presence: BinarySensorDeviceClass.PRESENCE, 37 | Attribute.sound: BinarySensorDeviceClass.SOUND, 38 | Attribute.tamper: BinarySensorDeviceClass.PROBLEM, 39 | Attribute.valve: BinarySensorDeviceClass.OPENING, 40 | Attribute.water: BinarySensorDeviceClass.MOISTURE, 41 | } 42 | ATTRIB_TO_ENTTIY_CATEGORY = { 43 | Attribute.tamper: EntityCategory.DIAGNOSTIC, 44 | } 45 | 46 | 47 | async def async_setup_entry( 48 | hass: HomeAssistant, 49 | config_entry: ConfigEntry, 50 | async_add_entities: AddEntitiesCallback, 51 | ) -> None: 52 | """Add binary sensors for a config entry.""" 53 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 54 | sensors = [] 55 | for device in broker.devices.values(): 56 | for capability in broker.get_assigned(device.device_id, "binary_sensor"): 57 | attrib = CAPABILITY_TO_ATTRIB[capability] 58 | sensors.append(SmartThingsBinarySensor(device, "main", attrib)) 59 | 60 | device_capabilities_for_binary_sensor = broker.get_assigned( 61 | device.device_id, "binary_sensor" 62 | ) 63 | for component in device.components: 64 | for capability in device.components[component]: 65 | if capability not in device_capabilities_for_binary_sensor: 66 | continue 67 | attrib = CAPABILITY_TO_ATTRIB[capability] 68 | sensors.append(SmartThingsBinarySensor(device, component, attrib)) 69 | async_add_entities(sensors) 70 | 71 | 72 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 73 | """Return all capabilities supported if minimum required are present.""" 74 | return [ 75 | capability for capability in CAPABILITY_TO_ATTRIB if capability in capabilities 76 | ] 77 | 78 | 79 | class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): 80 | """Define a SmartThings Binary Sensor.""" 81 | 82 | def __init__(self, device, component, attribute): 83 | """Init the class.""" 84 | super().__init__(device) 85 | self._component = component 86 | self._attribute = attribute 87 | if self._component == "main": 88 | self._attr_name = f"{device.label} {attribute}" 89 | self._attr_unique_id = f"{device.device_id}.{attribute}" 90 | else: 91 | self._attr_name = f"{device.label} {component} {attribute}" 92 | self._attr_unique_id = f"{device.device_id}.{component}.{attribute}" 93 | self._attr_device_class = ATTRIB_TO_CLASS[attribute] 94 | self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute) 95 | 96 | @property 97 | def is_on(self): 98 | """Return true if the binary sensor is on.""" 99 | if self._component == "main": 100 | return self._device.status.is_on(self._attribute) 101 | return self._device.status.components[self._component].is_on(self._attribute) 102 | -------------------------------------------------------------------------------- /custom_components/smartthings/button.py: -------------------------------------------------------------------------------- 1 | """Support for buttons through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | from collections import namedtuple 5 | from collections.abc import Sequence 6 | 7 | from pysmartthings.device import DeviceEntity 8 | 9 | from homeassistant.components.button import ButtonEntity 10 | 11 | from . import SmartThingsEntity 12 | from .const import DATA_BROKERS, DOMAIN 13 | 14 | Map = namedtuple( 15 | "map", 16 | "button_command name icon device_class extra_state_attributes", 17 | ) 18 | 19 | CAPABILITY_TO_BUTTON = { 20 | "custom.dustFilter": [ 21 | Map( 22 | "resetDustFilter", 23 | "Reset Dust Filter", 24 | "mdi:air-filter", 25 | None, 26 | [ 27 | "dustFilterUsageStep", 28 | "dustFilterUsage", 29 | "dustFilterLastResetDate", 30 | "dustFilterStatus", 31 | "dustFilterCapacity", 32 | "dustFilterResetType", 33 | ], 34 | ) 35 | ], 36 | "custom.waterFilter": [ 37 | Map( 38 | "resetWaterFilter", 39 | "Reset Water Filter", 40 | "mdi:air-filter", 41 | None, 42 | [ 43 | "waterFilterUsageStep", 44 | "waterFilterUsage", 45 | "waterFilterStatus", 46 | "waterFilterResetType", 47 | "waterFilterLastResetDate", 48 | ], 49 | ) 50 | ], 51 | } 52 | 53 | 54 | async def async_setup_entry(hass, config_entry, async_add_entities): 55 | """Add switches for a config entry.""" 56 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 57 | buttons = [] 58 | for device in broker.devices.values(): 59 | for capability in broker.get_assigned(device.device_id, "button"): 60 | maps = CAPABILITY_TO_BUTTON[capability] 61 | buttons.extend( 62 | [ 63 | SmartThingsButton( 64 | device, 65 | capability, 66 | m.button_command, 67 | m.name, 68 | m.icon, 69 | m.device_class, 70 | m.extra_state_attributes, 71 | ) 72 | for m in maps 73 | ] 74 | ) 75 | 76 | async_add_entities(buttons) 77 | 78 | 79 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 80 | """Return all capabilities supported if minimum required are present.""" 81 | # Must be able to be turned on. 82 | return [ 83 | capability for capability in CAPABILITY_TO_BUTTON if capability in capabilities 84 | ] 85 | 86 | 87 | class SmartThingsButton(SmartThingsEntity, ButtonEntity): 88 | """Define a SmartThings button.""" 89 | 90 | def __init__( 91 | self, 92 | device: DeviceEntity, 93 | capability: str, 94 | button_command: str | None, 95 | name: str, 96 | icon: str | None, 97 | device_class: str | None, 98 | extra_state_attributes: str | None, 99 | ) -> None: 100 | """Init the class.""" 101 | super().__init__(device) 102 | self._capability = capability 103 | self._button_command = button_command 104 | self._name = name 105 | self._icon = icon 106 | self._attr_device_class = device_class 107 | self._extra_state_attributes = extra_state_attributes 108 | 109 | async def async_press(self) -> None: 110 | """Handle the button press.""" 111 | await self._device.command("main", self._capability, self._button_command, []) 112 | 113 | @property 114 | def name(self) -> str: 115 | """Return the name of the switch.""" 116 | return f"{self._device.label} {self._name}" 117 | 118 | @property 119 | def unique_id(self) -> str: 120 | """Return a unique ID.""" 121 | return f"{self._device.device_id}.{self._name}" 122 | 123 | @property 124 | def icon(self) -> str | None: 125 | return self._icon 126 | 127 | @property 128 | def extra_state_attributes(self): 129 | """Return device specific state attributes.""" 130 | state_attributes = {} 131 | if self._extra_state_attributes is not None: 132 | attributes = self._extra_state_attributes 133 | for attribute in attributes: 134 | value = self._device.status.attributes[attribute].value 135 | if value is not None: 136 | state_attributes[attribute] = value 137 | return state_attributes 138 | -------------------------------------------------------------------------------- /custom_components/smartthings/cover.py: -------------------------------------------------------------------------------- 1 | """Support for covers through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Sequence 5 | from typing import Any 6 | 7 | from pysmartthings import Attribute, Capability 8 | 9 | from homeassistant.components.cover import ( 10 | ATTR_POSITION, 11 | DOMAIN as COVER_DOMAIN, 12 | CoverDeviceClass, 13 | CoverEntity, 14 | CoverEntityFeature, 15 | CoverState, 16 | ) 17 | from homeassistant.config_entries import ConfigEntry 18 | from homeassistant.const import ATTR_BATTERY_LEVEL 19 | from homeassistant.core import HomeAssistant 20 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 21 | 22 | from . import SmartThingsEntity 23 | from .const import DATA_BROKERS, DOMAIN 24 | 25 | VALUE_TO_STATE = { 26 | "closed": CoverState.CLOSED, 27 | "closing": CoverState.CLOSING, 28 | "open": CoverState.OPEN, 29 | "opening": CoverState.OPENING, 30 | "partially open": CoverState.OPEN, 31 | "unknown": None, 32 | } 33 | 34 | 35 | async def async_setup_entry( 36 | hass: HomeAssistant, 37 | config_entry: ConfigEntry, 38 | async_add_entities: AddEntitiesCallback, 39 | ) -> None: 40 | """Add covers for a config entry.""" 41 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 42 | async_add_entities( 43 | [ 44 | SmartThingsCover(device) 45 | for device in broker.devices.values() 46 | if broker.any_assigned(device.device_id, COVER_DOMAIN) 47 | ], 48 | True, 49 | ) 50 | 51 | 52 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 53 | """Return all capabilities supported if minimum required are present.""" 54 | min_required = [ 55 | Capability.door_control, 56 | Capability.garage_door_control, 57 | Capability.window_shade, 58 | ] 59 | # Must have one of the min_required 60 | if any(capability in capabilities for capability in min_required): 61 | # Return all capabilities supported/consumed 62 | return min_required + [ 63 | Capability.battery, 64 | Capability.switch_level, 65 | Capability.window_shade_level, 66 | ] 67 | 68 | return None 69 | 70 | 71 | class SmartThingsCover(SmartThingsEntity, CoverEntity): 72 | """Define a SmartThings cover.""" 73 | 74 | def __init__(self, device): 75 | """Initialize the cover class.""" 76 | super().__init__(device) 77 | self._current_cover_position = None 78 | self._state = None 79 | self._attr_supported_features = ( 80 | CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE 81 | ) 82 | if ( 83 | Capability.switch_level in device.capabilities 84 | or Capability.window_shade_level in device.capabilities 85 | ): 86 | self._attr_supported_features |= CoverEntityFeature.SET_POSITION 87 | 88 | if Capability.door_control in device.capabilities: 89 | self._attr_device_class = CoverDeviceClass.DOOR 90 | elif Capability.window_shade in device.capabilities: 91 | self._attr_device_class = CoverDeviceClass.SHADE 92 | elif Capability.garage_door_control in device.capabilities: 93 | self._attr_device_class = CoverDeviceClass.GARAGE 94 | 95 | async def async_close_cover(self, **kwargs: Any) -> None: 96 | """Close cover.""" 97 | # Same command for all 3 supported capabilities 98 | await self._device.close(set_status=True) 99 | # State is set optimistically in the commands above, therefore update 100 | # the entity state ahead of receiving the confirming push updates 101 | self.async_schedule_update_ha_state(True) 102 | 103 | async def async_open_cover(self, **kwargs: Any) -> None: 104 | """Open the cover.""" 105 | # Same for all capability types 106 | await self._device.open(set_status=True) 107 | # State is set optimistically in the commands above, therefore update 108 | # the entity state ahead of receiving the confirming push updates 109 | self.async_schedule_update_ha_state(True) 110 | 111 | async def async_set_cover_position(self, **kwargs: Any) -> None: 112 | """Move the cover to a specific position.""" 113 | if not self.supported_features & CoverEntityFeature.SET_POSITION: 114 | return 115 | # Do not set_status=True as device will report progress. 116 | if Capability.window_shade_level in self._device.capabilities: 117 | await self._device.set_window_shade_level( 118 | kwargs[ATTR_POSITION], set_status=False 119 | ) 120 | else: 121 | await self._device.set_level(kwargs[ATTR_POSITION], set_status=False) 122 | 123 | async def async_update(self) -> None: 124 | """Update the attrs of the cover.""" 125 | if Capability.door_control in self._device.capabilities: 126 | self._state = VALUE_TO_STATE.get(self._device.status.door) 127 | elif Capability.window_shade in self._device.capabilities: 128 | self._state = VALUE_TO_STATE.get(self._device.status.window_shade) 129 | elif Capability.garage_door_control in self._device.capabilities: 130 | self._state = VALUE_TO_STATE.get(self._device.status.door) 131 | 132 | if Capability.window_shade_level in self._device.capabilities: 133 | self._attr_current_cover_position = self._device.status.shade_level 134 | elif Capability.switch_level in self._device.capabilities: 135 | self._attr_current_cover_position = self._device.status.level 136 | 137 | self._attr_extra_state_attributes = {} 138 | battery = self._device.status.attributes[Attribute.battery].value 139 | if battery is not None: 140 | self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery 141 | 142 | @property 143 | def is_opening(self) -> bool: 144 | """Return if the cover is opening or not.""" 145 | return self._state == STATE_OPENING 146 | 147 | @property 148 | def is_closing(self) -> bool: 149 | """Return if the cover is closing or not.""" 150 | return self._state == STATE_CLOSING 151 | 152 | @property 153 | def is_closed(self) -> bool | None: 154 | """Return if the cover is closed or not.""" 155 | if self._state == STATE_CLOSED: 156 | return True 157 | return None if self._state is None else False 158 | -------------------------------------------------------------------------------- /custom_components/smartthings/fan.py: -------------------------------------------------------------------------------- 1 | """Support for fans through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | from collections.abc import Sequence 5 | import math 6 | from typing import Any 7 | 8 | from pysmartthings import Capability 9 | 10 | from homeassistant.components.fan import FanEntity, FanEntityFeature 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.util.percentage import ( 15 | percentage_to_ranged_value, 16 | ranged_value_to_percentage, 17 | ) 18 | from homeassistant.util.scaling import int_states_in_range 19 | 20 | from . import SmartThingsEntity 21 | from .const import DATA_BROKERS, DOMAIN 22 | from .capability import Attribute, Capability 23 | 24 | SPEED_RANGE = (1, 3) # off is not included 25 | 26 | 27 | async def async_setup_entry( 28 | hass: HomeAssistant, 29 | config_entry: ConfigEntry, 30 | async_add_entities: AddEntitiesCallback, 31 | ) -> None: 32 | """Add fans for a config entry.""" 33 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 34 | async_add_entities( 35 | [ 36 | SmartThingsFan(device) 37 | for device in broker.devices.values() 38 | if broker.any_assigned(device.device_id, "fan") 39 | ] 40 | ) 41 | 42 | 43 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 44 | """Return all capabilities supported if minimum required are present.""" 45 | 46 | # MUST support switch as we need a way to turn it on and off 47 | if Capability.switch not in capabilities: 48 | return None 49 | 50 | # These are all optional but at least one must be supported 51 | optional = [ 52 | Capability.air_conditioner_fan_mode, 53 | Capability.fan_speed, 54 | Capability.hood_fan_speed, 55 | ] 56 | 57 | # If none of the optional capabilities are supported then error 58 | if not any(capability in capabilities for capability in optional): 59 | return None 60 | 61 | supported = [Capability.switch] 62 | 63 | for capability in optional: 64 | if capability in capabilities: 65 | supported.append(capability) 66 | 67 | return supported 68 | 69 | 70 | class SmartThingsFan(SmartThingsEntity, FanEntity): 71 | """Define a SmartThings Fan.""" 72 | 73 | def __init__(self, device): 74 | """Init the class.""" 75 | super().__init__(device) 76 | if self._device.get_capability(Capability.hood_fan_speed): 77 | self._attr_speed_count = self._device.status.settable_max_fan_speed + 1 78 | else: 79 | self._attr_speed_count = int_states_in_range(SPEED_RANGE) 80 | self._attr_supported_features = self._determine_features() 81 | 82 | def _determine_features(self): 83 | flags = FanEntityFeature(0) 84 | 85 | if ( 86 | self._device.get_capability(Capability.fan_speed) 87 | or self._device.get_capability(Capability.hood_fan_speed) 88 | ): 89 | flags |= FanEntityFeature.SET_SPEED 90 | if self._device.get_capability(Capability.air_conditioner_fan_mode): 91 | flags |= FanEntityFeature.PRESET_MODE 92 | 93 | return flags 94 | 95 | async def async_set_percentage(self, percentage: int) -> None: 96 | """Set the speed percentage of the fan.""" 97 | await self._async_set_percentage(percentage) 98 | 99 | async def _async_set_percentage(self, percentage: int | None) -> None: 100 | if percentage is None: 101 | await self._device.switch_on(set_status=True) 102 | elif percentage == 0: 103 | await self._device.switch_off(set_status=True) 104 | else: 105 | if self._device.get_capability(Capability.hood_fan_speed): 106 | max_speed = self._device.status.settable_max_fan_speed 107 | value = math.ceil(percentage_to_ranged_value((0, max_speed), percentage)) 108 | await self._device.command( 109 | component_id="main", 110 | capability=Capability.hood_fan_speed, 111 | command="setHoodFan", 112 | arguments=[value], 113 | set_status=True, 114 | ) 115 | else: 116 | value = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) 117 | await self._device.set_fan_speed(value, set_status=True) 118 | # State is set optimistically in the command above, therefore update 119 | # the entity state ahead of receiving the confirming push updates 120 | self.async_write_ha_state() 121 | 122 | async def async_set_preset_mode(self, preset_mode: str) -> None: 123 | """Set the preset_mode of the fan.""" 124 | await self._device.set_fan_mode(preset_mode, set_status=True) 125 | self.async_write_ha_state() 126 | 127 | async def async_turn_on( 128 | self, 129 | percentage: int | None = None, 130 | preset_mode: str | None = None, 131 | **kwargs: Any, 132 | ) -> None: 133 | """Turn the fan on.""" 134 | if FanEntityFeature.SET_SPEED in self._attr_supported_features: 135 | # If speed is set in features then turn the fan on with the speed. 136 | await self._async_set_percentage(percentage) 137 | else: 138 | # If speed is not valid then turn on the fan with the 139 | await self._device.switch_on(set_status=True) 140 | # State is set optimistically in the command above, therefore update 141 | # the entity state ahead of receiving the confirming push updates 142 | self.async_write_ha_state() 143 | 144 | async def async_turn_off(self, **kwargs: Any) -> None: 145 | """Turn the fan off.""" 146 | await self._device.switch_off(set_status=True) 147 | # State is set optimistically in the command above, therefore update 148 | # the entity state ahead of receiving the confirming push updates 149 | self.async_write_ha_state() 150 | 151 | @property 152 | def is_on(self) -> bool: 153 | """Return true if fan is on.""" 154 | return self._device.status.switch 155 | 156 | @property 157 | def percentage(self) -> int | None: 158 | """Return the current speed percentage.""" 159 | if self._device.get_capability(Capability.hood_fan_speed): 160 | max_speed = self._device.status.settable_max_fan_speed 161 | return ranged_value_to_percentage( 162 | (0, max_speed), self._device.status.hood_fan_speed 163 | ) 164 | return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) 165 | 166 | @property 167 | def preset_mode(self) -> str | None: 168 | """Return the current preset mode, e.g., auto, smart, interval, favorite. 169 | 170 | Requires FanEntityFeature.PRESET_MODE. 171 | """ 172 | return self._device.status.fan_mode 173 | 174 | @property 175 | def preset_modes(self) -> list[str] | None: 176 | """Return a list of available preset modes. 177 | 178 | Requires FanEntityFeature.PRESET_MODE. 179 | """ 180 | return self._device.status.supported_ac_fan_modes 181 | -------------------------------------------------------------------------------- /custom_components/smartthings/switch.py: -------------------------------------------------------------------------------- 1 | """Support for switches through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from collections.abc import Sequence 7 | from typing import Any 8 | 9 | # from pysmartthings import Capability 10 | from .capability import Capability 11 | 12 | from homeassistant.components.switch import SwitchEntity 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | 17 | from . import SmartThingsEntity 18 | from .const import DATA_BROKERS, DOMAIN 19 | 20 | from .capability import ( 21 | ATTRIBUTES, 22 | CAPABILITIES, 23 | CAPABILITIES_TO_ATTRIBUTES, 24 | ATTRIBUTE_ON_VALUES, 25 | ATTRIBUTE_OFF_VALUES, 26 | Attribute, 27 | Capability, 28 | ) 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | async def async_setup_entry( 33 | hass: HomeAssistant, 34 | config_entry: ConfigEntry, 35 | async_add_entities: AddEntitiesCallback, 36 | ) -> None: 37 | """Add switches for a config entry.""" 38 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 39 | 40 | _LOGGER.debug( 41 | "NB looking for switches", 42 | ) 43 | 44 | # async_add_entities( 45 | # SmartThingsSwitch(device,"main", "switch") 46 | # for device in broker.devices.values() 47 | # if broker.any_assigned(device.device_id, "switch") 48 | # ) 49 | 50 | myswitches = [] 51 | 52 | for device in broker.devices.values(): 53 | _LOGGER.debug( 54 | "NB first switch device: %s components: %s", 55 | device.device_id, 56 | device.components, 57 | ) 58 | if broker.any_assigned(device.device_id, "switch"): 59 | components = device.components 60 | capabilities = device.capabilities 61 | _LOGGER.debug( 62 | "NB about to add_entities switch for device : %s com=%s cap= %s", 63 | device.device_id, 64 | components, 65 | capabilities, 66 | ) 67 | if "samsungce.lamp" in capabilities: 68 | _LOGGER.debug( 69 | "NB found samsungce.lamp in main section ", 70 | ) 71 | myswitches.append(SmartThingsSwitch(device,"main", "brightnessLevel")) 72 | else: 73 | myswitches.append(SmartThingsSwitch(device,"main", "switch")) 74 | 75 | async_add_entities(myswitches) 76 | # 77 | # if broker.any_assigned(device.device_id, "samsungce.lamp"): 78 | # _LOGGER.debug( 79 | # "NB found any_assigned samsungce.lamp ", 80 | # ) 81 | # async_add_entities(SmartThingsSwitch(device,"main", "switch")) 82 | 83 | 84 | switches = [] 85 | for device in broker.devices.values(): 86 | for component in device.components: 87 | if "switch" in device.components[component]: 88 | switches.append(SmartThingsSwitch(device, component, "switch")) 89 | 90 | capability = device.components[component] 91 | 92 | _LOGGER.debug( 93 | "NB switch component: %s capability : %s ", 94 | component, 95 | capability, 96 | ) 97 | 98 | if "samsungce.lamp" in capability: 99 | switches.append(SmartThingsSwitch(device, component, "brightnessLevel")) 100 | _LOGGER.debug( 101 | "NB found samsungce.lamp ", 102 | ) 103 | 104 | 105 | 106 | async_add_entities(switches) 107 | 108 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 109 | """Return all capabilities supported if minimum required are present.""" 110 | 111 | _LOGGER.debug( 112 | "NB switch get_capabilities: %s ", 113 | capabilities, 114 | ) 115 | 116 | 117 | # Must be able to be turned on/off. 118 | if Capability.switch in capabilities: 119 | _LOGGER.debug( 120 | "NB switch found switch in capabilities: %s ", 121 | capabilities, 122 | ) 123 | return [Capability.switch, Capability.energy_meter, Capability.power_meter] 124 | if Capability.oven_light in capabilities: 125 | _LOGGER.debug( 126 | "NB switch found oven_light in capabilities: %s ", 127 | capabilities, 128 | ) 129 | return [Capability.oven_light] 130 | return None 131 | 132 | 133 | class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): 134 | """Define a SmartThings switch.""" 135 | 136 | def __init__(self, device, component, attribute): 137 | """Init the class.""" 138 | super().__init__(device) 139 | self._component = component 140 | self._attribute = attribute 141 | 142 | @property 143 | def name(self) -> str: 144 | """Return the name of the switch.""" 145 | if self._attribute == "brightnessLevel": 146 | switch_name = "light" 147 | else: 148 | switch_name = "switch" 149 | 150 | if self._component == "main": 151 | return f"{self._device.label} {switch_name}" 152 | return f"{self._device.label} {self._component} {switch_name}" 153 | 154 | @property 155 | def unique_id(self) -> str: 156 | """Return a unique ID.""" 157 | 158 | if self._attribute == "brightnessLevel": 159 | switch_name = "light" 160 | else: 161 | switch_name = "" 162 | 163 | if self._component == "main": 164 | return f"{self._device.device_id}.{switch_name}" 165 | return f"{self._device.device_id}.{self._component}.{switch_name}" 166 | 167 | async def async_turn_off(self, **kwargs: Any) -> None: 168 | """Turn the switch off.""" 169 | 170 | if self._attribute == "brightnessLevel": 171 | _LOGGER.debug( 172 | "NB switch async_turn_off samsungce.lamp: component = %s attribute: %s ", 173 | self._component, 174 | self._attribute, 175 | ) 176 | # await self._device.set_brightnesslevel(level="off", set_status=True, component_id=self._component) 177 | await self._device.command(self._component, "samsungce.lamp", "setBrightnessLevel", ["off"] ) 178 | else: 179 | await self._device.switch_off(set_status=True, component_id=self._component) 180 | 181 | # State is set optimistically in the command above, therefore update 182 | # the entity state ahead of receiving the confirming push updates 183 | self.async_write_ha_state() 184 | 185 | async def async_turn_on(self, **kwargs: Any) -> None: 186 | """Turn the switch on.""" 187 | 188 | if self._attribute == "brightnessLevel": 189 | _LOGGER.debug( 190 | "NB switch async_turn_on samsungce.lamp: component = %s attribute: %s ", 191 | self._component, 192 | self._attribute, 193 | ) 194 | # await self._device.set_brightnesslevel(level="high", set_status=True, component_id=self._component) 195 | await self._device.command(self._component, "samsungce.lamp", "setBrightnessLevel", ["high"] ) 196 | else: 197 | await self._device.switch_on(set_status=True, component_id=self._component) 198 | 199 | 200 | # State is set optimistically in the command above, therefore update 201 | # the entity state ahead of receiving the confirming push updates 202 | self.async_write_ha_state() 203 | 204 | @property 205 | def is_on(self) -> bool: 206 | """Return true if switch is on.""" 207 | _LOGGER.debug( 208 | "NB switch is_on first: component = %s attrib: %s status: %s", 209 | self._component, 210 | self._attribute, 211 | self._device.status.switch, 212 | ) 213 | 214 | if self._component == "main": 215 | if self._attribute == "brightnessLevel": 216 | 217 | value = self._device.status.attributes[self._attribute].value 218 | 219 | _LOGGER.debug( 220 | "NB switch is_on brightnessLevel value = %s", 221 | value, 222 | ) 223 | if value == "high": 224 | return True 225 | else: 226 | return False 227 | else: 228 | return self._device.status.switch 229 | 230 | value = ( 231 | self._device.status.components[self._component] 232 | .attributes[self._attribute] 233 | .value 234 | ) 235 | _LOGGER.debug( 236 | "NB switch is_on second: component = %s status: %s : %s: %s", 237 | self._component, 238 | self._device.status.switch, 239 | self._device.status.components[self._component].switch, 240 | value, 241 | ) 242 | 243 | if self._device.status.components[self._component].attributes[self._attribute].value == "high": 244 | return True 245 | 246 | return self._device.status.components[self._component].switch 247 | 248 | 249 | 250 | -------------------------------------------------------------------------------- /custom_components/smartthings/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow to configure SmartThings.""" 2 | from http import HTTPStatus 3 | import logging 4 | 5 | from aiohttp import ClientResponseError 6 | from pysmartthings import APIResponseError, AppOAuth, SmartThings 7 | from pysmartthings.installedapp import format_install_url 8 | import voluptuous as vol 9 | 10 | from homeassistant import config_entries 11 | from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET 12 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 13 | 14 | from .const import ( 15 | APP_OAUTH_CLIENT_NAME, 16 | APP_OAUTH_SCOPES, 17 | CONF_APP_ID, 18 | CONF_INSTALLED_APP_ID, 19 | CONF_LOCATION_ID, 20 | CONF_REFRESH_TOKEN, 21 | DOMAIN, 22 | VAL_UID_MATCHER, 23 | ) 24 | from .smartapp import ( 25 | create_app, 26 | find_app, 27 | format_unique_id, 28 | get_webhook_url, 29 | setup_smartapp, 30 | setup_smartapp_endpoint, 31 | update_app, 32 | validate_webhook_requirements, 33 | ) 34 | 35 | _LOGGER = logging.getLogger(__name__) 36 | 37 | 38 | class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 39 | """Handle configuration of SmartThings integrations.""" 40 | 41 | VERSION = 2 42 | 43 | def __init__(self) -> None: 44 | """Create a new instance of the flow handler.""" 45 | self.access_token = None 46 | self.app_id = None 47 | self.api = None 48 | self.oauth_client_secret = None 49 | self.oauth_client_id = None 50 | self.installed_app_id = None 51 | self.refresh_token = None 52 | self.location_id = None 53 | self.endpoints_initialized = False 54 | 55 | async def async_step_import(self, user_input=None): 56 | """Occurs when a previously entry setup fails and is re-initiated.""" 57 | return await self.async_step_user(user_input) 58 | 59 | async def async_step_user(self, user_input=None): 60 | """Validate and confirm webhook setup.""" 61 | if not self.endpoints_initialized: 62 | self.endpoints_initialized = True 63 | await setup_smartapp_endpoint( 64 | self.hass, len(self._async_current_entries()) == 0 65 | ) 66 | webhook_url = get_webhook_url(self.hass) 67 | 68 | # Abort if the webhook is invalid 69 | if not validate_webhook_requirements(self.hass): 70 | return self.async_abort( 71 | reason="invalid_webhook_url", 72 | description_placeholders={ 73 | "webhook_url": webhook_url, 74 | "component_url": ( 75 | "https://www.home-assistant.io/integrations/smartthings/" 76 | ), 77 | }, 78 | ) 79 | 80 | # Show the confirmation 81 | if user_input is None: 82 | return self.async_show_form( 83 | step_id="user", 84 | description_placeholders={"webhook_url": webhook_url}, 85 | ) 86 | 87 | # Show the next screen 88 | return await self.async_step_pat() 89 | 90 | async def async_step_pat(self, user_input=None): 91 | """Get the Personal Access Token and validate it.""" 92 | errors = {} 93 | if user_input is None or CONF_ACCESS_TOKEN not in user_input: 94 | return self._show_step_pat(errors) 95 | 96 | self.access_token = user_input[CONF_ACCESS_TOKEN] 97 | 98 | # Ensure token is a UUID 99 | if not VAL_UID_MATCHER.match(self.access_token): 100 | errors[CONF_ACCESS_TOKEN] = "token_invalid_format" 101 | return self._show_step_pat(errors) 102 | 103 | # Setup end-point 104 | self.api = SmartThings(async_get_clientsession(self.hass), self.access_token) 105 | try: 106 | app = await find_app(self.hass, self.api) 107 | if app: 108 | await app.refresh() # load all attributes 109 | await update_app(self.hass, app) 110 | # Find an existing entry to copy the oauth client 111 | existing = next( 112 | ( 113 | entry 114 | for entry in self._async_current_entries() 115 | if entry.data[CONF_APP_ID] == app.app_id 116 | ), 117 | None, 118 | ) 119 | if existing: 120 | self.oauth_client_id = existing.data[CONF_CLIENT_ID] 121 | self.oauth_client_secret = existing.data[CONF_CLIENT_SECRET] 122 | else: 123 | # Get oauth client id/secret by regenerating it 124 | app_oauth = AppOAuth(app.app_id) 125 | app_oauth.client_name = APP_OAUTH_CLIENT_NAME 126 | app_oauth.scope.extend(APP_OAUTH_SCOPES) 127 | client = await self.api.generate_app_oauth(app_oauth) 128 | self.oauth_client_secret = client.client_secret 129 | self.oauth_client_id = client.client_id 130 | else: 131 | app, client = await create_app(self.hass, self.api) 132 | self.oauth_client_secret = client.client_secret 133 | self.oauth_client_id = client.client_id 134 | setup_smartapp(self.hass, app) 135 | self.app_id = app.app_id 136 | 137 | except APIResponseError as ex: 138 | if ex.is_target_error(): 139 | errors["base"] = "webhook_error" 140 | else: 141 | errors["base"] = "app_setup_error" 142 | _LOGGER.exception( 143 | "API error setting up the SmartApp: %s", ex.raw_error_response 144 | ) 145 | return self._show_step_pat(errors) 146 | except ClientResponseError as ex: 147 | if ex.status == HTTPStatus.UNAUTHORIZED: 148 | errors[CONF_ACCESS_TOKEN] = "token_unauthorized" 149 | _LOGGER.debug( 150 | "Unauthorized error received setting up SmartApp", exc_info=True 151 | ) 152 | elif ex.status == HTTPStatus.FORBIDDEN: 153 | errors[CONF_ACCESS_TOKEN] = "token_forbidden" 154 | _LOGGER.debug( 155 | "Forbidden error received setting up SmartApp", exc_info=True 156 | ) 157 | else: 158 | errors["base"] = "app_setup_error" 159 | _LOGGER.exception("Unexpected error setting up the SmartApp") 160 | return self._show_step_pat(errors) 161 | except Exception: # pylint:disable=broad-except 162 | errors["base"] = "app_setup_error" 163 | _LOGGER.exception("Unexpected error setting up the SmartApp") 164 | return self._show_step_pat(errors) 165 | 166 | return await self.async_step_select_location() 167 | 168 | async def async_step_select_location(self, user_input=None): 169 | """Ask user to select the location to setup.""" 170 | if user_input is None or CONF_LOCATION_ID not in user_input: 171 | # Get available locations 172 | existing_locations = [ 173 | entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries() 174 | ] 175 | locations = await self.api.locations() 176 | locations_options = { 177 | location.location_id: location.name 178 | for location in locations 179 | if location.location_id not in existing_locations 180 | } 181 | if not locations_options: 182 | return self.async_abort(reason="no_available_locations") 183 | 184 | return self.async_show_form( 185 | step_id="select_location", 186 | data_schema=vol.Schema( 187 | {vol.Required(CONF_LOCATION_ID): vol.In(locations_options)} 188 | ), 189 | ) 190 | 191 | self.location_id = user_input[CONF_LOCATION_ID] 192 | await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id)) 193 | return await self.async_step_authorize() 194 | 195 | async def async_step_authorize(self, user_input=None): 196 | """Wait for the user to authorize the app installation.""" 197 | user_input = {} if user_input is None else user_input 198 | self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID) 199 | self.refresh_token = user_input.get(CONF_REFRESH_TOKEN) 200 | if self.installed_app_id is None: 201 | # Launch the external setup URL 202 | url = format_install_url(self.app_id, self.location_id) 203 | return self.async_external_step(step_id="authorize", url=url) 204 | 205 | return self.async_external_step_done(next_step_id="install") 206 | 207 | def _show_step_pat(self, errors): 208 | if self.access_token is None: 209 | # Get the token from an existing entry to make it easier to setup multiple locations. 210 | self.access_token = next( 211 | ( 212 | entry.data.get(CONF_ACCESS_TOKEN) 213 | for entry in self._async_current_entries() 214 | ), 215 | None, 216 | ) 217 | 218 | return self.async_show_form( 219 | step_id="pat", 220 | data_schema=vol.Schema( 221 | {vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str} 222 | ), 223 | errors=errors, 224 | description_placeholders={ 225 | "token_url": "https://account.smartthings.com/tokens", 226 | "component_url": ( 227 | "https://www.home-assistant.io/integrations/smartthings/" 228 | ), 229 | }, 230 | ) 231 | 232 | async def async_step_install(self, data=None): 233 | """Create a config entry at completion of a flow and authorization of the app.""" 234 | data = { 235 | CONF_ACCESS_TOKEN: self.access_token, 236 | CONF_REFRESH_TOKEN: self.refresh_token, 237 | CONF_CLIENT_ID: self.oauth_client_id, 238 | CONF_CLIENT_SECRET: self.oauth_client_secret, 239 | CONF_LOCATION_ID: self.location_id, 240 | CONF_APP_ID: self.app_id, 241 | CONF_INSTALLED_APP_ID: self.installed_app_id, 242 | } 243 | 244 | location = await self.api.location(data[CONF_LOCATION_ID]) 245 | 246 | return self.async_create_entry(title=location.name, data=data) 247 | -------------------------------------------------------------------------------- /custom_components/smartthings/light.py: -------------------------------------------------------------------------------- 1 | """Support for lights through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | import asyncio 7 | from collections.abc import Sequence 8 | from typing import Any 9 | 10 | from pysmartthings import Capability 11 | 12 | from homeassistant.components.light import ( 13 | ATTR_BRIGHTNESS, 14 | ATTR_COLOR_TEMP, 15 | ATTR_HS_COLOR, 16 | ATTR_TRANSITION, 17 | ColorMode, 18 | LightEntity, 19 | LightEntityFeature, 20 | brightness_supported, 21 | ) 22 | from homeassistant.config_entries import ConfigEntry 23 | from homeassistant.core import HomeAssistant 24 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 25 | import homeassistant.util.color as color_util 26 | 27 | from . import SmartThingsEntity 28 | from .const import DATA_BROKERS, DOMAIN 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | async def async_setup_entry( 34 | hass: HomeAssistant, 35 | config_entry: ConfigEntry, 36 | async_add_entities: AddEntitiesCallback, 37 | ) -> None: 38 | """Add lights for a config entry.""" 39 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 40 | 41 | _LOGGER.debug( 42 | "NB looking for lights", 43 | ) 44 | 45 | async_add_entities( 46 | [ 47 | SmartThingsLight(device,"main", "brightnessLevel") 48 | for device in broker.devices.values() 49 | if broker.any_assigned(device.device_id, "light") 50 | ], 51 | True, 52 | ) 53 | 54 | lights = [] 55 | for device in broker.devices.values(): 56 | for component in device.components: 57 | if "light" in device.components[component]: 58 | lights.append(SmartThingsLight(device, component, "brightnessLevel")) 59 | 60 | capability = device.components[component] 61 | 62 | _LOGGER.debug( 63 | "NB light capability : %s ", 64 | capability, 65 | ) 66 | 67 | async_add_entities(lights) 68 | 69 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 70 | """Return all capabilities supported if minimum required are present.""" 71 | 72 | # Called from __init__.py class DeviceBroker 73 | 74 | supported = [ 75 | Capability.switch, 76 | Capability.switch_level, 77 | Capability.color_control, 78 | Capability.color_temperature, 79 | ] 80 | 81 | _LOGGER.debug( 82 | "NB light get_capabilities: %s ", 83 | capabilities, 84 | ) 85 | 86 | # Must be able to be turned on/off. 87 | # if Capability.switch not in capabilities: 88 | # return None 89 | # Must have one of these 90 | light_capabilities = [ 91 | Capability.color_control, 92 | Capability.color_temperature, 93 | Capability.switch_level, 94 | ] 95 | if any(capability in capabilities for capability in light_capabilities): 96 | return supported 97 | return None 98 | 99 | 100 | def convert_scale(value, value_scale, target_scale, round_digits=4): 101 | """Convert a value to a different scale.""" 102 | return round(value * target_scale / value_scale, round_digits) 103 | 104 | 105 | class SmartThingsLight(SmartThingsEntity, LightEntity): 106 | """Define a SmartThings Light.""" 107 | 108 | _attr_supported_color_modes: set[ColorMode] 109 | 110 | # SmartThings does not expose this attribute, instead it's 111 | # implemented within each device-type handler. This value is the 112 | # lowest kelvin found supported across 20+ handlers. 113 | _attr_max_mireds = 500 # 2000K 114 | 115 | # SmartThings does not expose this attribute, instead it's 116 | # implemented within each device-type handler. This value is the 117 | # highest kelvin found supported across 20+ handlers. 118 | _attr_min_mireds = 111 # 9000K 119 | 120 | def __init__(self, device, component, attribute): 121 | """Initialize a SmartThingsLight.""" 122 | super().__init__(device) 123 | self._attr_supported_color_modes = self._determine_color_modes() 124 | self._attr_supported_features = self._determine_features() 125 | self._component = component 126 | self._attribute = attribute 127 | 128 | if self._component == "main": 129 | self._attr_name = f"{device.label}" 130 | self._attr_unique_id = f"{device.device_id}.{attribute}" 131 | else: 132 | self._attr_name = f"{device.label} {component}" 133 | self._attr_unique_id = f"{device.device_id}.{component}.{attribute}" 134 | 135 | def _determine_color_modes(self): 136 | """Get features supported by the device.""" 137 | color_modes = set() 138 | # Color Temperature 139 | if Capability.color_temperature in self._device.capabilities: 140 | color_modes.add(ColorMode.COLOR_TEMP) 141 | # Color 142 | if Capability.color_control in self._device.capabilities: 143 | color_modes.add(ColorMode.HS) 144 | # Brightness 145 | if not color_modes and Capability.switch_level in self._device.capabilities: 146 | color_modes.add(ColorMode.BRIGHTNESS) 147 | if not color_modes: 148 | color_modes.add(ColorMode.ONOFF) 149 | 150 | return color_modes 151 | 152 | def _determine_features(self) -> LightEntityFeature: 153 | """Get features supported by the device.""" 154 | features = LightEntityFeature(0) 155 | # Transition 156 | if Capability.switch_level in self._device.capabilities: 157 | features |= LightEntityFeature.TRANSITION 158 | 159 | return features 160 | 161 | async def async_turn_on(self, **kwargs: Any) -> None: 162 | """Turn the light on.""" 163 | tasks = [] 164 | # Color temperature 165 | if ATTR_COLOR_TEMP in kwargs: 166 | tasks.append(self.async_set_color_temp(kwargs[ATTR_COLOR_TEMP])) 167 | # Color 168 | if ATTR_HS_COLOR in kwargs: 169 | tasks.append(self.async_set_color(kwargs[ATTR_HS_COLOR])) 170 | if tasks: 171 | # Set temp/color first 172 | await asyncio.gather(*tasks) 173 | 174 | # Switch/brightness/transition 175 | if ATTR_BRIGHTNESS in kwargs: 176 | await self.async_set_level( 177 | kwargs[ATTR_BRIGHTNESS], kwargs.get(ATTR_TRANSITION, 0) 178 | ) 179 | else: 180 | await self._device.switch_on(set_status=True) 181 | 182 | # State is set optimistically in the commands above, therefore update 183 | # the entity state ahead of receiving the confirming push updates 184 | self.async_schedule_update_ha_state(True) 185 | 186 | async def async_turn_off(self, **kwargs: Any) -> None: 187 | """Turn the light off.""" 188 | # Switch/transition 189 | if ATTR_TRANSITION in kwargs: 190 | await self.async_set_level(0, int(kwargs[ATTR_TRANSITION])) 191 | else: 192 | await self._device.switch_off(set_status=True,component_id=self._component) 193 | 194 | #async def switch_off( 195 | # self, set_status: bool = False, *, component_id: str = "main" 196 | # ) -> bool: 197 | 198 | # State is set optimistically in the commands above, therefore update 199 | # the entity state ahead of receiving the confirming push updates 200 | self.async_schedule_update_ha_state(True) 201 | 202 | 203 | 204 | async def async_update(self) -> None: 205 | """Update entity attributes when the device status has changed.""" 206 | # Brightness and transition 207 | if brightness_supported(self._attr_supported_color_modes): 208 | self._attr_brightness = int( 209 | convert_scale(self._device.status.level, 100, 255, 0) 210 | ) 211 | # Color Temperature 212 | if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: 213 | self._attr_color_temp = color_util.color_temperature_kelvin_to_mired( 214 | self._device.status.color_temperature 215 | ) 216 | # Color 217 | if ColorMode.HS in self._attr_supported_color_modes: 218 | self._attr_hs_color = ( 219 | convert_scale(self._device.status.hue, 100, 360), 220 | self._device.status.saturation, 221 | ) 222 | 223 | async def async_set_color(self, hs_color): 224 | """Set the color of the device.""" 225 | hue = convert_scale(float(hs_color[0]), 360, 100) 226 | hue = max(min(hue, 100.0), 0.0) 227 | saturation = max(min(float(hs_color[1]), 100.0), 0.0) 228 | await self._device.set_color(hue, saturation, set_status=True) 229 | 230 | async def async_set_color_temp(self, value: float): 231 | """Set the color temperature of the device.""" 232 | kelvin = color_util.color_temperature_mired_to_kelvin(value) 233 | kelvin = max(min(kelvin, 30000), 1) 234 | await self._device.set_color_temperature(kelvin, set_status=True) 235 | 236 | async def async_set_level(self, brightness: int, transition: int): 237 | """Set the brightness of the light over transition.""" 238 | level = int(convert_scale(brightness, 255, 100, 0)) 239 | # Due to rounding, set level to 1 (one) so we don't inadvertently 240 | # turn off the light when a low brightness is set. 241 | level = 1 if level == 0 and brightness > 0 else level 242 | level = max(min(level, 100), 0) 243 | duration = int(transition) 244 | await self._device.set_level(level, duration, set_status=True) 245 | 246 | @property 247 | def color_mode(self) -> ColorMode: 248 | """Return the color mode of the light.""" 249 | if len(self._attr_supported_color_modes) == 1: 250 | # The light supports only a single color mode 251 | return list(self._attr_supported_color_modes)[0] 252 | 253 | # The light supports hs + color temp, determine which one it is 254 | if self._attr_hs_color and self._attr_hs_color[1]: 255 | return ColorMode.HS 256 | return ColorMode.COLOR_TEMP 257 | 258 | # @property 259 | # def is_on(self) -> bool: 260 | # """Return true if light is on.""" 261 | # return self._device.status.switch 262 | 263 | @property 264 | def is_on(self) -> bool: 265 | """Return true if switch is on.""" 266 | value = ( 267 | self._device.status.components[self._component] 268 | .attributes[self._attribute] 269 | .value 270 | ) 271 | _LOGGER.debug( 272 | "NB light is_on: component = %s status: %s : %s: %s", 273 | self._component, 274 | self._device.status.switch, 275 | self._device.status.components[self._component].switch, 276 | value, 277 | ) 278 | 279 | if self._component == "main": 280 | return self._device.status.switch 281 | 282 | if self._device.status.components[self._component].attributes[self._attribute].value == "high": 283 | return True 284 | else: 285 | return False 286 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /custom_components/smartthings/number.py: -------------------------------------------------------------------------------- 1 | """Support for numbers through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from collections import namedtuple 7 | from collections.abc import Sequence 8 | 9 | import asyncio 10 | 11 | from typing import Literal 12 | 13 | from . import Attribute, Capability 14 | from pysmartthings.device import DeviceEntity, Command 15 | 16 | from homeassistant.components.number import NumberEntity, NumberMode 17 | 18 | from homeassistant.components.sensor import SensorDeviceClass 19 | 20 | 21 | from . import SmartThingsEntity 22 | from .const import DATA_BROKERS, DOMAIN 23 | 24 | from homeassistant.const import ( 25 | AREA_SQUARE_METERS, 26 | CONCENTRATION_PARTS_PER_MILLION, 27 | LIGHT_LUX, 28 | PERCENTAGE, 29 | EntityCategory, 30 | UnitOfElectricPotential, 31 | UnitOfEnergy, 32 | UnitOfMass, 33 | UnitOfPower, 34 | UnitOfTemperature, 35 | UnitOfVolume, 36 | ) 37 | 38 | UNITS = { 39 | "C": UnitOfTemperature.CELSIUS, 40 | "F": UnitOfTemperature.FAHRENHEIT, 41 | } 42 | 43 | Map = namedtuple( 44 | "map", 45 | "attribute command name unit_of_measurement icon min_value max_value step mode", 46 | ) 47 | 48 | _LOGGER = logging.getLogger(__name__) 49 | 50 | CAPABILITY_TO_NUMBER = { 51 | Capability.thermostat_cooling_setpoint: [ 52 | Map( 53 | Attribute.cooling_setpoint, 54 | "set_cooling_setpoint", 55 | "Cooling Setpoint", 56 | None, 57 | "mdi:thermometer", 58 | -22, 59 | 500, 60 | 1, 61 | NumberMode.AUTO, 62 | ) 63 | ], 64 | } 65 | 66 | async def async_setup_entry(hass, config_entry, async_add_entities): 67 | """Add numbers for a config entries.""" 68 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 69 | numbers = [] 70 | for device in broker.devices.values(): 71 | for capability in broker.get_assigned(device.device_id, "number"): 72 | 73 | _LOGGER.debug( 74 | "NB first number capability loop: %s: capability: %s ", 75 | device.device_id, 76 | capability, 77 | ) 78 | 79 | maps = CAPABILITY_TO_NUMBER[capability] 80 | numbers.extend( 81 | [ 82 | SmartThingsNumber( 83 | device, 84 | "main", 85 | m.attribute, 86 | m.command, 87 | m.name, 88 | m.unit_of_measurement, 89 | m.icon, 90 | m.min_value, 91 | m.max_value, 92 | m.step, 93 | m.mode, 94 | ) 95 | for m in maps 96 | ] 97 | ) 98 | 99 | 100 | device_capabilities_for_number = broker.get_assigned(device.device_id, "number") 101 | 102 | for component in device.components: 103 | _LOGGER.debug( 104 | "NB component loop: %s: %s ", 105 | device.device_id, 106 | component, 107 | ) 108 | for capability in device.components[component]: 109 | _LOGGER.debug( 110 | "NB second number capability loop: %s: %s : %s ", 111 | device.device_id, 112 | component, 113 | capability, 114 | ) 115 | if capability not in device_capabilities_for_number: 116 | _LOGGER.debug( 117 | "NB capability not found: %s: %s : %s ", 118 | device.device_id, 119 | component, 120 | capability, 121 | ) 122 | continue 123 | 124 | 125 | maps = CAPABILITY_TO_NUMBER[capability] 126 | 127 | numbers.extend( 128 | [ 129 | SmartThingsNumber( 130 | device, 131 | component, 132 | m.attribute, 133 | m.command, 134 | m.name, 135 | m.unit_of_measurement, 136 | m.icon, 137 | m.min_value, 138 | m.max_value, 139 | m.step, 140 | m.mode, 141 | ) 142 | for m in maps 143 | ] 144 | ) 145 | 146 | 147 | async_add_entities(numbers) 148 | 149 | 150 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 151 | """Return all capabilities supported if minimum required are present.""" 152 | # Must have a numeric value that is selectable. 153 | 154 | _LOGGER.debug( 155 | "NB number get_capabilities: %s ", 156 | capabilities, 157 | ) 158 | 159 | return [ 160 | capability for capability in CAPABILITY_TO_NUMBER if capability in capabilities 161 | ] 162 | 163 | 164 | class SmartThingsNumber(SmartThingsEntity, NumberEntity): 165 | """Define a SmartThings Number.""" 166 | 167 | def __init__( 168 | self, 169 | device: DeviceEntity, 170 | component: str, 171 | attribute: str, 172 | command: str, 173 | name: str, 174 | unit_of_measurement: str | None, 175 | icon: str | None, 176 | min_value: str | None, 177 | max_value: str | None, 178 | step: str | None, 179 | mode: str | None, 180 | ) -> None: 181 | """Init the class.""" 182 | super().__init__(device) 183 | self._component = component 184 | self._attribute = attribute 185 | self._command = command 186 | self._name = name 187 | self._attr_native_unit_of_measurement = unit_of_measurement 188 | self._icon = icon 189 | self._attr_native_min_value = min_value 190 | self._attr_native_max_value = max_value 191 | self._attr_native_step = step 192 | self._attr_mode = mode 193 | 194 | async def async_set_native_value(self, value: float) -> None: 195 | """Set the number value.""" 196 | _LOGGER.debug( 197 | "NB number set_native_value device: %s component: %s attribute: %s command: %s value: %s ", 198 | self._device.device_id, 199 | self._component, 200 | self._attribute, 201 | self._command, 202 | value, 203 | ) 204 | # await getattr(self._device, self._command)(int(value), set_status=True) 205 | 206 | # Defined in device.py async def command(self, component_id: str, capability, command, args=None) -> bool: 207 | await self._device.command(self._component, "thermostatCoolingSetpoint", "setCoolingSetpoint", [int(value)] ) 208 | 209 | self.async_write_ha_state() 210 | 211 | @property 212 | def name(self) -> str: 213 | """Return the name of the number.""" 214 | if self._component == "main": 215 | return f"{self._device.label} {self._name}" 216 | return f"{self._device.label} {self._component} {self._name}" 217 | 218 | @property 219 | def unique_id(self) -> str: 220 | """Return a unique ID.""" 221 | if self._component == "main": 222 | return f"{self._device.device_id}.{self._attribute}" 223 | return f"{self._device.device_id}.{self._component}.{self._attribute}" 224 | 225 | 226 | @property 227 | def native_value(self) -> float: 228 | """Return Value.""" 229 | # return self._device.status.attributes[self._attribute].value 230 | 231 | """Return the state of the sensor.""" 232 | _LOGGER.debug( 233 | "NB Return the state component: %s ", 234 | self._component, 235 | ) 236 | if self._component == "main": 237 | value = self._device.status.attributes[self._attribute].value 238 | else: 239 | value = ( 240 | self._device.status.components[self._component] 241 | .attributes[self._attribute] 242 | .value 243 | ) 244 | 245 | _LOGGER.debug( 246 | "NB Number Return the value for component: %s attribute: %s value: %s ", 247 | self._component, 248 | self._attribute, 249 | value, 250 | ) 251 | 252 | return value 253 | 254 | @property 255 | def icon(self) -> str: 256 | """Return Icon.""" 257 | return self._icon 258 | 259 | @property 260 | def native_min_value(self) -> float: 261 | """Define mimimum level.""" 262 | # Max and min are hardcoded for Family Hub Fridge/Freezer because the actual ranges are stored in 263 | # a separate capability called custom.thermostatSetpointControl instead of where they should be 264 | # under temperatureMeasurement -> range 265 | 266 | if self._component == "main": 267 | unit = self._device.status.attributes[self._attribute].unit 268 | else: 269 | unit = ( 270 | self._device.status.components[self._component] 271 | .attributes[self._attribute] 272 | .unit 273 | ) 274 | if self._component == "cooler": 275 | if unit == "F": 276 | return 34 277 | elif unit == "C": 278 | return 1 279 | elif self._component == "freezer": 280 | if unit == "F": 281 | return -8 282 | elif unit == "C": 283 | return -22 284 | 285 | return self._attr_native_min_value 286 | 287 | @property 288 | def native_max_value(self) -> float: 289 | """Define maximum level.""" 290 | # Max and min are hardcoded for Family Hub Fridge/Freezer because the actual ranges are stored in 291 | # a separate capability called custom.thermostatSetpointControl instead of where they should be 292 | # under temperatureMeasurement -> range 293 | 294 | if self._component == "main": 295 | unit = self._device.status.attributes[self._attribute].unit 296 | else: 297 | unit = ( 298 | self._device.status.components[self._component] 299 | .attributes[self._attribute] 300 | .unit 301 | ) 302 | if self._component == "cooler": 303 | if unit == "F": 304 | return 44 305 | elif unit == "C": 306 | return 6 307 | elif self._component == "freezer": 308 | if unit == "F": 309 | return 5 310 | elif unit == "C": 311 | return -15 312 | 313 | return self._attr_native_max_value 314 | 315 | @property 316 | def native_step(self) -> float: 317 | """Define stepping size""" 318 | return self._attr_native_step 319 | 320 | @property 321 | def native_unit_of_measurement(self) -> str | None: 322 | """Return unit of measurement""" 323 | # unit = self._device.status.attributes[self._attribute].unit 324 | if self._component == "main": 325 | unit = self._device.status.attributes[self._attribute].unit 326 | else: 327 | unit = ( 328 | self._device.status.components[self._component] 329 | .attributes[self._attribute] 330 | .unit 331 | ) 332 | 333 | _LOGGER.debug( 334 | "NB Return the number native_unit_of_measurement: %s : %s : %s ", 335 | unit, 336 | self._component, 337 | self._attr_name, 338 | ) 339 | return UNITS.get(unit, unit) if unit else self._attr_native_unit_of_measurement 340 | 341 | @property 342 | def mode(self) -> Literal["auto", "slider", "box"]: 343 | """Return representation mode""" 344 | return self._attr_mode 345 | 346 | 347 | -------------------------------------------------------------------------------- /custom_components/smartthings/capability.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines SmartThings capabilities and attributes. 3 | 4 | https://smartthings.developer.samsung.com/docs/api-ref/capabilities.html 5 | """ 6 | 7 | CAPABILITIES_TO_ATTRIBUTES = { 8 | "accelerationSensor": ["acceleration"], 9 | "activityLightingMode": ["lightingMode"], 10 | "airConditionerFanMode": ["fanMode", "supportedAcFanModes"], 11 | "airConditionerMode": ["airConditionerMode", "supportedAcModes"], 12 | "airFlowDirection": ["airFlowDirection"], 13 | "airQualitySensor": ["airQuality"], 14 | "alarm": ["alarm"], 15 | "audioMute": ["mute"], 16 | "audioVolume": ["volume"], 17 | "battery": ["battery"], 18 | "bodyMassIndexMeasurement": ["bmiMeasurement"], 19 | "bodyWeightMeasurement": ["bodyWeightMeasurement"], 20 | "button": ["button", "numberOfButtons", "supportedButtonValues"], 21 | "carbonDioxideMeasurement": ["carbonDioxide"], 22 | "carbonMonoxideDetector": ["carbonMonoxide"], 23 | "carbonMonoxideMeasurement": ["carbonMonoxideLevel"], 24 | "colorControl": ["color", "hue", "saturation"], 25 | "colorTemperature": ["colorTemperature"], 26 | "contactSensor": ["contact"], 27 | "demandResponseLoadControl": ["drlcStatus"], 28 | "dishwasherMode": ["dishwasherMode"], 29 | "dishwasherOperatingState": [ 30 | "machineState", 31 | "supportedMachineStates", 32 | "dishwasherJobState", 33 | "completionTime", 34 | ], 35 | "samsungce.dishwasherWashingCourse": [ 36 | "washingCourse" 37 | ], 38 | "doorControl": ["door"], 39 | "samsungce.doorState" : ["doorState"], 40 | "dryerMode": ["dryerMode"], 41 | "dryerOperatingState": [ 42 | "machineState", 43 | "supportedMachineStates", 44 | "dryerJobState", 45 | "completionTime", 46 | ], 47 | "dustSensor": ["fineDustLevel", "dustLevel"], 48 | "energyMeter": ["energy"], 49 | "equivalentCarbonDioxideMeasurement": ["equivalentCarbonDioxideMeasurement"], 50 | "execute": ["data"], 51 | "fanOscillationMode": ["fanOscillationMode", "supportedFanOscillationModes"], 52 | "fanSpeed": ["fanSpeed"], 53 | "filterStatus": ["filterStatus"], 54 | "formaldehydeMeasurement": ["formaldehydeLevel"], 55 | "garageDoorControl": ["door"], 56 | "gasMeter": [ 57 | "gasMeter", 58 | "gasMeterCalorific", 59 | "gasMeterConversion", 60 | "gasMeterPrecision", 61 | "gasMeterTime", 62 | "gasMeterVolume", 63 | ], 64 | "illuminanceMeasurement": ["illuminance"], 65 | "infraredLevel": ["infraredLevel"], 66 | "lock": ["lock"], 67 | "mediaInputSource": ["inputSource", "supportedInputSources"], 68 | "mediaPlaybackRepeat": ["playbackRepeatMode"], 69 | "mediaPlaybackShuffle": ["playbackShuffle"], 70 | "mediaPlayback": ["playbackStatus", "supportedPlaybackCommands"], 71 | "motionSensor": ["motion"], 72 | "ocf": [ 73 | "st", 74 | "mnfv", 75 | "mndt", 76 | "mnhw", 77 | "di", 78 | "mnsl", 79 | "dmv", 80 | "n", 81 | "vid", 82 | "mnmo", 83 | "mnmn", 84 | "mnml", 85 | "mnpv", 86 | "mnos", 87 | "pi", 88 | "icv", 89 | ], 90 | "odorSensor": ["odorLevel"], 91 | "ovenMode": ["ovenMode"], 92 | "samsungce.ovenMode": ["ovenMode"], 93 | "ovenOperatingState": [ 94 | "machineState", 95 | "supportedMachineStates", 96 | "ovenJobState", 97 | "completionTime", 98 | "operationTime", 99 | "progress", 100 | ], 101 | "samsungce.lamp": ["brightnessLevel"], 102 | "ovenSetpoint": ["ovenSetpoint"], 103 | "samsungce.meatProbe": [ 104 | "temperatureSetpoint", 105 | "temperature", 106 | "status", 107 | ], 108 | "powerConsumptionReport": ["powerConsumption"], 109 | "powerMeter": ["power"], 110 | "powerSource": ["powerSource"], 111 | "presenceSensor": ["presence"], 112 | "rapidCooling": ["rapidCooling"], 113 | "refrigerationSetpoint": ["refrigerationSetpoint"], 114 | "relativeHumidityMeasurement": ["humidity"], 115 | "robotCleanerCleaningMode": ["robotCleanerCleaningMode"], 116 | "robotCleanerMovement": ["robotCleanerMovement"], 117 | "robotCleanerTurboMode": ["robotCleanerTurboMode"], 118 | "signalStrength": ["lqi", "rssi"], 119 | "smokeDetector": ["smoke"], 120 | "soundSensor": ["sound"], 121 | "switchLevel": ["level"], 122 | "switch": ["switch"], 123 | "tamperAlert": ["tamper"], 124 | "temperatureMeasurement": ["temperature"], 125 | "thermostat": [ 126 | "coolingSetpoint", 127 | "coolingSetpointRange", 128 | "heatingSetpoint", 129 | "heatingSetpointRange", 130 | "schedule", 131 | "temperature", 132 | "thermostatFanMode", 133 | "supportedThermostatFanModes", 134 | "thermostatMode", 135 | "supportedThermostatModes", 136 | "thermostatOperatingState", 137 | "thermostatSetpoint", 138 | "thermostatSetpointRange", 139 | ], 140 | "thermostatCoolingSetpoint": ["coolingSetpoint"], 141 | "thermostatFanMode": ["thermostatFanMode", "supportedThermostatFanModes"], 142 | "thermostatHeatingSetpoint": ["heatingSetpoint"], 143 | "thermostatMode": ["thermostatMode", "supportedThermostatModes"], 144 | "thermostatOperatingState": ["thermostatOperatingState"], 145 | "thermostatSetpoint": ["thermostatSetpoint"], 146 | "threeAxis": ["threeAxis"], 147 | "tvChannel": ["tvChannel", "tvChannelName"], 148 | "tvocMeasurement": ["tvocLevel"], 149 | "ultravioletIndex": ["ultravioletIndex"], 150 | "valve": ["valve"], 151 | "voltageMeasurement": ["voltage"], 152 | "washerMode": ["washerMode"], 153 | "washerOperatingState": [ 154 | "machineState", 155 | "supportedMachineStates", 156 | "washerJobState", 157 | "completionTime", 158 | ], 159 | "custom.waterFilter": [ 160 | "waterFilterStatus", 161 | "waterFilterUsage", 162 | ], 163 | "waterSensor": ["water"], 164 | "windowShade": ["windowShade"], 165 | "windowShadeLevel": ["shadeLevel"], 166 | "windowShadePreset": ["presetPosition"], 167 | "samsungce.waterConsumptionReport": ["waterConsumption"], 168 | "samsungce.powerFreeze": ["activated"], 169 | "samsungce.hoodFanSpeed": ["hoodFanSpeed", "settableMaxFanSpeed", "settableMinFanSpeed", "supportedHoodFanSpeed"], 170 | } 171 | CAPABILITIES = list(CAPABILITIES_TO_ATTRIBUTES) 172 | ATTRIBUTES = { 173 | attrib 174 | for attributes in CAPABILITIES_TO_ATTRIBUTES.values() 175 | for attrib in attributes 176 | } 177 | 178 | 179 | class Capability: 180 | """Define common capabilities.""" 181 | 182 | acceleration_sensor = "accelerationSensor" 183 | activity_lighting_mode = "activityLightingMode" 184 | air_conditioner_fan_mode = "airConditionerFanMode" 185 | air_conditioner_mode = "airConditionerMode" 186 | air_flow_direction = "airFlowDirection" 187 | air_quality_sensor = "airQualitySensor" 188 | alarm = "alarm" 189 | audio_mute = "audioMute" 190 | audio_volume = "audioVolume" 191 | battery = "battery" 192 | body_mass_index_measurement = "bodyMassIndexMeasurement" 193 | body_weight_measurement = "bodyWeightMeasurement" 194 | button = "button" 195 | carbon_dioxide_measurement = "carbonDioxideMeasurement" 196 | carbon_monoxide_detector = "carbonMonoxideDetector" 197 | carbon_monoxide_measurement = "carbonMonoxideMeasurement" 198 | color_control = "colorControl" 199 | color_temperature = "colorTemperature" 200 | contact_sensor = "contactSensor" 201 | demand_response_load_control = "demandResponseLoadControl" 202 | dishwasher_mode = "dishwasherMode" 203 | dishwasher_operating_state = "dishwasherOperatingState" 204 | door_control = "doorControl" 205 | door_state = "samsungce.doorState" 206 | dryer_mode = "dryerMode" 207 | dryer_operating_state = "dryerOperatingState" 208 | dust_sensor = "dustSensor" 209 | energy_meter = "energyMeter" 210 | equivalent_carbon_dioxide_measurement = "equivalentCarbonDioxideMeasurement" 211 | execute = "execute" 212 | fan_oscillation_mode = "fanOscillationMode" 213 | fan_speed = "fanSpeed" 214 | filter_status = "filterStatus" 215 | formaldehyde_measurement = "formaldehydeMeasurement" 216 | garage_door_control = "garageDoorControl" 217 | gas_meter = "gasMeter" 218 | illuminance_measurement = "illuminanceMeasurement" 219 | infrared_level = "infraredLevel" 220 | lock = "lock" 221 | media_input_source = "mediaInputSource" 222 | media_playback = "mediaPlayback" 223 | media_playback_repeat = "mediaPlaybackRepeat" 224 | media_playback_shuffle = "mediaPlaybackShuffle" 225 | motion_sensor = "motionSensor" 226 | ocf = "ocf" 227 | odor_sensor = "odorSensor" 228 | oven_mode = "samsungce.ovenMode" 229 | oven_operating_state = "ovenOperatingState" 230 | oven_light = "samsungce.lamp" 231 | oven_setpoint = "ovenSetpoint" 232 | oven_meat_probe= "samsungce.meatProbe" 233 | power_consumption_report = "powerConsumptionReport" 234 | power_meter = "powerMeter" 235 | power_source = "powerSource" 236 | presence_sensor = "presenceSensor" 237 | rapid_cooling = "rapidCooling" 238 | refrigeration_setpoint = "refrigerationSetpoint" 239 | relative_humidity_measurement = "relativeHumidityMeasurement" 240 | robot_cleaner_cleaning_mode = "robotCleanerCleaningMode" 241 | robot_cleaner_movement = "robotCleanerMovement" 242 | robot_cleaner_turbo_mode = "robotCleanerTurboMode" 243 | signal_strength = "signalStrength" 244 | smoke_detector = "smokeDetector" 245 | sound_sensor = "soundSensor" 246 | switch = "switch" 247 | switch_level = "switchLevel" 248 | tamper_alert = "tamperAlert" 249 | temperature_measurement = "temperatureMeasurement" 250 | thermostat = "thermostat" 251 | thermostat_cooling_setpoint = "thermostatCoolingSetpoint" 252 | thermostat_fan_mode = "thermostatFanMode" 253 | thermostat_heating_setpoint = "thermostatHeatingSetpoint" 254 | thermostat_mode = "thermostatMode" 255 | thermostat_operating_state = "thermostatOperatingState" 256 | thermostat_setpoint = "thermostatSetpoint" 257 | three_axis = "threeAxis" 258 | tv_channel = "tvChannel" 259 | tvoc_measurement = "tvocMeasurement" 260 | ultraviolet_index = "ultravioletIndex" 261 | valve = "valve" 262 | voltage_measurement = "voltageMeasurement" 263 | washer_mode = "washerMode" 264 | washer_operating_state = "washerOperatingState" 265 | water_filter ="custom.waterFilter" 266 | water_sensor = "waterSensor" 267 | window_shade = "windowShade" 268 | window_shade_level = "windowShadeLevel" 269 | window_shade_preset = "windowShadePreset" 270 | water_consumption_report = "samsungce.waterConsumptionReport" 271 | power_freeze = "samsungce.powerFreeze" 272 | hood_fan_speed = "samsungce.hoodFanSpeed" 273 | dishwasher_washing_course = "samsungce.dishwasherWashingCourse" 274 | 275 | class Attribute: 276 | """Define common attributes.""" 277 | 278 | acceleration = "acceleration" 279 | air_conditioner_mode = "airConditionerMode" 280 | air_flow_direction = "airFlowDirection" 281 | air_quality = "airQuality" 282 | alarm = "alarm" 283 | battery = "battery" 284 | bmi_measurement = "bmiMeasurement" 285 | body_weight_measurement = "bodyWeightMeasurement" 286 | button = "button" 287 | carbon_dioxide = "carbonDioxide" 288 | carbon_monoxide = "carbonMonoxide" 289 | carbon_monoxide_level = "carbonMonoxideLevel" 290 | color = "color" 291 | color_temperature = "colorTemperature" 292 | completion_time = "completionTime" 293 | contact = "contact" 294 | cooling_setpoint = "coolingSetpoint" 295 | cooling_setpoint_range = "coolingSetpointRange" 296 | data = "data" 297 | di = "di" 298 | dishwasher_job_state = "dishwasherJobState" 299 | dishwasher_mode = "dishwasherMode" 300 | dmv = "dmv" 301 | door = "door" 302 | door_state = "doorState" 303 | drlc_status = "drlcStatus" 304 | dryer_job_state = "dryerJobState" 305 | dryer_mode = "dryerMode" 306 | dust_level = "dustLevel" 307 | energy = "energy" 308 | equivalent_carbon_dioxide_measurement = "equivalentCarbonDioxideMeasurement" 309 | fan_mode = "fanMode" 310 | fan_oscillation_mode = "fanOscillationMode" 311 | fan_speed = "fanSpeed" 312 | filter_status = "filterStatus" 313 | fine_dust_level = "fineDustLevel" 314 | formaldehyde_level = "formaldehydeLevel" 315 | gas_meter = "gasMeter" 316 | gas_meter_calorific = "gasMeterCalorific" 317 | gas_meter_conversion = "gasMeterConversion" 318 | gas_meter_precision = "gasMeterPrecision" 319 | gas_meter_time = "gasMeterTime" 320 | gas_meter_volume = "gasMeterVolume" 321 | heating_setpoint = "heatingSetpoint" 322 | heating_setpoint_range = "heatingSetpointRange" 323 | hue = "hue" 324 | humidity = "humidity" 325 | icv = "icv" 326 | illuminance = "illuminance" 327 | infrared_level = "infraredLevel" 328 | input_source = "inputSource" 329 | level = "level" 330 | lighting_mode = "lightingMode" 331 | lock = "lock" 332 | lqi = "lqi" 333 | machine_state = "machineState" 334 | mndt = "mndt" 335 | mnfv = "mnfv" 336 | mnhw = "mnhw" 337 | mnml = "mnml" 338 | mnmn = "mnmn" 339 | mnmo = "mnmo" 340 | mnos = "mnos" 341 | mnpv = "mnpv" 342 | mnsl = "mnsl" 343 | motion = "motion" 344 | mute = "mute" 345 | n = "n" 346 | number_of_buttons = "numberOfButtons" 347 | odor_level = "odorLevel" 348 | operation_time = "operationTime" 349 | oven_job_state = "ovenJobState" 350 | oven_mode = "ovenMode" 351 | oven_setpoint = "ovenSetpoint" 352 | brightness_level = "brightnessLevel" 353 | pi = "pi" 354 | playback_repeat_mode = "playbackRepeatMode" 355 | playback_shuffle = "playbackShuffle" 356 | playback_status = "playbackStatus" 357 | power = "power" 358 | power_consumption = "powerConsumption" 359 | power_source = "powerSource" 360 | presence = "presence" 361 | preset_position = "presetPosition" 362 | progress = "progress" 363 | rapid_cooling = "rapidCooling" 364 | refrigeration_setpoint = "refrigerationSetpoint" 365 | robot_cleaner_cleaning_mode = "robotCleanerCleaningMode" 366 | robot_cleaner_movement = "robotCleanerMovement" 367 | robot_cleaner_turbo_mode = "robotCleanerTurboMode" 368 | rssi = "rssi" 369 | saturation = "saturation" 370 | schedule = "schedule" 371 | shade_level = "shadeLevel" 372 | smoke = "smoke" 373 | sound = "sound" 374 | st = "st" 375 | supported_ac_fan_modes = "supportedAcFanModes" 376 | supported_ac_modes = "supportedAcModes" 377 | supported_button_values = "supportedButtonValues" 378 | supported_fan_oscillation_modes = "supportedFanOscillationModes" 379 | supported_input_sources = "supportedInputSources" 380 | supported_machine_states = "supportedMachineStates" 381 | supported_playback_commands = "supportedPlaybackCommands" 382 | supported_thermostat_fan_modes = "supportedThermostatFanModes" 383 | supported_thermostat_modes = "supportedThermostatModes" 384 | switch = "switch" 385 | tamper = "tamper" 386 | temperature = "temperature" 387 | temperature_set_point = "temperatureSetpoint" 388 | status = "status" 389 | thermostat_fan_mode = "thermostatFanMode" 390 | thermostat_mode = "thermostatMode" 391 | thermostat_operating_state = "thermostatOperatingState" 392 | thermostat_setpoint = "thermostatSetpoint" 393 | thermostat_setpoint_range = "thermostatSetpointRange" 394 | three_axis = "threeAxis" 395 | tv_channel = "tvChannel" 396 | tv_channel_name = "tvChannelName" 397 | tvoc_level = "tvocLevel" 398 | ultraviolet_index = "ultravioletIndex" 399 | valve = "valve" 400 | vid = "vid" 401 | voltage = "voltage" 402 | volume = "volume" 403 | washer_job_state = "washerJobState" 404 | washer_mode = "washerMode" 405 | water = "water" 406 | water_filter_status = "waterFilterStatus" 407 | water_filter_usage = "waterFilterUsage" 408 | window_shade = "windowShade" 409 | water_consumption = "waterConsumption" 410 | activated = "activated" 411 | washing_course = "washingCourse" 412 | 413 | 414 | ATTRIBUTE_ON_VALUES = { 415 | Attribute.acceleration: "active", 416 | Attribute.contact: "open", 417 | Attribute.filter_status: "replace", 418 | Attribute.motion: "active", 419 | Attribute.mute: "muted", 420 | Attribute.playback_shuffle: "enabled", 421 | Attribute.presence: "present", 422 | Attribute.sound: "detected", 423 | Attribute.switch: "on", 424 | Attribute.tamper: "detected", 425 | Attribute.valve: "open", 426 | Attribute.water: "wet", 427 | Attribute.brightness_level: "high" 428 | } 429 | 430 | ATTRIBUTE_OFF_VALUES = { 431 | Attribute.acceleration: "inactive", 432 | Attribute.contact: "closed", 433 | Attribute.filter_status: "normal", 434 | Attribute.motion: "inactive", 435 | Attribute.mute: "unmuted", 436 | Attribute.playback_shuffle: "disabled", 437 | Attribute.presence: "not present", 438 | Attribute.sound: "not detected", 439 | Attribute.switch: "off", 440 | Attribute.tamper: "clear", 441 | Attribute.valve: "closed", 442 | Attribute.water: "dry", 443 | Attribute.brightness_level: "off" 444 | } 445 | -------------------------------------------------------------------------------- /custom_components/smartthings/smartapp.py: -------------------------------------------------------------------------------- 1 | """SmartApp functionality to receive cloud-push notifications.""" 2 | 3 | import asyncio 4 | import functools 5 | import logging 6 | import secrets 7 | from typing import Any 8 | from urllib.parse import urlparse 9 | from uuid import uuid4 10 | 11 | from aiohttp import web 12 | from pysmartapp import Dispatcher, SmartAppManager 13 | from pysmartapp.const import SETTINGS_APP_ID 14 | from pysmartthings import ( 15 | APP_TYPE_WEBHOOK, 16 | CLASSIFICATION_AUTOMATION, 17 | App, 18 | AppEntity, 19 | AppOAuth, 20 | AppSettings, 21 | InstalledAppStatus, 22 | SmartThings, 23 | SourceType, 24 | Subscription, 25 | SubscriptionEntity, 26 | ) 27 | 28 | from .capability import CAPABILITIES 29 | 30 | 31 | from homeassistant.components import cloud, webhook 32 | from homeassistant.const import CONF_WEBHOOK_ID 33 | from homeassistant.core import HomeAssistant 34 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 35 | from homeassistant.helpers.dispatcher import ( 36 | async_dispatcher_connect, 37 | async_dispatcher_send, 38 | ) 39 | from homeassistant.helpers.network import NoURLAvailableError, get_url 40 | from homeassistant.helpers.storage import Store 41 | 42 | from .const import ( 43 | APP_NAME_PREFIX, 44 | APP_OAUTH_CLIENT_NAME, 45 | APP_OAUTH_SCOPES, 46 | CONF_CLOUDHOOK_URL, 47 | CONF_INSTALLED_APP_ID, 48 | CONF_INSTANCE_ID, 49 | CONF_REFRESH_TOKEN, 50 | DATA_BROKERS, 51 | DATA_MANAGER, 52 | DOMAIN, 53 | IGNORED_CAPABILITIES, 54 | SETTINGS_INSTANCE_ID, 55 | SIGNAL_SMARTAPP_PREFIX, 56 | STORAGE_KEY, 57 | STORAGE_VERSION, 58 | SUBSCRIPTION_WARNING_LIMIT, 59 | ) 60 | 61 | _LOGGER = logging.getLogger(__name__) 62 | 63 | 64 | def format_unique_id(app_id: str, location_id: str) -> str: 65 | """Format the unique id for a config entry.""" 66 | return f"{app_id}_{location_id}" 67 | 68 | 69 | async def find_app(hass: HomeAssistant, api: SmartThings) -> AppEntity | None: 70 | """Find an existing SmartApp for this installation of hass.""" 71 | apps = await api.apps() 72 | for app in [app for app in apps if app.app_name.startswith(APP_NAME_PREFIX)]: 73 | # Load settings to compare instance id 74 | settings = await app.settings() 75 | if ( 76 | settings.settings.get(SETTINGS_INSTANCE_ID) 77 | == hass.data[DOMAIN][CONF_INSTANCE_ID] 78 | ): 79 | return app 80 | return None 81 | 82 | 83 | async def validate_installed_app(api, installed_app_id: str): 84 | """Ensure the specified installed SmartApp is valid and functioning. 85 | 86 | Query the API for the installed SmartApp and validate that it is tied to 87 | the specified app_id and is in an authorized state. 88 | """ 89 | installed_app = await api.installed_app(installed_app_id) 90 | if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED: 91 | raise RuntimeWarning( 92 | f"Installed SmartApp instance '{installed_app.display_name}' " 93 | f"({installed_app.installed_app_id}) is not AUTHORIZED " 94 | f"but instead {installed_app.installed_app_status}" 95 | ) 96 | return installed_app 97 | 98 | 99 | def validate_webhook_requirements(hass: HomeAssistant) -> bool: 100 | """Ensure Home Assistant is setup properly to receive webhooks.""" 101 | if cloud.async_active_subscription(hass): 102 | return True 103 | if hass.data[DOMAIN][CONF_CLOUDHOOK_URL] is not None: 104 | return True 105 | return get_webhook_url(hass).lower().startswith("https://") 106 | 107 | 108 | def get_webhook_url(hass: HomeAssistant) -> str: 109 | """Get the URL of the webhook. 110 | 111 | Return the cloudhook if available, otherwise local webhook. 112 | """ 113 | cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] 114 | if cloud.async_active_subscription(hass) and cloudhook_url is not None: 115 | return cloudhook_url 116 | return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) 117 | 118 | 119 | def _get_app_template(hass: HomeAssistant): 120 | try: 121 | endpoint = f"at {get_url(hass, allow_cloud=False, prefer_external=True)}" 122 | except NoURLAvailableError: 123 | endpoint = "" 124 | 125 | cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] 126 | if cloudhook_url is not None: 127 | endpoint = "via Nabu Casa" 128 | description = f"{hass.config.location_name} {endpoint}" 129 | 130 | return { 131 | "app_name": APP_NAME_PREFIX + str(uuid4()), 132 | "display_name": "Home Assistant", 133 | "description": description, 134 | "webhook_target_url": get_webhook_url(hass), 135 | "app_type": APP_TYPE_WEBHOOK, 136 | "single_instance": True, 137 | "classifications": [CLASSIFICATION_AUTOMATION], 138 | } 139 | 140 | 141 | async def create_app(hass: HomeAssistant, api): 142 | """Create a SmartApp for this instance of hass.""" 143 | # Create app from template attributes 144 | template = _get_app_template(hass) 145 | app = App() 146 | for key, value in template.items(): 147 | setattr(app, key, value) 148 | app, client = await api.create_app(app) 149 | _LOGGER.debug("Created SmartApp '%s' (%s)", app.app_name, app.app_id) 150 | 151 | # Set unique hass id in settings 152 | settings = AppSettings(app.app_id) 153 | settings.settings[SETTINGS_APP_ID] = app.app_id 154 | settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID] 155 | await api.update_app_settings(settings) 156 | _LOGGER.debug( 157 | "Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id 158 | ) 159 | 160 | # Set oauth scopes 161 | oauth = AppOAuth(app.app_id) 162 | oauth.client_name = APP_OAUTH_CLIENT_NAME 163 | oauth.scope.extend(APP_OAUTH_SCOPES) 164 | await api.update_app_oauth(oauth) 165 | _LOGGER.debug("Updated App OAuth for SmartApp '%s' (%s)", app.app_name, app.app_id) 166 | return app, client 167 | 168 | 169 | async def update_app(hass: HomeAssistant, app): 170 | """Ensure the SmartApp is up-to-date and update if necessary.""" 171 | template = _get_app_template(hass) 172 | template.pop("app_name") # don't update this 173 | update_required = False 174 | for key, value in template.items(): 175 | if getattr(app, key) != value: 176 | update_required = True 177 | setattr(app, key, value) 178 | if update_required: 179 | await app.save() 180 | _LOGGER.debug( 181 | "SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id 182 | ) 183 | 184 | 185 | def setup_smartapp(hass, app): 186 | """Configure an individual SmartApp in hass. 187 | 188 | Register the SmartApp with the SmartAppManager so that hass will service 189 | lifecycle events (install, event, etc...). A unique SmartApp is created 190 | for each SmartThings account that is configured in hass. 191 | """ 192 | manager = hass.data[DOMAIN][DATA_MANAGER] 193 | if smartapp := manager.smartapps.get(app.app_id): 194 | # already setup 195 | return smartapp 196 | smartapp = manager.register(app.app_id, app.webhook_public_key) 197 | smartapp.name = app.display_name 198 | smartapp.description = app.description 199 | smartapp.permissions.extend(APP_OAUTH_SCOPES) 200 | return smartapp 201 | 202 | 203 | async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool): 204 | """Configure the SmartApp webhook in hass. 205 | 206 | SmartApps are an extension point within the SmartThings ecosystem and 207 | is used to receive push updates (i.e. device updates) from the cloud. 208 | """ 209 | if hass.data.get(DOMAIN): 210 | # already setup 211 | if not fresh_install: 212 | return 213 | 214 | # We're doing a fresh install, clean up 215 | await unload_smartapp_endpoint(hass) 216 | 217 | # Get/create config to store a unique id for this hass instance. 218 | store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) 219 | 220 | if fresh_install or not (config := await store.async_load()): 221 | # Create config 222 | config = { 223 | CONF_INSTANCE_ID: str(uuid4()), 224 | CONF_WEBHOOK_ID: secrets.token_hex(), 225 | CONF_CLOUDHOOK_URL: None, 226 | } 227 | await store.async_save(config) 228 | 229 | # Register webhook 230 | webhook.async_register( 231 | hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook 232 | ) 233 | 234 | # Create webhook if eligible 235 | cloudhook_url = config.get(CONF_CLOUDHOOK_URL) 236 | if ( 237 | cloudhook_url is None 238 | and cloud.async_active_subscription(hass) 239 | and not hass.config_entries.async_entries(DOMAIN) 240 | ): 241 | cloudhook_url = await cloud.async_create_cloudhook( 242 | hass, config[CONF_WEBHOOK_ID] 243 | ) 244 | config[CONF_CLOUDHOOK_URL] = cloudhook_url 245 | await store.async_save(config) 246 | _LOGGER.debug("Created cloudhook '%s'", cloudhook_url) 247 | 248 | # SmartAppManager uses a dispatcher to invoke callbacks when push events 249 | # occur. Use hass' implementation instead of the built-in one. 250 | dispatcher = Dispatcher( 251 | signal_prefix=SIGNAL_SMARTAPP_PREFIX, 252 | connect=functools.partial(async_dispatcher_connect, hass), 253 | send=functools.partial(async_dispatcher_send, hass), 254 | ) 255 | # Path is used in digital signature validation 256 | path = ( 257 | urlparse(cloudhook_url).path 258 | if cloudhook_url 259 | else webhook.async_generate_path(config[CONF_WEBHOOK_ID]) 260 | ) 261 | manager = SmartAppManager(path, dispatcher=dispatcher) 262 | manager.connect_install(functools.partial(smartapp_install, hass)) 263 | manager.connect_update(functools.partial(smartapp_update, hass)) 264 | manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) 265 | 266 | hass.data[DOMAIN] = { 267 | DATA_MANAGER: manager, 268 | CONF_INSTANCE_ID: config[CONF_INSTANCE_ID], 269 | DATA_BROKERS: {}, 270 | CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID], 271 | # Will not be present if not enabled 272 | CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL), 273 | } 274 | _LOGGER.debug( 275 | "Setup endpoint for %s", 276 | cloudhook_url 277 | if cloudhook_url 278 | else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]), 279 | ) 280 | 281 | 282 | async def unload_smartapp_endpoint(hass: HomeAssistant): 283 | """Tear down the component configuration.""" 284 | if DOMAIN not in hass.data: 285 | return 286 | # Remove the cloudhook if it was created 287 | cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] 288 | if cloudhook_url and cloud.async_is_logged_in(hass): 289 | await cloud.async_delete_cloudhook(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) 290 | # Remove cloudhook from storage 291 | store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) 292 | await store.async_save( 293 | { 294 | CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID], 295 | CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID], 296 | CONF_CLOUDHOOK_URL: None, 297 | } 298 | ) 299 | _LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url) 300 | # Remove the webhook 301 | webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) 302 | # Disconnect all brokers 303 | for broker in hass.data[DOMAIN][DATA_BROKERS].values(): 304 | broker.disconnect() 305 | # Remove all handlers from manager 306 | hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all() 307 | # Remove the component data 308 | hass.data.pop(DOMAIN) 309 | 310 | 311 | async def smartapp_sync_subscriptions( 312 | hass: HomeAssistant, 313 | auth_token: str, 314 | location_id: str, 315 | installed_app_id: str, 316 | devices, 317 | ): 318 | """Synchronize subscriptions of an installed up.""" 319 | api = SmartThings(async_get_clientsession(hass), auth_token) 320 | tasks = [] 321 | 322 | async def create_subscription(target: str): 323 | sub = Subscription() 324 | sub.installed_app_id = installed_app_id 325 | sub.location_id = location_id 326 | sub.source_type = SourceType.CAPABILITY 327 | sub.capability = target 328 | try: 329 | await api.create_subscription(sub) 330 | _LOGGER.debug( 331 | "Created subscription for '%s' under app '%s'", target, installed_app_id 332 | ) 333 | except Exception as error: # noqa: BLE001 334 | _LOGGER.error( 335 | "Failed to create subscription for '%s' under app '%s': %s", 336 | target, 337 | installed_app_id, 338 | error, 339 | ) 340 | 341 | async def delete_subscription(sub: SubscriptionEntity): 342 | try: 343 | await api.delete_subscription(installed_app_id, sub.subscription_id) 344 | _LOGGER.debug( 345 | ( 346 | "Removed subscription for '%s' under app '%s' because it was no" 347 | " longer needed" 348 | ), 349 | sub.capability, 350 | installed_app_id, 351 | ) 352 | except Exception as error: # noqa: BLE001 353 | _LOGGER.error( 354 | "Failed to remove subscription for '%s' under app '%s': %s", 355 | sub.capability, 356 | installed_app_id, 357 | error, 358 | ) 359 | 360 | # Build set of capabilities and prune unsupported ones 361 | capabilities = set() 362 | for device in devices: 363 | capabilities.update(device.capabilities) 364 | for component in device.components: 365 | capabilities.update(device.components[component]) 366 | # Remove items not defined in the library 367 | capabilities.intersection_update(CAPABILITIES) 368 | # Remove unused capabilities 369 | capabilities.difference_update(IGNORED_CAPABILITIES) 370 | capability_count = len(capabilities) 371 | if capability_count > SUBSCRIPTION_WARNING_LIMIT: 372 | _LOGGER.warning( 373 | ( 374 | "Some device attributes may not receive push updates and there may be" 375 | " subscription creation failures under app '%s' because %s" 376 | " subscriptions are required but there is a limit of %s per app" 377 | ), 378 | installed_app_id, 379 | capability_count, 380 | SUBSCRIPTION_WARNING_LIMIT, 381 | ) 382 | _LOGGER.debug( 383 | "Synchronizing subscriptions for %s capabilities under app '%s': %s", 384 | capability_count, 385 | installed_app_id, 386 | capabilities, 387 | ) 388 | 389 | # Get current subscriptions and find differences 390 | subscriptions = await api.subscriptions(installed_app_id) 391 | for subscription in subscriptions: 392 | if subscription.capability in capabilities: 393 | capabilities.remove(subscription.capability) 394 | else: 395 | # Delete the subscription 396 | tasks.append(delete_subscription(subscription)) 397 | 398 | # Remaining capabilities need subscriptions created 399 | tasks.extend([create_subscription(c) for c in capabilities]) 400 | 401 | if tasks: 402 | await asyncio.gather(*tasks) 403 | else: 404 | _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) 405 | 406 | 407 | async def _continue_flow( 408 | hass: HomeAssistant, 409 | app_id: str, 410 | location_id: str, 411 | installed_app_id: str, 412 | refresh_token: str, 413 | ): 414 | """Continue a config flow if one is in progress for the specific installed app.""" 415 | unique_id = format_unique_id(app_id, location_id) 416 | flow = next( 417 | ( 418 | flow 419 | for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) 420 | if flow["context"].get("unique_id") == unique_id 421 | ), 422 | None, 423 | ) 424 | if flow is not None: 425 | await hass.config_entries.flow.async_configure( 426 | flow["flow_id"], 427 | { 428 | CONF_INSTALLED_APP_ID: installed_app_id, 429 | CONF_REFRESH_TOKEN: refresh_token, 430 | }, 431 | ) 432 | _LOGGER.debug( 433 | "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", 434 | flow["flow_id"], 435 | installed_app_id, 436 | app_id, 437 | ) 438 | 439 | 440 | async def smartapp_install(hass: HomeAssistant, req, resp, app): 441 | """Handle a SmartApp installation and continue the config flow.""" 442 | await _continue_flow( 443 | hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token 444 | ) 445 | _LOGGER.debug( 446 | "Installed SmartApp '%s' under parent app '%s'", 447 | req.installed_app_id, 448 | app.app_id, 449 | ) 450 | 451 | 452 | async def smartapp_update(hass: HomeAssistant, req, resp, app): 453 | """Handle a SmartApp update and either update the entry or continue the flow.""" 454 | entry = next( 455 | ( 456 | entry 457 | for entry in hass.config_entries.async_entries(DOMAIN) 458 | if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id 459 | ), 460 | None, 461 | ) 462 | if entry: 463 | hass.config_entries.async_update_entry( 464 | entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token} 465 | ) 466 | _LOGGER.debug( 467 | "Updated config entry '%s' for SmartApp '%s' under parent app '%s'", 468 | entry.entry_id, 469 | req.installed_app_id, 470 | app.app_id, 471 | ) 472 | 473 | await _continue_flow( 474 | hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token 475 | ) 476 | _LOGGER.debug( 477 | "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id 478 | ) 479 | 480 | 481 | async def smartapp_uninstall(hass: HomeAssistant, req, resp, app): 482 | """Handle when a SmartApp is removed from a location by the user. 483 | 484 | Find and delete the config entry representing the integration. 485 | """ 486 | entry = next( 487 | ( 488 | entry 489 | for entry in hass.config_entries.async_entries(DOMAIN) 490 | if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id 491 | ), 492 | None, 493 | ) 494 | if entry: 495 | # Add as job not needed because the current coroutine was invoked 496 | # from the dispatcher and is not being awaited. 497 | await hass.config_entries.async_remove(entry.entry_id) 498 | 499 | _LOGGER.debug( 500 | "Uninstalled SmartApp '%s' under parent app '%s'", 501 | req.installed_app_id, 502 | app.app_id, 503 | ) 504 | 505 | 506 | async def smartapp_webhook(hass: HomeAssistant, webhook_id: str, request): 507 | """Handle a smartapp lifecycle event callback from SmartThings. 508 | 509 | Requests from SmartThings are digitally signed and the SmartAppManager 510 | validates the signature for authenticity. 511 | """ 512 | manager = hass.data[DOMAIN][DATA_MANAGER] 513 | data = await request.json() 514 | result = await manager.handle_request(data, request.headers) 515 | return web.json_response(result) 516 | -------------------------------------------------------------------------------- /custom_components/smartthings/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for SmartThings Cloud.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | from collections.abc import Iterable 6 | from http import HTTPStatus 7 | import importlib 8 | import logging 9 | 10 | from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError 11 | from pysmartapp.event import EVENT_TYPE_DEVICE 12 | # from pysmartthings import Attribute, Capability, SmartThings 13 | from pysmartthings import SmartThings 14 | from pysmartthings.device import DeviceEntity 15 | 16 | from .capability import ( 17 | ATTRIBUTES, 18 | CAPABILITIES, 19 | CAPABILITIES_TO_ATTRIBUTES, 20 | ATTRIBUTE_ON_VALUES, 21 | ATTRIBUTE_OFF_VALUES, 22 | Attribute, 23 | Capability, 24 | ) 25 | 26 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 27 | from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET 28 | from homeassistant.core import HomeAssistant 29 | from homeassistant.exceptions import ConfigEntryNotReady 30 | from homeassistant.helpers import config_validation as cv 31 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 32 | from homeassistant.helpers.device_registry import DeviceInfo 33 | from homeassistant.helpers.dispatcher import ( 34 | async_dispatcher_connect, 35 | async_dispatcher_send, 36 | ) 37 | from homeassistant.helpers.entity import Entity 38 | from homeassistant.helpers.event import async_track_time_interval 39 | from homeassistant.helpers.typing import ConfigType 40 | from homeassistant.loader import async_get_loaded_integration 41 | from homeassistant.setup import SetupPhases, async_pause_setup 42 | 43 | from .config_flow import SmartThingsFlowHandler # noqa: F401 44 | from .const import ( 45 | CONF_APP_ID, 46 | CONF_INSTALLED_APP_ID, 47 | CONF_LOCATION_ID, 48 | CONF_REFRESH_TOKEN, 49 | DATA_BROKERS, 50 | DATA_MANAGER, 51 | DOMAIN, 52 | EVENT_BUTTON, 53 | PLATFORMS, 54 | SIGNAL_SMARTTHINGS_UPDATE, 55 | TOKEN_REFRESH_INTERVAL, 56 | ) 57 | from .smartapp import ( 58 | format_unique_id, 59 | setup_smartapp, 60 | setup_smartapp_endpoint, 61 | smartapp_sync_subscriptions, 62 | unload_smartapp_endpoint, 63 | validate_installed_app, 64 | validate_webhook_requirements, 65 | ) 66 | 67 | _LOGGER = logging.getLogger(__name__) 68 | 69 | CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) 70 | 71 | 72 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 73 | """Initialize the SmartThings platform.""" 74 | await setup_smartapp_endpoint(hass, False) 75 | return True 76 | 77 | 78 | async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 79 | """Handle migration of a previous version config entry. 80 | 81 | A config entry created under a previous version must go through the 82 | integration setup again so we can properly retrieve the needed data 83 | elements. Force this by removing the entry and triggering a new flow. 84 | """ 85 | # Remove the entry which will invoke the callback to delete the app. 86 | hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) 87 | # only create new flow if there isn't a pending one for SmartThings. 88 | if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): 89 | hass.async_create_task( 90 | hass.config_entries.flow.async_init( 91 | DOMAIN, context={"source": SOURCE_IMPORT} 92 | ) 93 | ) 94 | 95 | # Return False because it could not be migrated. 96 | return False 97 | 98 | 99 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 100 | """Initialize config entry which represents an installed SmartApp.""" 101 | # For backwards compat 102 | if entry.unique_id is None: 103 | hass.config_entries.async_update_entry( 104 | entry, 105 | unique_id=format_unique_id( 106 | entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID] 107 | ), 108 | ) 109 | 110 | if not validate_webhook_requirements(hass): 111 | _LOGGER.warning( 112 | "The 'base_url' of the 'http' integration must be configured and start with" 113 | " 'https://'" 114 | ) 115 | return False 116 | 117 | api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) 118 | 119 | # Ensure platform modules are loaded since the DeviceBroker will 120 | # import them below and we want them to be cached ahead of time 121 | # so the integration does not do blocking I/O in the event loop 122 | # to import the modules. 123 | await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) 124 | 125 | remove_entry = False 126 | try: 127 | # See if the app is already setup. This occurs when there are 128 | # installs in multiple SmartThings locations (valid use-case) 129 | manager = hass.data[DOMAIN][DATA_MANAGER] 130 | smart_app = manager.smartapps.get(entry.data[CONF_APP_ID]) 131 | if not smart_app: 132 | # Validate and setup the app. 133 | app = await api.app(entry.data[CONF_APP_ID]) 134 | smart_app = setup_smartapp(hass, app) 135 | 136 | # Validate and retrieve the installed app. 137 | installed_app = await validate_installed_app( 138 | api, entry.data[CONF_INSTALLED_APP_ID] 139 | ) 140 | 141 | # Get scenes 142 | scenes = await async_get_entry_scenes(entry, api) 143 | 144 | # Get SmartApp token to sync subscriptions 145 | token = await api.generate_tokens( 146 | entry.data[CONF_CLIENT_ID], 147 | entry.data[CONF_CLIENT_SECRET], 148 | entry.data[CONF_REFRESH_TOKEN], 149 | ) 150 | hass.config_entries.async_update_entry( 151 | entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token} 152 | ) 153 | 154 | # Get devices and their current status 155 | devices = await api.devices(location_ids=[installed_app.location_id]) 156 | 157 | async def retrieve_device_status(device): 158 | try: 159 | await device.status.refresh() 160 | except ClientResponseError: 161 | _LOGGER.debug( 162 | ( 163 | "Unable to update status for device: %s (%s), the device will" 164 | " be excluded" 165 | ), 166 | device.label, 167 | device.device_id, 168 | exc_info=True, 169 | ) 170 | devices.remove(device) 171 | 172 | await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy())) 173 | 174 | # Sync device subscriptions 175 | await smartapp_sync_subscriptions( 176 | hass, 177 | token.access_token, 178 | installed_app.location_id, 179 | installed_app.installed_app_id, 180 | devices, 181 | ) 182 | 183 | # Setup device broker 184 | with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS): 185 | # DeviceBroker has a side effect of importing platform 186 | # modules when its created. In the future this should be 187 | # refactored to not do this. 188 | broker = await hass.async_add_import_executor_job( 189 | DeviceBroker, hass, entry, token, smart_app, devices, scenes 190 | ) 191 | broker.connect() 192 | hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker 193 | 194 | except ClientResponseError as ex: 195 | if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): 196 | _LOGGER.exception( 197 | ( 198 | "Unable to setup configuration entry '%s' - please reconfigure the" 199 | " integration" 200 | ), 201 | entry.title, 202 | ) 203 | remove_entry = True 204 | else: 205 | _LOGGER.debug(ex, exc_info=True) 206 | raise ConfigEntryNotReady from ex 207 | except (ClientConnectionError, RuntimeWarning) as ex: 208 | _LOGGER.debug(ex, exc_info=True) 209 | raise ConfigEntryNotReady from ex 210 | 211 | if remove_entry: 212 | hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) 213 | # only create new flow if there isn't a pending one for SmartThings. 214 | if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): 215 | hass.async_create_task( 216 | hass.config_entries.flow.async_init( 217 | DOMAIN, context={"source": SOURCE_IMPORT} 218 | ) 219 | ) 220 | return False 221 | 222 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 223 | return True 224 | 225 | 226 | async def async_get_entry_scenes(entry: ConfigEntry, api): 227 | """Get the scenes within an integration.""" 228 | try: 229 | return await api.scenes(location_id=entry.data[CONF_LOCATION_ID]) 230 | except ClientResponseError as ex: 231 | if ex.status == HTTPStatus.FORBIDDEN: 232 | _LOGGER.exception( 233 | ( 234 | "Unable to load scenes for configuration entry '%s' because the" 235 | " access token does not have the required access" 236 | ), 237 | entry.title, 238 | ) 239 | else: 240 | raise 241 | return [] 242 | 243 | 244 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 245 | """Unload a config entry.""" 246 | broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None) 247 | if broker: 248 | broker.disconnect() 249 | 250 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 251 | 252 | 253 | async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 254 | """Perform clean-up when entry is being removed.""" 255 | api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) 256 | 257 | # Remove the installed_app, which if already removed raises a HTTPStatus.FORBIDDEN error. 258 | installed_app_id = entry.data[CONF_INSTALLED_APP_ID] 259 | try: 260 | await api.delete_installed_app(installed_app_id) 261 | except ClientResponseError as ex: 262 | if ex.status == HTTPStatus.FORBIDDEN: 263 | _LOGGER.debug( 264 | "Installed app %s has already been removed", 265 | installed_app_id, 266 | exc_info=True, 267 | ) 268 | else: 269 | raise 270 | _LOGGER.debug("Removed installed app %s", installed_app_id) 271 | 272 | # Remove the app if not referenced by other entries, which if already 273 | # removed raises a HTTPStatus.FORBIDDEN error. 274 | all_entries = hass.config_entries.async_entries(DOMAIN) 275 | app_id = entry.data[CONF_APP_ID] 276 | app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id) 277 | if app_count > 1: 278 | _LOGGER.debug( 279 | ( 280 | "App %s was not removed because it is in use by other configuration" 281 | " entries" 282 | ), 283 | app_id, 284 | ) 285 | return 286 | # Remove the app 287 | try: 288 | await api.delete_app(app_id) 289 | except ClientResponseError as ex: 290 | if ex.status == HTTPStatus.FORBIDDEN: 291 | _LOGGER.debug("App %s has already been removed", app_id, exc_info=True) 292 | else: 293 | raise 294 | _LOGGER.debug("Removed app %s", app_id) 295 | 296 | if len(all_entries) == 1: 297 | await unload_smartapp_endpoint(hass) 298 | 299 | 300 | class DeviceBroker: 301 | """Manages an individual SmartThings config entry.""" 302 | 303 | def __init__( 304 | self, 305 | hass: HomeAssistant, 306 | entry: ConfigEntry, 307 | token, 308 | smart_app, 309 | devices: Iterable, 310 | scenes: Iterable, 311 | ) -> None: 312 | """Create a new instance of the DeviceBroker.""" 313 | self._hass = hass 314 | self._entry = entry 315 | self._installed_app_id = entry.data[CONF_INSTALLED_APP_ID] 316 | self._smart_app = smart_app 317 | self._token = token 318 | self._event_disconnect = None 319 | self._regenerate_token_remove = None 320 | self._assignments = self._assign_capabilities(devices) 321 | self.devices = {device.device_id: device for device in devices} 322 | self.scenes = {scene.scene_id: scene for scene in scenes} 323 | 324 | def _assign_capabilities(self, devices: Iterable): 325 | """Assign platforms to capabilities.""" 326 | assignments = {} 327 | for device in devices: 328 | capabilities = device.capabilities.copy() 329 | slots = {} 330 | for platform in PLATFORMS: 331 | platform_module = importlib.import_module( 332 | f".{platform}", self.__module__ 333 | ) 334 | if not hasattr(platform_module, "get_capabilities"): 335 | continue 336 | assigned = platform_module.get_capabilities(capabilities) 337 | if not assigned: 338 | continue 339 | # Draw-down capabilities and set slot assignment 340 | for capability in assigned: 341 | if capability not in capabilities: 342 | continue 343 | capabilities.remove(capability) 344 | slots[capability] = platform 345 | assignments[device.device_id] = slots 346 | return assignments 347 | 348 | def connect(self): 349 | """Connect handlers/listeners for device/lifecycle events.""" 350 | 351 | # Setup interval to regenerate the refresh token on a periodic basis. 352 | # Tokens expire in 30 days and once expired, cannot be recovered. 353 | async def regenerate_refresh_token(now): 354 | """Generate a new refresh token and update the config entry.""" 355 | await self._token.refresh( 356 | self._entry.data[CONF_CLIENT_ID], 357 | self._entry.data[CONF_CLIENT_SECRET], 358 | ) 359 | self._hass.config_entries.async_update_entry( 360 | self._entry, 361 | data={ 362 | **self._entry.data, 363 | CONF_REFRESH_TOKEN: self._token.refresh_token, 364 | }, 365 | ) 366 | _LOGGER.debug( 367 | "Regenerated refresh token for installed app: %s", 368 | self._installed_app_id, 369 | ) 370 | 371 | self._regenerate_token_remove = async_track_time_interval( 372 | self._hass, regenerate_refresh_token, TOKEN_REFRESH_INTERVAL 373 | ) 374 | 375 | # Connect handler to incoming device events 376 | self._event_disconnect = self._smart_app.connect_event(self._event_handler) 377 | 378 | def disconnect(self): 379 | """Disconnects handlers/listeners for device/lifecycle events.""" 380 | if self._regenerate_token_remove: 381 | self._regenerate_token_remove() 382 | if self._event_disconnect: 383 | self._event_disconnect() 384 | 385 | def get_assigned(self, device_id: str, platform: str): 386 | """Get the capabilities assigned to the platform.""" 387 | slots = self._assignments.get(device_id, {}) 388 | return [key for key, value in slots.items() if value == platform] 389 | 390 | def any_assigned(self, device_id: str, platform: str): 391 | """Return True if the platform has any assigned capabilities.""" 392 | slots = self._assignments.get(device_id, {}) 393 | return any(value for value in slots.values() if value == platform) 394 | 395 | async def _event_handler(self, req, resp, app): 396 | """Broker for incoming events.""" 397 | # Do not process events received from a different installed app 398 | # under the same parent SmartApp (valid use-scenario) 399 | if req.installed_app_id != self._installed_app_id: 400 | return 401 | 402 | updated_devices = set() 403 | for evt in req.events: 404 | if evt.event_type != EVENT_TYPE_DEVICE: 405 | continue 406 | if not (device := self.devices.get(evt.device_id)): 407 | continue 408 | device.status.apply_attribute_update( 409 | evt.component_id, 410 | evt.capability, 411 | evt.attribute, 412 | evt.value, 413 | data=evt.data, 414 | ) 415 | 416 | # Fire events for buttons 417 | if ( 418 | evt.capability == Capability.button 419 | and evt.attribute == Attribute.button 420 | ): 421 | data = { 422 | "component_id": evt.component_id, 423 | "device_id": evt.device_id, 424 | "location_id": evt.location_id, 425 | "value": evt.value, 426 | "name": device.label, 427 | "data": evt.data, 428 | } 429 | self._hass.bus.async_fire(EVENT_BUTTON, data) 430 | _LOGGER.debug("Fired button event: %s", data) 431 | else: 432 | data = { 433 | "location_id": evt.location_id, 434 | "device_id": evt.device_id, 435 | "component_id": evt.component_id, 436 | "capability": evt.capability, 437 | "attribute": evt.attribute, 438 | "value": evt.value, 439 | "data": evt.data, 440 | } 441 | _LOGGER.debug("Push update received: %s", data) 442 | 443 | updated_devices.add(device.device_id) 444 | 445 | async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices) 446 | 447 | 448 | class SmartThingsEntity(Entity): 449 | """Defines a SmartThings entity.""" 450 | 451 | _attr_should_poll = False 452 | 453 | def __init__(self, device: DeviceEntity) -> None: 454 | """Initialize the instance.""" 455 | self._device = device 456 | self._dispatcher_remove = None 457 | self._attr_name = device.label 458 | self._attr_unique_id = device.device_id 459 | self._attr_device_info = DeviceInfo( 460 | configuration_url="https://account.smartthings.com", 461 | identifiers={(DOMAIN, device.device_id)}, 462 | manufacturer=device.status.ocf_manufacturer_name, 463 | model=device.status.ocf_model_number, 464 | name=device.label, 465 | hw_version=device.status.ocf_hardware_version, 466 | sw_version=device.status.ocf_firmware_version, 467 | ) 468 | 469 | async def async_added_to_hass(self): 470 | """Device added to hass.""" 471 | 472 | async def async_update_state(devices): 473 | """Update device state.""" 474 | if self._device.device_id in devices: 475 | await self.async_update_ha_state(True) 476 | 477 | self._dispatcher_remove = async_dispatcher_connect( 478 | self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state 479 | ) 480 | 481 | async def async_will_remove_from_hass(self) -> None: 482 | """Disconnect the device when removed.""" 483 | if self._dispatcher_remove: 484 | self._dispatcher_remove() 485 | -------------------------------------------------------------------------------- /custom_components/smartthings/climate.py: -------------------------------------------------------------------------------- 1 | """Support for climate devices through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | from collections.abc import Iterable, Sequence 6 | import logging 7 | from typing import Any 8 | 9 | from pysmartthings import Attribute, Capability 10 | 11 | from homeassistant.components.climate import ( 12 | ATTR_HVAC_MODE, 13 | ATTR_TARGET_TEMP_HIGH, 14 | ATTR_TARGET_TEMP_LOW, 15 | DOMAIN as CLIMATE_DOMAIN, 16 | SWING_BOTH, 17 | SWING_HORIZONTAL, 18 | SWING_OFF, 19 | SWING_VERTICAL, 20 | ClimateEntity, 21 | ClimateEntityFeature, 22 | HVACAction, 23 | HVACMode, 24 | ) 25 | from homeassistant.config_entries import ConfigEntry 26 | from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature 27 | from homeassistant.core import HomeAssistant 28 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 29 | 30 | from . import SmartThingsEntity 31 | from .const import DATA_BROKERS, DOMAIN 32 | 33 | ATTR_OPERATION_STATE = "operation_state" 34 | MODE_TO_STATE = { 35 | "auto": HVACMode.HEAT_COOL, 36 | "cool": HVACMode.COOL, 37 | "eco": HVACMode.AUTO, 38 | "rush hour": HVACMode.AUTO, 39 | "emergency heat": HVACMode.HEAT, 40 | "heat": HVACMode.HEAT, 41 | "off": HVACMode.OFF, 42 | } 43 | STATE_TO_MODE = { 44 | HVACMode.HEAT_COOL: "auto", 45 | HVACMode.COOL: "cool", 46 | HVACMode.HEAT: "heat", 47 | HVACMode.OFF: "off", 48 | } 49 | 50 | OPERATING_STATE_TO_ACTION = { 51 | "cooling": HVACAction.COOLING, 52 | "fan only": HVACAction.FAN, 53 | "heating": HVACAction.HEATING, 54 | "idle": HVACAction.IDLE, 55 | "pending cool": HVACAction.COOLING, 56 | "pending heat": HVACAction.HEATING, 57 | "vent economizer": HVACAction.FAN, 58 | } 59 | 60 | AC_MODE_TO_STATE = { 61 | "auto": HVACMode.HEAT_COOL, 62 | "cool": HVACMode.COOL, 63 | "dry": HVACMode.DRY, 64 | "coolClean": HVACMode.COOL, 65 | "dryClean": HVACMode.DRY, 66 | "heat": HVACMode.HEAT, 67 | "heatClean": HVACMode.HEAT, 68 | "fanOnly": HVACMode.FAN_ONLY, 69 | } 70 | STATE_TO_AC_MODE = { 71 | HVACMode.HEAT_COOL: "auto", 72 | HVACMode.COOL: "cool", 73 | HVACMode.DRY: "dry", 74 | HVACMode.HEAT: "heat", 75 | HVACMode.FAN_ONLY: "fanOnly", 76 | } 77 | 78 | SWING_TO_FAN_OSCILLATION = { 79 | SWING_BOTH: "all", 80 | SWING_HORIZONTAL: "horizontal", 81 | SWING_VERTICAL: "vertical", 82 | SWING_OFF: "fixed", 83 | } 84 | 85 | FAN_OSCILLATION_TO_SWING = { 86 | value: key for key, value in SWING_TO_FAN_OSCILLATION.items() 87 | } 88 | 89 | 90 | WINDFREE = "windFree" 91 | 92 | UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} 93 | 94 | _LOGGER = logging.getLogger(__name__) 95 | 96 | 97 | async def async_setup_entry( 98 | hass: HomeAssistant, 99 | config_entry: ConfigEntry, 100 | async_add_entities: AddEntitiesCallback, 101 | ) -> None: 102 | """Add climate entities for a config entry.""" 103 | ac_capabilities = [ 104 | Capability.air_conditioner_mode, 105 | Capability.air_conditioner_fan_mode, 106 | Capability.switch, 107 | Capability.temperature_measurement, 108 | Capability.thermostat_cooling_setpoint, 109 | ] 110 | 111 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 112 | entities: list[ClimateEntity] = [] 113 | for device in broker.devices.values(): 114 | if not broker.any_assigned(device.device_id, CLIMATE_DOMAIN): 115 | continue 116 | if all(capability in device.capabilities for capability in ac_capabilities): 117 | entities.append(SmartThingsAirConditioner(device)) 118 | else: 119 | entities.append(SmartThingsThermostat(device)) 120 | async_add_entities(entities, True) 121 | 122 | 123 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 124 | """Return all capabilities supported if minimum required are present.""" 125 | supported = [ 126 | Capability.air_conditioner_mode, 127 | Capability.demand_response_load_control, 128 | Capability.air_conditioner_fan_mode, 129 | Capability.switch, 130 | Capability.thermostat, 131 | Capability.thermostat_cooling_setpoint, 132 | Capability.thermostat_fan_mode, 133 | Capability.thermostat_heating_setpoint, 134 | Capability.thermostat_mode, 135 | Capability.thermostat_operating_state, 136 | ] 137 | # Can have this legacy/deprecated capability 138 | if Capability.thermostat in capabilities: 139 | return supported 140 | # Or must have all of these thermostat capabilities 141 | thermostat_capabilities = [ 142 | Capability.temperature_measurement, 143 | Capability.thermostat_cooling_setpoint, 144 | Capability.thermostat_heating_setpoint, 145 | Capability.thermostat_mode, 146 | ] 147 | if all(capability in capabilities for capability in thermostat_capabilities): 148 | return supported 149 | # Or must have all of these A/C capabilities 150 | ac_capabilities = [ 151 | Capability.air_conditioner_mode, 152 | Capability.air_conditioner_fan_mode, 153 | Capability.switch, 154 | Capability.temperature_measurement, 155 | Capability.thermostat_cooling_setpoint, 156 | ] 157 | if all(capability in capabilities for capability in ac_capabilities): 158 | return supported 159 | return None 160 | 161 | 162 | class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): 163 | """Define a SmartThings climate entities.""" 164 | 165 | def __init__(self, device): 166 | """Init the class.""" 167 | super().__init__(device) 168 | self._attr_supported_features = self._determine_features() 169 | self._hvac_mode = None 170 | self._hvac_modes = None 171 | 172 | def _determine_features(self): 173 | flags = ( 174 | ClimateEntityFeature.TARGET_TEMPERATURE 175 | | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE 176 | ) 177 | if self._device.get_capability( 178 | Capability.thermostat_fan_mode, Capability.thermostat 179 | ): 180 | flags |= ClimateEntityFeature.FAN_MODE 181 | return flags 182 | 183 | async def async_set_fan_mode(self, fan_mode: str) -> None: 184 | """Set new target fan mode.""" 185 | await self._device.set_thermostat_fan_mode(fan_mode, set_status=True) 186 | 187 | # State is set optimistically in the command above, therefore update 188 | # the entity state ahead of receiving the confirming push updates 189 | self.async_schedule_update_ha_state(True) 190 | 191 | async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 192 | """Set new target operation mode.""" 193 | mode = STATE_TO_MODE[hvac_mode] 194 | await self._device.set_thermostat_mode(mode, set_status=True) 195 | 196 | # State is set optimistically in the command above, therefore update 197 | # the entity state ahead of receiving the confirming push updates 198 | self.async_schedule_update_ha_state(True) 199 | 200 | async def async_set_temperature(self, **kwargs: Any) -> None: 201 | """Set new operation mode and target temperatures.""" 202 | # Operation state 203 | if operation_state := kwargs.get(ATTR_HVAC_MODE): 204 | mode = STATE_TO_MODE[operation_state] 205 | await self._device.set_thermostat_mode(mode, set_status=True) 206 | await self.async_update() 207 | 208 | # Heat/cool setpoint 209 | heating_setpoint = None 210 | cooling_setpoint = None 211 | if self.hvac_mode == HVACMode.HEAT: 212 | heating_setpoint = kwargs.get(ATTR_TEMPERATURE) 213 | elif self.hvac_mode == HVACMode.COOL: 214 | cooling_setpoint = kwargs.get(ATTR_TEMPERATURE) 215 | else: 216 | heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW) 217 | cooling_setpoint = kwargs.get(ATTR_TARGET_TEMP_HIGH) 218 | tasks = [] 219 | if heating_setpoint is not None: 220 | tasks.append( 221 | self._device.set_heating_setpoint( 222 | round(heating_setpoint, 3), set_status=True 223 | ) 224 | ) 225 | if cooling_setpoint is not None: 226 | tasks.append( 227 | self._device.set_cooling_setpoint( 228 | round(cooling_setpoint, 3), set_status=True 229 | ) 230 | ) 231 | await asyncio.gather(*tasks) 232 | 233 | # State is set optimistically in the commands above, therefore update 234 | # the entity state ahead of receiving the confirming push updates 235 | self.async_schedule_update_ha_state(True) 236 | 237 | async def async_update(self) -> None: 238 | """Update the attributes of the climate device.""" 239 | thermostat_mode = self._device.status.thermostat_mode 240 | self._hvac_mode = MODE_TO_STATE.get(thermostat_mode) 241 | if self._hvac_mode is None: 242 | _LOGGER.debug( 243 | "Device %s (%s) returned an invalid hvac mode: %s", 244 | self._device.label, 245 | self._device.device_id, 246 | thermostat_mode, 247 | ) 248 | 249 | modes = set() 250 | supported_modes = self._device.status.supported_thermostat_modes 251 | if isinstance(supported_modes, Iterable): 252 | for mode in supported_modes: 253 | if (state := MODE_TO_STATE.get(mode)) is not None: 254 | modes.add(state) 255 | else: 256 | _LOGGER.debug( 257 | ( 258 | "Device %s (%s) returned an invalid supported thermostat" 259 | " mode: %s" 260 | ), 261 | self._device.label, 262 | self._device.device_id, 263 | mode, 264 | ) 265 | else: 266 | _LOGGER.debug( 267 | "Device %s (%s) returned invalid supported thermostat modes: %s", 268 | self._device.label, 269 | self._device.device_id, 270 | supported_modes, 271 | ) 272 | self._hvac_modes = list(modes) 273 | 274 | @property 275 | def current_humidity(self): 276 | """Return the current humidity.""" 277 | return self._device.status.humidity 278 | 279 | @property 280 | def current_temperature(self): 281 | """Return the current temperature.""" 282 | return self._device.status.temperature 283 | 284 | @property 285 | def fan_mode(self): 286 | """Return the fan setting.""" 287 | return self._device.status.thermostat_fan_mode 288 | 289 | @property 290 | def fan_modes(self): 291 | """Return the list of available fan modes.""" 292 | return self._device.status.supported_thermostat_fan_modes 293 | 294 | @property 295 | def hvac_action(self) -> HVACAction | None: 296 | """Return the current running hvac operation if supported.""" 297 | return OPERATING_STATE_TO_ACTION.get( 298 | self._device.status.thermostat_operating_state 299 | ) 300 | 301 | @property 302 | def hvac_mode(self) -> HVACMode: 303 | """Return current operation ie. heat, cool, idle.""" 304 | return self._hvac_mode 305 | 306 | @property 307 | def hvac_modes(self) -> list[HVACMode]: 308 | """Return the list of available operation modes.""" 309 | return self._hvac_modes 310 | 311 | @property 312 | def target_temperature(self): 313 | """Return the temperature we try to reach.""" 314 | if self.hvac_mode == HVACMode.COOL: 315 | return self._device.status.cooling_setpoint 316 | if self.hvac_mode == HVACMode.HEAT: 317 | return self._device.status.heating_setpoint 318 | return None 319 | 320 | @property 321 | def target_temperature_high(self): 322 | """Return the highbound target temperature we try to reach.""" 323 | if self.hvac_mode == HVACMode.HEAT_COOL: 324 | return self._device.status.cooling_setpoint 325 | return None 326 | 327 | @property 328 | def target_temperature_low(self): 329 | """Return the lowbound target temperature we try to reach.""" 330 | if self.hvac_mode == HVACMode.HEAT_COOL: 331 | return self._device.status.heating_setpoint 332 | return None 333 | 334 | @property 335 | def temperature_unit(self): 336 | """Return the unit of measurement.""" 337 | return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit) 338 | 339 | 340 | class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): 341 | """Define a SmartThings Air Conditioner.""" 342 | 343 | _hvac_modes: list[HVACMode] 344 | 345 | def __init__(self, device) -> None: 346 | """Init the class.""" 347 | super().__init__(device) 348 | self._hvac_modes = [] 349 | self._attr_preset_mode = None 350 | self._attr_preset_modes = self._determine_preset_modes() 351 | self._attr_swing_modes = self._determine_swing_modes() 352 | self._attr_supported_features = self._determine_supported_features() 353 | 354 | def _determine_supported_features(self) -> ClimateEntityFeature: 355 | features = ( 356 | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE 357 | ) 358 | if self._device.get_capability(Capability.fan_oscillation_mode): 359 | features |= ClimateEntityFeature.SWING_MODE 360 | if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0: 361 | features |= ClimateEntityFeature.PRESET_MODE 362 | return features 363 | 364 | async def async_set_fan_mode(self, fan_mode: str) -> None: 365 | """Set new target fan mode.""" 366 | await self._device.set_fan_mode(fan_mode, set_status=True) 367 | 368 | # setting the fan must reset the preset mode (it deactivates the windFree function) 369 | self._attr_preset_mode = None 370 | 371 | # State is set optimistically in the command above, therefore update 372 | # the entity state ahead of receiving the confirming push updates 373 | self.async_write_ha_state() 374 | 375 | async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 376 | """Set new target operation mode.""" 377 | if hvac_mode == HVACMode.OFF: 378 | await self.async_turn_off() 379 | return 380 | tasks = [] 381 | # Turn on the device if it's off before setting mode. 382 | if not self._device.status.switch: 383 | tasks.append(self._device.switch_on(set_status=True)) 384 | tasks.append( 385 | self._device.set_air_conditioner_mode( 386 | STATE_TO_AC_MODE[hvac_mode], set_status=True 387 | ) 388 | ) 389 | await asyncio.gather(*tasks) 390 | # State is set optimistically in the command above, therefore update 391 | # the entity state ahead of receiving the confirming push updates 392 | self.async_write_ha_state() 393 | 394 | async def async_set_temperature(self, **kwargs: Any) -> None: 395 | """Set new target temperature.""" 396 | tasks = [] 397 | # operation mode 398 | if operation_mode := kwargs.get(ATTR_HVAC_MODE): 399 | if operation_mode == HVACMode.OFF: 400 | tasks.append(self._device.switch_off(set_status=True)) 401 | else: 402 | if not self._device.status.switch: 403 | tasks.append(self._device.switch_on(set_status=True)) 404 | tasks.append(self.async_set_hvac_mode(operation_mode)) 405 | # temperature 406 | tasks.append( 407 | self._device.set_cooling_setpoint(kwargs[ATTR_TEMPERATURE], set_status=True) 408 | ) 409 | await asyncio.gather(*tasks) 410 | # State is set optimistically in the command above, therefore update 411 | # the entity state ahead of receiving the confirming push updates 412 | self.async_write_ha_state() 413 | 414 | async def async_turn_on(self) -> None: 415 | """Turn device on.""" 416 | await self._device.switch_on(set_status=True) 417 | # State is set optimistically in the command above, therefore update 418 | # the entity state ahead of receiving the confirming push updates 419 | self.async_write_ha_state() 420 | 421 | async def async_turn_off(self) -> None: 422 | """Turn device off.""" 423 | await self._device.switch_off(set_status=True) 424 | # State is set optimistically in the command above, therefore update 425 | # the entity state ahead of receiving the confirming push updates 426 | self.async_write_ha_state() 427 | 428 | async def async_update(self) -> None: 429 | """Update the calculated fields of the AC.""" 430 | modes = {HVACMode.OFF} 431 | for mode in self._device.status.supported_ac_modes: 432 | if (state := AC_MODE_TO_STATE.get(mode)) is not None: 433 | modes.add(state) 434 | else: 435 | _LOGGER.debug( 436 | "Device %s (%s) returned an invalid supported AC mode: %s", 437 | self._device.label, 438 | self._device.device_id, 439 | mode, 440 | ) 441 | self._hvac_modes = list(modes) 442 | 443 | @property 444 | def current_temperature(self) -> float | None: 445 | """Return the current temperature.""" 446 | return self._device.status.temperature 447 | 448 | @property 449 | def extra_state_attributes(self) -> dict[str, Any]: 450 | """Return device specific state attributes. 451 | 452 | Include attributes from the Demand Response Load Control (drlc) 453 | and Power Consumption capabilities. 454 | """ 455 | attributes = [ 456 | "drlc_status_duration", 457 | "drlc_status_level", 458 | "drlc_status_start", 459 | "drlc_status_override", 460 | ] 461 | state_attributes = {} 462 | for attribute in attributes: 463 | value = getattr(self._device.status, attribute) 464 | if value is not None: 465 | state_attributes[attribute] = value 466 | return state_attributes 467 | 468 | @property 469 | def fan_mode(self) -> str: 470 | """Return the fan setting.""" 471 | return self._device.status.fan_mode 472 | 473 | @property 474 | def fan_modes(self) -> list[str]: 475 | """Return the list of available fan modes.""" 476 | return self._device.status.supported_ac_fan_modes 477 | 478 | @property 479 | def hvac_mode(self) -> HVACMode | None: 480 | """Return current operation ie. heat, cool, idle.""" 481 | if not self._device.status.switch: 482 | return HVACMode.OFF 483 | return AC_MODE_TO_STATE.get(self._device.status.air_conditioner_mode) 484 | 485 | @property 486 | def hvac_modes(self) -> list[HVACMode]: 487 | """Return the list of available operation modes.""" 488 | return self._hvac_modes 489 | 490 | @property 491 | def target_temperature(self) -> float: 492 | """Return the temperature we try to reach.""" 493 | return self._device.status.cooling_setpoint 494 | 495 | @property 496 | def temperature_unit(self) -> str: 497 | """Return the unit of measurement.""" 498 | return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] 499 | 500 | def _determine_swing_modes(self) -> list[str] | None: 501 | """Return the list of available swing modes.""" 502 | supported_swings = None 503 | supported_modes = self._device.status.attributes[ 504 | Attribute.supported_fan_oscillation_modes 505 | ][0] 506 | if supported_modes is not None: 507 | supported_swings = [ 508 | FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes 509 | ] 510 | return supported_swings 511 | 512 | async def async_set_swing_mode(self, swing_mode: str) -> None: 513 | """Set swing mode.""" 514 | fan_oscillation_mode = SWING_TO_FAN_OSCILLATION[swing_mode] 515 | await self._device.set_fan_oscillation_mode(fan_oscillation_mode) 516 | 517 | # setting the fan must reset the preset mode (it deactivates the windFree function) 518 | self._attr_preset_mode = None 519 | 520 | self.async_schedule_update_ha_state(True) 521 | 522 | @property 523 | def swing_mode(self) -> str: 524 | """Return the swing setting.""" 525 | return FAN_OSCILLATION_TO_SWING.get( 526 | self._device.status.fan_oscillation_mode, SWING_OFF 527 | ) 528 | 529 | def _determine_preset_modes(self) -> list[str] | None: 530 | """Return a list of available preset modes.""" 531 | supported_modes: list | None = self._device.status.attributes[ 532 | "supportedAcOptionalMode" 533 | ].value 534 | if supported_modes and WINDFREE in supported_modes: 535 | return [WINDFREE] 536 | return None 537 | 538 | async def async_set_preset_mode(self, preset_mode: str) -> None: 539 | """Set special modes (currently only windFree is supported).""" 540 | result = await self._device.command( 541 | "main", 542 | "custom.airConditionerOptionalMode", 543 | "setAcOptionalMode", 544 | [preset_mode], 545 | ) 546 | if result: 547 | self._device.status.update_attribute_value("acOptionalMode", preset_mode) 548 | 549 | self._attr_preset_mode = preset_mode 550 | 551 | self.async_write_ha_state() 552 | -------------------------------------------------------------------------------- /custom_components/smartthings/sensor.py: -------------------------------------------------------------------------------- 1 | """Support for sensors through the SmartThings cloud API.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from collections import namedtuple 7 | from collections.abc import Sequence 8 | 9 | 10 | from pysmartthings.device import DeviceEntity 11 | 12 | from homeassistant.components.sensor import ( 13 | SensorDeviceClass, 14 | SensorEntity, 15 | SensorStateClass, 16 | ) 17 | from homeassistant.config_entries import ConfigEntry 18 | from homeassistant.const import ( 19 | AREA_SQUARE_METERS, 20 | CONCENTRATION_PARTS_PER_MILLION, 21 | LIGHT_LUX, 22 | PERCENTAGE, 23 | EntityCategory, 24 | UnitOfElectricPotential, 25 | UnitOfEnergy, 26 | UnitOfMass, 27 | UnitOfPower, 28 | UnitOfTemperature, 29 | UnitOfVolume, 30 | ) 31 | from homeassistant.core import HomeAssistant 32 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 33 | from homeassistant.util import dt as dt_util 34 | from homeassistant.helpers import entity_platform 35 | import voluptuous as vol 36 | 37 | from . import SmartThingsEntity 38 | 39 | from . import Capability 40 | from . import Attribute 41 | 42 | from .const import DATA_BROKERS, DOMAIN 43 | 44 | Map = namedtuple( 45 | "Map", "attribute name default_unit device_class state_class entity_category" 46 | ) 47 | 48 | _LOGGER = logging.getLogger(__name__) 49 | 50 | SERVICE_COMMAND = "send_command" 51 | 52 | CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { 53 | Capability.activity_lighting_mode: [ 54 | Map( 55 | Attribute.lighting_mode, 56 | "Activity Lighting Mode", 57 | None, 58 | None, 59 | None, 60 | EntityCategory.DIAGNOSTIC, 61 | ) 62 | ], 63 | Capability.air_conditioner_mode: [ 64 | Map( 65 | Attribute.air_conditioner_mode, 66 | "Air Conditioner Mode", 67 | None, 68 | None, 69 | None, 70 | EntityCategory.DIAGNOSTIC, 71 | ) 72 | ], 73 | Capability.air_quality_sensor: [ 74 | Map( 75 | Attribute.air_quality, 76 | "Air Quality", 77 | "CAQI", 78 | None, 79 | SensorStateClass.MEASUREMENT, 80 | None, 81 | ) 82 | ], 83 | Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None, None, None)], 84 | Capability.audio_volume: [ 85 | Map(Attribute.volume, "Volume", PERCENTAGE, None, None, None) 86 | ], 87 | Capability.battery: [ 88 | Map( 89 | Attribute.battery, 90 | "Battery", 91 | PERCENTAGE, 92 | SensorDeviceClass.BATTERY, 93 | None, 94 | EntityCategory.DIAGNOSTIC, 95 | ) 96 | ], 97 | Capability.body_mass_index_measurement: [ 98 | Map( 99 | Attribute.bmi_measurement, 100 | "Body Mass Index", 101 | f"{UnitOfMass.KILOGRAMS}/{AREA_SQUARE_METERS}", 102 | None, 103 | SensorStateClass.MEASUREMENT, 104 | None, 105 | ) 106 | ], 107 | Capability.body_weight_measurement: [ 108 | Map( 109 | Attribute.body_weight_measurement, 110 | "Body Weight", 111 | UnitOfMass.KILOGRAMS, 112 | SensorDeviceClass.WEIGHT, 113 | SensorStateClass.MEASUREMENT, 114 | None, 115 | ) 116 | ], 117 | Capability.carbon_dioxide_measurement: [ 118 | Map( 119 | Attribute.carbon_dioxide, 120 | "Carbon Dioxide Measurement", 121 | CONCENTRATION_PARTS_PER_MILLION, 122 | SensorDeviceClass.CO2, 123 | SensorStateClass.MEASUREMENT, 124 | None, 125 | ) 126 | ], 127 | Capability.carbon_monoxide_detector: [ 128 | Map( 129 | Attribute.carbon_monoxide, 130 | "Carbon Monoxide Detector", 131 | None, 132 | None, 133 | None, 134 | None, 135 | ) 136 | ], 137 | Capability.carbon_monoxide_measurement: [ 138 | Map( 139 | Attribute.carbon_monoxide_level, 140 | "Carbon Monoxide Measurement", 141 | CONCENTRATION_PARTS_PER_MILLION, 142 | SensorDeviceClass.CO, 143 | SensorStateClass.MEASUREMENT, 144 | None, 145 | ) 146 | ], 147 | Capability.dishwasher_operating_state: [ 148 | Map( 149 | Attribute.machine_state, "Dishwasher Machine State", None, None, None, None 150 | ), 151 | Map( 152 | Attribute.dishwasher_job_state, 153 | "Dishwasher Job State", 154 | None, 155 | None, 156 | None, 157 | None, 158 | ), 159 | Map( 160 | Attribute.completion_time, 161 | "Dishwasher Completion Time", 162 | None, 163 | SensorDeviceClass.TIMESTAMP, 164 | None, 165 | None, 166 | ), 167 | ], 168 | Capability.dryer_mode: [ 169 | Map( 170 | Attribute.dryer_mode, 171 | "Dryer Mode", 172 | None, 173 | None, 174 | None, 175 | EntityCategory.DIAGNOSTIC, 176 | ) 177 | ], 178 | Capability.dryer_operating_state: [ 179 | Map(Attribute.machine_state, "Dryer Machine State", None, None, None, None), 180 | Map(Attribute.dryer_job_state, "Dryer Job State", None, None, None, None), 181 | Map( 182 | Attribute.completion_time, 183 | "Dryer Completion Time", 184 | None, 185 | SensorDeviceClass.TIMESTAMP, 186 | None, 187 | None, 188 | ), 189 | ], 190 | Capability.dust_sensor: [ 191 | Map( 192 | Attribute.fine_dust_level, 193 | "Fine Dust Level", 194 | None, 195 | None, 196 | SensorStateClass.MEASUREMENT, 197 | None, 198 | ), 199 | Map( 200 | Attribute.dust_level, 201 | "Dust Level", 202 | None, 203 | None, 204 | SensorStateClass.MEASUREMENT, 205 | None, 206 | ), 207 | ], 208 | Capability.energy_meter: [ 209 | Map( 210 | Attribute.energy, 211 | "Energy Meter", 212 | UnitOfEnergy.KILO_WATT_HOUR, 213 | SensorDeviceClass.ENERGY, 214 | SensorStateClass.TOTAL_INCREASING, 215 | None, 216 | ) 217 | ], 218 | Capability.equivalent_carbon_dioxide_measurement: [ 219 | Map( 220 | Attribute.equivalent_carbon_dioxide_measurement, 221 | "Equivalent Carbon Dioxide Measurement", 222 | CONCENTRATION_PARTS_PER_MILLION, 223 | SensorDeviceClass.CO2, 224 | SensorStateClass.MEASUREMENT, 225 | None, 226 | ) 227 | ], 228 | Capability.formaldehyde_measurement: [ 229 | Map( 230 | Attribute.formaldehyde_level, 231 | "Formaldehyde Measurement", 232 | CONCENTRATION_PARTS_PER_MILLION, 233 | None, 234 | SensorStateClass.MEASUREMENT, 235 | None, 236 | ) 237 | ], 238 | Capability.gas_meter: [ 239 | Map( 240 | Attribute.gas_meter, 241 | "Gas Meter", 242 | UnitOfEnergy.KILO_WATT_HOUR, 243 | SensorDeviceClass.ENERGY, 244 | SensorStateClass.MEASUREMENT, 245 | None, 246 | ), 247 | Map( 248 | Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None, None, None 249 | ), 250 | Map( 251 | Attribute.gas_meter_time, 252 | "Gas Meter Time", 253 | None, 254 | SensorDeviceClass.TIMESTAMP, 255 | None, 256 | None, 257 | ), 258 | Map( 259 | Attribute.gas_meter_volume, 260 | "Gas Meter Volume", 261 | UnitOfVolume.CUBIC_METERS, 262 | SensorDeviceClass.GAS, 263 | SensorStateClass.MEASUREMENT, 264 | None, 265 | ), 266 | ], 267 | Capability.illuminance_measurement: [ 268 | Map( 269 | Attribute.illuminance, 270 | "Illuminance", 271 | LIGHT_LUX, 272 | SensorDeviceClass.ILLUMINANCE, 273 | SensorStateClass.MEASUREMENT, 274 | None, 275 | ) 276 | ], 277 | Capability.infrared_level: [ 278 | Map( 279 | Attribute.infrared_level, 280 | "Infrared Level", 281 | PERCENTAGE, 282 | None, 283 | SensorStateClass.MEASUREMENT, 284 | None, 285 | ) 286 | ], 287 | Capability.media_input_source: [ 288 | Map(Attribute.input_source, "Media Input Source", None, None, None, None) 289 | ], 290 | Capability.media_playback_repeat: [ 291 | Map( 292 | Attribute.playback_repeat_mode, 293 | "Media Playback Repeat", 294 | None, 295 | None, 296 | None, 297 | None, 298 | ) 299 | ], 300 | Capability.media_playback_shuffle: [ 301 | Map( 302 | Attribute.playback_shuffle, "Media Playback Shuffle", None, None, None, None 303 | ) 304 | ], 305 | Capability.media_playback: [ 306 | Map(Attribute.playback_status, "Media Playback Status", None, None, None, None) 307 | ], 308 | Capability.odor_sensor: [ 309 | Map(Attribute.odor_level, "Odor Sensor", None, None, None, None) 310 | ], 311 | Capability.oven_mode: [ 312 | Map( 313 | Attribute.oven_mode, 314 | "Oven Mode", 315 | None, 316 | None, 317 | None, 318 | None, 319 | ) 320 | ], 321 | Capability.oven_operating_state: [ 322 | Map(Attribute.machine_state, "Oven Machine State", None, None, None, None), 323 | Map(Attribute.oven_job_state, "Oven Job State", None, None, None, None), 324 | Map(Attribute.completion_time, "Oven Completion Time", None, None, None, None), 325 | Map(Attribute.progress, "Progress", None, None, None, None), 326 | Map(Attribute.operation_time, "Cook Time", None, None, None, None), 327 | ], 328 | Capability.oven_setpoint: [ 329 | Map(Attribute.oven_setpoint, "Oven Set Point", None, None, None, None) 330 | ], 331 | 332 | Capability.door_state: [ 333 | Map(Attribute.door_state, "Oven Door State", None, None, None, None) 334 | ], 335 | 336 | Capability.oven_meat_probe: [ 337 | Map(Attribute.temperature_set_point, "Probe Temperature Setpoint", None, SensorDeviceClass.TEMPERATURE, None, None), 338 | # Map(Attribute.temperature, "Probe Temperature", None, SensorDeviceClass.TEMPERATURE, SensorStateClass.MEASUREMENT, None), 339 | Map(Attribute.status, "Probe Status", None, None, None, None), 340 | ], 341 | 342 | # Capability.oven_light: [ 343 | # Map(Attribute.brightness_level, "Oven Light", None, None, None, None) 344 | # ], 345 | 346 | 347 | Capability.power_consumption_report: [], 348 | Capability.power_meter: [ 349 | Map( 350 | Attribute.power, 351 | "Power Meter", 352 | UnitOfPower.WATT, 353 | SensorDeviceClass.POWER, 354 | SensorStateClass.MEASUREMENT, 355 | None, 356 | ) 357 | ], 358 | Capability.power_source: [ 359 | Map( 360 | Attribute.power_source, 361 | "Power Source", 362 | None, 363 | None, 364 | None, 365 | EntityCategory.DIAGNOSTIC, 366 | ) 367 | ], 368 | Capability.refrigeration_setpoint: [ 369 | Map( 370 | Attribute.refrigeration_setpoint, 371 | "Refrigeration Setpoint", 372 | None, 373 | SensorDeviceClass.TEMPERATURE, 374 | None, 375 | None, 376 | ) 377 | ], 378 | Capability.relative_humidity_measurement: [ 379 | Map( 380 | Attribute.humidity, 381 | "Relative Humidity Measurement", 382 | PERCENTAGE, 383 | SensorDeviceClass.HUMIDITY, 384 | SensorStateClass.MEASUREMENT, 385 | None, 386 | ) 387 | ], 388 | Capability.robot_cleaner_cleaning_mode: [ 389 | Map( 390 | Attribute.robot_cleaner_cleaning_mode, 391 | "Robot Cleaner Cleaning Mode", 392 | None, 393 | None, 394 | None, 395 | EntityCategory.DIAGNOSTIC, 396 | ) 397 | ], 398 | Capability.robot_cleaner_movement: [ 399 | Map( 400 | Attribute.robot_cleaner_movement, 401 | "Robot Cleaner Movement", 402 | None, 403 | None, 404 | None, 405 | None, 406 | ) 407 | ], 408 | Capability.robot_cleaner_turbo_mode: [ 409 | Map( 410 | Attribute.robot_cleaner_turbo_mode, 411 | "Robot Cleaner Turbo Mode", 412 | None, 413 | None, 414 | None, 415 | EntityCategory.DIAGNOSTIC, 416 | ) 417 | ], 418 | Capability.signal_strength: [ 419 | Map( 420 | Attribute.lqi, 421 | "LQI Signal Strength", 422 | None, 423 | None, 424 | SensorStateClass.MEASUREMENT, 425 | EntityCategory.DIAGNOSTIC, 426 | ), 427 | Map( 428 | Attribute.rssi, 429 | "RSSI Signal Strength", 430 | None, 431 | SensorDeviceClass.SIGNAL_STRENGTH, 432 | SensorStateClass.MEASUREMENT, 433 | EntityCategory.DIAGNOSTIC, 434 | ), 435 | ], 436 | Capability.smoke_detector: [ 437 | Map(Attribute.smoke, "Smoke Detector", None, None, None, None) 438 | ], 439 | 440 | Capability.temperature_measurement: [ 441 | Map( 442 | Attribute.temperature, 443 | "Temperature Measurement", 444 | None, 445 | SensorDeviceClass.TEMPERATURE, 446 | SensorStateClass.MEASUREMENT, 447 | None, 448 | ) 449 | ], 450 | 451 | Capability.thermostat_cooling_setpoint: [ 452 | Map( 453 | Attribute.cooling_setpoint, 454 | "Thermostat Cooling Setpoint", 455 | None, 456 | SensorDeviceClass.TEMPERATURE, 457 | None, 458 | None, 459 | ) 460 | ], 461 | Capability.thermostat_fan_mode: [ 462 | Map( 463 | Attribute.thermostat_fan_mode, 464 | "Thermostat Fan Mode", 465 | None, 466 | None, 467 | None, 468 | EntityCategory.DIAGNOSTIC, 469 | ) 470 | ], 471 | Capability.thermostat_heating_setpoint: [ 472 | Map( 473 | Attribute.heating_setpoint, 474 | "Thermostat Heating Setpoint", 475 | None, 476 | SensorDeviceClass.TEMPERATURE, 477 | None, 478 | EntityCategory.DIAGNOSTIC, 479 | ) 480 | ], 481 | Capability.thermostat_mode: [ 482 | Map( 483 | Attribute.thermostat_mode, 484 | "Thermostat Mode", 485 | None, 486 | None, 487 | None, 488 | EntityCategory.DIAGNOSTIC, 489 | ) 490 | ], 491 | Capability.thermostat_operating_state: [ 492 | Map( 493 | Attribute.thermostat_operating_state, 494 | "Thermostat Operating State", 495 | None, 496 | None, 497 | None, 498 | None, 499 | ) 500 | ], 501 | Capability.thermostat_setpoint: [ 502 | Map( 503 | Attribute.thermostat_setpoint, 504 | "Thermostat Setpoint", 505 | None, 506 | SensorDeviceClass.TEMPERATURE, 507 | None, 508 | EntityCategory.DIAGNOSTIC, 509 | ) 510 | ], 511 | Capability.three_axis: [], 512 | Capability.tv_channel: [ 513 | Map(Attribute.tv_channel, "Tv Channel", None, None, None, None), 514 | Map(Attribute.tv_channel_name, "Tv Channel Name", None, None, None, None), 515 | ], 516 | Capability.tvoc_measurement: [ 517 | Map( 518 | Attribute.tvoc_level, 519 | "Tvoc Measurement", 520 | CONCENTRATION_PARTS_PER_MILLION, 521 | None, 522 | SensorStateClass.MEASUREMENT, 523 | None, 524 | ) 525 | ], 526 | Capability.ultraviolet_index: [ 527 | Map( 528 | Attribute.ultraviolet_index, 529 | "Ultraviolet Index", 530 | None, 531 | None, 532 | SensorStateClass.MEASUREMENT, 533 | None, 534 | ) 535 | ], 536 | Capability.voltage_measurement: [ 537 | Map( 538 | Attribute.voltage, 539 | "Voltage Measurement", 540 | UnitOfElectricPotential.VOLT, 541 | SensorDeviceClass.VOLTAGE, 542 | SensorStateClass.MEASUREMENT, 543 | None, 544 | ) 545 | ], 546 | Capability.washer_mode: [ 547 | Map( 548 | Attribute.washer_mode, 549 | "Washer Mode", 550 | None, 551 | None, 552 | None, 553 | EntityCategory.DIAGNOSTIC, 554 | ) 555 | ], 556 | Capability.washer_operating_state: [ 557 | Map(Attribute.machine_state, "Washer Machine State", None, None, None, None), 558 | Map(Attribute.washer_job_state, "Washer Job State", None, None, None, None), 559 | Map( 560 | Attribute.completion_time, 561 | "Washer Completion Time", 562 | None, 563 | SensorDeviceClass.TIMESTAMP, 564 | None, 565 | None, 566 | ), 567 | ], 568 | Capability.water_filter: [ 569 | Map(Attribute.water_filter_status, "Water Filter Status", None, None, None, None), 570 | Map(Attribute.water_filter_usage, "Water Filter Usage", PERCENTAGE, None, SensorStateClass.MEASUREMENT, None), 571 | ], 572 | 573 | Capability.water_consumption_report: [ 574 | Map( 575 | Attribute.water_consumption, 576 | "Water Consumption", 577 | None, 578 | None, 579 | None, 580 | None, 581 | ) 582 | ], 583 | 584 | Capability.power_freeze: [ 585 | Map( 586 | Attribute.activated, 587 | "Power Freeze", 588 | None, 589 | None, 590 | None, 591 | None, 592 | ) 593 | ], 594 | Capability.dishwasher_washing_course: [ 595 | Map( 596 | Attribute.washing_course, 597 | "Dishwasher Course Selected", 598 | None, 599 | None, 600 | None, 601 | None, 602 | ) 603 | ], 604 | } 605 | 606 | 607 | UNITS = { 608 | "C": UnitOfTemperature.CELSIUS, 609 | "F": UnitOfTemperature.FAHRENHEIT, 610 | "lux": LIGHT_LUX, 611 | } 612 | 613 | THREE_AXIS_NAMES = ["X Coordinate", "Y Coordinate", "Z Coordinate"] 614 | POWER_CONSUMPTION_REPORT_NAMES = [ 615 | "energy", 616 | "power", 617 | "deltaEnergy", 618 | "powerEnergy", 619 | "energySaved", 620 | ] 621 | 622 | 623 | async def async_setup_entry( 624 | hass: HomeAssistant, 625 | config_entry: ConfigEntry, 626 | async_add_entities: AddEntitiesCallback, 627 | ) -> None: 628 | """Add sensors for a config entry.""" 629 | broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] 630 | entities: list[SensorEntity] = [] 631 | for device in broker.devices.values(): 632 | _LOGGER.debug( 633 | "NB device loop: %s: %s ", 634 | device.device_id, 635 | device.components, 636 | ) 637 | for capability in broker.get_assigned(device.device_id, "sensor"): 638 | if capability == Capability.three_axis: 639 | entities.extend( 640 | [ 641 | SmartThingsThreeAxisSensor(device, "main", index) 642 | for index in range(len(THREE_AXIS_NAMES)) 643 | ] 644 | ) 645 | elif capability == Capability.power_consumption_report: 646 | entities.extend( 647 | [ 648 | SmartThingsPowerConsumptionSensor(device, "main", report_name) 649 | for report_name in POWER_CONSUMPTION_REPORT_NAMES 650 | ] 651 | ) 652 | else: 653 | 654 | maps = CAPABILITY_TO_SENSORS[capability] 655 | entities.extend( 656 | [ 657 | SmartThingsSensor( 658 | device, 659 | "main", 660 | m.attribute, 661 | m.name, 662 | m.default_unit, 663 | m.device_class, 664 | m.state_class, 665 | m.entity_category, 666 | ) 667 | for m in maps 668 | ] 669 | ) 670 | 671 | device_capabilities_for_sensor = broker.get_assigned(device.device_id, "sensor") 672 | 673 | _LOGGER.debug( 674 | "NB device_capabilities_for_sensor: %s", 675 | device_capabilities_for_sensor, 676 | ) 677 | 678 | for component in device.components: 679 | _LOGGER.debug( 680 | "NB component loop: %s: %s ", 681 | device.device_id, 682 | component, 683 | ) 684 | for capability in device.components[component]: 685 | _LOGGER.debug( 686 | "NB capability loop: %s: %s : %s ", 687 | device.device_id, 688 | component, 689 | capability, 690 | ) 691 | if capability not in device_capabilities_for_sensor: 692 | _LOGGER.debug( 693 | "NB capability not found: %s: %s : %s ", 694 | device.device_id, 695 | component, 696 | capability, 697 | ) 698 | continue 699 | if capability == Capability.three_axis: 700 | entities.extend( 701 | [ 702 | SmartThingsThreeAxisSensor(device, component, index) 703 | for index in range(len(THREE_AXIS_NAMES)) 704 | ] 705 | ) 706 | elif capability == Capability.power_consumption_report: 707 | entities.extend( 708 | [ 709 | SmartThingsPowerConsumptionSensor( 710 | device, component, report_name 711 | ) 712 | for report_name in POWER_CONSUMPTION_REPORT_NAMES 713 | ] 714 | ) 715 | else: 716 | maps = CAPABILITY_TO_SENSORS[capability] 717 | entities.extend( 718 | [ 719 | SmartThingsSensor( 720 | device, 721 | component, 722 | m.attribute, 723 | m.name, 724 | m.default_unit, 725 | m.device_class, 726 | m.state_class, 727 | m.entity_category, 728 | ) 729 | for m in maps 730 | ] 731 | ) 732 | 733 | if broker.any_assigned(device.device_id, "switch"): 734 | for capability in (Capability.energy_meter, Capability.power_meter): 735 | maps = CAPABILITY_TO_SENSORS[capability] 736 | entities.extend( 737 | [ 738 | SmartThingsSensor( 739 | device, 740 | "main", 741 | m.attribute, 742 | m.name, 743 | m.default_unit, 744 | m.device_class, 745 | m.state_class, 746 | m.entity_category, 747 | ) 748 | for m in maps 749 | ] 750 | ) 751 | 752 | async_add_entities(entities) 753 | 754 | platform = entity_platform.async_get_current_platform() 755 | # platform.async_register_entity_service(SERVICE_COMMAND, None, "async_send_command") 756 | 757 | platform.async_register_entity_service( 758 | SERVICE_COMMAND, 759 | { 760 | vol.Required("command"): vol.Coerce(str), 761 | # vol.Required("params"): vol.Coerce(str), 762 | vol.Required("capability"): vol.Coerce(str), 763 | vol.Optional("action"): vol.Coerce(str), 764 | }, 765 | "async_send_command", 766 | ) 767 | 768 | # vol.Required(ATTR_COMMAND): vol.All(cv.ensure_list, [cv.string]), 769 | 770 | 771 | def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: 772 | """Return all capabilities supported if minimum required are present. Called from __init__py""" 773 | return [ 774 | capability for capability in CAPABILITY_TO_SENSORS if capability in capabilities 775 | ] 776 | 777 | 778 | class SmartThingsSensor(SmartThingsEntity, SensorEntity): 779 | """Define a SmartThings Sensor.""" 780 | 781 | def __init__( 782 | self, 783 | device: DeviceEntity, 784 | component: str, 785 | attribute: str, 786 | name: str, 787 | default_unit: str, 788 | device_class: SensorDeviceClass, 789 | state_class: str | None, 790 | entity_category: EntityCategory | None, 791 | ) -> None: 792 | """Init the class.""" 793 | super().__init__(device) 794 | self._component = component 795 | self._attribute = attribute 796 | 797 | _LOGGER.debug( 798 | "NB def __init__ device: %s component: %s %s ", 799 | device.device_id, 800 | component, 801 | attribute, 802 | ) 803 | if self._component == "main": 804 | self._attr_name = f"{device.label} {name}" 805 | self._attr_unique_id = f"{device.device_id}.{attribute}" 806 | else: 807 | self._attr_name = f"{device.label} {component} {name}" 808 | self._attr_unique_id = f"{device.device_id}.{component}.{attribute}" 809 | self._attr_device_class = device_class 810 | self._default_unit = default_unit 811 | self._attr_state_class = state_class 812 | self._attr_entity_category = entity_category 813 | 814 | @property 815 | def native_value(self): 816 | """Return the state of the sensor.""" 817 | _LOGGER.debug( 818 | "NB Return the state component: %s ", 819 | self._component, 820 | ) 821 | if self._component == "main": 822 | value = self._device.status.attributes[self._attribute].value 823 | else: 824 | value = ( 825 | self._device.status.components[self._component] 826 | .attributes[self._attribute] 827 | .value 828 | ) 829 | 830 | _LOGGER.debug( 831 | "NB Return the sensor value for component %s attribute: %s = %s ", 832 | self._component, 833 | self._attr_name, 834 | value, 835 | ) 836 | 837 | if self.device_class != SensorDeviceClass.TIMESTAMP: 838 | return value 839 | 840 | return dt_util.parse_datetime(value) 841 | 842 | @property 843 | def native_unit_of_measurement(self): 844 | """Return the unit this state is expressed in.""" 845 | # unit = self._device.status.attributes[self._attribute].unit 846 | 847 | if self._component == "main": 848 | unit = self._device.status.attributes[self._attribute].unit 849 | else: 850 | unit = ( 851 | self._device.status.components[self._component] 852 | .attributes[self._attribute] 853 | .unit 854 | ) 855 | 856 | _LOGGER.debug( 857 | "NB Return the sensor native_unit_of_measurement: %s : %s : %s ", 858 | unit, 859 | self._component, 860 | self._attr_name, 861 | ) 862 | return UNITS.get(unit, unit) if unit else self._default_unit 863 | 864 | 865 | async def async_send_command( 866 | self, 867 | command: str, 868 | # params: dict[str, Any] | list[Any] | None = None, 869 | capability: str, 870 | action: str | None = None, 871 | **kwargs: Any, 872 | ) -> None: 873 | """Send a command""" 874 | _LOGGER.debug( 875 | "NB switch send_my_command: %s capability: %s action: %s kwargs: %s", 876 | command, 877 | capability, 878 | action, 879 | kwargs, 880 | ) 881 | if action == None: 882 | await self._device.command(self._component, capability, command) 883 | else: 884 | await self._device.command(self._component, capability, command, [action] ) 885 | 886 | class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): 887 | """Define a SmartThings Three Axis Sensor.""" 888 | 889 | def __init__(self, device, index): 890 | """Init the class.""" 891 | super().__init__(device) 892 | self._index = index 893 | self._attr_name = f"{device.label} {THREE_AXIS_NAMES[index]}" 894 | self._attr_unique_id = f"{device.device_id} {THREE_AXIS_NAMES[index]}" 895 | 896 | @property 897 | def native_value(self): 898 | """Return the state of the sensor.""" 899 | three_axis = self._device.status.attributes[Attribute.three_axis].value 900 | try: 901 | return three_axis[self._index] 902 | except (TypeError, IndexError): 903 | return None 904 | 905 | 906 | class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): 907 | """Define a SmartThings Sensor.""" 908 | 909 | def __init__( 910 | self, 911 | device: DeviceEntity, 912 | component: str, 913 | report_name: str, 914 | ) -> None: 915 | """Init the class.""" 916 | super().__init__(device) 917 | self._component = component 918 | self.report_name = report_name 919 | if component == "main": 920 | self._attr_name = f"{device.label} {report_name}" 921 | self._attr_unique_id = f"{device.device_id}.{report_name}_meter" 922 | else: 923 | self._attr_name = f"{device.label} {component} {report_name}" 924 | self._attr_unique_id = f"{device.device_id}.{component}.{report_name}_meter" 925 | if self.report_name == "power": 926 | self._attr_state_class = SensorStateClass.MEASUREMENT 927 | self._attr_device_class = SensorDeviceClass.POWER 928 | self._attr_native_unit_of_measurement = UnitOfPower.WATT 929 | else: 930 | self._attr_state_class = SensorStateClass.TOTAL_INCREASING 931 | self._attr_device_class = SensorDeviceClass.ENERGY 932 | self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 933 | 934 | @property 935 | def native_value(self): 936 | """Return the state of the sensor.""" 937 | if self._component == "main": 938 | value = self._device.status.attributes[Attribute.power_consumption].value 939 | else: 940 | value = ( 941 | self._device.status.components[self._component] 942 | .attributes[Attribute.power_consumption] 943 | .value 944 | ) 945 | if value is None or value.get(self.report_name) is None: 946 | return None 947 | if self.report_name == "power": 948 | return value[self.report_name] 949 | return value[self.report_name] / 1000 950 | 951 | @property 952 | def extra_state_attributes(self): 953 | """Return specific state attributes.""" 954 | if self.report_name == "power": 955 | attributes = [ 956 | "power_consumption_start", 957 | "power_consumption_end", 958 | ] 959 | state_attributes = {} 960 | for attribute in attributes: 961 | if self._component == "main": 962 | value = getattr(self._device.status, attribute) 963 | else: 964 | value = getattr( 965 | self._device.status.components[self._component], attribute 966 | ) 967 | if value is not None: 968 | state_attributes[attribute] = value 969 | return state_attributes 970 | return None 971 | 972 | --------------------------------------------------------------------------------