Skip to content

Commit

Permalink
Scenes (#133)
Browse files Browse the repository at this point in the history
* Add response object for scenes testing.

* Split base device into two classes.

* Support scenes

* Added scenes to tests.

* Bump version number
  • Loading branch information
elahd authored Oct 27, 2023
1 parent 14a6490 commit 793464c
Show file tree
Hide file tree
Showing 19 changed files with 476 additions and 160 deletions.
11 changes: 5 additions & 6 deletions .devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,21 @@
"source.fixAll.ruff": true,
"source.organizeImports": true
},
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnPaste": false,
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"editor.formatOnType": true
},
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"mypy-type-checker.args": ["--config-file=pyproject.toml"],
"mypy-type-checker.reportingScope": "workspace",
"mypy-type-checker.importStrategy": "useBundled",
"python.analysis.autoSearchPaths": false,
"python.analysis.extraPaths": ["/workspaces/pyalarmdotcomajax"],
"python.formatting.provider": "black",
"python.linting.enabled": true,
"python.languageServer": "Pylance",
"python.testing.pytestEnabled": true,
"python.linting.mypyEnabled": false,
// "python.linting.pylintEnabled": true,
"python.analysis.exclude": ["/**/.*/", "**/__pycache__"],
"python.analysis.diagnosticMode": "workspace",
"python.analysis.typeCheckingMode": "off",
Expand Down
6 changes: 6 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
"problemMatcher": [],
"type": "shell"
},
{
"command": "pip install --editable /workspaces/pyalarmdotcomajax --config-settings editable_mode=strict",
"label": "Install pyalarmdotcomajax in editable mode",
"problemMatcher": [],
"type": "shell"
}
],
"version": "2.0.0"
}
66 changes: 49 additions & 17 deletions pyalarmdotcomajax/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
)
from pyalarmdotcomajax.websockets.client import WebSocketClient, WebSocketState

__version__ = "0.5.8"
__version__ = "0.5.9"

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -98,6 +98,8 @@ class AlarmController:
KEEP_ALIVE_SIGNAL_INTERVAL_S = 60
SESSION_REFRESH_DEFAULT_INTERVAL_MS = 780000 # 13 minutes. Sessions expire at 15.

SCENE_REFRESH_INTERVAL_M = 60

# LOGIN & SESSION: END

def __init__(
Expand Down Expand Up @@ -161,6 +163,13 @@ def __init__(
self._last_session_refresh: datetime = datetime.now()
self._session_timer: SessionTimer | None = None

#
# SCENE REFRESH ATTRIBUTES
#

self._last_scene_update: datetime | None = None
self._scene_object_cache: list[dict] = []

#
# CLI ATTRIBUTES
#
Expand Down Expand Up @@ -230,7 +239,10 @@ async def async_update(self) -> None:

if not self._active_system_id:
self._active_system_id = await self._async_get_active_system()
has_image_sensors = await self._async_has_image_sensors(self._active_system_id)
has_image_sensors = await self._async_device_type_present(
self._active_system_id, DeviceType.IMAGE_SENSOR
)
has_scenes = await self._async_device_type_present(self._active_system_id, DeviceType.SCENE)

await self._async_get_trouble_conditions()

Expand All @@ -248,6 +260,21 @@ async def async_update(self) -> None:

extension_results = await self._async_update__query_multi_device_extensions(raw_devices)

#
# QUERY SCENES
#
# Scenes have no state, so we only need to update for new/deleted scenes. We refresh less frequently than we do for stateful devices to save time.

if has_scenes:
# Refresh scene cache if stale.
if not self._last_scene_update or (
datetime.now() > self._last_scene_update + timedelta(minutes=self.SCENE_REFRESH_INTERVAL_M)
):
self._scene_object_cache = await self._async_get_devices_by_device_type(DeviceType.SCENE)
self._last_scene_update = datetime.now()

raw_devices.extend(self._scene_object_cache)

#
# QUERY IMAGE SENSORS
#
Expand All @@ -259,8 +286,7 @@ async def async_update(self) -> None:

if has_image_sensors:
# Get detailed image sensor data and add to raw device list.
image_sensors = await self._async_get_devices_by_device_type(DeviceType.IMAGE_SENSOR)
raw_devices.extend(image_sensors)
raw_devices.extend(await self._async_get_devices_by_device_type(DeviceType.IMAGE_SENSOR))

# Get recent images
device_type_specific_data = await self._async_get_recent_images()
Expand All @@ -276,7 +302,7 @@ async def async_update(self) -> None:
for partition_raw in raw_devices
if partition_raw["type"] == AttributeRegistry.get_relationship_id_from_devicetype(DeviceType.PARTITION)
]:
partition_instance: AllDevices_t = await self._async_update__build_device(
partition_instance: AllDevices_t = await self._async_update__build_hardware_device(
partition_raw, device_type_specific_data, extension_results
)

Expand All @@ -287,13 +313,15 @@ async def async_update(self) -> None:

raw_devices.remove(partition_raw)

# device_type: DeviceType = AttributeRegistry.get_devicetype_from_relationship_id(raw_device["type"])

#
# BUILD DEVICES
#

for device_raw in raw_devices:
try:
device_instance: AllDevices_t = await self._async_update__build_device(
device_instance: AllDevices_t = await self._async_update__build_hardware_device(
device_raw, device_type_specific_data, extension_results
)

Expand Down Expand Up @@ -785,7 +813,7 @@ async def _reload_session_context(self) -> None:

self._last_session_refresh = datetime.now()

async def _async_update__build_device(
async def _async_update__build_hardware_device(
self,
raw_device: dict,
device_type_specific_data: dict[str, DeviceTypeSpecificData],
Expand Down Expand Up @@ -819,7 +847,7 @@ async def _async_update__build_device(
children.append((sub_device["id"], DeviceType(family_name)))

#
# BUILD DEVICE INSTANCE
# BUILD HARDWARE DEVICE INSTANCE
#

entity_id = raw_device["id"]
Expand All @@ -831,8 +859,8 @@ async def _async_update__build_device(
raw_device_data=raw_device,
user_profile=self._user_profile,
children=children,
device_type_specific_data=device_type_specific_data.get(entity_id),
send_action_callback=self.async_send_command,
device_type_specific_data=device_type_specific_data.get(entity_id),
config_change_callback=(
extension_controller.submit_change
if (extension_controller := device_extension_results.get("controller"))
Expand Down Expand Up @@ -979,18 +1007,20 @@ async def _async_get_recent_images(self) -> dict[str, DeviceTypeSpecificData]:
else:
return device_type_specific_data

async def _async_has_image_sensors(self, system_id: str, retry_on_failure: bool = True) -> bool:
"""Check whether image sensors are present in system.
async def _async_device_type_present(
self, system_id: str, device_type: DeviceType, retry_on_failure: bool = True
) -> bool:
"""Check whether a specific device type is present in system.
Check is required because image sensors are not shown in the device catalog endpoint.
Check is required because some devices are not shown in the device catalog endpoint.
"""

# TODO: Needs changes to support multi-system environments

try:
log.info(f"Checking system {system_id} for image sensors.")
log.info(f"Checking system {system_id} for {device_type}s.")

# Find image sensors.
# Find devices.

async with self._websession.get(
url=AttributeRegistry.get_endpoints(DeviceType.SYSTEM)["primary"].format(c.URL_BASE, system_id),
Expand All @@ -1000,14 +1030,16 @@ async def _async_has_image_sensors(self, system_id: str, retry_on_failure: bool

await self._async_handle_server_errors(json_rsp, "image sensors", retry_on_failure)

return len(json_rsp["data"].get("relationships", {}).get("imageSensors", {}).get("data", [])) > 0
device_type_id = AttributeRegistry.get_type_id_from_devicetype(device_type)

return len(json_rsp["data"].get("relationships", {}).get(device_type_id, {}).get("data", [])) > 0

except (aiohttp.ClientResponseError, KeyError) as err:
log.exception("Failed to get image sensors.")
log.exception(f"Failed to get {device_type}s.")
raise UnexpectedResponse from err
except TryAgain:
if retry_on_failure:
return await self._async_has_image_sensors(system_id, retry_on_failure=False)
return await self._async_device_type_present(system_id, device_type, retry_on_failure=False)

raise

Expand Down
27 changes: 15 additions & 12 deletions pyalarmdotcomajax/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from pyalarmdotcomajax.devices.registry import AllDevices_t, AttributeRegistry

from . import AlarmController
from .devices import BaseDevice, DeviceType
from .devices import BaseDevice, BatteryState, DeviceType
from .devices.sensor import Sensor
from .exceptions import (
AuthenticationFailed,
Expand Down Expand Up @@ -322,6 +322,9 @@ async def cli() -> None:
cprint(f"Unable to find a device with ID {device_id}.", "red")
sys.exit(0)

if not hasattr(device, "settings"):
return

try:
config_option: ConfigurationOption = device.settings[setting_slug]
except KeyError:
Expand Down Expand Up @@ -483,26 +486,26 @@ def _print_element_tearsheet(

output_str += "\n"

# BATTERY
if element.battery_critical:
battery = "Critical"
elif element.battery_low:
battery = "Low"
else:
battery = "Normal"

# ATTRIBUTES
output_str += "ATTRIBUTES: "

if isinstance(element.device_subtype, Sensor.Subtype) or element.state or battery or element.read_only:
has_battery = element.battery_state is not BatteryState.NO_BATTERY

if (
isinstance(element.device_subtype, Sensor.Subtype)
or element.state
or has_battery
or element.read_only
or isinstance(element.attributes, BaseDevice.DeviceAttributes)
):
if isinstance(element.device_subtype, Sensor.Subtype):
output_str += f'[TYPE: {element.device_subtype.name.title().replace("_"," ")}] '

if element.state:
output_str += f"[STATE: {element.state.name.title()}] "

if battery:
output_str += f"[BATTERY: {battery}] "
if has_battery:
output_str += f'[BATTERY: {element.battery_state.name.title().replace("_"," ")}] '

if element.read_only:
output_str += f"[READ ONLY: {element.read_only}] "
Expand Down
Loading

0 comments on commit 793464c

Please sign in to comment.