From cf7cedf09b794d09f9326a540bcc45162eb5df8d Mon Sep 17 00:00:00 2001 From: Kamil Pustelnik <147668889+kpustelnik@users.noreply.github.com> Date: Sat, 9 Aug 2025 10:11:54 +0200 Subject: [PATCH 01/25] Update domains model --- homeassistant_api/models/domains.py | 98 +++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 82d68441..b6b14377 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -83,16 +83,104 @@ def __getattr__(self, attr: str): except AttributeError as e: raise e from err +''' TODO: Shall we use that? +class ServiceFieldSelectorText(BaseModel): + multiple: Optional[bool] = None # Submitted as List[str] if multiple + +class ServiceFieldSelectorNumber(BaseModel): + mode: Optional[str] = None + step: Optional[str | float | int] = None + min: Optional[float | int] = None + max: Optional[float | int] = None + unit_of_measurement: Optional[str] = None + unit: Optional[str] = None + +class ServiceFieldSelectorEntity(BaseModel): + multiple: Optional[bool] = None # Submitted as List[str] if multiple + integration: Optional[str] = None + domain: Optional[str] = None + +class ServiceFieldSelectorDevice(BaseModel): + multiple: Optional[bool] = None # Submitted as List[str] if multiple + integration: Optional[str] = None + domain: Optional[str] = None + +class ServiceFieldSelectorSelect(BaseModel): + options: List[str] + translation_key: Optional[str] = None + multiple: Optional[bool] = None # Submitted as List[str] if multiple + +class ServiceFieldSelectorBoolean(BaseModel): + pass + +class ServiceFieldSelectorTheme(BaseModel): + include_default: Optional[bool] = None + +class ServiceFieldSelectorConstant(BaseModel): + label: str + value: bool + +class ServiceFieldSelectorObject(BaseModel): + pass + +class ServiceFieldSelector(BaseModel): + text: Optional[ServiceFieldSelectorText] = None + config_entry: Optional[ServiceFieldSelectorText] = None # Treat like text + conversation_agent: Optional[ServiceFieldSelectorText] = None # Treat like text + number: Optional[ServiceFieldSelectorNumber] = None + duration: Optional[ServiceFieldSelectorText] = None # Treat like text + entity: Optional[ServiceFieldSelectorEntity] = None + select: Optional[ServiceFieldSelectorSelect] = None + boolean: Optional[ServiceFieldSelectorBoolean] = None + theme: Optional[ServiceFieldSelectorTheme] = None + color_temp: Optional[ServiceFieldSelectorNumber] = None + datetime: Optional[ServiceFieldSelectorText] = None # Treat like text + time: Optional[ServiceFieldSelectorText] = None # Treat like text + date: Optional[ServiceFieldSelectorText] = None # Treat like text + statistic: Optional[ServiceFieldSelectorEntity] = None # Treat like entities + object: Optional[ServiceFieldSelectorObject] = None + template: Optional[ServiceFieldSelectorText] = None # Treat like text + color_rgb: Optional[ServiceFieldSelectorObject] = None # Treat like object + device: Optional[ServiceFieldSelectorDevice] = None # Treat like entity + icon: Optional[ServiceFieldSelectorText] = None # Treat like text + constant: Optional[ServiceFieldSelectorConstant] = None +''' + +class ServiceFieldFilter(BaseModel): + supported_features: Optional[List[int] | int] = None # Bitset (any needs to be supported) + attribute: Optional[Dict[str, List[str] | str]] = None class ServiceField(BaseModel): """Model for service parameters/fields.""" description: Optional[str] = None - example: Any = None - selector: Optional[Dict[str, Any]] = None + example: Optional[Any] = None # Afaik its one of the following: str | int | float | bool | List[str] | Dict + default: Optional[Any] = None name: Optional[str] = None required: Optional[bool] = None + advanced: Optional[bool] = None + selector: Optional[Dict[str, Any]] = None # TODO: I believe it would be beneficial to parse it the way I do + filter: Optional[ServiceFieldFilter] = None + +class ServiceFieldCollection(BaseModel): + collapsed: Optional[bool] = None + fields: Dict[str, ServiceField] + +class ServiceTargetDevice(BaseModel): + pass # Not really sure what it's used for - it's only in one place (reload config entries) +class ServiceTargetEntity(BaseModel): + domain: Optional[List[str]] = None + supported_features: Optional[List[int] | int] = None # Bitset flags + integration: Optional[str] = None + # `area_id``, `device_id``, `entity_id`, `label_id` can be passed as a target + +class ServiceTarget(BaseModel): + device: Optional[ServiceTargetDevice] = None # Not currently used for anything? (The only action which has it also has `entity` target) + entity: Optional[ServiceTargetEntity] = None + +class ServiceResponse(BaseModel): + optional: Optional[bool] = None class Service(BaseModel): """Model representing services from homeassistant""" @@ -101,7 +189,9 @@ class Service(BaseModel): domain: Domain = Field(exclude=True, repr=False) name: Optional[str] = None description: Optional[str] = None - fields: Optional[Dict[str, ServiceField]] = None + fields: Optional[Dict[str, ServiceField | ServiceFieldCollection]] = None + target: Optional[ServiceTarget] = None + response: Optional[ServiceResponse] = None def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ Tuple[State, ...], @@ -111,7 +201,7 @@ def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ ]: """Triggers the service associated with this object.""" if entity_id is not None: - service_data["entity_id"] = entity_id + service_data["entity_id"] = entity_id # TODO: I believe the function should not enforce the target to be `entity_id` as it can be one of the following: `area_id``, `device_id``, `entity_id`, `label_id` try: return self.domain._client.trigger_service_with_response( self.domain.domain_id, From b3aa068a65918cd383a9e142e44825f419284978 Mon Sep 17 00:00:00 2001 From: Kamil Pustelnik <147668889+kpustelnik@users.noreply.github.com> Date: Sat, 9 Aug 2025 10:13:30 +0200 Subject: [PATCH 02/25] Update state model --- homeassistant_api/models/states.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant_api/models/states.py b/homeassistant_api/models/states.py index e5f8c272..ef7fd6f8 100644 --- a/homeassistant_api/models/states.py +++ b/homeassistant_api/models/states.py @@ -47,6 +47,9 @@ class State(BaseModel): last_updated: Optional[DatetimeIsoField] = Field( default_factory=datetime.utcnow, description="The last time the state updated." ) + last_reported: Optional[DatetimeIsoField] = Field( + default_factory=datetime.utcnow, description="The last time the state was reported." + ) context: Optional[Context] = Field( None, description="Provides information about the context of the state." ) From f7ad5239a7df7dee29a82ad5dc1299de010c94f9 Mon Sep 17 00:00:00 2001 From: Kamil Pustelnik <147668889+kpustelnik@users.noreply.github.com> Date: Sat, 9 Aug 2025 10:18:00 +0200 Subject: [PATCH 03/25] Import List from typing --- homeassistant_api/models/domains.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index b6b14377..c2c59ff7 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -2,7 +2,7 @@ import gc import inspect -from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union, cast, List from pydantic import Field From c9776be927eaa2aa7feab35ba11416806459af5b Mon Sep 17 00:00:00 2001 From: Kamil Pustelnik <147668889+kpustelnik@users.noreply.github.com> Date: Wed, 13 Aug 2025 12:14:04 +0200 Subject: [PATCH 04/25] Change | to Union --- homeassistant_api/models/domains.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index c2c59ff7..ed79e17b 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -89,9 +89,9 @@ class ServiceFieldSelectorText(BaseModel): class ServiceFieldSelectorNumber(BaseModel): mode: Optional[str] = None - step: Optional[str | float | int] = None - min: Optional[float | int] = None - max: Optional[float | int] = None + step: Optional[Union[str, float, int]] = None + min: Optional[Union[float, int]] = None + max: Optional[Union[float, int]] = None unit_of_measurement: Optional[str] = None unit: Optional[str] = None @@ -147,8 +147,8 @@ class ServiceFieldSelector(BaseModel): ''' class ServiceFieldFilter(BaseModel): - supported_features: Optional[List[int] | int] = None # Bitset (any needs to be supported) - attribute: Optional[Dict[str, List[str] | str]] = None + supported_features: Optional[Union[List[int], int]] = None # Bitset (any needs to be supported) + attribute: Optional[Dict[str, Union[List[str], str]]] = None class ServiceField(BaseModel): """Model for service parameters/fields.""" @@ -171,7 +171,7 @@ class ServiceTargetDevice(BaseModel): class ServiceTargetEntity(BaseModel): domain: Optional[List[str]] = None - supported_features: Optional[List[int] | int] = None # Bitset flags + supported_features: Optional[Union[List[int], int]] = None # Bitset flags integration: Optional[str] = None # `area_id``, `device_id``, `entity_id`, `label_id` can be passed as a target @@ -189,7 +189,7 @@ class Service(BaseModel): domain: Domain = Field(exclude=True, repr=False) name: Optional[str] = None description: Optional[str] = None - fields: Optional[Dict[str, ServiceField | ServiceFieldCollection]] = None + fields: Optional[Dict[str, Union[ServiceField, ServiceFieldCollection]]] = None target: Optional[ServiceTarget] = None response: Optional[ServiceResponse] = None From 2f42979a73e7b9e6db36e9a353cf80ef86411c2b Mon Sep 17 00:00:00 2001 From: Kamil Pustelnik <147668889+kpustelnik@users.noreply.github.com> Date: Sun, 24 Aug 2025 13:48:09 +0200 Subject: [PATCH 05/25] Update the service models --- homeassistant_api/models/domains.py | 401 ++++++++++++++++++++++++---- 1 file changed, 342 insertions(+), 59 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index ed79e17b..61bdffe9 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -1,8 +1,10 @@ """File for Service and Domain data models""" +from __future__ import annotations import gc import inspect from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union, cast, List +from enum import Enum from pydantic import Field @@ -83,102 +85,383 @@ def __getattr__(self, attr: str): except AttributeError as e: raise e from err -''' TODO: Shall we use that? -class ServiceFieldSelectorText(BaseModel): - multiple: Optional[bool] = None # Submitted as List[str] if multiple +# Sources: +# https://developers.home-assistant.io/docs/dev_101_services/ +# https://www.home-assistant.io/docs/blueprint/selectors/#date-selector +# https://github.com/home-assistant/frontend/blob/dev/src/data/selector.ts +# https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/types.ts -class ServiceFieldSelectorNumber(BaseModel): - mode: Optional[str] = None - step: Optional[Union[str, float, int]] = None - min: Optional[Union[float, int]] = None - max: Optional[Union[float, int]] = None - unit_of_measurement: Optional[str] = None +number = Union[int, float] + +# Helpers +class ServiceFieldSelectorEntityFilter(BaseModel): + integration: Optional[str] = None + domain: Optional[Union[List[str], str]] = None + device_class: Optional[Union[List[str], str]] = None + supported_features: Optional[Union[List[int], int]] = None + +class ServiceFieldSelectorDeviceFilter(BaseModel): + integration: Optional[str] = None + manufacturer: Optional[str] = None + model: Optional[str] = None + model_id: Optional[str] = None + +class CropOptions(BaseModel): + round: bool + type: Optional[str] # "image/jpeg" / "image/png" + quality: Optional[number] = None + aspectRatio: Optional[number] = None + +class SelectBoxOptionImage(BaseModel): + src: str + src_dark: Optional[str] = None + flip_rtl: Optional[bool] = None + +class ServiceFieldSelectorNumberMode(str, Enum): + BOX = 'box' + SLIDER = 'slider' + +class ServiceFieldSelectorSelectMode(str, Enum): + LIST = 'list' + DROPDOWN = 'dropdown' + BOX = 'box' + +class ServiceFieldSelectorQRCodeErrorCorrectionLevel(str, Enum): + LOW = 'low' + MEDIUM = 'medium' + QUARTILE = 'quartile' + HIGH = 'high' + +class ServiceFieldSelectorTextType(str, Enum): + NUMBER = 'number' + TEXT = 'text' + SEARCH = 'search' + TEL = 'tel' + URL = 'url' + EMAIL = 'email' + PASSWORD = 'password' + DATE = 'date' + MONTH = 'month' + WEEK = 'week' + TIME = 'time' + DATETIME_LOCAL = 'datetime-local' + COLOR = 'color' + +# Selectors +class ServiceFieldSelectorAction(BaseModel): + optionsInSidebar: Optional[bool] = None + +class ServiceFieldSelectorAddon(BaseModel): + name: Optional[str] = None + slug: Optional[str] = None + +class ServiceFieldSelectorArea(BaseModel): + entity: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None + device: Optional[Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter]] = None + multiple: Optional[bool] = None + +class ServiceFieldSelectorAreasDisplay(BaseModel): + pass + +class ServiceFieldSelectorAttribute(BaseModel): + entity_id: Optional[Union[List[str], str]] = None + hide_attributes: Optional[List[str]] = None + +class ServiceFieldSelectorAssistPipeline(BaseModel): + include_last_used: Optional[bool] = None + +class ServiceFieldSelectorBackground(BaseModel): + original: Optional[bool] = None + crop: Optional[CropOptions] = None + +class ServiceFieldSelectorBackupLocation(BaseModel): + pass + +class ServiceFieldSelectorBoolean(BaseModel): + pass + +class ServiceFieldSelectorButtonToggle(BaseModel): + options: List[Union[str, ServiceFieldSelectorSelectOption]] + translation_key: Optional[str] + sort: Optional[bool] + +class ServiceFieldSelectorColorRGB(BaseModel): + pass + +class ServiceFieldSelectorColorTemp(BaseModel): unit: Optional[str] = None + min: Optional[number] = None + max: Optional[number] = None + min_mireds: Optional[number] = None + max_mireds: Optional[number] = None -class ServiceFieldSelectorEntity(BaseModel): - multiple: Optional[bool] = None # Submitted as List[str] if multiple +class ServiceFieldSelectorCondition(BaseModel): + optionsInSidebar: Optional[bool] = None + +class ServiceFieldSelectorConfigEntry(BaseModel): integration: Optional[str] = None - domain: Optional[str] = None +class ServiceFieldSelectorConstant(BaseModel): + label: Optional[str] = None + value: Union[str, number, bool] + translation_key: Optional[str] = None + +class ServiceFieldSelectorConversationAgent(BaseModel): + language: Optional[str] = None # filtering by language not supported + +class ServiceFieldSelectorCountry(BaseModel): + countries: List[str] = None + no_sort: Optional[bool] = None + +class ServiceFieldSelectorDate(BaseModel): + pass + +class ServiceFieldSelectorDateTime(BaseModel): + pass + class ServiceFieldSelectorDevice(BaseModel): - multiple: Optional[bool] = None # Submitted as List[str] if multiple + entity: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None + filter: Optional[Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter]] = None + multiple: Optional[bool] = None + +class ServiceFieldSelectorDeviceLegacy(ServiceFieldSelectorDevice): + integration: Optional[str] = None + manufacturer: Optional[str] = None + model: Optional[str] = None + +class ServiceFieldSelectorDuration(BaseModel): + enable_day: Optional[bool] = None + enable_millisecond: Optional[bool] = None + +class ServiceFieldSelectorEntity(BaseModel): + multiple: Optional[bool] = None + include_entities: Optional[List[str]] = None + exclude_entities: Optional[List[str]] = None + filter: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None + reorder: Optional[bool] = None + +class ServiceFieldSelectorEntityLegacy(ServiceFieldSelectorEntity): integration: Optional[str] = None - domain: Optional[str] = None + domain: Optional[Union[List[str], str]] = None + device_class: Optional[Union[List[str], str]] = None + +class ServiceFieldSelectorFloor(BaseModel): + entity: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None + device: Optional[Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter]] = None + multiple: Optional[bool] = None + +class ServiceFieldSelectorFile(BaseModel): + accept: str + +class ServiceFieldSelectorIcon(BaseModel): + placeholder: Optional[str] = None + fallbackPath: Optional[str] = None + +class ServiceFieldSelectorImage(BaseModel): + original: Optional[bool] = None + crop: Optional[CropOptions] = None + +class ServiceFieldSelectorLabel(BaseModel): + multiple: Optional[bool] = None + +class ServiceFieldSelectorLanguage(BaseModel): + languages: Optional[List[str]] = None + native_name: Optional[bool] = None + no_sort: Optional[bool] = None + +class ServiceFieldSelectorLocation(BaseModel): + radius: Optional[bool] = None + radius_readonly: Optional[bool] = None + icon: Optional[str] = None + +class ServiceFieldSelectorMedia(BaseModel): + accept: Optional[List[str]] = None + +class ServiceFieldSelectorNavigation(BaseModel): + pass + +class ServiceFieldSelectorNumber(BaseModel): + min: Optional[number] = None + max: Optional[number] = None + step: Optional[Union[number, str]] = None + unit_of_measurement: Optional[str] = None + mode: Optional[ServiceFieldSelectorNumberMode] = None + slider_ticks: Optional[bool] = None + translation_key: Optional[str] = None + +class ServiceFieldSelectorObjectField(BaseModel): + selector: ServiceFieldSelector + label: Optional[str] = None + required: Optional[bool] = None + +class ServiceFieldSelectorObject(BaseModel): + label_field: Optional[str] = None + description_field: Optional[str] = None + translation_key: Optional[str] = None + fields: Dict[str, ServiceFieldSelectorObjectField] = None + multiple: Optional[bool] = None + +class ServiceFieldSelectorQRCode(BaseModel): + data: str + scale: Optional[number] = None + error_correction_level: Optional[ServiceFieldSelectorQRCodeErrorCorrectionLevel] = None + center_image: Optional[str] = None + +class ServiceFieldSelectorSelectOption(BaseModel): + label: str + value: Any + description: Optional[str] = None + image: Optional[Union[str, SelectBoxOptionImage]] = None + disable: Optional[bool] = None class ServiceFieldSelectorSelect(BaseModel): - options: List[str] + multiple: Optional[bool] = None + custom_value: Optional[bool] = None + mode: Optional[ServiceFieldSelectorSelectMode] = None + options: List[Union[str, ServiceFieldSelectorSelectOption]] = None translation_key: Optional[str] = None - multiple: Optional[bool] = None # Submitted as List[str] if multiple + sort: Optional[bool] = None + reorder: Optional[bool] = None + box_max_columns: Optional[int] = None -class ServiceFieldSelectorBoolean(BaseModel): +class ServiceFieldSelectorSelector(BaseModel): + pass + +class ServiceFieldSelectorStateOption(BaseModel): + label: str + value: Any + +class ServiceFieldSelectorState(BaseModel): + extra_options: Optional[List[ServiceFieldSelectorStateOption]] + entity_id: Optional[Union[str, List[str]]] + attribute: Optional[str] = None + hide_states: Optional[List[str]] = None + multiple: Optional[bool] = None + +class ServiceFieldSelectorStatistic(BaseModel): + device_class: Optional[str] = None + multiple: Optional[bool] = None + +class ServiceFieldSelectorTarget(BaseModel): + entity: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None + device: Optional[Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter]] = None + +class ServiceFieldSelectorTemplate(BaseModel): pass +class ServiceFieldSelectorSTT(BaseModel): + language: Optional[str] = None + +class ServiceFieldSelectorText(BaseModel): + multiline: Optional[bool] = None + type: Optional[ServiceFieldSelectorTextType] = None + prefix: Optional[str] = None + suffix: Optional[str] = None + autocomplete: Optional[str] = None + multiple: Optional[bool] = None + class ServiceFieldSelectorTheme(BaseModel): include_default: Optional[bool] = None -class ServiceFieldSelectorConstant(BaseModel): - label: str - value: bool +class ServiceFieldSelectorTime(BaseModel): + no_second: Optional[bool] = None -class ServiceFieldSelectorObject(BaseModel): +class ServiceFieldSelectorTrigger(BaseModel): + pass + +class ServiceFieldSelectorTTS(BaseModel): + language: Optional[str] = None + +class ServiceFieldSelectorTTSVoice(BaseModel): + engineId: Optional[str] = None + language: Optional[str] = None + +class ServiceFieldSelectorUIAction(BaseModel): pass +class ServiceFieldSelectorUIColor(BaseModel): + default_color: Optional[str] = None + include_none: Optional[bool] = None + include_state: Optional[bool] = None + +class ServiceFieldSelectorUIStateContext(BaseModel): + entity_id: Optional[str] = None + allow_name: Optional[bool] = None + class ServiceFieldSelector(BaseModel): - text: Optional[ServiceFieldSelectorText] = None - config_entry: Optional[ServiceFieldSelectorText] = None # Treat like text - conversation_agent: Optional[ServiceFieldSelectorText] = None # Treat like text + action: Optional[ServiceFieldSelectorAction] = None + addon: Optional[ServiceFieldSelectorAddon] = None + area: Optional[ServiceFieldSelectorArea] = None + areas_display: Optional[ServiceFieldSelectorAreasDisplay] = None + attribute: Optional[ServiceFieldSelectorAttribute] = None + assist_pipeline: Optional[ServiceFieldSelectorAssistPipeline] = None + backup_location: Optional[ServiceFieldSelectorBackupLocation] = None + background: Optional[ServiceFieldSelectorBackground] = None + boolean: Optional[ServiceFieldSelectorBoolean] = None + button_toggle: Optional[ServiceFieldSelectorButtonToggle] = None + color_rgb: Optional[ServiceFieldSelectorColorRGB] = None + color_temp: Optional[ServiceFieldSelectorColorTemp] = None + condition: Optional[ServiceFieldSelectorCondition] = None + config_entry: Optional[ServiceFieldSelectorConfigEntry] = None + constant: Optional[ServiceFieldSelectorConstant] = None + conversation_agent: Optional[ServiceFieldSelectorConversationAgent] = None + country: Optional[ServiceFieldSelectorCountry] = None + date: Optional[ServiceFieldSelectorDate] = None + datetime: Optional[ServiceFieldSelectorDateTime] = None + device: Optional[Union[ServiceFieldSelectorDevice, ServiceFieldSelectorDeviceLegacy]] = None + duration: Optional[ServiceFieldSelectorDuration] = None + entity: Optional[Union[ServiceFieldSelectorEntity, ServiceFieldSelectorEntityLegacy]] = None + floor: Optional[ServiceFieldSelectorFloor] = None + file: Optional[ServiceFieldSelectorFile] = None + icon: Optional[ServiceFieldSelectorIcon] = None + image: Optional[ServiceFieldSelectorImage] = None + label: Optional[ServiceFieldSelectorLabel] = None + language: Optional[ServiceFieldSelectorLanguage] = None + location: Optional[ServiceFieldSelectorLocation] = None + media: Optional[ServiceFieldSelectorMedia] = None + navigation: Optional[ServiceFieldSelectorNavigation] = None number: Optional[ServiceFieldSelectorNumber] = None - duration: Optional[ServiceFieldSelectorText] = None # Treat like text - entity: Optional[ServiceFieldSelectorEntity] = None + object: Optional[ServiceFieldSelectorObject] = None + qr_code: Optional[ServiceFieldSelectorQRCode] = None select: Optional[ServiceFieldSelectorSelect] = None - boolean: Optional[ServiceFieldSelectorBoolean] = None + selector: Optional[ServiceFieldSelectorSelector] = None + state: Optional[ServiceFieldSelectorState] = None + statistic: Optional[ServiceFieldSelectorStatistic] = None + target: Optional[ServiceFieldSelectorTarget] = None + template: Optional[ServiceFieldSelectorTemplate] = None + stt: Optional[ServiceFieldSelectorSTT] = None + text: Optional[ServiceFieldSelectorText] = None theme: Optional[ServiceFieldSelectorTheme] = None - color_temp: Optional[ServiceFieldSelectorNumber] = None - datetime: Optional[ServiceFieldSelectorText] = None # Treat like text - time: Optional[ServiceFieldSelectorText] = None # Treat like text - date: Optional[ServiceFieldSelectorText] = None # Treat like text - statistic: Optional[ServiceFieldSelectorEntity] = None # Treat like entities - object: Optional[ServiceFieldSelectorObject] = None - template: Optional[ServiceFieldSelectorText] = None # Treat like text - color_rgb: Optional[ServiceFieldSelectorObject] = None # Treat like object - device: Optional[ServiceFieldSelectorDevice] = None # Treat like entity - icon: Optional[ServiceFieldSelectorText] = None # Treat like text - constant: Optional[ServiceFieldSelectorConstant] = None -''' + time: Optional[ServiceFieldSelectorTime] = None + trigger: Optional[ServiceFieldSelectorTrigger] = None + tts: Optional[ServiceFieldSelectorTTS] = None + tts_voice: Optional[ServiceFieldSelectorTTSVoice] = None + ui_action: Optional[ServiceFieldSelectorUIAction] = None + ui_color: Optional[ServiceFieldSelectorUIColor] = None + ui_state_content: Optional[ServiceFieldSelectorUIStateContext] = None + +# Service bases class ServiceFieldFilter(BaseModel): - supported_features: Optional[Union[List[int], int]] = None # Bitset (any needs to be supported) + supported_features: Optional[Union[List[int], int]] = None # Bitset (any needs to be supported [or all within specified list]) attribute: Optional[Dict[str, Union[List[str], str]]] = None class ServiceField(BaseModel): """Model for service parameters/fields.""" description: Optional[str] = None - example: Optional[Any] = None # Afaik its one of the following: str | int | float | bool | List[str] | Dict - default: Optional[Any] = None + example: Optional[Union[str, number, bool, List[str], Dict]] = None + default: Optional[Union[str, number, bool, List[str], Dict]] = None name: Optional[str] = None required: Optional[bool] = None advanced: Optional[bool] = None selector: Optional[Dict[str, Any]] = None # TODO: I believe it would be beneficial to parse it the way I do filter: Optional[ServiceFieldFilter] = None -class ServiceFieldCollection(BaseModel): +class ServiceFieldCollection(BaseModel, extra='forbid'): collapsed: Optional[bool] = None fields: Dict[str, ServiceField] -class ServiceTargetDevice(BaseModel): - pass # Not really sure what it's used for - it's only in one place (reload config entries) - -class ServiceTargetEntity(BaseModel): - domain: Optional[List[str]] = None - supported_features: Optional[Union[List[int], int]] = None # Bitset flags - integration: Optional[str] = None - # `area_id``, `device_id``, `entity_id`, `label_id` can be passed as a target - -class ServiceTarget(BaseModel): - device: Optional[ServiceTargetDevice] = None # Not currently used for anything? (The only action which has it also has `entity` target) - entity: Optional[ServiceTargetEntity] = None - class ServiceResponse(BaseModel): optional: Optional[bool] = None @@ -187,10 +470,10 @@ class Service(BaseModel): service_id: str domain: Domain = Field(exclude=True, repr=False) - name: Optional[str] = None + name: str description: Optional[str] = None fields: Optional[Dict[str, Union[ServiceField, ServiceFieldCollection]]] = None - target: Optional[ServiceTarget] = None + target: Optional[ServiceFieldSelectorTarget] = None response: Optional[ServiceResponse] = None def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ @@ -201,7 +484,7 @@ def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ ]: """Triggers the service associated with this object.""" if entity_id is not None: - service_data["entity_id"] = entity_id # TODO: I believe the function should not enforce the target to be `entity_id` as it can be one of the following: `area_id``, `device_id``, `entity_id`, `label_id` + service_data["entity_id"] = entity_id # TODO: I believe the function should not enforce the target to be `entity_id` as it can be one of the following: `area_id`, `device_id`, `floor_id`, `entity_id`, `label_id` try: return self.domain._client.trigger_service_with_response( self.domain.domain_id, From b7942d620533ab648356b2f2e6a49091f1f7c608 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 12 Aug 2025 15:29:56 -0500 Subject: [PATCH 06/25] For future, disallow extra json keys in API responses so we can catch them --- homeassistant_api/models/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant_api/models/base.py b/homeassistant_api/models/base.py index e003683d..99695b44 100644 --- a/homeassistant_api/models/base.py +++ b/homeassistant_api/models/base.py @@ -18,4 +18,5 @@ class BaseModel(PydanticBaseModel): model_config = ConfigDict( arbitrary_types_allowed=True, validate_assignment=True, + extra="forbid" ) From 2e26a6feaf6d567d38f386e91b150e491784e76c Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 12 Aug 2025 15:30:34 -0500 Subject: [PATCH 07/25] Fix misc type hinting --- homeassistant_api/processing.py | 9 ++++++--- homeassistant_api/rawclient.py | 15 ++++++++------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant_api/processing.py b/homeassistant_api/processing.py index ade160c4..dc089936 100644 --- a/homeassistant_api/processing.py +++ b/homeassistant_api/processing.py @@ -82,9 +82,12 @@ def process(self) -> Any: elif isinstance(self._response, (Response, CachedResponse)): status_code = self._response.status_code content = self._response.content + else: + raise TypeError( + f"Unsupported response type: {type(self._response).__name__}" + ) if self._decode_bytes and isinstance(content, bytes): content = content.decode() - if status_code in (200, 201): return self.process_content(async_=async_) if status_code == 400: @@ -117,7 +120,7 @@ def process_json(response: ResponseType) -> dict[str, Any]: @Processing.processor("text/plain") # type: ignore[arg-type] -@Processing.processor("application/octet-stream") +@Processing.processor("application/octet-stream") # pyright: ignore[reportArgumentType] def process_text(response: ResponseType) -> str: """Returns the plaintext of the reponse.""" return response.text @@ -135,7 +138,7 @@ async def async_process_json(response: AsyncResponseType) -> dict[str, Any]: @Processing.processor("text/plain") # type: ignore[arg-type] -@Processing.processor("application/octet-stream") +@Processing.processor("application/octet-stream") # pyright: ignore[reportArgumentType] async def async_process_text(response: AsyncResponseType) -> str: """Returns the plaintext of the reponse.""" return await response.text() diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 14e19b4e..80c33261 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -98,13 +98,12 @@ def request( if self.global_request_kwargs is not None: kwargs.update(self.global_request_kwargs) logger.debug("%s request to %s", method, self.endpoint(path)) - if self.cache_session: - resp = self.cache_session.request( - method, - self.endpoint(path) + f"?{params}" * bool(params), - headers=self.prepare_headers(headers), - **kwargs, - ) + resp = self.cache_session.request( + method, + self.endpoint(path) + f"?{params}" * bool(params), + headers=self.prepare_headers(headers), + **kwargs, + ) except requests.exceptions.Timeout as err: raise RequestTimeoutError( f'Home Assistant did not respond in time (timeout: {kwargs.get("timeout", 300)} sec)', @@ -268,6 +267,8 @@ def get_domains(self) -> Dict[str, Domain]: :code:`GET /api/services` """ data = self.request("services") + import pprint + pprint.pprint(*(domain for domain in data if domain["domain"] == "light")) domains = map( lambda json: Domain.from_json(json, client=cast(Client, self)), cast(Tuple[Dict[str, Any], ...], data), From 8a65e44084315fe90f1353b332acc049312df326 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 12 Aug 2025 15:30:55 -0500 Subject: [PATCH 08/25] Remove deprecated utcnow --- homeassistant_api/models/states.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant_api/models/states.py b/homeassistant_api/models/states.py index ef7fd6f8..6be059cb 100644 --- a/homeassistant_api/models/states.py +++ b/homeassistant_api/models/states.py @@ -1,6 +1,6 @@ """Module for the Entity State model.""" -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, Optional from pydantic import Field @@ -41,14 +41,15 @@ class State(BaseModel): {}, description="A dictionary of extra attributes of the state." ) last_changed: DatetimeIsoField = Field( - default_factory=datetime.utcnow, + default_factory=lambda: datetime.now(timezone.utc), description="The last time the state was changed.", ) last_updated: Optional[DatetimeIsoField] = Field( - default_factory=datetime.utcnow, description="The last time the state updated." + default_factory=lambda: datetime.now(timezone.utc), description="The last time the state updated.", ) last_reported: Optional[DatetimeIsoField] = Field( - default_factory=datetime.utcnow, description="The last time the state was reported." + default_factory=lambda: datetime.now(timezone.utc), + description="The last time the state was reported to the server. Only used by some integrations.", ) context: Optional[Context] = Field( None, description="Provides information about the context of the state." From 11d4c3cad94ae23292b7d95e9cd4d982e6b0af4c Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 30 Aug 2025 12:25:09 -0600 Subject: [PATCH 09/25] Clean up code --- homeassistant_api/models/base.py | 2 +- homeassistant_api/models/domains.py | 206 +++++++++++++++++++++------- homeassistant_api/models/states.py | 3 +- homeassistant_api/processing.py | 4 +- homeassistant_api/rawclient.py | 1 + 5 files changed, 165 insertions(+), 51 deletions(-) diff --git a/homeassistant_api/models/base.py b/homeassistant_api/models/base.py index 99695b44..0acaa55a 100644 --- a/homeassistant_api/models/base.py +++ b/homeassistant_api/models/base.py @@ -18,5 +18,5 @@ class BaseModel(PydanticBaseModel): model_config = ConfigDict( arbitrary_types_allowed=True, validate_assignment=True, - extra="forbid" + extra="forbid", ) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 61bdffe9..40a69700 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -1,10 +1,21 @@ """File for Service and Domain data models""" from __future__ import annotations + import gc import inspect -from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union, cast, List from enum import Enum +from typing import ( + TYPE_CHECKING, + Any, + Coroutine, + Dict, + List, + Optional, + Tuple, + Union, + cast, +) from pydantic import Field @@ -85,6 +96,7 @@ def __getattr__(self, attr: str): except AttributeError as e: raise e from err + # Sources: # https://developers.home-assistant.io/docs/dev_101_services/ # https://www.home-assistant.io/docs/blueprint/selectors/#date-selector @@ -93,6 +105,7 @@ def __getattr__(self, attr: str): number = Union[int, float] + # Helpers class ServiceFieldSelectorEntityFilter(BaseModel): integration: Optional[str] = None @@ -100,94 +113,117 @@ class ServiceFieldSelectorEntityFilter(BaseModel): device_class: Optional[Union[List[str], str]] = None supported_features: Optional[Union[List[int], int]] = None + class ServiceFieldSelectorDeviceFilter(BaseModel): integration: Optional[str] = None manufacturer: Optional[str] = None model: Optional[str] = None model_id: Optional[str] = None + class CropOptions(BaseModel): round: bool - type: Optional[str] # "image/jpeg" / "image/png" + type: Optional[str] # "image/jpeg" / "image/png" quality: Optional[number] = None aspectRatio: Optional[number] = None + class SelectBoxOptionImage(BaseModel): src: str src_dark: Optional[str] = None flip_rtl: Optional[bool] = None - + + class ServiceFieldSelectorNumberMode(str, Enum): - BOX = 'box' - SLIDER = 'slider' + BOX = "box" + SLIDER = "slider" + class ServiceFieldSelectorSelectMode(str, Enum): - LIST = 'list' - DROPDOWN = 'dropdown' - BOX = 'box' + LIST = "list" + DROPDOWN = "dropdown" + BOX = "box" + class ServiceFieldSelectorQRCodeErrorCorrectionLevel(str, Enum): - LOW = 'low' - MEDIUM = 'medium' - QUARTILE = 'quartile' - HIGH = 'high' + LOW = "low" + MEDIUM = "medium" + QUARTILE = "quartile" + HIGH = "high" + class ServiceFieldSelectorTextType(str, Enum): - NUMBER = 'number' - TEXT = 'text' - SEARCH = 'search' - TEL = 'tel' - URL = 'url' - EMAIL = 'email' - PASSWORD = 'password' - DATE = 'date' - MONTH = 'month' - WEEK = 'week' - TIME = 'time' - DATETIME_LOCAL = 'datetime-local' - COLOR = 'color' + NUMBER = "number" + TEXT = "text" + SEARCH = "search" + TEL = "tel" + URL = "url" + EMAIL = "email" + PASSWORD = "password" + DATE = "date" + MONTH = "month" + WEEK = "week" + TIME = "time" + DATETIME_LOCAL = "datetime-local" + COLOR = "color" + # Selectors class ServiceFieldSelectorAction(BaseModel): optionsInSidebar: Optional[bool] = None + class ServiceFieldSelectorAddon(BaseModel): name: Optional[str] = None slug: Optional[str] = None + class ServiceFieldSelectorArea(BaseModel): - entity: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None - device: Optional[Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter]] = None + entity: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None + device: Optional[ + Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter] + ] = None multiple: Optional[bool] = None + class ServiceFieldSelectorAreasDisplay(BaseModel): pass + class ServiceFieldSelectorAttribute(BaseModel): entity_id: Optional[Union[List[str], str]] = None hide_attributes: Optional[List[str]] = None + class ServiceFieldSelectorAssistPipeline(BaseModel): include_last_used: Optional[bool] = None + class ServiceFieldSelectorBackground(BaseModel): original: Optional[bool] = None crop: Optional[CropOptions] = None + class ServiceFieldSelectorBackupLocation(BaseModel): pass + class ServiceFieldSelectorBoolean(BaseModel): pass + class ServiceFieldSelectorButtonToggle(BaseModel): options: List[Union[str, ServiceFieldSelectorSelectOption]] translation_key: Optional[str] sort: Optional[bool] + class ServiceFieldSelectorColorRGB(BaseModel): pass + class ServiceFieldSelectorColorTemp(BaseModel): unit: Optional[str] = None min: Optional[number] = None @@ -195,91 +231,123 @@ class ServiceFieldSelectorColorTemp(BaseModel): min_mireds: Optional[number] = None max_mireds: Optional[number] = None + class ServiceFieldSelectorCondition(BaseModel): optionsInSidebar: Optional[bool] = None + class ServiceFieldSelectorConfigEntry(BaseModel): integration: Optional[str] = None - + + class ServiceFieldSelectorConstant(BaseModel): label: Optional[str] = None value: Union[str, number, bool] translation_key: Optional[str] = None + class ServiceFieldSelectorConversationAgent(BaseModel): - language: Optional[str] = None # filtering by language not supported + language: Optional[str] = None # filtering by language not supported + class ServiceFieldSelectorCountry(BaseModel): - countries: List[str] = None + countries: Optional[List[str]] = None no_sort: Optional[bool] = None + class ServiceFieldSelectorDate(BaseModel): pass + class ServiceFieldSelectorDateTime(BaseModel): pass + class ServiceFieldSelectorDevice(BaseModel): - entity: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None - filter: Optional[Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter]] = None + entity: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None + filter: Optional[ + Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter] + ] = None multiple: Optional[bool] = None + class ServiceFieldSelectorDeviceLegacy(ServiceFieldSelectorDevice): integration: Optional[str] = None manufacturer: Optional[str] = None model: Optional[str] = None - + + class ServiceFieldSelectorDuration(BaseModel): enable_day: Optional[bool] = None enable_millisecond: Optional[bool] = None + class ServiceFieldSelectorEntity(BaseModel): multiple: Optional[bool] = None include_entities: Optional[List[str]] = None exclude_entities: Optional[List[str]] = None - filter: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None + filter: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None reorder: Optional[bool] = None + class ServiceFieldSelectorEntityLegacy(ServiceFieldSelectorEntity): integration: Optional[str] = None domain: Optional[Union[List[str], str]] = None device_class: Optional[Union[List[str], str]] = None + class ServiceFieldSelectorFloor(BaseModel): - entity: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None - device: Optional[Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter]] = None + entity: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None + device: Optional[ + Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter] + ] = None multiple: Optional[bool] = None + class ServiceFieldSelectorFile(BaseModel): accept: str + class ServiceFieldSelectorIcon(BaseModel): placeholder: Optional[str] = None fallbackPath: Optional[str] = None + class ServiceFieldSelectorImage(BaseModel): original: Optional[bool] = None crop: Optional[CropOptions] = None + class ServiceFieldSelectorLabel(BaseModel): multiple: Optional[bool] = None + class ServiceFieldSelectorLanguage(BaseModel): languages: Optional[List[str]] = None native_name: Optional[bool] = None no_sort: Optional[bool] = None + class ServiceFieldSelectorLocation(BaseModel): radius: Optional[bool] = None radius_readonly: Optional[bool] = None icon: Optional[str] = None + class ServiceFieldSelectorMedia(BaseModel): accept: Optional[List[str]] = None + class ServiceFieldSelectorNavigation(BaseModel): pass + class ServiceFieldSelectorNumber(BaseModel): min: Optional[number] = None max: Optional[number] = None @@ -289,24 +357,30 @@ class ServiceFieldSelectorNumber(BaseModel): slider_ticks: Optional[bool] = None translation_key: Optional[str] = None + class ServiceFieldSelectorObjectField(BaseModel): selector: ServiceFieldSelector label: Optional[str] = None required: Optional[bool] = None + class ServiceFieldSelectorObject(BaseModel): label_field: Optional[str] = None description_field: Optional[str] = None translation_key: Optional[str] = None - fields: Dict[str, ServiceFieldSelectorObjectField] = None + fields: Optional[Dict[str, ServiceFieldSelectorObjectField]] = None multiple: Optional[bool] = None + class ServiceFieldSelectorQRCode(BaseModel): data: str scale: Optional[number] = None - error_correction_level: Optional[ServiceFieldSelectorQRCodeErrorCorrectionLevel] = None + error_correction_level: Optional[ServiceFieldSelectorQRCodeErrorCorrectionLevel] = ( + None + ) center_image: Optional[str] = None + class ServiceFieldSelectorSelectOption(BaseModel): label: str value: Any @@ -314,23 +388,27 @@ class ServiceFieldSelectorSelectOption(BaseModel): image: Optional[Union[str, SelectBoxOptionImage]] = None disable: Optional[bool] = None + class ServiceFieldSelectorSelect(BaseModel): multiple: Optional[bool] = None custom_value: Optional[bool] = None mode: Optional[ServiceFieldSelectorSelectMode] = None - options: List[Union[str, ServiceFieldSelectorSelectOption]] = None + options: Optional[List[Union[str, ServiceFieldSelectorSelectOption]]] = None translation_key: Optional[str] = None sort: Optional[bool] = None reorder: Optional[bool] = None box_max_columns: Optional[int] = None + class ServiceFieldSelectorSelector(BaseModel): pass + class ServiceFieldSelectorStateOption(BaseModel): label: str value: Any + class ServiceFieldSelectorState(BaseModel): extra_options: Optional[List[ServiceFieldSelectorStateOption]] entity_id: Optional[Union[str, List[str]]] @@ -338,20 +416,29 @@ class ServiceFieldSelectorState(BaseModel): hide_states: Optional[List[str]] = None multiple: Optional[bool] = None + class ServiceFieldSelectorStatistic(BaseModel): device_class: Optional[str] = None multiple: Optional[bool] = None + class ServiceFieldSelectorTarget(BaseModel): - entity: Optional[Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter]] = None - device: Optional[Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter]] = None + entity: Optional[ + Union[List[ServiceFieldSelectorEntityFilter], ServiceFieldSelectorEntityFilter] + ] = None + device: Optional[ + Union[List[ServiceFieldSelectorDeviceFilter], ServiceFieldSelectorDeviceFilter] + ] = None + class ServiceFieldSelectorTemplate(BaseModel): pass + class ServiceFieldSelectorSTT(BaseModel): language: Optional[str] = None + class ServiceFieldSelectorText(BaseModel): multiline: Optional[bool] = None type: Optional[ServiceFieldSelectorTextType] = None @@ -360,34 +447,43 @@ class ServiceFieldSelectorText(BaseModel): autocomplete: Optional[str] = None multiple: Optional[bool] = None + class ServiceFieldSelectorTheme(BaseModel): include_default: Optional[bool] = None + class ServiceFieldSelectorTime(BaseModel): no_second: Optional[bool] = None + class ServiceFieldSelectorTrigger(BaseModel): pass + class ServiceFieldSelectorTTS(BaseModel): language: Optional[str] = None + class ServiceFieldSelectorTTSVoice(BaseModel): engineId: Optional[str] = None language: Optional[str] = None + class ServiceFieldSelectorUIAction(BaseModel): pass + class ServiceFieldSelectorUIColor(BaseModel): default_color: Optional[str] = None include_none: Optional[bool] = None include_state: Optional[bool] = None + class ServiceFieldSelectorUIStateContext(BaseModel): entity_id: Optional[str] = None allow_name: Optional[bool] = None + class ServiceFieldSelector(BaseModel): action: Optional[ServiceFieldSelectorAction] = None addon: Optional[ServiceFieldSelectorAddon] = None @@ -408,9 +504,13 @@ class ServiceFieldSelector(BaseModel): country: Optional[ServiceFieldSelectorCountry] = None date: Optional[ServiceFieldSelectorDate] = None datetime: Optional[ServiceFieldSelectorDateTime] = None - device: Optional[Union[ServiceFieldSelectorDevice, ServiceFieldSelectorDeviceLegacy]] = None + device: Optional[ + Union[ServiceFieldSelectorDevice, ServiceFieldSelectorDeviceLegacy] + ] = None duration: Optional[ServiceFieldSelectorDuration] = None - entity: Optional[Union[ServiceFieldSelectorEntity, ServiceFieldSelectorEntityLegacy]] = None + entity: Optional[ + Union[ServiceFieldSelectorEntity, ServiceFieldSelectorEntityLegacy] + ] = None floor: Optional[ServiceFieldSelectorFloor] = None file: Optional[ServiceFieldSelectorFile] = None icon: Optional[ServiceFieldSelectorIcon] = None @@ -440,12 +540,17 @@ class ServiceFieldSelector(BaseModel): ui_color: Optional[ServiceFieldSelectorUIColor] = None ui_state_content: Optional[ServiceFieldSelectorUIStateContext] = None + # Service bases + class ServiceFieldFilter(BaseModel): - supported_features: Optional[Union[List[int], int]] = None # Bitset (any needs to be supported [or all within specified list]) + supported_features: Optional[Union[List[int], int]] = ( + None # Bitset (any needs to be supported [or all within specified list]) + ) attribute: Optional[Dict[str, Union[List[str], str]]] = None + class ServiceField(BaseModel): """Model for service parameters/fields.""" @@ -455,16 +560,21 @@ class ServiceField(BaseModel): name: Optional[str] = None required: Optional[bool] = None advanced: Optional[bool] = None - selector: Optional[Dict[str, Any]] = None # TODO: I believe it would be beneficial to parse it the way I do + selector: Optional[Dict[str, Any]] = ( + None # TODO: I believe it would be beneficial to parse it the way I do + ) filter: Optional[ServiceFieldFilter] = None -class ServiceFieldCollection(BaseModel, extra='forbid'): + +class ServiceFieldCollection(BaseModel, extra="forbid"): collapsed: Optional[bool] = None fields: Dict[str, ServiceField] + class ServiceResponse(BaseModel): optional: Optional[bool] = None + class Service(BaseModel): """Model representing services from homeassistant""" @@ -484,7 +594,9 @@ def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ ]: """Triggers the service associated with this object.""" if entity_id is not None: - service_data["entity_id"] = entity_id # TODO: I believe the function should not enforce the target to be `entity_id` as it can be one of the following: `area_id`, `device_id`, `floor_id`, `entity_id`, `label_id` + service_data["entity_id"] = ( + entity_id # TODO: I believe the function should not enforce the target to be `entity_id` as it can be one of the following: `area_id`, `device_id`, `floor_id`, `entity_id`, `label_id` + ) try: return self.domain._client.trigger_service_with_response( self.domain.domain_id, diff --git a/homeassistant_api/models/states.py b/homeassistant_api/models/states.py index 6be059cb..faa4113d 100644 --- a/homeassistant_api/models/states.py +++ b/homeassistant_api/models/states.py @@ -45,7 +45,8 @@ class State(BaseModel): description="The last time the state was changed.", ) last_updated: Optional[DatetimeIsoField] = Field( - default_factory=lambda: datetime.now(timezone.utc), description="The last time the state updated.", + default_factory=lambda: datetime.now(timezone.utc), + description="The last time the state updated.", ) last_reported: Optional[DatetimeIsoField] = Field( default_factory=lambda: datetime.now(timezone.utc), diff --git a/homeassistant_api/processing.py b/homeassistant_api/processing.py index dc089936..eb9636f4 100644 --- a/homeassistant_api/processing.py +++ b/homeassistant_api/processing.py @@ -120,7 +120,7 @@ def process_json(response: ResponseType) -> dict[str, Any]: @Processing.processor("text/plain") # type: ignore[arg-type] -@Processing.processor("application/octet-stream") # pyright: ignore[reportArgumentType] +@Processing.processor("application/octet-stream") # pyright: ignore[reportArgumentType] def process_text(response: ResponseType) -> str: """Returns the plaintext of the reponse.""" return response.text @@ -138,7 +138,7 @@ async def async_process_json(response: AsyncResponseType) -> dict[str, Any]: @Processing.processor("text/plain") # type: ignore[arg-type] -@Processing.processor("application/octet-stream") # pyright: ignore[reportArgumentType] +@Processing.processor("application/octet-stream") # pyright: ignore[reportArgumentType] async def async_process_text(response: AsyncResponseType) -> str: """Returns the plaintext of the reponse.""" return await response.text() diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 80c33261..9cb8c8b4 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -268,6 +268,7 @@ def get_domains(self) -> Dict[str, Domain]: """ data = self.request("services") import pprint + pprint.pprint(*(domain for domain in data if domain["domain"] == "light")) domains = map( lambda json: Domain.from_json(json, client=cast(Client, self)), From 0b30a80170ff03fef598d32637c213412373011f Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 30 Aug 2025 13:20:04 -0600 Subject: [PATCH 10/25] Add JSONType --- homeassistant_api/models/domains.py | 51 +++++++++++++-------------- homeassistant_api/models/events.py | 6 ++-- homeassistant_api/models/states.py | 12 ++++--- homeassistant_api/models/websocket.py | 8 +++-- homeassistant_api/processing.py | 12 ++++--- homeassistant_api/rawasyncclient.py | 24 ++++++------- homeassistant_api/rawbaseclient.py | 20 +++++++---- homeassistant_api/rawclient.py | 36 +++++++++---------- homeassistant_api/rawwebsocket.py | 20 ++++++----- homeassistant_api/utils.py | 2 ++ homeassistant_api/websocket.py | 26 +++++++------- 11 files changed, 120 insertions(+), 97 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 40a69700..44fa24c7 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -20,6 +20,7 @@ from pydantic import Field from homeassistant_api.errors import RequestError +from homeassistant_api.utils import JSONType from .base import BaseModel from .states import State @@ -55,13 +56,13 @@ def __init__( @classmethod def from_json( - cls, json: Dict[str, Any], client: Union["Client", "WebsocketClient"] + cls, json: Dict[str, JSONType], client: Union["Client", "WebsocketClient"] ) -> "Domain": """Constructs Domain and Service models from json data.""" if "domain" not in json or "services" not in json: raise ValueError("Missing services or domain attribute in json argument.") domain = cls(domain_id=cast(str, json.get("domain")), _client=client) - services = json.get("services") + services = cast(dict[str, dict[str, JSONType]], json.get("services")) assert isinstance(services, dict) for service_id, data in services.items(): domain._add_service(service_id, **data) @@ -103,8 +104,6 @@ def __getattr__(self, attr: str): # https://github.com/home-assistant/frontend/blob/dev/src/data/selector.ts # https://github.com/home-assistant/home-assistant-js-websocket/blob/master/lib/types.ts -number = Union[int, float] - # Helpers class ServiceFieldSelectorEntityFilter(BaseModel): @@ -124,8 +123,8 @@ class ServiceFieldSelectorDeviceFilter(BaseModel): class CropOptions(BaseModel): round: bool type: Optional[str] # "image/jpeg" / "image/png" - quality: Optional[number] = None - aspectRatio: Optional[number] = None + quality: Optional[int | float] = None + aspectRatio: Optional[int | float] = None class SelectBoxOptionImage(BaseModel): @@ -226,10 +225,10 @@ class ServiceFieldSelectorColorRGB(BaseModel): class ServiceFieldSelectorColorTemp(BaseModel): unit: Optional[str] = None - min: Optional[number] = None - max: Optional[number] = None - min_mireds: Optional[number] = None - max_mireds: Optional[number] = None + min: Optional[int | float] = None + max: Optional[int | float] = None + min_mireds: Optional[int | float] = None + max_mireds: Optional[int | float] = None class ServiceFieldSelectorCondition(BaseModel): @@ -242,7 +241,7 @@ class ServiceFieldSelectorConfigEntry(BaseModel): class ServiceFieldSelectorConstant(BaseModel): label: Optional[str] = None - value: Union[str, number, bool] + value: Union[str, int, float, bool] translation_key: Optional[str] = None @@ -349,9 +348,9 @@ class ServiceFieldSelectorNavigation(BaseModel): class ServiceFieldSelectorNumber(BaseModel): - min: Optional[number] = None - max: Optional[number] = None - step: Optional[Union[number, str]] = None + min: Optional[int | float] = None + max: Optional[int | float] = None + step: Optional[Union[int | float, str]] = None unit_of_measurement: Optional[str] = None mode: Optional[ServiceFieldSelectorNumberMode] = None slider_ticks: Optional[bool] = None @@ -374,7 +373,7 @@ class ServiceFieldSelectorObject(BaseModel): class ServiceFieldSelectorQRCode(BaseModel): data: str - scale: Optional[number] = None + scale: Optional[int | float] = None error_correction_level: Optional[ServiceFieldSelectorQRCodeErrorCorrectionLevel] = ( None ) @@ -555,14 +554,12 @@ class ServiceField(BaseModel): """Model for service parameters/fields.""" description: Optional[str] = None - example: Optional[Union[str, number, bool, List[str], Dict]] = None - default: Optional[Union[str, number, bool, List[str], Dict]] = None + example: Optional[JSONType] = None + default: Optional[JSONType] = None name: Optional[str] = None required: Optional[bool] = None advanced: Optional[bool] = None - selector: Optional[Dict[str, Any]] = ( - None # TODO: I believe it would be beneficial to parse it the way I do - ) + selector: Optional[ServiceFieldSelector] = None filter: Optional[ServiceFieldFilter] = None @@ -588,8 +585,8 @@ class Service(BaseModel): def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ Tuple[State, ...], - Tuple[Tuple[State, ...], Dict[str, Any]], - dict[str, Any], + Tuple[Tuple[State, ...], dict[str, JSONType]], + dict[str, JSONType], None, ]: """Triggers the service associated with this object.""" @@ -612,7 +609,7 @@ def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ async def async_trigger( self, entity_id: Optional[str] = None, **service_data - ) -> Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]]: + ) -> Union[Tuple[State, ...], Tuple[Tuple[State, ...], dict[str, JSONType]]]: """Triggers the service associated with this object.""" if entity_id is not None: service_data["entity_id"] = entity_id @@ -639,12 +636,14 @@ async def async_trigger( def __call__(self, entity_id: Optional[str] = None, **service_data) -> Union[ Union[ Tuple[State, ...], - Tuple[Tuple[State, ...], Dict[str, Any]], - dict[str, Any], + Tuple[Tuple[State, ...], dict[str, JSONType]], + dict[str, JSONType], None, ], Coroutine[ - Any, Any, Union[Tuple[State, ...], Tuple[Tuple[State, ...], Dict[str, Any]]] + Any, + Any, + Union[Tuple[State, ...], Tuple[Tuple[State, ...], dict[str, JSONType]]], ], ]: """ diff --git a/homeassistant_api/models/events.py b/homeassistant_api/models/events.py index 61de6da3..3007b1dc 100644 --- a/homeassistant_api/models/events.py +++ b/homeassistant_api/models/events.py @@ -1,9 +1,11 @@ """Event Model File""" -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Optional from pydantic import Field +from homeassistant_api.utils import JSONType + from .base import BaseModel if TYPE_CHECKING: @@ -38,6 +40,6 @@ async def async_fire(self, **event_data) -> str: return await self._client.async_fire_event(self.event, **event_data) @classmethod - def from_json(cls, json: Dict[str, Any], client: "Client") -> "Event": + def from_json(cls, json: dict[str, JSONType], client: "Client") -> "Event": """Constructs Event model from json data""" return cls(**json, _client=client) diff --git a/homeassistant_api/models/states.py b/homeassistant_api/models/states.py index faa4113d..895d3046 100644 --- a/homeassistant_api/models/states.py +++ b/homeassistant_api/models/states.py @@ -1,11 +1,13 @@ """Module for the Entity State model.""" from datetime import datetime, timezone -from typing import Any, Dict, Optional +from typing import Optional from pydantic import Field -from .base import BaseModel, DatetimeIsoField +from homeassistant_api.utils import JSONType + +from homeassistant_api.base import BaseModel, DatetimeIsoField class Context(BaseModel): @@ -25,7 +27,7 @@ class Context(BaseModel): ) @classmethod - def from_json(cls, json: Dict[str, Any]) -> "Context": + def from_json(cls, json: dict[str, JSONType]) -> "Context": """Constructs Context model from json data""" return cls.model_validate(json) @@ -37,7 +39,7 @@ class State(BaseModel): state: str = Field( ..., description="The string representation of the state of the entity." ) - attributes: Dict[str, Any] = Field( + attributes: dict[str, JSONType] = Field( {}, description="A dictionary of extra attributes of the state." ) last_changed: DatetimeIsoField = Field( @@ -57,6 +59,6 @@ class State(BaseModel): ) @classmethod - def from_json(cls, json: Dict[str, Any]) -> "State": + def from_json(cls, json: dict[str, JSONType]) -> "State": """Constructs State model from json data""" return cls.model_validate(json) diff --git a/homeassistant_api/models/websocket.py b/homeassistant_api/models/websocket.py index 7aae4b38..c31819d2 100644 --- a/homeassistant_api/models/websocket.py +++ b/homeassistant_api/models/websocket.py @@ -2,6 +2,8 @@ from typing import Any, Literal, Optional, Union +from homeassistant_api.utils import JSONType + from .base import BaseModel from .states import Context, DatetimeIsoField @@ -67,7 +69,7 @@ class FiredEvent(BaseModel): """A model to parse the `event` key of fired event websocket responses.""" event_type: str - data: dict[str, Any] + data: dict[str, JSONType] origin: Literal["LOCAL", "REMOTE"] # REMOTE if another API client or webhook fired the event @@ -79,14 +81,14 @@ class FiredEvent(BaseModel): class TemplateEvent(BaseModel): result: str - listeners: dict[str, Any] + listeners: dict[str, JSONType] class FiredTrigger(BaseModel): """A model to parse the `trigger` key of fired event websocket responses.""" context: Optional[Context] - variables: dict[str, Any] + variables: dict[str, JSONType] class EventResponse(BaseModel): diff --git a/homeassistant_api/processing.py b/homeassistant_api/processing.py index eb9636f4..137fd3a3 100644 --- a/homeassistant_api/processing.py +++ b/homeassistant_api/processing.py @@ -11,7 +11,9 @@ from requests import Response from requests_cache.models.response import CachedResponse -from .errors import ( +from homeassistant_api.utils import JSONType + +from homeassistant_api.errors import ( EndpointNotFoundError, InternalServerError, MalformedDataError, @@ -109,10 +111,10 @@ def process(self) -> Any: # List of default processors @Processing.processor("application/json") # type: ignore[arg-type] -def process_json(response: ResponseType) -> dict[str, Any]: +def process_json(response: ResponseType) -> dict[str, JSONType]: """Returns the json dict content of the response.""" try: - return cast(dict[str, Any], response.json()) + return cast(dict[str, JSONType], response.json()) except (json.JSONDecodeError, simplejson.JSONDecodeError) as err: raise MalformedDataError( f"Home Assistant responded with non-json response: {repr(response.text)}" @@ -127,10 +129,10 @@ def process_text(response: ResponseType) -> str: @Processing.processor("application/json") # type: ignore[arg-type] -async def async_process_json(response: AsyncResponseType) -> dict[str, Any]: +async def async_process_json(response: AsyncResponseType) -> dict[str, JSONType]: """Returns the json dict content of the response.""" try: - return cast(dict[str, Any], await response.json()) + return cast(dict[str, JSONType], await response.json()) except (json.JSONDecodeError, simplejson.JSONDecodeError) as err: raise MalformedDataError( f"Home Assistant responded with non-json response: {repr(await response.text())}" diff --git a/homeassistant_api/rawasyncclient.py b/homeassistant_api/rawasyncclient.py index 516e5453..0d6e0f8f 100644 --- a/homeassistant_api/rawasyncclient.py +++ b/homeassistant_api/rawasyncclient.py @@ -21,13 +21,13 @@ ) import aiohttp -import aiohttp_client_cache +import aiohttp_client_cache.session from .errors import BadTemplateError, RequestError, RequestTimeoutError from .models import Domain, Entity, Event, Group, History, LogbookEntry, State from .processing import AsyncResponseType, Processing from .rawbaseclient import RawBaseClient -from .utils import prepare_entity_id +from .utils import JSONType, prepare_entity_id if TYPE_CHECKING: from homeassistant_api import Client @@ -47,14 +47,14 @@ class RawAsyncClient(RawBaseClient): """ # pylint: disable=line-too-long async_cache_session: Union[ - aiohttp_client_cache.CachedSession, aiohttp.ClientSession + aiohttp_client_cache.session.CachedSession, aiohttp.ClientSession ] def __init__( self, *args, async_cache_session: Union[ - aiohttp_client_cache.CachedSession, + aiohttp_client_cache.session.CachedSession, Literal[False], Literal[None], ] = None, # Explicitly disable cache with async_cache_session=False @@ -129,12 +129,12 @@ async def async_get_error_log(self) -> str: """ return cast(str, await self.async_request("error_log")) - async def async_get_config(self) -> Dict[str, Any]: + async def async_get_config(self) -> dict[str, JSONType]: """ Returns the yaml configuration of homeassistant. :code:`GET /api/config` """ - return cast(Dict[str, Any], await self.async_request("config")) + return cast(dict[str, JSONType], await self.async_request("config")) async def async_get_logbook_entries( self, @@ -272,7 +272,7 @@ async def async_get_domains(self) -> Dict[str, Domain]: data = await self.async_request("services") domains = map( lambda json: Domain.from_json(json, client=cast(Client, self)), - cast(Tuple[Dict[str, Any], ...], data), + cast(Tuple[dict[str, JSONType], ...], data), ) return {domain.domain_id: domain for domain in domains} @@ -288,7 +288,7 @@ async def async_trigger_service( self, domain: str, service: str, - **service_data: Union[Dict[str, Any], List[Any], str], + **service_data: Union[dict[str, JSONType], List[Any], str], ) -> Tuple[State, ...]: """ Tells Home Assistant to trigger a service, returns all states changed while in the process of being called. @@ -305,8 +305,8 @@ async def async_trigger_service_with_response( self, domain: str, service: str, - **service_data: Union[Dict[str, Any], List[Any], str], - ) -> tuple[tuple[State, ...], dict[str, Any]]: + **service_data: Union[dict[str, JSONType], List[Any], str], + ) -> tuple[tuple[State, ...], dict[str, JSONType]]: """ Tells Home Assistant to trigger a service, returns the response from the service call. :code:`POST /api/services//` @@ -314,7 +314,7 @@ async def async_trigger_service_with_response( Returns a list of the states changed and the response from the service call. """ data = cast( - dict[str, Any], + dict[str, dict[str, JSONType]], await self.async_request( join("services", domain, service) + "?return_response", method="POST", @@ -383,7 +383,7 @@ async def async_get_events(self) -> Tuple[Event, ...]: return tuple( map( lambda json: Event.from_json(json, client=cast(Client, self)), - cast(List[Dict[str, Any]], data), + cast(List[dict[str, JSONType]], data), ) ) diff --git a/homeassistant_api/rawbaseclient.py b/homeassistant_api/rawbaseclient.py index c49d1fa4..a8a40d1c 100644 --- a/homeassistant_api/rawbaseclient.py +++ b/homeassistant_api/rawbaseclient.py @@ -2,9 +2,11 @@ from datetime import datetime, timedelta from posixpath import join -from typing import Any, Dict, Iterable, Optional, Tuple, Union +from typing import Dict, Iterable, Mapping, Optional, Tuple, Union from urllib.parse import quote_plus +from homeassistant_api.utils import JSONType + from .models import Entity @@ -13,20 +15,20 @@ class RawBaseClient: api_url: str token: str - global_request_kwargs: Dict[str, Any] + global_request_kwargs: dict[str, JSONType] def __init__( self, api_url: str, token: str, *, - global_request_kwargs: Optional[Dict[str, str]] = None, + global_request_kwargs: Optional[Mapping[str, str]] = None, ) -> None: if global_request_kwargs is None: global_request_kwargs = {} self.api_url = api_url self.token = token.strip() - self.global_request_kwargs = global_request_kwargs + self.global_request_kwargs = dict(global_request_kwargs) if not api_url.endswith("/"): self.api_url += "/" @@ -136,8 +138,14 @@ def prepare_get_logbook_entry_params( # Parameters are already URL encoded automatically. params.update(end_time=end_timestamp) if start_timestamp is not None: - if isinstance(start_timestamp, datetime): - url = join("logbook/", start_timestamp.isoformat()) + url = join( + "logbook/", + ( + start_timestamp.isoformat() + if isinstance(start_timestamp, datetime) + else start_timestamp + ), + ) else: url = "logbook" return params, url diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 9cb8c8b4..b3bcd150 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -22,12 +22,12 @@ import requests import requests_cache -from homeassistant_api.utils import prepare_entity_id +from homeassistant_api.utils import JSONType, prepare_entity_id -from .errors import BadTemplateError, RequestError, RequestTimeoutError -from .models import Domain, Entity, Event, Group, History, LogbookEntry, State -from .processing import Processing, ResponseType -from .rawbaseclient import RawBaseClient +from homeassistant_api.errors import BadTemplateError, RequestError, RequestTimeoutError +from homeassistant_api.models import Domain, Entity, Event, Group, History, LogbookEntry, State +from homeassistant_api.processing import Processing, ResponseType +from homeassistant_api.rawbaseclient import RawBaseClient if TYPE_CHECKING: from homeassistant_api import Client @@ -124,12 +124,12 @@ def get_error_log(self) -> str: """ return cast(str, self.request("error_log")) - def get_config(self) -> Dict[str, Any]: + def get_config(self) -> dict[str, JSONType]: """ Returns the yaml configuration of homeassistant. :code:`GET /api/config` """ - return cast(Dict[str, Any], self.request("config")) + return cast(dict[str, JSONType], self.request("config")) def get_logbook_entries( self, @@ -200,7 +200,7 @@ def check_api_config(self) -> bool: :code:`POST /api/config/core/check_config` """ res = cast( - Dict[str, Any], self.request("config/core/check_config", method="POST") + dict[str, str], self.request("config/core/check_config", method="POST") ) valid = {"valid": True, "invalid": False}.get(res["result"], False) return valid @@ -211,7 +211,7 @@ def check_api_running(self) -> bool: :code:`GET /api/` """ res = self.request("") - return cast(Dict[str, Any], res).get("message") == "API running." + return cast(dict[str, JSONType], res).get("message") == "API running." # Entity methods def get_entities(self) -> Dict[str, Group]: @@ -272,7 +272,7 @@ def get_domains(self) -> Dict[str, Domain]: pprint.pprint(*(domain for domain in data if domain["domain"] == "light")) domains = map( lambda json: Domain.from_json(json, client=cast(Client, self)), - cast(Tuple[Dict[str, Any], ...], data), + cast(Tuple[dict[str, JSONType], ...], data), ) return {domain.domain_id: domain for domain in domains} @@ -298,14 +298,14 @@ def trigger_service( method="POST", json=service_data, ) - return tuple(map(State.from_json, cast(List[Dict[str, Any]], data))) + return tuple(map(State.from_json, cast(List[dict[str, JSONType]], data))) def trigger_service_with_response( self, domain: str, service: str, **service_data, - ) -> tuple[tuple[State, ...], dict[str, Any]]: + ) -> tuple[tuple[State, ...], dict[str, JSONType]]: """ Tells Home Assistant to trigger a service, returns the response from the service call. :code:`POST /api/services//` @@ -313,7 +313,7 @@ def trigger_service_with_response( Returns a list of the states changed and the response from the service call. """ data = cast( - dict[str, Any], + dict[str, dict[str, JSONType]], self.request( join("services", domain, service) + "?return_response", method="POST", @@ -346,7 +346,7 @@ def get_state( # pylint: disable=duplicate-code entity_id=entity_id, ) data = self.request(join("states", entity_id)) - return State.from_json(cast(Dict[str, Any], data)) + return State.from_json(cast(dict[str, JSONType], data)) def set_state( # pylint: disable=duplicate-code self, @@ -362,7 +362,7 @@ def set_state( # pylint: disable=duplicate-code method="POST", json=json.loads(state.model_dump_json()), ) - return State.from_json(cast(Dict[str, Any], data)) + return State.from_json(cast(dict[str, JSONType], data)) def get_states(self) -> Tuple[State, ...]: """ @@ -370,7 +370,7 @@ def get_states(self) -> Tuple[State, ...]: :code:`GET /api/states` """ data = self.request("states") - states = map(State.from_json, cast(List[Dict[str, Any]], data)) + states = map(State.from_json, cast(List[dict[str, JSONType]], data)) return tuple(states) # Event methods @@ -383,7 +383,7 @@ def get_events(self) -> Tuple[Event, ...]: return tuple( map( lambda json: Event.from_json(json, client=cast(Client, self)), - cast(List[Dict[str, Any]], data), + cast(List[dict[str, JSONType]], data), ) ) @@ -407,7 +407,7 @@ def fire_event(self, event_type: str, **event_data) -> Optional[str]: method="POST", json=event_data, ) - return cast(dict[str, Any], data).get("message") + return cast(dict[str, str], data).get("message") def get_components(self) -> Tuple[str, ...]: """ diff --git a/homeassistant_api/rawwebsocket.py b/homeassistant_api/rawwebsocket.py index 30c7d399..9f7e41e0 100644 --- a/homeassistant_api/rawwebsocket.py +++ b/homeassistant_api/rawwebsocket.py @@ -21,6 +21,7 @@ PingResponse, ResultResponse, ) +from homeassistant_api.utils import JSONType logger = logging.getLogger(__name__) @@ -60,6 +61,8 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): + if not self._conn: + raise ReceivingError("Connection is not open!") self._conn.__exit__(exc_type, exc_value, traceback) self._conn = None @@ -68,14 +71,14 @@ def _request_id(self) -> int: self._id_counter += 1 return self._id_counter - def _send(self, data: dict[str, Any]) -> None: + def _send(self, data: dict[str, JSONType]) -> None: """Send a message to the websocket server.""" logger.debug(f"Sending message: {data}") if self._conn is None: raise ReceivingError("Connection is not open!") self._conn.send(json.dumps(data)) - def _recv(self) -> dict[str, Any]: + def _recv(self) -> dict[str, JSONType]: """Receive a message from the websocket server.""" if self._conn is None: raise ReceivingError("Connection is not open!") @@ -108,7 +111,7 @@ def send(self, type: str, include_id: bool = True, **data: Any) -> int: return data["id"] return -1 # non-command messages don't have an id - def check_success(self, data: dict[str, Any]) -> None: + def check_success(self, data: dict[str, JSONType]) -> None: """Check if a command message was successful.""" try: error_resp = ErrorResponse.model_validate(data) @@ -116,7 +119,7 @@ def check_success(self, data: dict[str, Any]) -> None: except ValidationError: pass - def handle_recv(self, data: dict[str, Any]) -> None: + def handle_recv(self, data: dict[str, JSONType]) -> None: """Handle a received message.""" if "id" not in data: raise ReceivingError( @@ -125,16 +128,17 @@ def handle_recv(self, data: dict[str, Any]) -> None: self.check_success(data) self.parse_response(data) - def parse_response(self, data: dict[str, Any]) -> None: + def parse_response(self, data: dict[str, JSONType]) -> None: + data_id = cast(int, data["id"]) if data.get("type") == "pong": logger.info("Received pong message") - self._ping_responses[data["id"]].end = time.perf_counter_ns() + self._ping_responses[data_id].end = time.perf_counter_ns() elif data.get("type") == "result": logger.info("Received result message") - self._result_responses[data["id"]] = ResultResponse.model_validate(data) + self._result_responses[data_id] = ResultResponse.model_validate(data) elif data.get("type") == "event": logger.info("Received event message %s", data["event"]) - self._event_responses[data["id"]].append(EventResponse.model_validate(data)) + self._event_responses[data_id].append(EventResponse.model_validate(data)) else: raise ReceivingError(f"Received unexpected message type: {data}") diff --git a/homeassistant_api/utils.py b/homeassistant_api/utils.py index ff2947ce..291d678f 100644 --- a/homeassistant_api/utils.py +++ b/homeassistant_api/utils.py @@ -1,6 +1,8 @@ import re from typing import Optional +JSONType = int | float | str | bool | list["JSONType"] | dict[str, "JSONType"] + def format_entity_id(entity_id: str) -> str: """Takes in a string and formats it into valid snake_case.""" diff --git a/homeassistant_api/websocket.py b/homeassistant_api/websocket.py index 81eac53a..a2cfd564 100644 --- a/homeassistant_api/websocket.py +++ b/homeassistant_api/websocket.py @@ -1,7 +1,7 @@ import contextlib import logging import urllib.parse as urlparse -from typing import Any, Dict, Generator, Optional, Tuple, Union, cast +from typing import Dict, Generator, Optional, Tuple, Union, cast from homeassistant_api.models import Domain, Entity, Group, State from homeassistant_api.models.states import Context @@ -12,7 +12,7 @@ ResultResponse, TemplateEvent, ) -from homeassistant_api.utils import prepare_entity_id +from homeassistant_api.utils import JSONType, prepare_entity_id from .rawwebsocket import RawWebsocketClient @@ -63,14 +63,14 @@ def get_rendered_template(self, template: str) -> str: self._unsubscribe(id) return cast(TemplateEvent, cast(EventResponse, second).event).result - def get_config(self) -> dict[str, Any]: + def get_config(self) -> dict[str, JSONType]: """ Get the Home Assistant configuration. Sends command :code:`{"type": "get_config", ...}`. """ return cast( - dict[str, Any], + dict[str, JSONType], cast( ResultResponse, self.recv(self.send("get_config")), @@ -86,7 +86,7 @@ def get_states(self) -> Tuple[State, ...]: return tuple( State.from_json(state) for state in cast( - list[dict[str, Any]], + list[dict[str, JSONType]], cast(ResultResponse, self.recv(self.send("get_states"))).result, ) ) @@ -179,7 +179,7 @@ def get_domains(self) -> dict[str, Domain]: {"domain": item[0], "services": item[1]}, client=self, ), - cast(dict[str, Any], cast(ResultResponse, resp).result).items(), + cast(dict[str, JSONType], cast(ResultResponse, resp).result).items(), ) return {domain.domain_id: domain for domain in domains} @@ -221,7 +221,7 @@ def trigger_service( assert ( cast( - dict[str, Any], + dict[str, JSONType], cast(ResultResponse, data).result, ).get("response") is None @@ -233,7 +233,7 @@ def trigger_service_with_response( service: str, entity_id: Optional[str] = None, **service_data, - ) -> dict[str, Any]: + ) -> dict[str, JSONType]: """ Trigger a service (that returns a response) and return the response. @@ -250,7 +250,9 @@ def trigger_service_with_response( data = self.recv(self.send("call_service", include_id=True, **params)) - return cast(dict[str, Any], cast(ResultResponse, data).result)["response"] + return cast(dict[str, dict[str, JSONType]], cast(ResultResponse, data).result)[ + "response" + ] @contextlib.contextmanager def listen_events( @@ -285,7 +287,7 @@ def _subscribe_events(self, event_type: Optional[str]) -> int: @contextlib.contextmanager def listen_trigger( self, trigger: str, **trigger_fields - ) -> Generator[Generator[dict[str, Any], None, None], None, None]: + ) -> Generator[Generator[dict[str, JSONType], None, None], None, None]: """ Listen to a Home Assistant trigger. Allows additional trigger keyword parameters with :code:`**kwargs` (i.e. passing :code:`tag_id=...` for NFC tag triggers). @@ -364,12 +366,12 @@ def fire_event(self, event_type: str, **event_data) -> Context: Sends command :code:`{"type": "fire_event", ...}`. """ - params: dict[str, Any] = {"event_type": event_type} + params: dict[str, JSONType] = {"event_type": event_type} if event_data: params["event_data"] = event_data return Context.from_json( cast( - dict[str, dict[str, Any]], + dict[str, dict[str, JSONType]], cast( ResultResponse, self.recv(self.send("fire_event", include_id=True, **params)), From 65d788fdc8242fb0694fba30bce9841e41628546 Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 30 Aug 2025 13:27:05 -0600 Subject: [PATCH 11/25] Remove debugging imports and things --- homeassistant_api/models/domains.py | 2 +- homeassistant_api/models/states.py | 2 +- homeassistant_api/rawclient.py | 13 +++++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 44fa24c7..026ba1d9 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -563,7 +563,7 @@ class ServiceField(BaseModel): filter: Optional[ServiceFieldFilter] = None -class ServiceFieldCollection(BaseModel, extra="forbid"): +class ServiceFieldCollection(BaseModel): collapsed: Optional[bool] = None fields: Dict[str, ServiceField] diff --git a/homeassistant_api/models/states.py b/homeassistant_api/models/states.py index 895d3046..a6fcdafd 100644 --- a/homeassistant_api/models/states.py +++ b/homeassistant_api/models/states.py @@ -7,7 +7,7 @@ from homeassistant_api.utils import JSONType -from homeassistant_api.base import BaseModel, DatetimeIsoField +from homeassistant_api.models.base import BaseModel, DatetimeIsoField class Context(BaseModel): diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index b3bcd150..3847968e 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -25,7 +25,15 @@ from homeassistant_api.utils import JSONType, prepare_entity_id from homeassistant_api.errors import BadTemplateError, RequestError, RequestTimeoutError -from homeassistant_api.models import Domain, Entity, Event, Group, History, LogbookEntry, State +from homeassistant_api.models import ( + Domain, + Entity, + Event, + Group, + History, + LogbookEntry, + State, +) from homeassistant_api.processing import Processing, ResponseType from homeassistant_api.rawbaseclient import RawBaseClient @@ -267,9 +275,6 @@ def get_domains(self) -> Dict[str, Domain]: :code:`GET /api/services` """ data = self.request("services") - import pprint - - pprint.pprint(*(domain for domain in data if domain["domain"] == "light")) domains = map( lambda json: Domain.from_json(json, client=cast(Client, self)), cast(Tuple[dict[str, JSONType], ...], data), From b80386dbfd9656573fd07779569bc5903cde1a92 Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 30 Aug 2025 13:43:27 -0600 Subject: [PATCH 12/25] Fix circular type schema error --- docs/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 45daf466..75eb722c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,6 +46,8 @@ "sphinx.ext.autosectionlabel", ] +autodoc_pydantic_model_show_json_error_strategy = 'coerce' + resource_links = { "repo": "https://github.com/GrandMoff100/HomeassistantAPI/", "issues": "https://github.com/GrandMoff100/HomeassistantAPI/issues", From 0907e5a1663c15ef1ce8b5fa19c35cbb1639e58f Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 30 Aug 2025 14:00:51 -0600 Subject: [PATCH 13/25] Fix type aliasing --- .pre-commit-config.yaml | 2 +- docs/conf.py | 4 +++- homeassistant_api/models/base.py | 5 +++++ homeassistant_api/models/states.py | 5 +++++ homeassistant_api/models/websocket.py | 4 ++-- homeassistant_api/processing.py | 2 +- homeassistant_api/rawclient.py | 2 +- homeassistant_api/rawwebsocket.py | 4 ++-- homeassistant_api/utils.py | 4 ++-- 9 files changed, 22 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4217510d..b99dbd07 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: additional_dependencies: - types-requests - types-simplejson - rev: "v0.931" + rev: "v1.17.1" - repo: https://github.com/pre-commit/pre-commit-hooks hooks: - id: trailing-whitespace diff --git a/docs/conf.py b/docs/conf.py index 75eb722c..498cd754 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,9 @@ # The full version, including alpha/beta/rc tags with open("../pyproject.toml") as f: pyproject = f.read() - release = version = re.search('version = "(.+?)"', pyproject).group(1) + search_result = re.search('version = "(.+?)"', pyproject) + assert search_result is not None + release = version = search_result.group(1) # -- General configuration --------------------------------------------------- diff --git a/homeassistant_api/models/base.py b/homeassistant_api/models/base.py index 0acaa55a..bc32d290 100644 --- a/homeassistant_api/models/base.py +++ b/homeassistant_api/models/base.py @@ -6,6 +6,11 @@ from pydantic import BaseModel as PydanticBaseModel from pydantic import ConfigDict, PlainSerializer +__all__ = ( + "BaseModel", + "DatetimeIsoField", +) + DatetimeIsoField = Annotated[ datetime, PlainSerializer(lambda x: x.isoformat(), return_type=str, when_used="json"), diff --git a/homeassistant_api/models/states.py b/homeassistant_api/models/states.py index a6fcdafd..e6b3ff2e 100644 --- a/homeassistant_api/models/states.py +++ b/homeassistant_api/models/states.py @@ -9,6 +9,11 @@ from homeassistant_api.models.base import BaseModel, DatetimeIsoField +__all__ = ( + "Context", + "State", +) + class Context(BaseModel): """Model for entity state contexts.""" diff --git a/homeassistant_api/models/websocket.py b/homeassistant_api/models/websocket.py index c31819d2..d5ead047 100644 --- a/homeassistant_api/models/websocket.py +++ b/homeassistant_api/models/websocket.py @@ -4,8 +4,8 @@ from homeassistant_api.utils import JSONType -from .base import BaseModel -from .states import Context, DatetimeIsoField +from .base import BaseModel, DatetimeIsoField +from .states import Context __all__ = ( "AuthRequired", diff --git a/homeassistant_api/processing.py b/homeassistant_api/processing.py index 137fd3a3..4307a351 100644 --- a/homeassistant_api/processing.py +++ b/homeassistant_api/processing.py @@ -61,7 +61,7 @@ def process_content(self, *, async_: bool = False) -> Any: calls the processor with the response. """ - mimetype_header = self._response.headers.get( # type: ignore [arg-type] + mimetype_header = self._response.headers.get( "content-type", "text/plain", ) diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 3847968e..046da960 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -73,7 +73,7 @@ def __init__( if cache_session is False: self.cache_session = requests.Session() elif cache_session is None: - self.cache_session = requests_cache.CachedSession( # type: ignore[attr-defined] + self.cache_session = requests_cache.CachedSession( cache_name="default_cache", backend="memory", expire_after=300, diff --git a/homeassistant_api/rawwebsocket.py b/homeassistant_api/rawwebsocket.py index 9f7e41e0..3661b300 100644 --- a/homeassistant_api/rawwebsocket.py +++ b/homeassistant_api/rawwebsocket.py @@ -84,7 +84,7 @@ def _recv(self) -> dict[str, JSONType]: raise ReceivingError("Connection is not open!") _bytes = self._conn.recv() logger.debug("Received message: %s", _bytes) - return json.loads(_bytes) + return cast(dict[str, JSONType], json.loads(_bytes)) def send(self, type: str, include_id: bool = True, **data: Any) -> int: """ @@ -95,7 +95,7 @@ def send(self, type: str, include_id: bool = True, **data: Any) -> int: if include_id: # auth messages don't have an id data["id"] = self._request_id() data["type"] = type - + assert isinstance(data["id"], int) self._send(data) if "id" in data: diff --git a/homeassistant_api/utils.py b/homeassistant_api/utils.py index 291d678f..8aca701a 100644 --- a/homeassistant_api/utils.py +++ b/homeassistant_api/utils.py @@ -1,7 +1,7 @@ import re -from typing import Optional +from typing import Optional, TypeAlias, Union -JSONType = int | float | str | bool | list["JSONType"] | dict[str, "JSONType"] +JSONType: TypeAlias = Union[int, float, str, bool, list["JSONType"], dict[str, "JSONType"]] def format_entity_id(entity_id: str) -> str: From 6aa0dd79723736e0fb5d5a2f57b9d6cf0d7d336e Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 30 Aug 2025 14:11:02 -0600 Subject: [PATCH 14/25] Fix TypeAlias import --- homeassistant_api/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant_api/utils.py b/homeassistant_api/utils.py index 8aca701a..10f41bc2 100644 --- a/homeassistant_api/utils.py +++ b/homeassistant_api/utils.py @@ -1,5 +1,7 @@ import re -from typing import Optional, TypeAlias, Union +from typing import Optional, Union +from typing_extensions import TypeAlias + JSONType: TypeAlias = Union[int, float, str, bool, list["JSONType"], dict[str, "JSONType"]] From 8e0ab318726ed5c21417a5940883a7a06174f2d7 Mon Sep 17 00:00:00 2001 From: Nate Date: Sat, 30 Aug 2025 14:11:21 -0600 Subject: [PATCH 15/25] Update dependencies --- poetry.lock | 123 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 74 insertions(+), 49 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7ce8961f..1cf1d6d6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -119,7 +119,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] +speedups = ["Brotli", "aiodns (>=3.3.0)", "brotlicffi"] [[package]] name = "aiohttp-client-cache" @@ -250,12 +250,12 @@ files = [ ] [package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "autodoc-pydantic" @@ -316,8 +316,8 @@ typing-extensions = {version = ">=4.1.0,<4.6.3 || >4.6.3", markers = "python_ver bson = ["pymongo (>=4.4.0)"] cbor2 = ["cbor2 (>=5.4.6)"] msgpack = ["msgpack (>=1.0.5)"] -msgspec = ["msgspec (>=0.18.5) ; implementation_name == \"cpython\""] -orjson = ["orjson (>=3.9.2) ; implementation_name == \"cpython\""] +msgspec = ["msgspec (>=0.18.5)"] +orjson = ["orjson (>=3.9.2)"] pyyaml = ["pyyaml (>=6.0)"] tomlkit = ["tomlkit (>=0.11.8)"] ujson = ["ujson (>=5.7.0)"] @@ -545,7 +545,7 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +toml = ["tomli"] [[package]] name = "distlib" @@ -602,7 +602,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] +typing = ["typing-extensions (>=4.12.2)"] [[package]] name = "frozenlist" @@ -750,12 +750,12 @@ files = [ zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] @@ -977,48 +977,61 @@ typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} [[package]] name = "mypy" -version = "1.11.2" +version = "1.17.1" description = "Optional static typing for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["styling"] files = [ - {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, - {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, - {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, - {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, - {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, - {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, - {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, - {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, - {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, - {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, - {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, - {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, - {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, - {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, - {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, - {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, - {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, - {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, - {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, - {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, - {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, - {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, - {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, - {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, - {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, - {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, - {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, + {file = "mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972"}, + {file = "mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df"}, + {file = "mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390"}, + {file = "mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94"}, + {file = "mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58"}, + {file = "mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd"}, + {file = "mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b"}, + {file = "mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5"}, + {file = "mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb"}, + {file = "mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056"}, + {file = "mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341"}, + {file = "mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb"}, + {file = "mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7"}, + {file = "mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6"}, + {file = "mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849"}, + {file = "mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14"}, + {file = "mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733"}, + {file = "mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0"}, + {file = "mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a"}, + {file = "mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91"}, + {file = "mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d1092694f166a7e56c805caaf794e0585cabdbf1df36911c414e4e9abb62ae9"}, + {file = "mypy-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79d44f9bfb004941ebb0abe8eff6504223a9c1ac51ef967d1263c6572bbebc99"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b01586eed696ec905e61bd2568f48740f7ac4a45b3a468e6423a03d3788a51a8"}, + {file = "mypy-1.17.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43808d9476c36b927fbcd0b0255ce75efe1b68a080154a38ae68a7e62de8f0f8"}, + {file = "mypy-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:feb8cc32d319edd5859da2cc084493b3e2ce5e49a946377663cc90f6c15fb259"}, + {file = "mypy-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7598cf74c3e16539d4e2f0b8d8c318e00041553d83d4861f87c7a72e95ac24d"}, + {file = "mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9"}, + {file = "mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01"}, ] [package.dependencies] -mypy-extensions = ">=1.0.0" +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.6.0" +typing_extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] @@ -1059,6 +1072,18 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["styling"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + [[package]] name = "platformdirs" version = "4.3.6" @@ -1578,7 +1603,7 @@ urllib3 = ">=1.25.5" [package.extras] all = ["boto3 (>=1.15)", "botocore (>=1.18)", "itsdangerous (>=2.0)", "pymongo (>=3)", "pyyaml (>=5.4)", "redis (>=3)", "ujson (>=4.0)"] bson = ["bson (>=0.5)"] -docs = ["furo (>=2021.9.8)", "linkify-it-py (>=1.0.1,<2.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx (==4.3.0)", "sphinx-autodoc-typehints (>=1.11,<2.0)", "sphinx-automodapi (>=0.13,<0.15)", "sphinx-copybutton (>=0.3,<0.5)", "sphinx-inline-tabs (>=2022.1.2b11) ; python_version >= \"3.8\"", "sphinx-notfound-page (>=0.8)", "sphinx-panels (>=0.6,<0.7)", "sphinxcontrib-apidoc (>=0.3,<0.4)"] +docs = ["furo (>=2021.9.8)", "linkify-it-py (>=1.0.1,<2.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx (==4.3.0)", "sphinx-autodoc-typehints (>=1.11,<2.0)", "sphinx-automodapi (>=0.13,<0.15)", "sphinx-copybutton (>=0.3,<0.5)", "sphinx-inline-tabs (>=2022.1.2b11)", "sphinx-notfound-page (>=0.8)", "sphinx-panels (>=0.6,<0.7)", "sphinxcontrib-apidoc (>=0.3,<0.4)"] dynamodb = ["boto3 (>=1.15)", "botocore (>=1.18)"] json = ["ujson (>=4.0)"] mongodb = ["pymongo (>=3)"] @@ -2058,7 +2083,7 @@ files = [ ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2082,7 +2107,7 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] name = "websockets" @@ -2274,11 +2299,11 @@ files = [ ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] From 557d01181ce59fbd70fdca5d9157144d4db6b192 Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 1 Sep 2025 15:51:08 -0600 Subject: [PATCH 16/25] Actually fix recursion error with autodoc-pydantic --- docs/conf.py | 2 +- homeassistant_api/models/base.py | 1 + homeassistant_api/models/states.py | 3 +-- homeassistant_api/processing.py | 3 +-- homeassistant_api/rawclient.py | 3 +-- homeassistant_api/utils.py | 11 ++++++++--- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 498cd754..bb47cc19 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,7 +48,7 @@ "sphinx.ext.autosectionlabel", ] -autodoc_pydantic_model_show_json_error_strategy = 'coerce' +autodoc_pydantic_model_show_json = False resource_links = { "repo": "https://github.com/GrandMoff100/HomeassistantAPI/", diff --git a/homeassistant_api/models/base.py b/homeassistant_api/models/base.py index bc32d290..4cf455a3 100644 --- a/homeassistant_api/models/base.py +++ b/homeassistant_api/models/base.py @@ -24,4 +24,5 @@ class BaseModel(PydanticBaseModel): arbitrary_types_allowed=True, validate_assignment=True, extra="forbid", + protected_namespaces=(), ) diff --git a/homeassistant_api/models/states.py b/homeassistant_api/models/states.py index e6b3ff2e..0af16763 100644 --- a/homeassistant_api/models/states.py +++ b/homeassistant_api/models/states.py @@ -5,9 +5,8 @@ from pydantic import Field -from homeassistant_api.utils import JSONType - from homeassistant_api.models.base import BaseModel, DatetimeIsoField +from homeassistant_api.utils import JSONType __all__ = ( "Context", diff --git a/homeassistant_api/processing.py b/homeassistant_api/processing.py index 4307a351..e4fa548f 100644 --- a/homeassistant_api/processing.py +++ b/homeassistant_api/processing.py @@ -11,8 +11,6 @@ from requests import Response from requests_cache.models.response import CachedResponse -from homeassistant_api.utils import JSONType - from homeassistant_api.errors import ( EndpointNotFoundError, InternalServerError, @@ -23,6 +21,7 @@ UnauthorizedError, UnexpectedStatusCodeError, ) +from homeassistant_api.utils import JSONType logger = logging.getLogger(__name__) diff --git a/homeassistant_api/rawclient.py b/homeassistant_api/rawclient.py index 046da960..e78a3288 100644 --- a/homeassistant_api/rawclient.py +++ b/homeassistant_api/rawclient.py @@ -22,8 +22,6 @@ import requests import requests_cache -from homeassistant_api.utils import JSONType, prepare_entity_id - from homeassistant_api.errors import BadTemplateError, RequestError, RequestTimeoutError from homeassistant_api.models import ( Domain, @@ -36,6 +34,7 @@ ) from homeassistant_api.processing import Processing, ResponseType from homeassistant_api.rawbaseclient import RawBaseClient +from homeassistant_api.utils import JSONType, prepare_entity_id if TYPE_CHECKING: from homeassistant_api import Client diff --git a/homeassistant_api/utils.py b/homeassistant_api/utils.py index 10f41bc2..c027e421 100644 --- a/homeassistant_api/utils.py +++ b/homeassistant_api/utils.py @@ -1,9 +1,14 @@ import re -from typing import Optional, Union -from typing_extensions import TypeAlias +from typing import TYPE_CHECKING, Optional, Union +from typing_extensions import TypeAlias -JSONType: TypeAlias = Union[int, float, str, bool, list["JSONType"], dict[str, "JSONType"]] +if TYPE_CHECKING: + JSONType: TypeAlias = Union[ + int, float, str, bool, list["JSONType"], dict[str, "JSONType"] + ] +else: + JSONType = type("JSONType", (object,), {}) def format_entity_id(entity_id: str) -> str: From 195defe9ed10d0378e1fb3fd501039b0bc9b6a14 Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 1 Sep 2025 15:57:40 -0600 Subject: [PATCH 17/25] Fix typing issues --- homeassistant_api/models/domains.py | 20 ++++++++++---------- homeassistant_api/processing.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 026ba1d9..0fe8ac7c 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -123,8 +123,8 @@ class ServiceFieldSelectorDeviceFilter(BaseModel): class CropOptions(BaseModel): round: bool type: Optional[str] # "image/jpeg" / "image/png" - quality: Optional[int | float] = None - aspectRatio: Optional[int | float] = None + quality: Optional[Union[int, float]] = None + aspectRatio: Optional[Union[int, float]] = None class SelectBoxOptionImage(BaseModel): @@ -225,10 +225,10 @@ class ServiceFieldSelectorColorRGB(BaseModel): class ServiceFieldSelectorColorTemp(BaseModel): unit: Optional[str] = None - min: Optional[int | float] = None - max: Optional[int | float] = None - min_mireds: Optional[int | float] = None - max_mireds: Optional[int | float] = None + min: Optional[Union[int, float]] = None + max: Optional[Union[int, float]] = None + min_mireds: Optional[Union[int, float]] = None + max_mireds: Optional[Union[int, float]] = None class ServiceFieldSelectorCondition(BaseModel): @@ -348,9 +348,9 @@ class ServiceFieldSelectorNavigation(BaseModel): class ServiceFieldSelectorNumber(BaseModel): - min: Optional[int | float] = None - max: Optional[int | float] = None - step: Optional[Union[int | float, str]] = None + min: Optional[Union[int, float]] = None + max: Optional[Union[int, float]] = None + step: Optional[Union[Union[int, float], str]] = None unit_of_measurement: Optional[str] = None mode: Optional[ServiceFieldSelectorNumberMode] = None slider_ticks: Optional[bool] = None @@ -373,7 +373,7 @@ class ServiceFieldSelectorObject(BaseModel): class ServiceFieldSelectorQRCode(BaseModel): data: str - scale: Optional[int | float] = None + scale: Optional[Union[int, float]] = None error_correction_level: Optional[ServiceFieldSelectorQRCodeErrorCorrectionLevel] = ( None ) diff --git a/homeassistant_api/processing.py b/homeassistant_api/processing.py index e4fa548f..1e430c71 100644 --- a/homeassistant_api/processing.py +++ b/homeassistant_api/processing.py @@ -121,7 +121,7 @@ def process_json(response: ResponseType) -> dict[str, JSONType]: @Processing.processor("text/plain") # type: ignore[arg-type] -@Processing.processor("application/octet-stream") # pyright: ignore[reportArgumentType] +@Processing.processor("application/octet-stream") # type: ignore[arg-type] def process_text(response: ResponseType) -> str: """Returns the plaintext of the reponse.""" return response.text @@ -139,7 +139,7 @@ async def async_process_json(response: AsyncResponseType) -> dict[str, JSONType] @Processing.processor("text/plain") # type: ignore[arg-type] -@Processing.processor("application/octet-stream") # pyright: ignore[reportArgumentType] +@Processing.processor("application/octet-stream") # type: ignore[arg-type] async def async_process_text(response: AsyncResponseType) -> str: """Returns the plaintext of the reponse.""" return await response.text() From 295144b650ce92b83cf651241ad158e5a5674360 Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 1 Sep 2025 16:14:09 -0600 Subject: [PATCH 18/25] Implement @kpustelnik's optional type changes --- homeassistant_api/models/domains.py | 16 ++++++++-------- homeassistant_api/utils.py | 5 +++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 0fe8ac7c..196fc197 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -122,7 +122,7 @@ class ServiceFieldSelectorDeviceFilter(BaseModel): class CropOptions(BaseModel): round: bool - type: Optional[str] # "image/jpeg" / "image/png" + type: Optional[str] = None # "image/jpeg" / "image/png" quality: Optional[Union[int, float]] = None aspectRatio: Optional[Union[int, float]] = None @@ -215,8 +215,8 @@ class ServiceFieldSelectorBoolean(BaseModel): class ServiceFieldSelectorButtonToggle(BaseModel): options: List[Union[str, ServiceFieldSelectorSelectOption]] - translation_key: Optional[str] - sort: Optional[bool] + translation_key: Optional[str] = None + sort: Optional[bool] = None class ServiceFieldSelectorColorRGB(BaseModel): @@ -250,7 +250,7 @@ class ServiceFieldSelectorConversationAgent(BaseModel): class ServiceFieldSelectorCountry(BaseModel): - countries: Optional[List[str]] = None + countries: List[str] no_sort: Optional[bool] = None @@ -367,7 +367,7 @@ class ServiceFieldSelectorObject(BaseModel): label_field: Optional[str] = None description_field: Optional[str] = None translation_key: Optional[str] = None - fields: Optional[Dict[str, ServiceFieldSelectorObjectField]] = None + fields: Dict[str, ServiceFieldSelectorObjectField] multiple: Optional[bool] = None @@ -392,7 +392,7 @@ class ServiceFieldSelectorSelect(BaseModel): multiple: Optional[bool] = None custom_value: Optional[bool] = None mode: Optional[ServiceFieldSelectorSelectMode] = None - options: Optional[List[Union[str, ServiceFieldSelectorSelectOption]]] = None + options: List[Union[str, ServiceFieldSelectorSelectOption]] translation_key: Optional[str] = None sort: Optional[bool] = None reorder: Optional[bool] = None @@ -409,8 +409,8 @@ class ServiceFieldSelectorStateOption(BaseModel): class ServiceFieldSelectorState(BaseModel): - extra_options: Optional[List[ServiceFieldSelectorStateOption]] - entity_id: Optional[Union[str, List[str]]] + extra_options: Optional[List[ServiceFieldSelectorStateOption]] = None + entity_id: Optional[Union[str, List[str]]] = None attribute: Optional[str] = None hide_states: Optional[List[str]] = None multiple: Optional[bool] = None diff --git a/homeassistant_api/utils.py b/homeassistant_api/utils.py index c027e421..f2d4034b 100644 --- a/homeassistant_api/utils.py +++ b/homeassistant_api/utils.py @@ -1,15 +1,16 @@ import re +import os from typing import TYPE_CHECKING, Optional, Union from typing_extensions import TypeAlias -if TYPE_CHECKING: +if TYPE_CHECKING or os.getenv("DOCUMENTATION_MODE") != "true": JSONType: TypeAlias = Union[ int, float, str, bool, list["JSONType"], dict[str, "JSONType"] ] else: JSONType = type("JSONType", (object,), {}) - + def format_entity_id(entity_id: str) -> str: """Takes in a string and formats it into valid snake_case.""" From 5e58e41021d4594df7837c991d66601715f0c268 Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 1 Sep 2025 16:26:53 -0600 Subject: [PATCH 19/25] Fix recursive type to work with pydantic --- homeassistant_api/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant_api/utils.py b/homeassistant_api/utils.py index f2d4034b..ea529d16 100644 --- a/homeassistant_api/utils.py +++ b/homeassistant_api/utils.py @@ -2,12 +2,12 @@ import os from typing import TYPE_CHECKING, Optional, Union -from typing_extensions import TypeAlias +from typing_extensions import TypeAliasType if TYPE_CHECKING or os.getenv("DOCUMENTATION_MODE") != "true": - JSONType: TypeAlias = Union[ - int, float, str, bool, list["JSONType"], dict[str, "JSONType"] - ] + JSONType = TypeAliasType( + "JSONType", "Union[int, float, str, bool, list[JSONType], dict[str, JSONType]]" + ) else: JSONType = type("JSONType", (object,), {}) From 85920c76ed457eccfc78c356ab490ceed2fc6d63 Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 1 Sep 2025 16:30:59 -0600 Subject: [PATCH 20/25] Tell ruff that Union actually is used --- homeassistant_api/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant_api/utils.py b/homeassistant_api/utils.py index ea529d16..2b0f29fc 100644 --- a/homeassistant_api/utils.py +++ b/homeassistant_api/utils.py @@ -1,6 +1,6 @@ -import re import os -from typing import TYPE_CHECKING, Optional, Union +import re +from typing import TYPE_CHECKING, Optional, Union # noqa: F401 from typing_extensions import TypeAliasType @@ -10,7 +10,7 @@ ) else: JSONType = type("JSONType", (object,), {}) - + def format_entity_id(entity_id: str) -> str: """Takes in a string and formats it into valid snake_case.""" From 066ace58581b15474ffbe7b465e55561fd30bdc2 Mon Sep 17 00:00:00 2001 From: Kamil Pustelnik <147668889+kpustelnik@users.noreply.github.com> Date: Tue, 2 Sep 2025 22:23:12 +0200 Subject: [PATCH 21/25] Patch object selector's fields property --- homeassistant_api/models/domains.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 196fc197..36f355b3 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -367,7 +367,7 @@ class ServiceFieldSelectorObject(BaseModel): label_field: Optional[str] = None description_field: Optional[str] = None translation_key: Optional[str] = None - fields: Dict[str, ServiceFieldSelectorObjectField] + fields: Optional[Dict[str, ServiceFieldSelectorObjectField]] = None multiple: Optional[bool] = None From 1236f1eb1e9bb703350949bc9efd69e836a3c18c Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 2 Sep 2025 15:22:36 -0600 Subject: [PATCH 22/25] Fix validation errors --- .gitignore | 3 ++- compose.yml | 2 +- entrypoint.sh | 2 ++ homeassistant_api/rawwebsocket.py | 3 ++- homeassistant_api/utils.py | 2 +- tests/test_client.py | 4 ++-- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 6ad19a3d..bc4adcbf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ venv/ .breakpoints # Informal testing -main.py +main*.py # Cache files *.sqlite @@ -88,6 +88,7 @@ instance/ # Scrapy stuff: .scrapy + # Sphinx documentation docs/_build/ diff --git a/compose.yml b/compose.yml index 2a7d627c..2422f9cc 100644 --- a/compose.yml +++ b/compose.yml @@ -19,7 +19,7 @@ services: HOMEASSISTANTAPI_URL: http://server:8123/api HOMEASSISTANTAPI_WS_URL: ws://server:8123/api/websocket HOMEASSISTANTAPI_TOKEN: ${HOMEASSISTANTAPI_TOKEN} - DELAY: 60 + DELAY: 10 networks: default: diff --git a/entrypoint.sh b/entrypoint.sh index 810078c3..f0260a25 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,3 +1,5 @@ . .venv/bin/activate +echo "Waiting $DELAY seconds for Home Assistant to start..." python -c "import time; time.sleep($DELAY)" +echo "Finished waiting $DELAY seconds. Starting tests..." pytest tests --disable-warnings --cov --cov-report xml:coverage/coverage.xml diff --git a/homeassistant_api/rawwebsocket.py b/homeassistant_api/rawwebsocket.py index 3661b300..d85e5c17 100644 --- a/homeassistant_api/rawwebsocket.py +++ b/homeassistant_api/rawwebsocket.py @@ -94,11 +94,12 @@ def send(self, type: str, include_id: bool = True, **data: Any) -> int: """ if include_id: # auth messages don't have an id data["id"] = self._request_id() + data["type"] = type - assert isinstance(data["id"], int) self._send(data) if "id" in data: + assert isinstance(data["id"], int) if data["type"] == "ping": self._ping_responses[data["id"]] = PingResponse( start=time.perf_counter_ns(), diff --git a/homeassistant_api/utils.py b/homeassistant_api/utils.py index 2b0f29fc..7e625482 100644 --- a/homeassistant_api/utils.py +++ b/homeassistant_api/utils.py @@ -6,7 +6,7 @@ if TYPE_CHECKING or os.getenv("DOCUMENTATION_MODE") != "true": JSONType = TypeAliasType( - "JSONType", "Union[int, float, str, bool, list[JSONType], dict[str, JSONType]]" + "JSONType", "Optional[Union[int, float, str, bool, list[JSONType], dict[str, JSONType]]]" ) else: JSONType = type("JSONType", (object,), {}) diff --git a/tests/test_client.py b/tests/test_client.py index 105db369..22c3a6d2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,6 @@ import os -import aiohttp_client_cache +import aiohttp_client_cache.session import requests_cache from homeassistant_api import Client, WebsocketClient @@ -28,7 +28,7 @@ async def test_custom_async_cached_session() -> None: async with Client( os.environ["HOMEASSISTANTAPI_URL"], os.environ["HOMEASSISTANTAPI_TOKEN"], - async_cache_session=aiohttp_client_cache.CachedSession( + async_cache_session=aiohttp_client_cache.session.CachedSession( cache=aiohttp_client_cache.SQLiteBackend( cache_name="test_custom_async_cached_session.sqlite", expire_after=10, From d46fafdf17cfcd297ec19efd9ec36ef2e29c8e9b Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 2 Sep 2025 15:26:34 -0600 Subject: [PATCH 23/25] Add new keys to Error model --- homeassistant_api/models/websocket.py | 3 +++ homeassistant_api/rawwebsocket.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant_api/models/websocket.py b/homeassistant_api/models/websocket.py index d5ead047..0ff1833a 100644 --- a/homeassistant_api/models/websocket.py +++ b/homeassistant_api/models/websocket.py @@ -45,6 +45,9 @@ class PingResponse(BaseModel): class Error(BaseModel): code: str message: str + translation_key: str + translation_placeholders: dict[str, str] + translation_domain: str class ErrorResponse(BaseModel): diff --git a/homeassistant_api/rawwebsocket.py b/homeassistant_api/rawwebsocket.py index d85e5c17..f353b19b 100644 --- a/homeassistant_api/rawwebsocket.py +++ b/homeassistant_api/rawwebsocket.py @@ -136,7 +136,11 @@ def parse_response(self, data: dict[str, JSONType]) -> None: self._ping_responses[data_id].end = time.perf_counter_ns() elif data.get("type") == "result": logger.info("Received result message") - self._result_responses[data_id] = ResultResponse.model_validate(data) + if data.get("success"): + self._result_responses[data_id] = ResultResponse.model_validate(data) + else: + error_resp = ErrorResponse.model_validate(data) + raise RequestError(error_resp.error.code, error_resp.error.message) elif data.get("type") == "event": logger.info("Received event message %s", data["event"]) self._event_responses[data_id].append(EventResponse.model_validate(data)) From 7e7870b0b766cec6e87a32a0fd53e4932b24c0c1 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 2 Sep 2025 20:55:18 -0600 Subject: [PATCH 24/25] Remove `entity_id` parameter --- homeassistant_api/models/domains.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 36f355b3..145f39f6 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -583,17 +583,13 @@ class Service(BaseModel): target: Optional[ServiceFieldSelectorTarget] = None response: Optional[ServiceResponse] = None - def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ + def trigger(self, **service_data) -> Union[ Tuple[State, ...], Tuple[Tuple[State, ...], dict[str, JSONType]], dict[str, JSONType], None, ]: """Triggers the service associated with this object.""" - if entity_id is not None: - service_data["entity_id"] = ( - entity_id # TODO: I believe the function should not enforce the target to be `entity_id` as it can be one of the following: `area_id`, `device_id`, `floor_id`, `entity_id`, `label_id` - ) try: return self.domain._client.trigger_service_with_response( self.domain.domain_id, @@ -608,12 +604,9 @@ def trigger(self, entity_id: Optional[str] = None, **service_data) -> Union[ ) async def async_trigger( - self, entity_id: Optional[str] = None, **service_data + self, **service_data ) -> Union[Tuple[State, ...], Tuple[Tuple[State, ...], dict[str, JSONType]]]: """Triggers the service associated with this object.""" - if entity_id is not None: - service_data["entity_id"] = entity_id - from homeassistant_api import WebsocketClient # prevent circular import if isinstance(self.domain._client, WebsocketClient): From 6d5b8672458ad7cb5ad3cb2c13ff1393deae692e Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 2 Sep 2025 21:02:57 -0600 Subject: [PATCH 25/25] Remove last usage of `entity_id` --- homeassistant_api/models/domains.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant_api/models/domains.py b/homeassistant_api/models/domains.py index 145f39f6..214e7eac 100644 --- a/homeassistant_api/models/domains.py +++ b/homeassistant_api/models/domains.py @@ -626,7 +626,7 @@ async def async_trigger( **service_data, ) - def __call__(self, entity_id: Optional[str] = None, **service_data) -> Union[ + def __call__(self, **service_data) -> Union[ Union[ Tuple[State, ...], Tuple[Tuple[State, ...], dict[str, JSONType]], @@ -648,7 +648,7 @@ def __call__(self, entity_id: Optional[str] = None, **service_data) -> Union[ if inspect.iscoroutinefunction( caller := gc.get_referrers(parent_frame.f_code)[0] ) or inspect.iscoroutine(caller): - return self.async_trigger(entity_id=entity_id, **service_data) + return self.async_trigger(**service_data) except IndexError: # pragma: no cover pass - return self.trigger(entity_id=entity_id, **service_data) + return self.trigger(**service_data)