class ModelConfig(FrigateBaseModel): path: Optional[str] = Field(title="Custom Object detection model path.") labelmap_path: Optional[str] = Field( title="Label map for custom object detector.") width: int = Field(default=320, title="Object detection model input width.") height: int = Field(default=320, title="Object detection model input height.") labelmap: Dict[int, str] = Field(default_factory=dict, title="Labelmap customization.") _merged_labelmap: Optional[Dict[int, str]] = PrivateAttr() _colormap: Dict[int, Tuple[int, int, int]] = PrivateAttr() @property def merged_labelmap(self) -> Dict[int, str]: return self._merged_labelmap @property def colormap(self) -> Dict[int, Tuple[int, int, int]]: return self._colormap def __init__(self, **config): super().__init__(**config) self._merged_labelmap = { **load_labels(config.get("labelmap_path", "/labelmap.txt")), **config.get("labelmap", {}), } cmap = plt.cm.get_cmap("tab10", len(self._merged_labelmap.keys())) self._colormap = {} for key, val in self._merged_labelmap.items(): self._colormap[val] = tuple( int(round(255 * c)) for c in cmap(key)[:3])
class StorageDistribution(ProtectBaseObject): recording_type_distributions: List[RecordingTypeDistribution] resolution_distributions: List[ResolutionDistribution] _recording_type_dict: Optional[Dict[ RecordingType, RecordingTypeDistribution]] = PrivateAttr(None) _resolution_dict: Optional[Dict[ ResolutionStorageType, ResolutionDistribution]] = PrivateAttr(None) def _get_recording_type_dict( self) -> Dict[RecordingType, RecordingTypeDistribution]: if self._recording_type_dict is None: self._recording_type_dict = {} for recording_type in self.recording_type_distributions: self._recording_type_dict[ recording_type.recording_type] = recording_type return self._recording_type_dict def _get_resolution_dict( self) -> Dict[ResolutionStorageType, ResolutionDistribution]: if self._resolution_dict is None: self._resolution_dict = {} for resolution in self.resolution_distributions: self._resolution_dict[resolution.resolution] = resolution return self._resolution_dict @property def timelapse_recordings(self) -> Optional[RecordingTypeDistribution]: return self._get_recording_type_dict().get(RecordingType.TIMELAPSE) @property def continuous_recordings(self) -> Optional[RecordingTypeDistribution]: return self._get_recording_type_dict().get(RecordingType.CONTINUOUS) @property def detections_recordings(self) -> Optional[RecordingTypeDistribution]: return self._get_recording_type_dict().get(RecordingType.DETECTIONS) @property def uhd_usage(self) -> Optional[ResolutionDistribution]: return self._get_resolution_dict().get(ResolutionStorageType.UHD) @property def hd_usage(self) -> Optional[ResolutionDistribution]: return self._get_resolution_dict().get(ResolutionStorageType.HD) @property def free(self) -> Optional[ResolutionDistribution]: return self._get_resolution_dict().get(ResolutionStorageType.FREE) def update_from_dict(self, data: Dict[str, Any]) -> StorageDistribution: # reset internal look ups when data changes self._recording_type_dict = None self._resolution_dict = None return super().update_from_dict(data)
class MeshPoint(BaseModel): """Mesh Point.""" # Vertex Index. vidx: int # 3D Coordinate of point. point: Tuple[float, float, float] # Point normal. normal: Optional[Tuple[float, float, float]] # Parent Mesh Data. _parent: Optional["MeshData"] = PrivateAttr(default=None) class Config: keep_untouched = (cached_property,) @property def faces(self) -> List["MeshFace"]: return [f for f in self._parent.faces if self.vidx in f.vertex_indices] @property def as_euclid(self) -> Point3: return Point3(*self.point) @cached_property def as_sympy(self) -> S.Point3D: return S.Point3D(*self.point) @property def as_vector(self) -> Vector3: return Vector3(*self.point) @property def normal_vector(self) -> Vector3: return Vector3(*self.normal)
class User(ProtectModelWithId): permissions: List[str] last_login_ip: Optional[str] last_login_time: Optional[datetime] is_owner: bool enable_notifications: bool has_accepted_invite: bool all_permissions: List[str] scopes: Optional[List[str]] = None location: Optional[UserLocation] name: str first_name: str last_name: str email: str local_username: str group_ids: List[str] cloud_account: Optional[CloudAccount] feature_flags: UserFeatureFlags # TODO: # settings # alertRules # notificationsV2 _groups: Optional[List[Group]] = PrivateAttr(None) @classmethod def _get_unifi_remaps(cls) -> Dict[str, str]: return {**super()._get_unifi_remaps(), "groups": "groupIds"} def unifi_dict(self, data: Optional[Dict[str, Any]] = None, exclude: Optional[Set[str]] = None) -> Dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) if "location" in data and data["location"] is None: del data["location"] return data @property def groups(self) -> List[Group]: """ Groups the user is in Will always be empty if the user only has read only access. """ if self._groups is not None: return self._groups self._groups = [ self.api.bootstrap.groups[g] for g in self.group_ids if g in self.api.bootstrap.groups ] return self._groups
class ZoneConfig(BaseModel): filters: Dict[str, FilterConfig] = Field( default_factory=dict, title="Zone filters." ) coordinates: Union[str, List[str]] = Field( title="Coordinates polygon for the defined zone." ) objects: List[str] = Field( default_factory=list, title="List of objects that can trigger the zone.", ) _color: Optional[Tuple[int, int, int]] = PrivateAttr() _contour: np.ndarray = PrivateAttr() @property def color(self) -> Tuple[int, int, int]: return self._color @property def contour(self) -> np.ndarray: return self._contour def __init__(self, **config): super().__init__(**config) self._color = config.get("color", (0, 0, 0)) coordinates = config["coordinates"] if isinstance(coordinates, list): self._contour = np.array( [[int(p.split(",")[0]), int(p.split(",")[1])] for p in coordinates] ) elif isinstance(coordinates, str): points = coordinates.split(",") self._contour = np.array( [[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)] ) else: self._contour = np.array([])
class FileModel(BaseModel, ABC): """Base class to represent models with a file representation. It therefore always has a `filepath` and if it is given on initilization, it will parse that file. The filepath can be relative, in which case the paths are expected to be resolved relative to some root model. If a path is absolute, this path will always be used, regardless of a root parent. When saving a model, if the current filepath is relative, the last resolved absolute path will be used. If the model has just been read, the This class extends the `validate` option of Pydantic, so when when a Path is given to a field with type `FileModel`, it doesn't error, but actually initializes the `FileModel`. Attributes: filepath (Optional[Path]): The path of this FileModel. This path can be either absolute or relative. If it is a relative path, it is assumed to be resolved from some root model. save_location (Path): A readonly property corresponding with the (current) save location of this FileModel. If read from a file or after saving recursively or after calling synchronize_filepath, this value will be updated to its new state. If made from memory and filepath is not set, it will correspond with cwd / filename.extension """ __slots__ = ["__weakref__"] # Use WeakValueDictionary to keep track of file paths with their respective parsed file models. _file_models_cache: WeakValueDictionary = WeakValueDictionary() filepath: Optional[Path] = None # Absolute anchor is used to resolve the save location when the filepath is relative. _absolute_anchor_path: Path = PrivateAttr(default_factory=Path.cwd) def __new__(cls, filepath: Optional[Path] = None, *args, **kwargs): """Creates a new model. If the file at the provided file path was already parsed, this instance is returned. Args: filepath (Optional[Path], optional): The file path to the file. Defaults to None. Returns: FileModel: A file model. """ with file_load_context() as context: if (file_model := context.retrieve_model(filepath)) is not None: return file_model else:
class LiveviewSlot(ProtectBaseObject): camera_ids: List[str] cycle_mode: str cycle_interval: int _cameras: Optional[List[Camera]] = PrivateAttr(None) @classmethod def _get_unifi_remaps(cls) -> Dict[str, str]: return {**super()._get_unifi_remaps(), "cameras": "cameraIds"} @property def cameras(self) -> List[Camera]: if self._cameras is not None: return self._cameras # user may not have permission to see the cameras in the liveview self._cameras = [self.api.bootstrap.cameras[g] for g in self.camera_ids if g in self.api.bootstrap.cameras] return self._cameras
class AEMOTableSchema(BaseModel): name: str namespace: str fieldnames: List[str] records: List[Union[Dict, BaseModel]] = [] # optionally it has a schema _record_schema: Optional[BaseModel] = PrivateAttr() # the url this table was taken from if any url_source: Optional[str] # the original content ?!?! # @NOTE does this make sense .. content_source: Optional[str] @property def full_name(self) -> str: return "{}_{}".format(self.namespace, self.name) @validator("name") def validate_name(cls, table_name: str) -> str: _table_name = table_name.strip().upper() return _table_name @validator("namespace") def validate_namespace(cls, namespace_name: str) -> str: _namespace_name = namespace_name.strip().upper() return _namespace_name @validator("fieldnames") def validate_fieldnames(cls, fieldnames: List[str]) -> List[str]: if not isinstance(fieldnames, list): return [] _fieldnames = [i.strip() for i in fieldnames] return _fieldnames def set_schema(self, schema: BaseModel) -> bool: self._record_schema = schema return True def get_records(self) -> Any: return self.records def add_record(self, record: Union[Dict, BaseModel]) -> bool: if self._record_schema: _record = None try: _record = self._record_schema(**record) # type: ignore except ValidationError as e: val_errors = e.errors() for ve in val_errors: ve_fieldname = ve["loc"][0] ve_val = "" if (record and isinstance(record, dict) and ve_fieldname in record): ve_val = record[ve_fieldname] logger.error("{} has error: {} '{}'".format( ve_fieldname, ve["msg"], ve_val)) return False self.records.append(_record) else: self.records.append(record) return True class Config: underscore_attrs_are_private = True
class AEMOTableSchema(BaseModel): name: str namespace: str fieldnames: List[str] _records: List[Union[Dict, BaseModel]] = [] # optionally it has a schema _record_schema: Optional[BaseModel] = PrivateAttr() # the url this table was taken from if any url_source: Optional[str] # the original content ?!?! # @NOTE does this make sense .. (it doesnt because it can be super large) content_source: Optional[str] @property def full_name(self) -> str: return "{}_{}".format(self.namespace, self.name) @validator("name") def validate_name(cls, table_name: str) -> str: _table_name = table_name.strip().upper() return _table_name @validator("namespace") def validate_namespace(cls, namespace_name: str) -> str: _namespace_name = namespace_name.strip().upper() return _namespace_name @validator("fieldnames") def validate_fieldnames(cls, fieldnames: List[str]) -> List[str]: if not isinstance(fieldnames, list): return [] _fieldnames = [i.strip() for i in fieldnames] return _fieldnames def set_schema(self, schema: BaseModel) -> bool: self._record_schema = schema return True @property def records(self) -> Any: _records = [] for _r in self._records: if isinstance(_r, MMSBase): _records.append(_r.dict()) if isinstance(_r, Dict): _records.append(_r) return _records def add_record(self, record: Union[Dict, BaseModel]) -> bool: if hasattr(self, "_record_schema") and self._record_schema: _record = None try: _record = self._record_schema(**record) # type: ignore except ValidationError as e: val_errors = e.errors() for ve in val_errors: ve_fieldname = ve["loc"][0] ve_val = "" if record and isinstance(record, dict) and ve_fieldname in record: ve_val = record[ve_fieldname] logger.error("{} has error: {} '{}'".format(ve_fieldname, ve["msg"], ve_val)) return False self._records.append(_record) else: self._records.append(record) return True def to_frame(self) -> Any: """ Return a pandas dataframe for the table """ if not _HAVE_PANDAS: return None _index_keys = [] if hasattr(self, "_record_schema") and self._record_schema: if hasattr(self._record_schema, "_primary_keys"): _index_keys = self._record_schema._primary_keys # type: ignore _df = pd.DataFrame(self.records) if len(_index_keys) > 0: logger.debug("Setting index to {}".format(_index_keys)) _df = _df.set_index(_index_keys) return _df class Config: underscore_attrs_are_private = True
class Bootstrap(ProtectBaseObject): auth_user_id: str access_key: str cameras: Dict[str, Camera] users: Dict[str, User] groups: Dict[str, Group] liveviews: Dict[str, Liveview] nvr: NVR viewers: Dict[str, Viewer] lights: Dict[str, Light] bridges: Dict[str, Bridge] sensors: Dict[str, Sensor] doorlocks: Dict[str, Doorlock] last_update_id: UUID # TODO: # legacyUFVs # displays # chimes # schedules # not directly from Unifi events: Dict[str, Event] = FixSizeOrderedDict() capture_ws_stats: bool = False _ws_stats: List[WSStat] = PrivateAttr([]) @classmethod def unifi_dict_to_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]: api = cls._get_api(data.get("api")) for model_type in ModelType.bootstrap_models(): key = model_type + "s" items: Dict[str, ProtectModel] = {} for item in data[key]: if api is not None and api.ignore_unadopted and not item.get( "isAdopted", True): continue items[item["id"]] = item data[key] = items return super().unifi_dict_to_dict(data) def unifi_dict(self, data: Optional[Dict[str, Any]] = None, exclude: Optional[Set[str]] = None) -> Dict[str, Any]: data = super().unifi_dict(data=data, exclude=exclude) if "events" in data: del data["events"] if "captureWsStats" in data: del data["captureWsStats"] for model_type in ModelType.bootstrap_models(): attr = model_type + "s" if attr in data and isinstance(data[attr], dict): data[attr] = list(data[attr].values()) return data @property def ws_stats(self) -> List[WSStat]: return self._ws_stats def clear_ws_stats(self) -> None: self._ws_stats = [] @property def auth_user(self) -> User: user: User = self.api.bootstrap.users[self.auth_user_id] return user def process_event(self, event: Event) -> None: if event.type in CAMERA_EVENT_ATTR_MAP and event.camera is not None: _process_camera_event(event) elif event.type == EventType.MOTION_LIGHT and event.light is not None: _process_light_event(event) elif event.type == EventType.MOTION_SENSOR and event.sensor is not None: _process_sensor_event(event) self.events[event.id] = event def _create_stat(self, packet: WSPacket, keys_set: List[str], filtered: bool) -> None: if self.capture_ws_stats: self._ws_stats.append( WSStat( model=packet.action_frame.data["modelKey"], action=packet.action_frame.data["action"], keys=list(packet.data_frame.data.keys()), keys_set=keys_set, size=len(packet.raw), filtered=filtered, )) def _get_frame_data( self, packet: WSPacket) -> Tuple[Dict[str, Any], Dict[str, Any]]: if self.capture_ws_stats: return deepcopy(packet.action_frame.data), deepcopy( packet.data_frame.data) return packet.action_frame.data, packet.data_frame.data def _process_add_packet( self, packet: WSPacket, data: Dict[str, Any]) -> Optional[WSSubscriptionMessage]: obj = create_from_unifi_dict(data, api=self._api) if isinstance(obj, Event): self.process_event(obj) elif isinstance(obj, NVR): self.nvr = obj elif (isinstance(obj, ProtectModelWithId) and obj.model is not None and obj.model.value in ModelType.bootstrap_models()): key = obj.model.value + "s" getattr(self, key)[obj.id] = obj else: _LOGGER.debug("Unexpected bootstrap model type for add: %s", obj.model) return None updated = obj.dict() self._create_stat(packet, list(updated.keys()), False) return WSSubscriptionMessage(action=WSAction.ADD, new_update_id=self.last_update_id, changed_data=updated, new_obj=obj) def _process_nvr_update( self, packet: WSPacket, data: Dict[str, Any], ignore_stats: bool) -> Optional[WSSubscriptionMessage]: data = _remove_stats_keys(data, ignore_stats) # nothing left to process if len(data) == 0: self._create_stat(packet, [], True) return None data = self.nvr.unifi_dict_to_dict(data) old_nvr = self.nvr.copy() self.nvr = self.nvr.update_from_dict(deepcopy(data)) self._create_stat(packet, list(data.keys()), False) return WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=self.last_update_id, changed_data=data, new_obj=self.nvr, old_obj=old_nvr, ) def _process_device_update( self, packet: WSPacket, action: Dict[str, Any], data: Dict[str, Any], ignore_stats: bool) -> Optional[WSSubscriptionMessage]: model_type = action["modelKey"] data = _remove_stats_keys(data, ignore_stats) # nothing left to process if len(data) == 0: self._create_stat(packet, [], True) return None key = model_type + "s" devices = getattr(self, key) if action["id"] in devices: obj: ProtectModelWithId = devices[action["id"]] data = obj.unifi_dict_to_dict(data) old_obj = obj.copy() obj = obj.update_from_dict(deepcopy(data)) now = utc_now() if isinstance(obj, Event): self.process_event(obj) elif isinstance(obj, Camera): if "last_ring" in data and obj.last_ring: is_recent = obj.last_ring + RECENT_EVENT_MAX >= now _LOGGER.debug("last_ring for %s (%s)", obj.id, is_recent) if is_recent: obj.set_ring_timeout() elif isinstance(obj, Sensor): if "alarm_triggered_at" in data and obj.alarm_triggered_at: is_recent = obj.alarm_triggered_at + RECENT_EVENT_MAX >= now _LOGGER.debug("alarm_triggered_at for %s (%s)", obj.id, is_recent) if is_recent: obj.set_alarm_timeout() elif "tampering_detected_at" in data and obj.tampering_detected_at: is_recent = obj.tampering_detected_at + RECENT_EVENT_MAX >= now _LOGGER.debug("tampering_detected_at for %s (%s)", obj.id, is_recent) if is_recent: obj.set_tampering_timeout() devices[action["id"]] = obj self._create_stat(packet, list(data.keys()), False) return WSSubscriptionMessage( action=WSAction.UPDATE, new_update_id=self.last_update_id, changed_data=data, new_obj=obj, old_obj=old_obj, ) # ignore updates to events that phase out if model_type != ModelType.EVENT.value: _LOGGER.debug("Unexpected %s: %s", key, action["id"]) return None def process_ws_packet( self, packet: WSPacket, models: Optional[Set[ModelType]] = None, ignore_stats: bool = False) -> Optional[WSSubscriptionMessage]: if models is None: models = set() if not isinstance(packet.action_frame, WSJSONPacketFrame): _LOGGER.debug("Unexpected action frame format: %s", packet.action_frame.payload_format) if not isinstance(packet.data_frame, WSJSONPacketFrame): _LOGGER.debug("Unexpected data frame format: %s", packet.data_frame.payload_format) action, data = self._get_frame_data(packet) if action["newUpdateId"] is not None: self.last_update_id = UUID(action["newUpdateId"]) if action["modelKey"] not in ModelType.values(): _LOGGER.debug("Unknown model type: %s", action["modelKey"]) self._create_stat(packet, [], True) return None if len(models) > 0 and ModelType( action["modelKey"]) not in models or len(data) == 0: self._create_stat(packet, [], True) return None if action["action"] == "add": return self._process_add_packet(packet, data) if action["action"] == "update": if action["modelKey"] == ModelType.NVR.value: return self._process_nvr_update(packet, data, ignore_stats) if action["modelKey"] in ModelType.bootstrap_models( ) or action["modelKey"] == ModelType.EVENT.value: return self._process_device_update(packet, action, data, ignore_stats) _LOGGER.debug("Unexpected bootstrap model type for update: %s", action["modelKey"]) self._create_stat(packet, [], True) return None
class MyPrivateAttr(BaseModel): _private_field: str = PrivateAttr()
class Event(ProtectModelWithId): type: EventType start: datetime end: Optional[datetime] score: int heatmap_id: Optional[str] camera_id: Optional[str] smart_detect_types: List[SmartDetectObjectType] smart_detect_event_ids: List[str] thumbnail_id: Optional[str] user_id: Optional[str] timestamp: Optional[datetime] metadata: Optional[EventMetadata] # TODO: # partition _smart_detect_events: Optional[List[Event]] = PrivateAttr(None) _smart_detect_track: Optional[SmartDetectTrack] = PrivateAttr(None) _smart_detect_zones: Optional[Dict[int, CameraZone]] = PrivateAttr(None) @classmethod def _get_unifi_remaps(cls) -> Dict[str, str]: return { **super()._get_unifi_remaps(), "camera": "cameraId", "heatmap": "heatmapId", "user": "******", "thumbnail": "thumbnailId", "smartDetectEvents": "smartDetectEventIds", } @classmethod def unifi_dict_to_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]: for key in {"start", "end", "timestamp"}.intersection(data.keys()): data[key] = process_datetime(data, key) return super().unifi_dict_to_dict(data) @property def camera(self) -> Optional[Camera]: if self.camera_id is None: return None return self.api.bootstrap.cameras.get(self.camera_id) @property def light(self) -> Optional[Light]: if self.metadata is None or self.metadata.light_id is None: return None return self.api.bootstrap.lights.get(self.metadata.light_id) @property def sensor(self) -> Optional[Sensor]: if self.metadata is None or self.metadata.sensor_id is None: return None return self.api.bootstrap.sensors.get(self.metadata.sensor_id) @property def user(self) -> Optional[User]: if self.user_id is None: return None return self.api.bootstrap.users.get(self.user_id) @property def smart_detect_events(self) -> List[Event]: if self._smart_detect_events is not None: return self._smart_detect_events self._smart_detect_events = [ self.api.bootstrap.events[g] for g in self.smart_detect_event_ids if g in self.api.bootstrap.events ] return self._smart_detect_events async def get_thumbnail(self, width: Optional[int] = None, height: Optional[int] = None) -> Optional[bytes]: """Gets thumbnail for event""" if self.thumbnail_id is None: return None return await self.api.get_event_thumbnail(self.thumbnail_id, width, height) async def get_heatmap(self) -> Optional[bytes]: """Gets heatmap for event""" if self.heatmap_id is None: return None return await self.api.get_event_heatmap(self.heatmap_id) async def get_video(self, channel_index: int = 0) -> Optional[bytes]: """Get the MP4 video clip for this given event Args: * `channel_index`: index of `CameraChannel` on the camera to use to retrieve video from Will raise an exception if event does not have a camera, end time or the channel index is wrong. """ if self.camera is None: raise BadRequest("Event does not have a camera") if self.end is None: raise BadRequest("Event is ongoing") return await self.api.get_camera_video(self.camera.id, self.start, self.end, channel_index) async def get_smart_detect_track(self) -> SmartDetectTrack: """ Gets smart detect track for given smart detect event. If event is not a smart detect event, it will raise a `BadRequest` """ if self.type != EventType.SMART_DETECT: raise BadRequest("Not a smart detect event") if self._smart_detect_track is None: self._smart_detect_track = await self.api.get_event_smart_detect_track( self.id) return self._smart_detect_track async def get_smart_detect_zones(self) -> Dict[int, CameraZone]: """Gets the triggering zones for the smart detection""" if self.camera is None: raise BadRequest("No camera on event") if self._smart_detect_zones is None: smart_track = await self.get_smart_detect_track() ids: Set[int] = set() for item in smart_track.payload: ids = ids | set(item.zone_ids) self._smart_detect_zones = { z.id: z for z in self.camera.smart_detect_zones if z.id in ids } return self._smart_detect_zones
class ProtectBaseObject(BaseModel): """ Base class for building Python objects from Unifi Protect JSON. * Provides `.unifi_dict_to_dict` to convert UFP JSON to a more Pythonic formatted dict (camel case to snake case) * Add attrs with matching Pyhonic name and they will automatically be populated from the UFP JSON if passed in to the constructer * Provides `.unifi_dict` to convert object back into UFP JSON """ _api: Optional[ProtectApiClient] = PrivateAttr(None) _initial_data: Dict[str, Any] = PrivateAttr() _protect_objs: ClassVar[Optional[Dict[str, Type[ProtectBaseObject]]]] = None _protect_lists: ClassVar[Optional[Dict[str, Type[ProtectBaseObject]]]] = None _protect_dicts: ClassVar[Optional[Dict[str, Type[ProtectBaseObject]]]] = None class Config: arbitrary_types_allowed = True validate_assignment = True def __init__(self, api: Optional[ProtectApiClient] = None, **data: Any) -> None: """ Base class for creating Python objects from UFP JSON data. Use the static method `.from_unifi_dict()` to create objects from UFP JSON data from then the main class constructor. """ super().__init__(**data) self._initial_data = self.dict( exclude=self._get_excluded_changed_fields()) self._api = api @classmethod def from_unifi_dict(cls, api: Optional[ProtectApiClient] = None, **data: Any) -> ProtectObject: """ Main constructor for `ProtectBaseObject` Args: * `api`: Optional reference to the ProtectAPIClient that created generated the UFP JSON * `data`: decoded UFP JSON `api` is is expected as a `@property`. If it is `None` and accessed, a `BadRequest` will be raised. API can be used for saving updates for the Protect object or fetching references to other objects (cameras, users, etc.) """ data["api"] = api data = cls.unifi_dict_to_dict(data) if is_debug(): data.pop("api", None) return cls(api=api, **data) # type: ignore obj = cls.construct(**data) return obj # type: ignore @classmethod def construct(cls, _fields_set: Optional[Set[str]] = None, **values: Any) -> ProtectObject: api = values.pop("api", None) for key, klass in cls._get_protect_objs().items(): if key in values and isinstance(values[key], dict): values[key] = klass.construct(**values[key]) for key, klass in cls._get_protect_lists().items(): if key in values and isinstance(values[key], list): values[key] = [ klass.construct(**v) if isinstance(v, dict) else v for v in values[key] ] for key, klass in cls._get_protect_dicts().items(): if key in values and isinstance(values[key], dict): values[key] = { k: klass.construct(**v) if isinstance(v, dict) else v for k, v in values[key].items() } obj = super().construct(_fields_set=_fields_set, **values) obj._initial_data = obj.dict( exclude=cls._get_excluded_changed_fields()) # pylint: disable=protected-access obj._api = api # pylint: disable=protected-access return obj # type: ignore @classmethod def _get_excluded_changed_fields(cls) -> Set[str]: """ Helper method for override in child classes for fields that excluded from calculating "changed" state for a model (`.initial_data` and `.get_changed()`) """ return set() @classmethod def _get_unifi_remaps(cls) -> Dict[str, str]: """ Helper method for overriding in child classes for remapping UFP JSON keys to Python ones that do not fit the simple camel case to snake case formula. Return format is { "ufpJsonName": "python_name" } """ return {} @classmethod def _set_protect_subtypes(cls) -> None: """Helper method to detect attrs of current class that are UFP Objects themselves""" cls._protect_objs = {} cls._protect_lists = {} cls._protect_dicts = {} for name, field in cls.__fields__.items(): try: if issubclass(field.type_, ProtectBaseObject): if field.shape == SHAPE_LIST: cls._protect_lists[name] = field.type_ if field.shape == SHAPE_DICT: cls._protect_dicts[name] = field.type_ else: cls._protect_objs[name] = field.type_ except TypeError: pass @classmethod def _get_protect_objs(cls) -> Dict[str, Type[ProtectBaseObject]]: """Helper method to get all child UFP objects""" if cls._protect_objs is not None: return cls._protect_objs cls._set_protect_subtypes() return cls._protect_objs # type: ignore @classmethod def _get_protect_lists(cls) -> Dict[str, Type[ProtectBaseObject]]: """Helper method to get all child of UFP objects (lists)""" if cls._protect_lists is not None: return cls._protect_lists cls._set_protect_subtypes() return cls._protect_lists # type: ignore @classmethod def _get_protect_dicts(cls) -> Dict[str, Type[ProtectBaseObject]]: """Helper method to get all child of UFP objects (dicts)""" if cls._protect_dicts is not None: return cls._protect_dicts cls._set_protect_subtypes() return cls._protect_dicts # type: ignore @classmethod def _get_api( cls, api: Optional[ProtectApiClient]) -> Optional[ProtectApiClient]: """Helper method to try to find and the current ProjtectAPIClient instance from given data""" if api is None and isinstance(cls, ProtectBaseObject) and hasattr( cls, "_api"): api = cls._api return api @classmethod def _clean_protect_obj(cls, data: Any, klass: Type[ProtectBaseObject], api: Optional[ProtectApiClient]) -> Any: if isinstance(data, dict): if api is not None: data["api"] = api return klass.unifi_dict_to_dict(data=data) return data @classmethod def _clean_protect_obj_list(cls, items: List[Any], klass: Type[ProtectBaseObject], api: Optional[ProtectApiClient]) -> List[Any]: cleaned_items: List[Any] = [] for item in items: cleaned_items.append(cls._clean_protect_obj(item, klass, api)) return cleaned_items @classmethod def _clean_protect_obj_dict( cls, items: Dict[Any, Any], klass: Type[ProtectBaseObject], api: Optional[ProtectApiClient]) -> Dict[Any, Any]: cleaned_items: Dict[Any, Any] = {} for key, value in items.items(): cleaned_items[key] = cls._clean_protect_obj(value, klass, api) return cleaned_items @classmethod def unifi_dict_to_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]: """ Takes a decoded UFP JSON dict and converts it into a Python dict * Remaps items from `._get_unifi_remaps()` * Converts camelCase keys to snake_case keys * Injects ProtectAPIClient into any child UFP object Dicts * Runs `.unifi_dict_to_dict` for any child UFP objects Args: * `data`: decoded UFP JSON dict """ # get the API client instance api = cls._get_api(data.get("api", None)) # remap keys that will not be converted correctly by snake_case convert for from_key, to_key in cls._get_unifi_remaps().items(): if from_key in data: data[to_key] = data.pop(from_key) # convert to snake_case for key in list(data.keys()): data[to_snake_case(key)] = data.pop(key) # remove extra fields for key, value in list(data.items()): if key == "api": continue if key not in cls.__fields__: del data[key] continue data[key] = convert_unifi_data(value, cls.__fields__[key]) # clean child UFP objs for key, klass in cls._get_protect_objs().items(): if key in data: data[key] = cls._clean_protect_obj(data[key], klass, api) for key, klass in cls._get_protect_lists().items(): if key in data and isinstance(data[key], list): data[key] = cls._clean_protect_obj_list(data[key], klass, api) for key, klass in cls._get_protect_dicts().items(): if key in data and isinstance(data[key], dict): data[key] = cls._clean_protect_obj_dict(data[key], klass, api) return data def _unifi_dict_protect_obj(self, data: Dict[str, Any], key: str, use_obj: bool) -> Any: value: Optional[Any] = data.get(key) if use_obj: value = getattr(self, key) if isinstance(value, ProtectBaseObject): value = value.unifi_dict() return value def _unifi_dict_protect_obj_list(self, data: Dict[str, Any], key: str, use_obj: bool) -> Any: value: Optional[Any] = data.get(key) if use_obj: value = getattr(self, key) if not isinstance(value, list): return value items: List[Any] = [] for item in value: if isinstance(item, ProtectBaseObject): item = item.unifi_dict() items.append(item) return items def _unifi_dict_protect_obj_dict(self, data: Dict[str, Any], key: str, use_obj: bool) -> Any: value: Optional[Any] = data.get(key) if use_obj: value = getattr(self, key) if not isinstance(value, dict): return value items: Dict[Any, Any] = {} for obj_key, obj in value.items(): if isinstance(obj, ProtectBaseObject): obj = obj.unifi_dict() items[obj_key] = obj return items def unifi_dict(self, data: Optional[Dict[str, Any]] = None, exclude: Optional[Set[str]] = None) -> Dict[str, Any]: """ Can either convert current Python object into UFP JSON dict or take the output of a `.dict()` call and convert it. * Remaps items from `._get_unifi_remaps()` in reverse * Converts snake_case to camelCase * Automatically removes any ProtectApiClient instances that might still be in the data * Automaitcally calls `.unifi_dict()` for any UFP Python objects that are detected Args: `data`: Optional output of `.dict()` for the Python object. If `None`, will call `.dict` first `exclude`: Optional set of fields to exclude from convert. Useful for subclassing and having custom processing for dumping to UFP JSON data. """ use_obj = False if data is None: excluded_fields = set(self._get_protect_objs().keys()) | set( self._get_protect_lists().keys()) if exclude is not None: excluded_fields = excluded_fields | exclude data = self.dict(exclude=excluded_fields) use_obj = True for key in self._get_protect_objs().keys(): if use_obj or key in data: data[key] = self._unifi_dict_protect_obj(data, key, use_obj) for key in self._get_protect_lists().keys(): if use_obj or key in data: data[key] = self._unifi_dict_protect_obj_list( data, key, use_obj) for key in self._get_protect_dicts().keys(): if use_obj or key in data: data[key] = self._unifi_dict_protect_obj_dict( data, key, use_obj) data: Dict[str, Any] = serialize_unifi_obj(data) for to_key, from_key in self._get_unifi_remaps().items(): if from_key in data: data[to_key] = data.pop(from_key) if "api" in data: del data["api"] return data def _inject_api(self, data: Dict[str, Any], api: Optional[ProtectApiClient]) -> Dict[str, Any]: data["api"] = api for key in self._get_protect_objs().keys(): if key in data: unifi_obj: Optional[Any] = getattr(self, key) if unifi_obj is not None and isinstance(unifi_obj, dict): unifi_obj["api"] = api for key in self._get_protect_lists().keys(): if key in data: new_items = [] for item in data[key]: if isinstance(item, dict): item["api"] = api new_items.append(item) data[key] = new_items for key in self._get_protect_dicts().keys(): if key in data: for item_key, item in data[key].items(): if isinstance(item, dict): item["api"] = api data[key][item_key] = item return data def update_from_dict(self: ProtectObject, data: Dict[str, Any]) -> ProtectObject: """Updates current object from a cleaned UFP JSON dict""" for key in self._get_protect_objs().keys(): if key in data: unifi_obj: Optional[Any] = getattr(self, key) if unifi_obj is not None and isinstance( unifi_obj, ProtectBaseObject): setattr(self, key, unifi_obj.update_from_dict(data.pop(key))) if "api" in data: del data["api"] if is_debug(): return self.copy(update=data) new_data = self.dict() new_data.update(data) new_data = self._inject_api(new_data, self._api) return self.construct(**new_data) # type: ignore def get_changed(self: ProtectObject) -> Dict[str, Any]: return dict_diff(self._initial_data, self.dict()) @property def api(self) -> ProtectApiClient: """ ProtectApiClient that the UFP object was created with. If no API Client was passed in time of creation, will raise `BadRequest` """ if self._api is None: raise BadRequest("API Client not initialized") return self._api
class MeshFace(BaseModel): """Mesh Face.""" # Face Index. fidx: int # Vertices that makeup face. vertex_indices: List[int] # Face normal. normal: Optional[Tuple[float, float, float]] # Face area. area: float # Vector representing face centroids. centroid: Optional[Tuple[float, float, float]] # Parent Mesh Data. _parent: Optional["MeshData"] = PrivateAttr(default=None) class Config: keep_untouched = (cached_property,) @property def vertices(self) -> List[MeshPoint]: return [v for v in self._parent.vertices if v.vidx in self.vertex_indices] @cached_property def sympy_vertices(self) -> List[S.Point3D]: return [p.as_sympy for p in self.vertices] @property def euclid_vertices(self) -> List[Point3]: return [p.as_euclid for p in self.vertices] @cached_property def as_sympy_plane(self) -> S.Plane: return S.Plane(*self.sympy_vertices, normal_vector=self.normal) @cached_property def missing_rect_vertex(self) -> S.Point3D: points_perms = itertools.permutations(self.sympy_vertices, 3) least_dist = None least_dist_point = None for perm in points_perms: midp = find_missing_rect_vertex(*perm) dist = self.centroid_point.distance(midp) if least_dist is None or least_dist > dist: least_dist = dist least_dist_point = midp return least_dist_point @cached_property def midpoint_by_canberra(self) -> S.Point3D: fp_1 = self.vertices[0].as_sympy fp_2 = max(self.sympy_vertices, key=lambda p: fp_1.canberra_distance(p)) return fp_1.midpoint(fp_2) @property def normal_vector(self) -> Vector3: return Vector3(*self.normal) @cached_property def centroid_point(self) -> S.Point3D: return S.Point3D(*self.centroid)
class CameraConfig(FrigateBaseModel): name: Optional[str] = Field(title="Camera name.", regex="^[a-zA-Z0-9_-]+$") ffmpeg: CameraFfmpegConfig = Field( title="FFmpeg configuration for the camera.") best_image_timeout: int = Field( default=60, title= "How long to wait for the image with the highest confidence score.", ) zones: Dict[str, ZoneConfig] = Field(default_factory=dict, title="Zone configuration.") record: RecordConfig = Field(default_factory=RecordConfig, title="Record configuration.") rtmp: RtmpConfig = Field(default_factory=RtmpConfig, title="RTMP restreaming configuration.") live: CameraLiveConfig = Field(default_factory=CameraLiveConfig, title="Live playback settings.") snapshots: SnapshotsConfig = Field(default_factory=SnapshotsConfig, title="Snapshot configuration.") mqtt: CameraMqttConfig = Field(default_factory=CameraMqttConfig, title="MQTT configuration.") objects: ObjectConfig = Field(default_factory=ObjectConfig, title="Object configuration.") motion: Optional[MotionConfig] = Field( title="Motion detection configuration.") detect: DetectConfig = Field(default_factory=DetectConfig, title="Object detection configuration.") timestamp_style: TimestampStyleConfig = Field( default_factory=TimestampStyleConfig, title="Timestamp style configuration.") _ffmpeg_cmds: List[Dict[str, List[str]]] = PrivateAttr() def __init__(self, **config): # Set zone colors if "zones" in config: colors = plt.cm.get_cmap("tab10", len(config["zones"])) config["zones"] = { name: { **z, "color": tuple(round(255 * c) for c in colors(idx)[:3]) } for idx, (name, z) in enumerate(config["zones"].items()) } # add roles to the input if there is only one if len(config["ffmpeg"]["inputs"]) == 1: config["ffmpeg"]["inputs"][0]["roles"] = [ "record", "rtmp", "detect" ] super().__init__(**config) @property def frame_shape(self) -> Tuple[int, int]: return self.detect.height, self.detect.width @property def frame_shape_yuv(self) -> Tuple[int, int]: return self.detect.height * 3 // 2, self.detect.width @property def ffmpeg_cmds(self) -> List[Dict[str, List[str]]]: return self._ffmpeg_cmds def create_ffmpeg_cmds(self): if "_ffmpeg_cmds" in self: return ffmpeg_cmds = [] for ffmpeg_input in self.ffmpeg.inputs: ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input) if ffmpeg_cmd is None: continue ffmpeg_cmds.append({ "roles": ffmpeg_input.roles, "cmd": ffmpeg_cmd }) self._ffmpeg_cmds = ffmpeg_cmds def _get_ffmpeg_cmd(self, ffmpeg_input: CameraInput): ffmpeg_output_args = [] if "detect" in ffmpeg_input.roles: detect_args = (self.ffmpeg.output_args.detect if isinstance( self.ffmpeg.output_args.detect, list) else self.ffmpeg.output_args.detect.split(" ")) ffmpeg_output_args = ([ "-r", str(self.detect.fps), "-s", f"{self.detect.width}x{self.detect.height}", ] + detect_args + ffmpeg_output_args + ["pipe:"]) if "rtmp" in ffmpeg_input.roles and self.rtmp.enabled: rtmp_args = (self.ffmpeg.output_args.rtmp if isinstance( self.ffmpeg.output_args.rtmp, list) else self.ffmpeg.output_args.rtmp.split(" ")) ffmpeg_output_args = (rtmp_args + [f"rtmp://127.0.0.1/live/{self.name}"] + ffmpeg_output_args) if "record" in ffmpeg_input.roles and self.record.enabled: record_args = (self.ffmpeg.output_args.record if isinstance( self.ffmpeg.output_args.record, list) else self.ffmpeg.output_args.record.split(" ")) ffmpeg_output_args = ( record_args + [f"{os.path.join(CACHE_DIR, self.name)}-%Y%m%d%H%M%S.mp4"] + ffmpeg_output_args) # if there arent any outputs enabled for this input if len(ffmpeg_output_args) == 0: return None global_args = ffmpeg_input.global_args or self.ffmpeg.global_args hwaccel_args = ffmpeg_input.hwaccel_args or self.ffmpeg.hwaccel_args input_args = ffmpeg_input.input_args or self.ffmpeg.input_args global_args = (global_args if isinstance(global_args, list) else global_args.split(" ")) hwaccel_args = (hwaccel_args if isinstance(hwaccel_args, list) else hwaccel_args.split(" ")) input_args = (input_args if isinstance(input_args, list) else input_args.split(" ")) cmd = (["ffmpeg"] + global_args + hwaccel_args + input_args + ["-i", ffmpeg_input.path] + ffmpeg_output_args) return [part for part in cmd if part != ""]
class AEMOTableSchema(BaseConfig): name: str namespace: str fieldnames: List[str] records: List[Dict[str, Any] | list] = Field(default_factory=list) # optionally it has a schema _record_schema: Optional[MMSBaseClass] = PrivateAttr() # the url this table was taken from if any url_source: Optional[str] # the original content ?!?! # @NOTE does this make sense .. (it doesnt because it can be super large) content_source: Optional[str] @property def full_name(self) -> str: return "{}_{}".format(self.namespace, self.name) @property def primary_key(self) -> Optional[List[str]]: if ( hasattr(self, "_record_schema") and isinstance(self._record_schema, MMSBaseClass) and hasattr(self._record_schema, "_primary_keys") ): return self._record_schema._primary_keys return None @validator("name") def validate_name(cls, table_name: str) -> str: _table_name = table_name.strip().lower() return _table_name @validator("namespace") def validate_namespace(cls, namespace_name: str) -> str: _namespace_name = namespace_name.strip().lower() return _namespace_name @validator("fieldnames") def validate_fieldnames(cls, fieldnames: List[str]) -> List[str]: if not isinstance(fieldnames, list): return [] _fieldnames = [i.lower() for i in fieldnames] return _fieldnames def set_schema(self, schema: MMSBaseClass) -> bool: self._record_schema = schema return True def add_record(self, record: Union[Dict, MMSBaseClass], values_only: bool = False) -> bool: if isinstance(record, dict) and hasattr(self, "_record_schema") and self._record_schema: _record = None if not isinstance(record, dict): raise Exception("Could not add record, not a dict: {}".format(record)) try: _record = self._record_schema(**record) # type: ignore except ValidationError as e: val_errors = e.errors() logger.debug(record) for ve in val_errors: ve_fieldname = ve["loc"][0] ve_val = "" if record and isinstance(record, dict) and ve_fieldname in record: ve_val = record[ve_fieldname] return False except Exception as e: logger.error("Record error: {}".format(e)) return False if values_only: self.records.append(list(_record.values())) else: self.records.append(_record) else: if values_only and isinstance(record, dict): self.records.append(list(record.values())) elif values_only and isinstance(record, list): self.records.append(record) else: self.records.append(record) return True def to_frame(self) -> Any: """Return a pandas dataframe for the table""" if not _HAVE_PANDAS: return None _index_keys = [] _df = pd.DataFrame(self.records) if hasattr(self, "_record_schema") and self._record_schema: if hasattr(self._record_schema, "_primary_keys"): _index_keys = self._record_schema._primary_keys # type: ignore if len(_index_keys) > 0: logger.debug("Setting index to {}".format(_index_keys)) try: _df = _df.set_index(_index_keys) except KeyError: logger.warn("Could not set index with columns: {}".format(", ".join(_index_keys))) return _df def to_csv(self, filename: str) -> None: logger.info(f"Writing table {self.full_name} with {len(self.records)} records") with open(filename, "w") as fh: csvwriter = csv.DictWriter(fh, fieldnames=self.fieldnames) csvwriter.writeheader() for record in self.records: csvwriter.writerow(dict(zip(self.fieldnames, record))) logger.info(f"Wrote records to {self.full_name}") class Config: underscore_attrs_are_private = True
class NameServerConfiguration(BaseSettings, PyroConfigMixin, YAMLMixin): """ The NameServer Settings class. Contains all applicable configuration parameters for running a nameserver. Parameters ---------- host : str, optional The hostname of the nameserver. Defaults to "localhost" for security. Can be set to "public", which is dynamically translated to the machine's ip address when the nameserver is started. ns_port : int, optional The port of the nameserver. Defaults to 9090. broadcast : bool, optional Whether to launch a broadcast server. Defaults to False. ns_bchost : str, optional The hostname of the broadcast server. Defaults to None. ns_bcport : int, optional The port of the broadcast server. Defaults to 9091. ns_autoclean : float, optional The interval in seconds at which the nameserver will ping registered objects and clean up unresponsive ones. Default is 0.0 (off). storage : str, optional A Pyro5-style storage string. You have several options: * ``memory``: Fast, volatile in-memory database. This is the default. * ``dbm[:dbfile]``: Persistent database using dbm. Optionally provide the filename to use (ignore for PyroLab to create automatically). This storage type does not support metadata. * ``sql[:dbfile]``: Persistent database using sqlite. Optionally provide the filename to use (ignore for PyroLab to create automatically). Examples -------- The following are examples of valid YAML configurations for nameservers. Keys not defined assume the default values. .. code-block:: yaml host: localhost ns_port: 9090 ns_autoclean: 0.0 storage: memory .. code-block:: yaml host: public ns_port: 9100 broadcast: false ns_bchost: null ns_bcport: 9091 ns_autoclean: 15.0 storage: sql """ host: str = "localhost" ns_port: int = 9090 broadcast: bool = False ns_bchost: Optional[bool] = None ns_bcport: int = 9091 ns_autoclean: float = 0.0 storage: str = "memory" _name: str = PrivateAttr("") @validator('storage') def valid_memory_format(cls, v: str): if v == "memory": return v elif any(v.startswith(storage) for storage in ["dbm", "sql"]): return v else: raise ValueError(f"Invalid storage specification: {v}") @property def name(self) -> str: return self._name def set_name(self, name: str) -> None: self._name = name def get_storage_location(self) -> Path: """ Returns the storage location for the given name. Returns ------- Path The path to the storage location. """ if self.storage in ["sql", "dbm"]: return f"{self.storage}:" + str( NAMESERVER_STORAGE / f"ns_{self.name}.{self.storage}") return self.storage def update_pyro_config(self) -> Dict[str, Any]: """ Sets all key-value attributes that are Pyro5 configuration options. Pyro5 attributes that this function automatically translates: | * HOST: "public" is translated to the machine's ip address | * NS_HOST: "public" is translated to the machine's ip address | * NS_BCHOST: "public" is translated to the machine's ip address One side effect of this function is that it also updates NS_HOST to the same value as HOST. This is usually desirable. Parameters ---------- values : dict, optional A dictionary of key-value pairs to update the configuration. If not provided, the model's attributes will be used. Returns ------- dict A dictionary of Pyro5 key-value pairs that were updated, for debugging or informational purposes. """ values = self.dict() values['ns_host'] = values['host'] return super().update_pyro_config(values=values)
class _Config(BaseSettings): # General Config. RENDERS_DIR: Path = Path("renders") # OpenSCAD Poly Segments. SEGMENTS: int = 48 # OpenSCAD Libraries. TMP_DIR: Path = Path("/tmp/") / "threedframe" LIB_DIR: Path = ROOT / "lib" MCAD_DIR: Path = LIB_DIR / "MCAD" DOTSCAD_DIR: Path = LIB_DIR / "dotSCAD" / "src" _mcad: Optional[Any] = PrivateAttr(None) _dotSCAD: Optional[Any] = PrivateAttr(None) @validator("LIB_DIR", "MCAD_DIR", "DOTSCAD_DIR") def validate_libs(cls, v: Path) -> Path: if not v.exists(): raise ValueError(f"Missing library: {v}") return v @validator("TMP_DIR", pre=True) def validate_tmp_dir(cls, v: Path) -> Path: v.mkdir(exist_ok=True) return v def create_lib_dir(self, lib_dir: Path): """Create library directory. Copies given lib to /tmp/ directory to simplify previewing generated models on host. """ rel_dir = lib_dir.relative_to(self.LIB_DIR) target_dir = self.TMP_DIR / rel_dir if target_dir.exists(): return target_dir target_dir.mkdir(parents=True, exist_ok=True) shutil.copytree(lib_dir, target_dir, dirs_exist_ok=True) return target_dir def setup_libs(self): mcad = self.create_lib_dir(self.MCAD_DIR) dotscad = self.create_lib_dir(self.DOTSCAD_DIR) with quiet_solid(): self._mcad = solid.import_scad(str(mcad)) self._dotSCAD = solid.import_scad(str(dotscad)) @property def mcad(self): if not self._mcad: self.setup_libs() return self._mcad @property def dotSCAD(self): if not self._dotSCAD: self.setup_libs() return self._dotSCAD # Model Params GAP: float = 0.02 # 3dPrinting fudge factor. CORE_SIZE: float = 1.4 * Constants.INCH SUPPORT_SIZE: float = 0.69 * Constants.INCH FIXTURE_WALL_THICKNESS: float = 6.0 FIXTURE_HOLE_SIZE: float = SUPPORT_SIZE + GAP FIXTURE_SIZE: float = FIXTURE_HOLE_SIZE + FIXTURE_WALL_THICKNESS