From b68f0fc20c558d8a3d3feacc898c2dfe0ed19d53 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 20 Dec 2023 22:41:41 +0100 Subject: [PATCH] Add and consolidate parameter handlers --- axis/vapix/interfaces/api_handler.py | 57 +++++++++++++- axis/vapix/interfaces/mqtt.py | 2 +- axis/vapix/interfaces/parameters/brand.py | 19 +++++ axis/vapix/interfaces/parameters/image.py | 17 +++++ axis/vapix/interfaces/parameters/io_port.py | 19 +++++ axis/vapix/interfaces/parameters/param_cgi.py | 75 ++++++++++++------- .../interfaces/parameters/param_handler.py | 44 +++++++++++ .../vapix/interfaces/parameters/properties.py | 17 +++++ axis/vapix/interfaces/parameters/ptz.py | 17 +++++ .../interfaces/parameters/stream_profile.py | 22 ++++++ axis/vapix/interfaces/port_cgi.py | 12 +-- axis/vapix/interfaces/ptz.py | 6 +- axis/vapix/interfaces/user_groups.py | 2 +- .../vapix/models/parameters/stream_profile.py | 2 +- tests/test_ptz.py | 3 +- 15 files changed, 268 insertions(+), 46 deletions(-) create mode 100644 axis/vapix/interfaces/parameters/brand.py create mode 100644 axis/vapix/interfaces/parameters/image.py create mode 100644 axis/vapix/interfaces/parameters/io_port.py create mode 100644 axis/vapix/interfaces/parameters/param_handler.py create mode 100644 axis/vapix/interfaces/parameters/properties.py create mode 100644 axis/vapix/interfaces/parameters/ptz.py create mode 100644 axis/vapix/interfaces/parameters/stream_profile.py diff --git a/axis/vapix/interfaces/api_handler.py b/axis/vapix/interfaces/api_handler.py index dd0e276f..ffc52f4d 100644 --- a/axis/vapix/interfaces/api_handler.py +++ b/axis/vapix/interfaces/api_handler.py @@ -4,6 +4,7 @@ from typing import ( TYPE_CHECKING, Any, + Callable, Generic, ItemsView, Iterator, @@ -17,8 +18,59 @@ from ..models.api import ApiItemT +CallbackType = Callable[[str], None] +SubscriptionType = CallbackType +UnsubscribeType = Callable[[], None] -class ApiHandler(ABC, Generic[ApiItemT]): +ID_FILTER_ALL = "*" + + +class SubscriptionHandler(ABC): + """Manage subscription and notification to subscribers.""" + + def __init__(self) -> None: + """Initialize subscription handler.""" + self._subscribers: dict[str, list[SubscriptionType]] = {ID_FILTER_ALL: []} + + def signal_subscribers(self, obj_id: str) -> None: + """Signal subscribers.""" + subscribers: list[SubscriptionType] = ( + self._subscribers.get(obj_id, []) + self._subscribers[ID_FILTER_ALL] + ) + for callback in subscribers: + callback(obj_id) + + def subscribe( + self, + callback: CallbackType, + id_filter: tuple[str] | str | None = None, + ) -> UnsubscribeType: + """Subscribe to added events.""" + subscription = callback + + _id_filter: tuple[str] + if id_filter is None: + _id_filter = (ID_FILTER_ALL,) + elif isinstance(id_filter, str): + _id_filter = (id_filter,) + + for obj_id in _id_filter: + if obj_id not in self._subscribers: + self._subscribers[obj_id] = [] + self._subscribers[obj_id].append(subscription) + + def unsubscribe() -> None: + for obj_id in _id_filter: + if obj_id not in self._subscribers: + continue + if subscription not in self._subscribers[obj_id]: + continue + self._subscribers[obj_id].remove(subscription) + + return unsubscribe + + +class ApiHandler(SubscriptionHandler, Generic[ApiItemT]): """Base class for a map of API Items.""" api_id: "ApiId" @@ -26,6 +78,7 @@ class ApiHandler(ABC, Generic[ApiItemT]): def __init__(self, vapix: "Vapix") -> None: """Initialize API items.""" + super().__init__() self.vapix = vapix self._items: dict[str, ApiItemT] = {} self.initialized = False @@ -45,7 +98,7 @@ def api_version(self) -> str | None: @abstractmethod async def _api_request(self) -> dict[str, ApiItemT]: - """Get API data method defined by subsclass.""" + """Get API data method defined by subclass.""" async def update(self) -> None: """Refresh data.""" diff --git a/axis/vapix/interfaces/mqtt.py b/axis/vapix/interfaces/mqtt.py index 84b014a6..d99ef283 100644 --- a/axis/vapix/interfaces/mqtt.py +++ b/axis/vapix/interfaces/mqtt.py @@ -60,7 +60,7 @@ class MqttClientHandler(ApiHandler[Any]): default_api_version = API_VERSION async def _api_request(self) -> dict[str, None]: - """Get API data method defined by subsclass.""" + """Get API data method defined by subclass.""" raise NotImplementedError async def configure_client(self, client_config: ClientConfig) -> None: diff --git a/axis/vapix/interfaces/parameters/brand.py b/axis/vapix/interfaces/parameters/brand.py new file mode 100644 index 00000000..29de8f6e --- /dev/null +++ b/axis/vapix/interfaces/parameters/brand.py @@ -0,0 +1,19 @@ +"""Brand parameters.""" + +from typing import cast + +from ...models.parameters.brand import BrandParam, BrandT +from .param_handler import ParamHandler + + +class BrandParameterHandler(ParamHandler[BrandParam]): + """Handler for brand parameters.""" + + parameter_group = "Brand" + + def get_params(self) -> dict[str, BrandParam]: + """Retrieve brand properties.""" + params = {} + if data := self.vapix.params.get_param(self.parameter_group): + params["0"] = BrandParam.decode(cast(BrandT, data)) + return params diff --git a/axis/vapix/interfaces/parameters/image.py b/axis/vapix/interfaces/parameters/image.py new file mode 100644 index 00000000..5364637b --- /dev/null +++ b/axis/vapix/interfaces/parameters/image.py @@ -0,0 +1,17 @@ +"""Image parameters.""" + +from ...models.parameters.image import ImageParam +from .param_handler import ParamHandler + + +class ImageParameterHandler(ParamHandler[ImageParam]): + """Handler for image parameters.""" + + parameter_group = "Image" + + def get_params(self) -> dict[str, ImageParam]: + """Retrieve brand properties.""" + params = {} + if data := self.vapix.params.get_param(self.parameter_group): + params["0"] = ImageParam.decode(data) + return params diff --git a/axis/vapix/interfaces/parameters/io_port.py b/axis/vapix/interfaces/parameters/io_port.py new file mode 100644 index 00000000..0b98bd63 --- /dev/null +++ b/axis/vapix/interfaces/parameters/io_port.py @@ -0,0 +1,19 @@ +"""I/O port parameters.""" + +from typing import cast + +from ...models.parameters.io_port import GetPortsResponse, Port +from .param_handler import ParamHandler + + +class IOPortParameterHandler(ParamHandler[Port]): + """Handler for I/O port parameters.""" + + parameter_group = "IOPort" + + def get_params(self) -> dict[str, Port]: + """Retrieve I/O port parameters.""" + params = {} + if data := self.vapix.params.get_param(self.parameter_group): + params.update(GetPortsResponse.from_dict(data).data) + return params diff --git a/axis/vapix/interfaces/parameters/param_cgi.py b/axis/vapix/interfaces/parameters/param_cgi.py index a35eae63..0e048132 100644 --- a/axis/vapix/interfaces/parameters/param_cgi.py +++ b/axis/vapix/interfaces/parameters/param_cgi.py @@ -5,15 +5,24 @@ Lists Brand, Image, Ports, Properties, PTZ, Stream profiles. """ -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast -from ...models.parameters.brand import BrandParam, BrandT +from ...models.parameters.brand import BrandParam from ...models.parameters.image import ImageParam from ...models.parameters.param_cgi import ParamRequest, params_to_dict from ...models.parameters.properties import PropertyParam from ...models.parameters.stream_profile import StreamProfileParam from ...models.stream_profile import StreamProfile from ..api_handler import ApiHandler +from .brand import BrandParameterHandler +from .image import ImageParameterHandler +from .io_port import IOPortParameterHandler +from .properties import PropertyParameterHandler +from .ptz import PtzParameterHandler +from .stream_profile import StreamProfileParameterHandler + +if TYPE_CHECKING: + from ...vapix import Vapix PROPERTY = "Properties.API.HTTP.Version=3" @@ -44,6 +53,17 @@ class Params(ApiHandler[Any]): """Represents all parameters of param.cgi.""" + def __init__(self, vapix: "Vapix") -> None: + """Initialize API items.""" + super().__init__(vapix) + + self.brand_handler = BrandParameterHandler(self) + self.image_handler = ImageParameterHandler(self) + self.io_port_handler = IOPortParameterHandler(self) + self.property_handler = PropertyParameterHandler(self) + self.ptz_handler = PtzParameterHandler(self) + self.stream_profile_handler = StreamProfileParameterHandler(self) + async def _api_request(self) -> dict[str, Any]: """Refresh data.""" await self.update() @@ -58,8 +78,10 @@ async def update(self, group: str = "") -> None: bytes_data = await self.vapix.new_request(ParamRequest(group)) data = params_to_dict(bytes_data.decode()) root = self._items.setdefault("root", {}) - if "root" in data: - root.update(data["root"]) + if objects := data.get("root"): + root.update(objects) + for obj_id in objects.keys(): + self.signal_subscribers(obj_id) def get_param(self, group: str) -> dict[str, Any]: """Get parameter group.""" @@ -69,39 +91,49 @@ def get_param(self, group: str) -> dict[str, Any]: async def update_brand(self) -> None: """Update brand group of parameters.""" - await self.update("Brand") + await self.brand_handler.update() @property - def brand(self) -> BrandParam: + def brand(self) -> BrandParam | None: """Provide brand parameters.""" - return BrandParam.decode(cast(BrandT, self.get_param("Brand"))) + return self.brand_handler.get_params().get("0") # Image async def update_image(self) -> None: """Update image group of parameters.""" - await self.update("Image") + await self.image_handler.update() @property def image_params(self) -> ImageParam: """Provide image parameters.""" + return self.image_handler.get_params().get("0") return ImageParam.decode(self.get_param("Image")) @property def image_sources(self) -> dict[str, Any]: """Image source information.""" - return self.get_param("Image") + data = {} + if params := self.image_handler.get_params().get("0"): + data = params.data + return data # Properties async def update_properties(self) -> None: """Update properties group of parameters.""" - await self.update("Properties") + await self.property_handler.update() + # await self.update("Properties") @property def properties(self) -> PropertyParam: """Provide property parameters.""" - return PropertyParam.decode(self.get_param("Properties")) + return self.property_handler.get_params().get("0") + # data = {} + # if params := self.property_handler.get_params().get("0"): + # data = params + # return data + # return PropertyParam.decode(self.get_param("Properties")) # PTZ @@ -118,32 +150,19 @@ def ptz_data(self) -> dict[str, Any]: async def update_stream_profiles(self) -> None: """Update stream profiles group of parameters.""" - await self.update("StreamProfile") + await self.stream_profile_handler.update() @property def stream_profiles_params(self) -> StreamProfileParam: """Provide stream profiles parameters.""" - return StreamProfileParam.decode(self.get_param("StreamProfile")) + return self.stream_profile_handler.get_params().get("0") @property def stream_profiles_max_groups(self) -> int: """Maximum number of supported stream profiles.""" - return self.get_param("StreamProfile").get("MaxGroups", 0) + return self.stream_profile_handler.get_params().get("0").max_groups @property def stream_profiles(self) -> list[StreamProfile]: """Return a list of stream profiles.""" - if not (data := self.get_param("StreamProfile")): - return [] - - profiles = dict(data) - del profiles["MaxGroups"] - - return [ - StreamProfile( - id=str(profile["Name"]), - description=str(profile["Description"]), - parameters=str(profile["Parameters"]), - ) - for profile in profiles.values() - ] + return self.stream_profile_handler.get_params().get("0").stream_profiles diff --git a/axis/vapix/interfaces/parameters/param_handler.py b/axis/vapix/interfaces/parameters/param_handler.py new file mode 100644 index 00000000..45384ebf --- /dev/null +++ b/axis/vapix/interfaces/parameters/param_handler.py @@ -0,0 +1,44 @@ +"""Parameter handler sub class of API handler. + +Generalises parameter specific handling like +- Subscribing to new data +- Defining parameter group +""" + +from abc import abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .param_cgi import Params + +from ...models.api import ApiItemT +from ...models.api_discovery import ApiId +from ..api_handler import ApiHandler + + +class ParamHandler(ApiHandler[ApiItemT]): + """Base class for a map of API Items.""" + + parameter_group: str + api_id = ApiId.PARAM_CGI + + def __init__(self, param_handler: "Params") -> None: + """Initialize API items.""" + super().__init__(param_handler.vapix) + param_handler.subscribe(self.update_params, self.parameter_group) + + @abstractmethod + def get_params(self) -> dict[str, ApiItemT]: + """Retrieve parameters from param_cgi class.""" + + def update_params(self, obj_id: str) -> None: + """Update parameter data. + + Callback from parameter handler subscription. + """ + self._items = self.get_params() + + async def _api_request(self) -> dict[str, ApiItemT]: + """Get API data method defined by subclass.""" + await self.vapix.params.update(self.parameter_group) + return self.get_params() diff --git a/axis/vapix/interfaces/parameters/properties.py b/axis/vapix/interfaces/parameters/properties.py new file mode 100644 index 00000000..eda33088 --- /dev/null +++ b/axis/vapix/interfaces/parameters/properties.py @@ -0,0 +1,17 @@ +"""Property parameters.""" + +from ...models.parameters.properties import PropertyParam +from .param_handler import ParamHandler + + +class PropertyParameterHandler(ParamHandler[PropertyParam]): + """Handler for property parameters.""" + + parameter_group = "Properties" + + def get_params(self) -> dict[str, PropertyParam]: + """Retrieve brand properties.""" + params = {} + if data := self.vapix.params.get_param(self.parameter_group): + params["0"] = PropertyParam.decode(data) + return params diff --git a/axis/vapix/interfaces/parameters/ptz.py b/axis/vapix/interfaces/parameters/ptz.py new file mode 100644 index 00000000..b3c5dea4 --- /dev/null +++ b/axis/vapix/interfaces/parameters/ptz.py @@ -0,0 +1,17 @@ +"""PTZ parameters.""" + +from ...models.parameters.ptz import GetPtzResponse, PtzItem +from .param_handler import ParamHandler + + +class PtzParameterHandler(ParamHandler[PtzItem]): + """Handler for PTZ parameters.""" + + parameter_group = "PTZ" + + def get_params(self) -> dict[str, PtzItem]: + """Retrieve brand properties.""" + params = {} + if data := self.vapix.params.get_param(self.parameter_group): + params.update(GetPtzResponse.from_dict(data).data) + return params diff --git a/axis/vapix/interfaces/parameters/stream_profile.py b/axis/vapix/interfaces/parameters/stream_profile.py new file mode 100644 index 00000000..852bba73 --- /dev/null +++ b/axis/vapix/interfaces/parameters/stream_profile.py @@ -0,0 +1,22 @@ +"""Stream profile parameters.""" + +from ...models.parameters.stream_profile import StreamProfileParam +from .param_handler import ParamHandler + + +class StreamProfileParameterHandler(ParamHandler[StreamProfileParam]): + """Handler for stream profile parameters.""" + + parameter_group = "StreamProfile" + + def get_params(self) -> dict[str, StreamProfileParam]: + """Retrieve brand properties.""" + return { + "0": StreamProfileParam.decode( + self.vapix.params.get_param(self.parameter_group) + ) + } + params = {} + if data := self.vapix.params.get_param(self.parameter_group): + params["0"] = StreamProfileParam.decode(data) + return params diff --git a/axis/vapix/interfaces/port_cgi.py b/axis/vapix/interfaces/port_cgi.py index 4d01e805..d378d5bc 100644 --- a/axis/vapix/interfaces/port_cgi.py +++ b/axis/vapix/interfaces/port_cgi.py @@ -8,12 +8,7 @@ Virtual input API. """ -from ..models.parameters.io_port import ( - GetPortsResponse, - Port, - PortAction, - PortDirection, -) +from ..models.parameters.io_port import Port, PortAction, PortDirection from ..models.port_cgi import PortActionRequest from .api_handler import ApiHandler @@ -29,13 +24,12 @@ async def _api_request(self) -> dict[str, Port]: async def get_ports(self) -> dict[str, Port]: """Retrieve privilege rights for current user.""" - await self.vapix.params.update("IOPort") + await self.vapix.params.io_port_handler.update() return self.process_ports() def process_ports(self) -> dict[str, Port]: """Process ports.""" - data = self.vapix.params.get_param("IOPort") - return GetPortsResponse.from_dict(data).data + return dict(self.vapix.params.io_port_handler.items()) async def action(self, id: str, action: PortAction) -> None: """Activate or deactivate an output.""" diff --git a/axis/vapix/interfaces/ptz.py b/axis/vapix/interfaces/ptz.py index 6e0d9bcd..797bbd87 100644 --- a/axis/vapix/interfaces/ptz.py +++ b/axis/vapix/interfaces/ptz.py @@ -5,7 +5,7 @@ and actual parameter values, check the specification of the Axis PTZ driver used. """ -from ..models.parameters.ptz import GetPtzResponse, PtzItem +from ..models.parameters.ptz import PtzItem from ..models.ptz_cgi import ( DeviceDriverRequest, PtzCommandRequest, @@ -28,12 +28,12 @@ async def _api_request(self) -> dict[str, PtzItem]: async def get_ptz(self) -> dict[str, PtzItem]: """Retrieve privilege rights for current user.""" - await self.vapix.params.update("PTZ") + await self.vapix.params.ptz_handler.update() return self.process_ptz() def process_ptz(self) -> dict[str, PtzItem]: """Process ports.""" - return GetPtzResponse.from_dict(self.vapix.params.get_param("PTZ")).data + return dict(self.vapix.params.ptz_handler.items()) async def control( self, diff --git a/axis/vapix/interfaces/user_groups.py b/axis/vapix/interfaces/user_groups.py index 22cf1eaa..eccab08d 100644 --- a/axis/vapix/interfaces/user_groups.py +++ b/axis/vapix/interfaces/user_groups.py @@ -12,7 +12,7 @@ class UserGroups(ApiHandler[User]): """User group access rights for Axis devices.""" async def _api_request(self) -> dict[str, User]: - """Get API data method defined by subsclass.""" + """Get API data method defined by subclass.""" return await self.get_user_groups() async def get_user_groups(self) -> dict[str, User]: diff --git a/axis/vapix/models/parameters/stream_profile.py b/axis/vapix/models/parameters/stream_profile.py index 16ba4827..cec2747a 100644 --- a/axis/vapix/models/parameters/stream_profile.py +++ b/axis/vapix/models/parameters/stream_profile.py @@ -32,7 +32,7 @@ def decode(cls, data: dict[str, Any]) -> Self: max_groups = int(data.get("MaxGroups", 0)) raw_profiles = dict(data) - del raw_profiles["MaxGroups"] + raw_profiles.pop("MaxGroups", None) profiles = [ StreamProfile( diff --git a/tests/test_ptz.py b/tests/test_ptz.py index 1ea524c6..104ad293 100644 --- a/tests/test_ptz.py +++ b/tests/test_ptz.py @@ -37,7 +37,8 @@ def test_limit(): async def test_update_ptz(ptz_control: PtzControl): """Verify that update ptz works.""" route = respx.get( - f"http://{HOST}:80/axis-cgi/param.cgi?action=list%26group%3Droot.PTZ" + f"http://{HOST}/axis-cgi/param.cgi?action=list%26group%3Droot.PTZ" + # f"http://{HOST}:80/axis-cgi/param.cgi?action=list%26group%3Droot.PTZ" ).respond( text=response_param_cgi_ptz, headers={"Content-Type": "text/plain"},