uiprotect 3.8.0__py3-none-any.whl → 7.32.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
uiprotect/cli/events.py CHANGED
@@ -4,7 +4,6 @@ from collections.abc import Callable
4
4
  from dataclasses import dataclass
5
5
  from datetime import datetime
6
6
  from pathlib import Path
7
- from typing import Optional
8
7
 
9
8
  import typer
10
9
  from rich.progress import Progress
@@ -43,13 +42,13 @@ ALL_COMMANDS: dict[str, Callable[..., None]] = {}
43
42
  @app.callback(invoke_without_command=True)
44
43
  def main(
45
44
  ctx: typer.Context,
46
- event_id: Optional[str] = ARG_EVENT_ID,
47
- start: Optional[datetime] = OPTION_START,
48
- end: Optional[datetime] = OPTION_END,
49
- limit: Optional[int] = OPTION_LIMIT,
50
- offset: Optional[int] = OPTION_OFFSET,
51
- types: Optional[list[d.EventType]] = OPTION_TYPES,
52
- smart_types: Optional[list[d.SmartDetectObjectType]] = OPTION_SMART_TYPES,
45
+ event_id: str | None = ARG_EVENT_ID,
46
+ start: datetime | None = OPTION_START,
47
+ end: datetime | None = OPTION_END,
48
+ limit: int | None = OPTION_LIMIT,
49
+ offset: int | None = OPTION_OFFSET,
50
+ types: list[d.EventType] | None = OPTION_TYPES,
51
+ smart_types: list[d.SmartDetectObjectType] | None = OPTION_SMART_TYPES,
53
52
  ) -> None:
54
53
  """
55
54
  Events CLI.
uiprotect/cli/lights.py CHANGED
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from datetime import timedelta
5
- from typing import Optional
6
5
 
7
6
  import typer
8
7
 
@@ -25,7 +24,7 @@ ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
25
24
 
26
25
 
27
26
  @app.callback(invoke_without_command=True)
28
- def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
27
+ def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
29
28
  """
30
29
  Lights device CLI.
31
30
 
@@ -59,7 +58,7 @@ def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
59
58
 
60
59
 
61
60
  @app.command()
62
- def camera(ctx: typer.Context, camera_id: Optional[str] = typer.Argument(None)) -> None:
61
+ def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None:
63
62
  """Returns or sets tha paired camera for a light."""
64
63
  base.require_device_id(ctx)
65
64
  obj: Light = ctx.obj.device
@@ -117,3 +116,12 @@ def set_duration(
117
116
  obj: Light = ctx.obj.device
118
117
 
119
118
  base.run(ctx, obj.set_duration(timedelta(seconds=duration)))
119
+
120
+
121
+ @app.command()
122
+ def set_flood_light(ctx: typer.Context, enabled: bool) -> None:
123
+ """Sets flood light (force on) for light device."""
124
+ base.require_device_id(ctx)
125
+ obj: Light = ctx.obj.device
126
+
127
+ base.run(ctx, obj.set_flood_light(enabled))
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Optional
5
4
 
6
5
  import typer
7
6
 
@@ -24,7 +23,7 @@ class LiveviewContext(base.CliContext):
24
23
 
25
24
 
26
25
  @app.callback(invoke_without_command=True)
27
- def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
26
+ def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
28
27
  """
29
28
  Liveviews CLI.
30
29
 
uiprotect/cli/sensors.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Optional
5
4
 
6
5
  import typer
7
6
 
@@ -24,7 +23,7 @@ ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
24
23
 
25
24
 
26
25
  @app.callback(invoke_without_command=True)
27
- def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
26
+ def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
28
27
  """
29
28
  Sensors device CLI.
30
29
 
@@ -58,7 +57,7 @@ def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
58
57
 
59
58
 
60
59
  @app.command()
61
- def camera(ctx: typer.Context, camera_id: Optional[str] = typer.Argument(None)) -> None:
60
+ def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None:
62
61
  """Returns or sets tha paired camera for a sensor."""
63
62
  base.require_device_id(ctx)
64
63
  obj: Sensor = ctx.obj.device
uiprotect/cli/viewers.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Optional
5
4
 
6
5
  import typer
7
6
 
@@ -24,7 +23,7 @@ ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
24
23
 
25
24
 
26
25
  @app.callback(invoke_without_command=True)
27
- def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
26
+ def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
28
27
  """
29
28
  Viewers device CLI.
30
29
 
@@ -60,7 +59,7 @@ def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
60
59
  @app.command()
61
60
  def liveview(
62
61
  ctx: typer.Context,
63
- liveview_id: Optional[str] = typer.Argument(None),
62
+ liveview_id: str | None = typer.Argument(None),
64
63
  ) -> None:
65
64
  """Returns or sets the current liveview."""
66
65
  base.require_device_id(ctx)
@@ -10,6 +10,7 @@ from .base import (
10
10
  from .bootstrap import Bootstrap
11
11
  from .convert import create_from_unifi_dict
12
12
  from .devices import (
13
+ AiPort,
13
14
  Bridge,
14
15
  Camera,
15
16
  CameraChannel,
@@ -85,6 +86,7 @@ __all__ = [
85
86
  "DEFAULT_TYPE",
86
87
  "NVR",
87
88
  "WS_HEADER_SIZE",
89
+ "AiPort",
88
90
  "AnalyticsOption",
89
91
  "AudioStyle",
90
92
  "Bootstrap",
uiprotect/data/base.py CHANGED
@@ -6,14 +6,16 @@ import asyncio
6
6
  import logging
7
7
  from collections.abc import Callable
8
8
  from datetime import datetime, timedelta
9
- from functools import cache, cached_property
10
- from ipaddress import IPv4Address
11
- from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar
9
+ from functools import cache
10
+ from ipaddress import IPv4Address, IPv6Address
11
+ from typing import TYPE_CHECKING, Any, NamedTuple
12
12
  from uuid import UUID
13
13
 
14
- from pydantic.v1 import BaseModel
15
- from pydantic.v1.fields import SHAPE_DICT, SHAPE_LIST, PrivateAttr
14
+ from convertertools import pop_dict_set_if_none, pop_dict_tuple
15
+ from pydantic import BaseModel, ConfigDict
16
+ from pydantic.fields import PrivateAttr
16
17
 
18
+ from .._compat import cached_property
17
19
  from ..exceptions import BadRequest, ClientError, NotAuthorized
18
20
  from ..utils import (
19
21
  asyncio_timeout,
@@ -30,6 +32,7 @@ from .types import (
30
32
  PermissionNode,
31
33
  ProtectWSPayloadFormat,
32
34
  StateType,
35
+ get_field_type,
33
36
  )
34
37
  from .websocket import (
35
38
  WSJSONPacketFrame,
@@ -48,7 +51,6 @@ if TYPE_CHECKING:
48
51
  from ..data.user import User
49
52
 
50
53
 
51
- ProtectObject = TypeVar("ProtectObject", bound="ProtectBaseObject")
52
54
  RECENT_EVENT_MAX = timedelta(seconds=30)
53
55
  EVENT_PING_INTERVAL = timedelta(seconds=3)
54
56
  EVENT_PING_INTERVAL_SECONDS = EVENT_PING_INTERVAL.total_seconds()
@@ -60,7 +62,7 @@ _LOGGER = logging.getLogger(__name__)
60
62
 
61
63
 
62
64
  @cache
63
- def _is_protect_base_object(cls: type) -> bool:
65
+ def _is_protect_base_object(cls: type[Any]) -> bool:
64
66
  """A cached version of `issubclass(cls, ProtectBaseObject)` to speed up the check."""
65
67
  return issubclass(cls, ProtectBaseObject)
66
68
 
@@ -91,12 +93,8 @@ class ProtectBaseObject(BaseModel):
91
93
  * Provides `.unifi_dict` to convert object back into UFP JSON
92
94
  """
93
95
 
94
- _api: ProtectApiClient = PrivateAttr(None)
95
-
96
- class Config:
97
- arbitrary_types_allowed = True
98
- validate_assignment = True
99
- copy_on_model_validation = "shallow"
96
+ _api: ProtectApiClient = PrivateAttr(None) # type: ignore[assignment]
97
+ model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)
100
98
 
101
99
  def __init__(self, api: ProtectApiClient | None = None, **data: Any) -> None:
102
100
  """
@@ -136,10 +134,12 @@ class ProtectBaseObject(BaseModel):
136
134
  data.pop("api", None)
137
135
  return cls(api=api, **data)
138
136
 
139
- return cls.construct(**data)
137
+ return cls.model_construct(**data)
140
138
 
141
139
  @classmethod
142
- def construct(cls, _fields_set: set[str] | None = None, **values: Any) -> Self:
140
+ def model_construct(
141
+ cls, _fields_set: set[str] | None = None, **values: Any
142
+ ) -> Self:
143
143
  api: ProtectApiClient | None = values.pop("api", None)
144
144
  (
145
145
  unifi_objs,
@@ -151,19 +151,21 @@ class ProtectBaseObject(BaseModel):
151
151
  ) = cls._get_protect_model()
152
152
  for key, value in values.items():
153
153
  if has_unifi_objs and key in unifi_objs and isinstance(value, dict):
154
- values[key] = unifi_objs[key].construct(**value)
154
+ values[key] = unifi_objs[key].model_construct(**value)
155
155
  elif has_unifi_lists and key in unifi_lists and isinstance(value, list):
156
156
  values[key] = [
157
- unifi_lists[key].construct(**v) if isinstance(v, dict) else v
157
+ unifi_lists[key].model_construct(**v) if isinstance(v, dict) else v
158
158
  for v in value
159
159
  ]
160
160
  elif has_unifi_dicts and key in unifi_dicts and isinstance(value, dict):
161
161
  values[key] = {
162
- k: unifi_dicts[key].construct(**v) if isinstance(v, dict) else v
162
+ k: unifi_dicts[key].model_construct(**v)
163
+ if isinstance(v, dict)
164
+ else v
163
165
  for k, v in value.items()
164
166
  }
165
167
 
166
- obj = super().construct(_fields_set=_fields_set, **values)
168
+ obj = super().model_construct(_fields_set=_fields_set, **values)
167
169
  if api is not None:
168
170
  obj._api = api
169
171
 
@@ -217,15 +219,16 @@ class ProtectBaseObject(BaseModel):
217
219
  lists: dict[str, type[ProtectBaseObject]] = {}
218
220
  dicts: dict[str, type[ProtectBaseObject]] = {}
219
221
 
220
- for name, field in cls.__fields__.items():
222
+ for name, field in cls.model_fields.items():
221
223
  try:
222
- if _is_protect_base_object(field.type_):
223
- if field.shape == SHAPE_LIST:
224
- lists[name] = field.type_
225
- elif field.shape == SHAPE_DICT:
226
- dicts[name] = field.type_
224
+ origin, type_ = get_field_type(field.annotation) # type: ignore[arg-type]
225
+ if _is_protect_base_object(type_):
226
+ if origin is list:
227
+ lists[name] = type_
228
+ elif origin is dict:
229
+ dicts[name] = type_
227
230
  else:
228
- objs[name] = field.type_
231
+ objs[name] = type_
229
232
  except TypeError:
230
233
  pass
231
234
 
@@ -311,25 +314,28 @@ class ProtectBaseObject(BaseModel):
311
314
 
312
315
  remaps = cls._get_unifi_remaps()
313
316
  # convert to snake_case and remove extra fields
314
- _fields = cls.__fields__
315
- for key in list(data):
316
- if key in remaps:
317
+ _fields = cls.model_fields
318
+ for key in data.copy():
319
+ current_key = key
320
+ if current_key in remaps:
317
321
  # remap keys that will not be converted correctly by snake_case convert
318
- remapped_key = remaps[key]
319
- data[remapped_key] = data.pop(key)
320
- key = remapped_key
322
+ remapped_key = remaps[current_key]
323
+ data[remapped_key] = data.pop(current_key)
324
+ current_key = remapped_key
321
325
 
322
- new_key = to_snake_case(key)
323
- data[new_key] = data.pop(key)
324
- key = new_key
326
+ new_key = to_snake_case(current_key)
327
+ data[new_key] = data.pop(current_key)
328
+ current_key = new_key
325
329
 
326
- if key == "api":
330
+ if current_key == "api":
327
331
  continue
328
332
 
329
- if key not in _fields:
330
- del data[key]
333
+ if current_key not in _fields:
334
+ del data[current_key]
331
335
  continue
332
- data[key] = convert_unifi_data(data[key], _fields[key])
336
+ data[current_key] = convert_unifi_data(
337
+ data[current_key], _fields[current_key]
338
+ )
333
339
 
334
340
  if not data:
335
341
  return data
@@ -367,7 +373,7 @@ class ProtectBaseObject(BaseModel):
367
373
  if isinstance(value, ProtectBaseObject):
368
374
  value = value.unifi_dict()
369
375
  elif isinstance(value, dict):
370
- value = klass.construct({}).unifi_dict(data=value) # type: ignore[arg-type]
376
+ value = klass.model_construct({}).unifi_dict(data=value) # type: ignore[arg-type]
371
377
 
372
378
  return value
373
379
 
@@ -388,7 +394,7 @@ class ProtectBaseObject(BaseModel):
388
394
  return [
389
395
  item.unifi_dict()
390
396
  if isinstance(item, ProtectBaseObject)
391
- else klass.construct({}).unifi_dict(data=item) # type: ignore[arg-type]
397
+ else klass.model_construct({}).unifi_dict(data=item) # type: ignore[arg-type]
392
398
  for item in value
393
399
  ]
394
400
 
@@ -435,7 +441,7 @@ class ProtectBaseObject(BaseModel):
435
441
  excluded_fields = self._get_excluded_fields()
436
442
  if exclude is not None:
437
443
  excluded_fields = excluded_fields.copy() | exclude
438
- data = self.dict(exclude=excluded_fields)
444
+ data = self.model_dump(exclude=excluded_fields)
439
445
  use_obj = True
440
446
 
441
447
  (
@@ -472,7 +478,7 @@ class ProtectBaseObject(BaseModel):
472
478
 
473
479
  return new_data
474
480
 
475
- def update_from_dict(cls: ProtectObject, data: dict[str, Any]) -> ProtectObject:
481
+ def update_from_dict(self, data: dict[str, Any]) -> Self:
476
482
  """
477
483
  Updates current object from a cleaned UFP JSON dict.
478
484
 
@@ -484,17 +490,17 @@ class ProtectBaseObject(BaseModel):
484
490
  has_unifi_objs,
485
491
  unifi_lists,
486
492
  has_unifi_lists,
487
- unifi_dicts,
488
- has_unifi_dicts,
489
- ) = cls._get_protect_model()
490
- api = cls._api
491
- _fields = cls.__fields__
493
+ _unifi_dicts,
494
+ _has_unifi_dicts,
495
+ ) = self._get_protect_model()
496
+ api = self._api
497
+ _fields = self.__class__.model_fields
492
498
  unifi_obj: ProtectBaseObject | None
493
499
  value: Any
494
500
 
495
501
  for key, item in data.items():
496
502
  if has_unifi_objs and key in unifi_objs and isinstance(item, dict):
497
- if (unifi_obj := getattr(cls, key)) is not None:
503
+ if (unifi_obj := getattr(self, key)) is not None:
498
504
  value = unifi_obj.update_from_dict(item)
499
505
  else:
500
506
  value = unifi_objs[key](**item, api=api)
@@ -508,17 +514,17 @@ class ProtectBaseObject(BaseModel):
508
514
  else:
509
515
  value = convert_unifi_data(item, _fields[key])
510
516
 
511
- setattr(cls, key, value)
517
+ setattr(self, key, value)
512
518
 
513
- return cls
519
+ return self
514
520
 
515
521
  def dict_with_excludes(self) -> dict[str, Any]:
516
522
  """Returns a dict of the current object without any UFP objects converted to dicts."""
517
523
  excludes = self.__class__._get_excluded_changed_fields()
518
- return self.dict(exclude=excludes)
524
+ return self.model_dump(exclude=excludes)
519
525
 
520
526
  def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]:
521
- return dict_diff(data_before_changes, self.dict())
527
+ return dict_diff(data_before_changes, self.model_dump())
522
528
 
523
529
  @property
524
530
  def api(self) -> ProtectApiClient:
@@ -538,7 +544,7 @@ class ProtectModel(ProtectBaseObject):
538
544
  automatically decoding a `modelKey` object into the correct UFP object and type
539
545
  """
540
546
 
541
- model: ModelType | None
547
+ model: ModelType | None = None
542
548
 
543
549
  @classmethod
544
550
  @cache
@@ -551,10 +557,7 @@ class ProtectModel(ProtectBaseObject):
551
557
  exclude: set[str] | None = None,
552
558
  ) -> dict[str, Any]:
553
559
  data = super().unifi_dict(data=data, exclude=exclude)
554
-
555
- if "modelKey" in data and data["modelKey"] is None:
556
- del data["modelKey"]
557
-
560
+ pop_dict_set_if_none(data, {"modelKey"})
558
561
  return data
559
562
 
560
563
 
@@ -580,7 +583,7 @@ class UpdateSynchronization:
580
583
  class ProtectModelWithId(ProtectModel):
581
584
  id: str
582
585
 
583
- _update_sync: UpdateSynchronization = PrivateAttr(None)
586
+ _update_sync: UpdateSynchronization = PrivateAttr(None) # type: ignore[assignment]
584
587
 
585
588
  def __init__(self, **data: Any) -> None:
586
589
  update_sync = data.pop("update_sync", None)
@@ -588,9 +591,11 @@ class ProtectModelWithId(ProtectModel):
588
591
  self._update_sync = update_sync or UpdateSynchronization()
589
592
 
590
593
  @classmethod
591
- def construct(cls, _fields_set: set[str] | None = None, **values: Any) -> Self:
594
+ def model_construct(
595
+ cls, _fields_set: set[str] | None = None, **values: Any
596
+ ) -> Self:
592
597
  update_sync = values.pop("update_sync", None)
593
- obj = super().construct(_fields_set=_fields_set, **values)
598
+ obj = super().model_construct(_fields_set=_fields_set, **values)
594
599
  obj._update_sync = update_sync or UpdateSynchronization()
595
600
  return obj
596
601
 
@@ -716,9 +721,9 @@ class ProtectModelWithId(ProtectModel):
716
721
  updated,
717
722
  )
718
723
 
719
- assert (
720
- self._update_sync.lock.locked()
721
- ), "save_device_changes should only be called when the update lock is held"
724
+ assert self._update_sync.lock.locked(), (
725
+ "save_device_changes should only be called when the update lock is held"
726
+ )
722
727
  read_only_fields = self.__class__._get_read_only_fields()
723
728
 
724
729
  if self.model is None:
@@ -795,15 +800,15 @@ class ProtectModelWithId(ProtectModel):
795
800
 
796
801
 
797
802
  class ProtectDeviceModel(ProtectModelWithId):
798
- name: str | None
803
+ name: str | None = None
799
804
  type: str
800
805
  mac: str
801
- host: IPv4Address | str | None
802
- up_since: datetime | None
803
- uptime: timedelta | None
804
- last_seen: datetime | None
805
- hardware_revision: str | None
806
- firmware_version: str | None
806
+ host: IPv4Address | str | None = None
807
+ up_since: datetime | None = None
808
+ uptime: timedelta | None = None
809
+ last_seen: datetime | None = None
810
+ hardware_revision: str | None = None
811
+ firmware_version: str | None = None
807
812
  is_updating: bool
808
813
  is_ssh_enabled: bool
809
814
 
@@ -854,12 +859,12 @@ class ProtectDeviceModel(ProtectModelWithId):
854
859
 
855
860
 
856
861
  class WiredConnectionState(ProtectBaseObject):
857
- phy_rate: float | None
862
+ phy_rate: float | None = None
858
863
 
859
864
 
860
865
  class WirelessConnectionState(ProtectBaseObject):
861
- signal_quality: int | None
862
- signal_strength: int | None
866
+ signal_quality: int | None = None
867
+ signal_strength: int | None = None
863
868
 
864
869
 
865
870
  class BluetoothConnectionState(WirelessConnectionState):
@@ -867,10 +872,10 @@ class BluetoothConnectionState(WirelessConnectionState):
867
872
 
868
873
 
869
874
  class WifiConnectionState(WirelessConnectionState):
870
- phy_rate: float | None
871
- channel: int | None
872
- frequency: int | None
873
- ssid: str | None
875
+ phy_rate: float | None = None
876
+ channel: int | None = None
877
+ frequency: int | None = None
878
+ ssid: str | None = None
874
879
  bssid: str | None = None
875
880
  tx_rate: float | None = None
876
881
  # requires 2.7.5+
@@ -882,10 +887,10 @@ class WifiConnectionState(WirelessConnectionState):
882
887
 
883
888
  class ProtectAdoptableDeviceModel(ProtectDeviceModel):
884
889
  state: StateType
885
- connection_host: IPv4Address | str | None
886
- connected_since: datetime | None
887
- latest_firmware_version: str | None
888
- firmware_build: str | None
890
+ connection_host: IPv4Address | IPv6Address | str | None = None
891
+ connected_since: datetime | None = None
892
+ latest_firmware_version: str | None = None
893
+ firmware_build: str | None = None
889
894
  is_adopting: bool
890
895
  is_adopted: bool
891
896
  is_adopted_by_other: bool
@@ -895,7 +900,7 @@ class ProtectAdoptableDeviceModel(ProtectDeviceModel):
895
900
  is_attempting_to_connect: bool
896
901
  is_connected: bool
897
902
  # requires 1.21+
898
- market_name: str | None
903
+ market_name: str | None = None
899
904
  # requires 2.7.5+
900
905
  fw_update_state: str | None = None
901
906
  # requires 2.8.14+
@@ -910,8 +915,8 @@ class ProtectAdoptableDeviceModel(ProtectDeviceModel):
910
915
  wired_connection_state: WiredConnectionState | None = None
911
916
  wifi_connection_state: WifiConnectionState | None = None
912
917
  bluetooth_connection_state: BluetoothConnectionState | None = None
913
- bridge_id: str | None
914
- is_downloading_firmware: bool | None
918
+ bridge_id: str | None = None
919
+ is_downloading_firmware: bool | None = None
915
920
 
916
921
  # TODO:
917
922
  # bridgeCandidates
@@ -955,13 +960,10 @@ class ProtectAdoptableDeviceModel(ProtectDeviceModel):
955
960
  exclude: set[str] | None = None,
956
961
  ) -> dict[str, Any]:
957
962
  data = super().unifi_dict(data=data, exclude=exclude)
958
- for key in (
959
- "wiredConnectionState",
960
- "wifiConnectionState",
961
- "bluetoothConnectionState",
962
- ):
963
- if key in data and data[key] is None:
964
- del data[key]
963
+ pop_dict_set_if_none(
964
+ data,
965
+ {"wiredConnectionState", "wifiConnectionState", "bluetoothConnectionState"},
966
+ )
965
967
  return data
966
968
 
967
969
  @classmethod
@@ -1055,7 +1057,7 @@ class ProtectAdoptableDeviceModel(ProtectDeviceModel):
1055
1057
 
1056
1058
 
1057
1059
  class ProtectMotionDeviceModel(ProtectAdoptableDeviceModel):
1058
- last_motion: datetime | None
1060
+ last_motion: datetime | None = None
1059
1061
  is_dark: bool
1060
1062
 
1061
1063
  # not directly from UniFi
@@ -1072,10 +1074,7 @@ class ProtectMotionDeviceModel(ProtectAdoptableDeviceModel):
1072
1074
  exclude: set[str] | None = None,
1073
1075
  ) -> dict[str, Any]:
1074
1076
  data = super().unifi_dict(data=data, exclude=exclude)
1075
-
1076
- if "lastMotionEventId" in data:
1077
- del data["lastMotionEventId"]
1078
-
1077
+ pop_dict_tuple(data, ("lastMotionEventId",))
1079
1078
  return data
1080
1079
 
1081
1080
  @property