77from datetime import datetime , timedelta
88import json
99import logging
10+ from typing import Any
1011
11- from aiohttp import ClientResponse , ClientSession
12+ from aiohttp import ClientResponse , ClientResponseError , ClientSession
1213
1314from homeassistant .config_entries import ConfigEntry
1415from homeassistant .const import (
2627 API_VERSION ,
2728 CONF_ADDITIONAL_PARAMETER ,
2829 CONF_ENABLE_SMART_HOME_MODE ,
30+ CONF_ENABLE_SMART_HOME_ZONE ,
2931 CONF_FETCH_FIRMWARE ,
3032 CONF_FETCH_NOTIFICATIONS ,
3133 CONF_PARAMETER_WHITELIST ,
@@ -92,7 +94,7 @@ def type(self) -> str:
9294 @property
9395 def valid_until (self ) -> datetime :
9496 """Return datetime value of 'validUntil'."""
95- return datetime (self .raw_data ( "validUntil" ) )
97+ return datetime . fromisoformat (self .raw_data [ "validUntil" ] )
9698
9799
98100class Notification :
@@ -166,23 +168,23 @@ def firmware_id(self) -> int:
166168 return int (self .raw_data ["firmwareId" ])
167169
168170 @property
169- def current_version (self ) -> str :
171+ def current_version (self ) -> str | None :
170172 """Return the current firmware version of the device."""
171- if self .raw_data [ "currentFwVersion" ] .strip () == "" :
173+ if self .raw_data . get ( "currentFwVersion" , "" ) .strip () == "" :
172174 return None
173175 return self .raw_data ["currentFwVersion" ].strip ()
174176
175177 @property
176- def pending_version (self ) -> str :
178+ def pending_version (self ) -> str | None :
177179 """Return the pending firmware version of the device."""
178- if self .raw_data [ "pendingFwVersion" ] .strip () == "" :
180+ if self .raw_data . get ( "pendingFwVersion" , "" ) .strip () == "" :
179181 return None
180182 return self .raw_data ["pendingFwVersion" ].strip ()
181183
182184 @property
183- def desired_version (self ) -> str :
185+ def desired_version (self ) -> str | None :
184186 """Return the desired firmware version of the device."""
185- if self .raw_data [ "desiredFwVersion" ] .strip () == "" :
187+ if self .raw_data . get ( "desiredFwVersion" , "" ) .strip () == "" :
186188 return None
187189 return self .raw_data ["desiredFwVersion" ].strip ()
188190
@@ -266,15 +268,15 @@ def max_value(self) -> int:
266268 @property
267269 def step_value (self ) -> int :
268270 """Return the step value of the parameter."""
269- return self .raw_data [ "stepValue" ]
271+ return self .raw_data . get ( "stepValue" , 1 )
270272
271273 @property
272274 def enum_values (self ) -> list [dict ]:
273275 """Return the enum values of the parameter."""
274276 return self .raw_data ["enumValues" ]
275277
276278 @property
277- def scale_value (self ) -> float | None :
279+ def scale_value (self ) -> float :
278280 """Return the scale value of the parameter."""
279281 if self .raw_data ["scaleValue" ]:
280282 return float (self .raw_data ["scaleValue" ])
@@ -287,11 +289,11 @@ def zone_id(self) -> str:
287289 return self .raw_data ["zoneId" ]
288290
289291 async def update_parameter (self , value ) -> None :
290- """Patch parameter if writable."""
292+ """Set parameter value if writable."""
291293 if not self .is_writable :
292294 return
293295 await self .device .system .api .patch_parameter (
294- self .device .id , str (self .id ), str ( value )
296+ self .device .id , str (self .id ), value
295297 )
296298
297299 def get_platform (self ) -> Platform :
@@ -316,7 +318,9 @@ def get_platform(self) -> Platform:
316318 if len (self .enum_values ) > 0 and self .is_writable :
317319 return Platform .SELECT
318320
319- if (self .max_value or self .min_value ) and self .is_writable :
321+ if (
322+ self .max_value is not None or self .min_value is not None
323+ ) and self .is_writable :
320324 return Platform .NUMBER
321325
322326 return Platform .SENSOR
@@ -331,24 +335,26 @@ def get_unit(self, parameter_unit) -> str:
331335 UnitOfTemperature ,
332336 UnitOfTime ,
333337 ):
334- for unit in units :
335- if parameter_unit .lower () == unit .lower ():
336- return str (unit )
338+ with suppress (ValueError ):
339+ for unit in units :
340+ if parameter_unit .lower () == unit .lower ():
341+ return str (unit )
337342
338343 return parameter_unit
339344
340345
341346class Zone :
342347 """Class that represents a zone object in the myUplink API."""
343348
344- def __init__ (self , raw_data : dict ) -> None :
349+ def __init__ (self , raw_data : dict , device : Device ) -> None :
345350 """Initialize a zone object."""
346351 self .raw_data = raw_data
352+ self .device = device
347353
348354 @property
349355 def id (self ) -> int :
350356 """Return the ID of the zone."""
351- return self .raw_data ["zoneId" ]
357+ return int ( self .raw_data ["zoneId" ])
352358
353359 @property
354360 def name (self ) -> str :
@@ -358,62 +364,107 @@ def name(self) -> str:
358364 @property
359365 def is_command_only (self ) -> bool :
360366 """Return if the zone is command only."""
361- return self .raw_data ["commandOnly" ]
367+ return bool ( self .raw_data ["commandOnly" ])
362368
363369 @property
364- def supported_modes (self ) -> str :
370+ def supported_modes (self ) -> str | None :
365371 """Return the supported modes of the zone."""
366- return self .raw_data [ "supportedModes" ]
372+ return self .raw_data . get ( "supportedModes" )
367373
368374 @property
369375 def mode (self ) -> str :
370376 """Return the current mode of the zone."""
371377 return self .raw_data ["mode" ]
372378
373379 @property
374- def temperature (self ) -> float :
380+ def temperature (self ) -> float | None :
375381 """Return the current temperature of the zone."""
376- return self .raw_data ["temperature" ]
382+ return (
383+ float (self .raw_data .get ("temperature" ))
384+ if self .raw_data .get ("temperature" ) is not None
385+ else None
386+ )
377387
378388 @property
379- def setpoint (self ) -> float :
389+ def setpoint (self ) -> float | None :
380390 """Return the target temperature of the zone."""
381- return self .raw_data ["setpoint" ]
391+ return (
392+ float (self .raw_data .get ("setpoint" ))
393+ if self .raw_data .get ("setpoint" ) is not None
394+ else None
395+ )
382396
383397 @property
384- def setpoint_heating (self ) -> float :
398+ def setpoint_heating (self ) -> float | None :
385399 """Return the heating setpoint value of the zone."""
386- return self .raw_data ["setpointHeat" ]
400+ return (
401+ float (self .raw_data .get ("setpointHeat" ))
402+ if self .raw_data .get ("setpointHeat" ) is not None
403+ else None
404+ )
387405
388406 @property
389- def setpoint_cooling (self ) -> float :
407+ def setpoint_cooling (self ) -> float | None :
390408 """Return the cooling setpoint value of the zone."""
391- return self .raw_data ["setpointCool" ]
409+ return (
410+ float (self .raw_data .get ("setpointCool" ))
411+ if self .raw_data .get ("setpointCool" ) is not None
412+ else None
413+ )
392414
393415 @property
394- def setpoint_range_min (self ) -> int :
416+ def setpoint_range_min (self ) -> int | None :
395417 """Return the minimum temperature range of the zone."""
396- return self .raw_data ["setpointRangeMin" ]
418+ return (
419+ int (self .raw_data .get ("setpointRangeMin" ))
420+ if self .raw_data .get ("setpointRangeMin" ) is not None
421+ else None
422+ )
397423
398424 @property
399- def setpoint_range_max (self ) -> int :
425+ def setpoint_range_max (self ) -> int | None :
400426 """Return the maximum temperature range of the zone."""
401- return self .raw_data ["setpointRangeMax" ]
427+ return (
428+ int (self .raw_data .get ("setpointRangeMax" ))
429+ if self .raw_data .get ("setpointRangeMax" ) is not None
430+ else None
431+ )
402432
403433 @property
404434 def is_celsius (self ) -> bool :
405435 """Return if the temperature in the zone is specified as celsius (true) or fahrenheit (false)."""
406- return self .raw_data ["isCelsius" ]
436+ return (
437+ bool (self .raw_data .get ("isCelsius" ))
438+ if self .raw_data .get ("isCelsius" ) is not None
439+ else True
440+ )
407441
408442 @property
409- def indoor_co2 (self ) -> int :
443+ def indoor_co2 (self ) -> int | None :
410444 """Return the indoor co2 level of the zone."""
411- return self .raw_data ["indoorCo2" ]
445+ return (
446+ int (self .raw_data .get ("indoorCo2" ))
447+ if self .raw_data .get ("indoorCo2" ) is not None
448+ else None
449+ )
412450
413451 @property
414- def indoor_umidity (self ) -> float :
452+ def indoor_humidity (self ) -> float | None :
415453 """Return the indoor humidity of the zone."""
416- return self .raw_data ["indoorHumidity" ]
454+ return (
455+ float (self .raw_data .get ("indoorHumidity" ))
456+ if self .raw_data .get ("indoorHumidity" ) is not None
457+ else None
458+ )
459+
460+ async def update_zone_property (self , property_name : str , value ) -> None :
461+ """Patch zone if writable."""
462+ if self .is_command_only :
463+ return
464+ await self .device .system .api .patch_zone_property (
465+ self .device .id , str (self .id ), property_name , value
466+ )
467+ self .raw_data [property_name ] = value
417468
418469
419470class Device :
@@ -463,23 +514,22 @@ def current_firmware_version(self) -> str:
463514 """Return the current firmware version of the device."""
464515 if "firmware" in self .raw_data :
465516 return self .raw_data ["firmware" ]["currentFwVersion" ]
466-
467- return self .raw_data ["currentFwVersion" ]
517+ return self .raw_data .get ("currentFwVersion" , "N/A" )
468518
469519 @property
470520 def desired_firmware_version (self ) -> str :
471521 """Return the desired firmware version of the device."""
472522 if "firmware" in self .raw_data :
473523 return self .raw_data ["firmware" ]["desiredFwVersion" ]
474-
475524 return "?"
476525
477526 async def async_fetch_data (self ) -> None :
478527 """Fetch data from myUplink API."""
479528 self .parameters = await self .system .api .get_parameters (self )
480529 if self .system .api .entry .options .get (CONF_FETCH_FIRMWARE , True ):
481530 self .firmware_info = await self .system .api .get_firmware_info (self )
482- # self.zones = await self.system.api.get_zones(self.id)
531+ if self .system .api .entry .options .get (CONF_ENABLE_SMART_HOME_ZONE , True ):
532+ self .zones = await self .system .api .get_zones (self )
483533
484534
485535class System :
@@ -516,7 +566,7 @@ def security_level(self) -> str:
516566 @property
517567 def has_alaram (self ) -> bool :
518568 """Return if the system has an alaram."""
519- return self .raw_data [ "has_alaram" ]
569+ return self .raw_data . get ( "hasAlarm" , False )
520570
521571 async def async_fetch_data (self ) -> None :
522572 """Fetch data from myUplink API."""
@@ -663,7 +713,9 @@ async def get_premium_manage(self, system: System) -> bool:
663713
664714 try :
665715 async with self .lock , self .throttle :
666- resp = await self .auth .request ("get" , f"systems/{ system .id } /subscriptions" )
716+ resp = await self .auth .request (
717+ "get" , f"systems/{ system .id } /subscriptions"
718+ )
667719
668720 # This will raise an exception for 4xx or 5xx errors
669721 resp .raise_for_status ()
@@ -674,9 +726,11 @@ async def get_premium_manage(self, system: System) -> bool:
674726 if Subscription (subscription ).type == "manage" :
675727 return True
676728
677- except Exception as err :
729+ except ClientResponseError as err :
678730 # We catch the 500 error (and others) here so the integration keeps running
679- _LOGGER .error ("Error fetching subscriptions for system %s: %s" , system .id , err )
731+ _LOGGER .error (
732+ "Error fetching subscriptions for system %s: %s" , system .id , err
733+ )
680734
681735 return False
682736
@@ -781,17 +835,17 @@ async def get_parameters(self, device: Device) -> list[Parameter]:
781835
782836 return list (unique_parameters .values ())
783837
784- async def get_zones (self , device_id ) -> list [Zone ]:
838+ async def get_zones (self , device : Device ) -> list [Zone ]:
785839 """Return all smart home zones for a device."""
786- _LOGGER .debug ("Fetch zones for device %s" , device_id )
840+ _LOGGER .debug ("Fetch zones for device %s" , device . id )
787841 async with self .lock , self .throttle :
788842 resp = await self .auth .request (
789- "get" , f"devices/{ device_id } /smart-home-zones" , headers = self .header
843+ "get" , f"devices/{ device . id } /smart-home-zones" , headers = self .header
790844 )
791845 resp .raise_for_status ()
792- return [Zone (zone ) for zone in await resp .json ()]
846+ return [Zone (zone , device ) for zone in await resp .json ()]
793847
794- async def patch_parameter (self , device_id , parameter_id : str , value : str ) -> bool :
848+ async def patch_parameter (self , device_id , parameter_id : str , value : Any ) -> bool :
795849 """Update the value of a parameter for a device."""
796850 _LOGGER .debug (
797851 "Patch parameter %s for device %s with value %s" ,
@@ -809,6 +863,27 @@ async def patch_parameter(self, device_id, parameter_id: str, value: str) -> boo
809863 resp .raise_for_status ()
810864 return resp .status == 200
811865
866+ async def patch_zone_property (
867+ self , device_id , zone_id : str , property_name : str , value : str
868+ ) -> bool :
869+ """Update the value of a zone property for a device."""
870+ _LOGGER .debug (
871+ "Patch property %s for zone %s of device %s with value %s" ,
872+ property_name ,
873+ zone_id ,
874+ device_id ,
875+ value ,
876+ )
877+ async with self .lock , self .throttle :
878+ resp = await self .auth .request (
879+ "patch" ,
880+ f"devices/{ device_id } /zones/{ zone_id } " ,
881+ data = json .dumps ({property_name : value }),
882+ headers = {"Content-Type" : "application/json-patch+json" },
883+ )
884+ resp .raise_for_status ()
885+ return resp .status == 200
886+
812887 def parse_int_keys (self , dct ):
813888 """Parse object keys into integers."""
814889 rval = {}
0 commit comments