Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for Sector Alarm event entities and log ingestion #218

Open
wants to merge 32 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0b2f19c
feat: Add support for Sector Alarm event entities and log ingestion
Nov 7, 2024
98cbe60
Updated based on feedback in PR
Nov 7, 2024
945d3ac
Use the new API, not the old one
Nov 8, 2024
4cb0ec1
Time convertion had some issues, updating
Nov 8, 2024
0850088
Introduce LockEventEntity
Nov 8, 2024
2533c29
Move processing to coordinator and let events handle the presentation
Nov 8, 2024
b070592
Nuke event firing
Nov 8, 2024
fb489bd
Remove unessary stuff
Nov 8, 2024
8a47c56
Remove unessary stuff
Nov 8, 2024
0bd3f12
Use global var
Nov 8, 2024
3ce2e3b
Shouldn't be static
Nov 8, 2024
821a3b9
Lets only focus on lock events for the time being
Nov 8, 2024
fdb6105
Removed queue handling
Nov 8, 2024
789039c
Fix filtering of categories
Nov 8, 2024
22079e9
More fixes
Nov 8, 2024
09dfd2a
Fix event processing and device matching in Sector Alarm integration
Nov 8, 2024
dbdebf2
Ditch creating a list of already processed events and look at the act…
Nov 8, 2024
7fc0b77
Normalized naming when comparing timestamp to entities, improved debu…
Nov 8, 2024
3e3d8cc
Remove queue reference
Nov 8, 2024
4e72b94
Minor crap
Nov 9, 2024
cbcd483
Process logs in reverse
Nov 9, 2024
cdc2874
Final adjustments
Nov 9, 2024
c4baf7c
Rewrite, again....
Nov 10, 2024
f052e34
More rewrites
Nov 10, 2024
2792b36
Some final fixes, now it's finally working
Nov 11, 2024
fd647a7
Merge with master
Nov 16, 2024
d3fe75f
Match up with master
Nov 16, 2024
dfbf9a5
Final fixes the handle events from the logs towards the Smart Locks
Nov 28, 2024
9f834c4
Mods
gjohansson-ST Dec 8, 2024
c47f207
Format
gjohansson-ST Dec 8, 2024
099770e
Fix
gjohansson-ST Dec 8, 2024
012f3db
Fix
gjohansson-ST Dec 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions custom_components/sector/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SectorAlarmConfigEntry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

# Register the listener for configuration updates
entry.async_on_unload(entry.add_update_listener(async_update_listener))

# Store the coordinator in hass.data using entry ID for unique identification
hass.data.setdefault(entry.entry_id, coordinator)
garnser marked this conversation as resolved.
Show resolved Hide resolved

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True
Expand Down
8 changes: 3 additions & 5 deletions custom_components/sector/camera.py
garnser marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,11 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
):
"""Set up Sector Alarm cameras."""
coordinator: SectorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
# Access coordinator directly by entry.entry_id
coordinator: SectorDataUpdateCoordinator = hass.data[entry.entry_id]
garnser marked this conversation as resolved.
Show resolved Hide resolved
devices = coordinator.data.get("devices", {})
cameras = devices.get("cameras", [])
entities = []

for camera_data in cameras:
entities.append(SectorAlarmCamera(coordinator, camera_data))
entities = [SectorAlarmCamera(coordinator, camera_data) for camera_data in cameras]

if entities:
async_add_entities(entities)
Expand Down
11 changes: 11 additions & 0 deletions custom_components/sector/client.py
garnser marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,17 @@ async def get_camera_image(self, serial_no):
_LOGGER.error("Failed to retrieve image for camera %s", serial_no)
return None

async def get_logs(self, take=100):
"""Retrieve logs from the API."""
url = f"{self.API_URL}/api/panel/GetLogs?panelId={self.panel_id}&take={take}"
response = await self._get(url)
if response:
return response
_LOGGER.error("Failed to retrieve logs")

return []


async def logout(self):
"""Logout from the API."""
logout_url = f"{self.API_URL}/api/Login/Logout"
Expand Down
1 change: 1 addition & 0 deletions custom_components/sector/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Platform.LOCK,
Platform.SENSOR,
Platform.SWITCH,
Platform.EVENT,
]

CATEGORY_MODEL_MAPPING = {
Expand Down
25 changes: 25 additions & 0 deletions custom_components/sector/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def __init__(self, hass: HomeAssistant, entry: SectorAlarmConfigEntry) -> None:
"""Initialize the coordinator."""
self.hass = hass
self.code_format = entry.options.get(CONF_CODE_FORMAT, 6)
self.last_processed_events = set()
_LOGGER.debug(
"Initializing SectorDataUpdateCoordinator with code_format: %s",
self.code_format,
Expand Down Expand Up @@ -88,6 +89,18 @@ async def _async_update_data(self):
else:
_LOGGER.debug("No locks data found.")

# Retrieve logs
raw_logs = await self.api.get_logs()
logs = self._filter_duplicate_logs(raw_logs)

# Add processed logs to data
data["logs"] = logs

# Update last processed events with the latest batch
self.last_processed_events.update(
{self._get_event_id(log) for log in logs}
)

# Process devices from different categories
for category_name, category_data in data.items():
_LOGGER.debug("Processing category: %s", category_name)
Expand Down Expand Up @@ -360,3 +373,15 @@ async def _async_update_data(self):
except Exception as error:
_LOGGER.exception("Failed to update data")
raise UpdateFailed(f"Failed to update data: {error}") from error

def _filter_duplicate_logs(self, logs):
garnser marked this conversation as resolved.
Show resolved Hide resolved
"""Filter out logs that were already processed."""
return [
log for log in logs
if self._get_event_id(log) not in self.last_processed_events
]

@staticmethod
def _get_event_id(log):
"""Create a unique identifier for each log event."""
return f"{log['LockName']}_{log['EventType']}_{log['Time']}"
140 changes: 140 additions & 0 deletions custom_components/sector/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Event platform for Sector Alarm integration."""

import logging
import asyncio
import pytz

from collections import defaultdict
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later
from datetime import datetime

from .coordinator import SectorAlarmConfigEntry, SectorDataUpdateCoordinator
from .entity import SectorAlarmBaseEntity

_LOGGER = logging.getLogger(__name__)

class SectorAlarmEvent(SectorAlarmBaseEntity, EventEntity):
"""Representation of a Sector Alarm log event."""

def __init__(self, coordinator: SectorDataUpdateCoordinator, device_serial: str, device_name: str, device_model: str):
"""Initialize the log event entity for a specific device."""
super().__init__(coordinator, device_serial, {"name": device_name}, device_model)

self._attr_unique_id = f"{device_serial}_event"
self._attr_name = f"{device_name} Event Log"
self._attr_device_class = "timestamp"
self._events = [] # List to store recent log events for this device
self._initialized = False
_LOGGER.debug("Sector Entry: Created SectorAlarmEvent for device: %s with serial: %s", device_name, device_serial)

async def async_added_to_hass(self):
"""Handle entity addition to Home Assistant."""
self._initialized = True # Entity is now added to Home Assistant
_LOGGER.debug("Sector Entry: SectorAlarmEvent entity added to Home Assistant for: %s", self._attr_name)

@property
def state(self) -> str:
"""Return the most recent event type as the state."""
return self._events[-1]["EventType"] if self._events else "No events"

@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes for the most recent log event."""
if not self._events:
return {}

recent_event = self._events[-1]

# Parse the ISO 8601 timestamp to a datetime object
time_str = recent_event.get("Time", "unknown")
try:
# Parse the string and localize it to UTC
timestamp = datetime.fromisoformat(time_str).astimezone(pytz.UTC)
garnser marked this conversation as resolved.
Show resolved Hide resolved
timestamp_str = timestamp.isoformat() # Convert back to ISO 8601 format
except ValueError:
_LOGGER.warning("Invalid timestamp format: %s", time_str)
timestamp_str = "unknown"

return {
"time": timestamp_str,
"channel": recent_event.get("Channel", "unknown"),
"user": recent_event.get("User", "unknown"),
}

@property
def event_types(self) -> list[str]:
"""Return all unique event types for this entity's logs."""
return list({event["EventType"] for event in self._events})

def add_event(self, log: dict):
"""Add a new log event to this entity."""
self._events.append(log)
if self._initialized:
_LOGGER.debug("Sector Entry: Adding event to %s: %s", self._attr_name, log)
self.async_write_ha_state() # Only update if entity is initialized
else:
_LOGGER.debug("Sector Entry: Event added to uninitialized entity %s: %s", self._attr_name, log)

async def async_setup_entry(
garnser marked this conversation as resolved.
Show resolved Hide resolved
hass: HomeAssistant,
entry: SectorAlarmConfigEntry,
async_add_entities: AddEntitiesCallback,
):
"""Set up Sector Alarm events for each device based on log entries."""
coordinator: SectorDataUpdateCoordinator = hass.data[entry.entry_id]

# Define setup_events to be triggered once the lock setup is complete
async def setup_events(event=None):
_LOGGER.debug("Sector Entry: sector_alarm_lock_setup_complete event received, starting event setup")
logs = coordinator.data.get("logs", [])
_LOGGER.debug("Sector Entry: Processing log entries: %d logs found", len(logs))

# Map device labels (LockName) to device serial numbers
device_map = {device_info["name"]: serial for serial, device_info in coordinator.data["devices"].items()}
_LOGGER.debug("Sector Entry: Device map created: %s", device_map)

# Group logs by device serial number, using the map
logs_by_device = defaultdict(list)
for log in logs:
lock_name = log.get("LockName")
_LOGGER.debug("Sector Entry: Processing log entry: %s", log)

# Skip entries with empty or missing LockName
if not lock_name:
_LOGGER.debug("Sector Entry: Skipping log entry with missing LockName: %s", log)
continue

device_serial = device_map.get(lock_name)
if device_serial:
logs_by_device[device_serial].append(log)
_LOGGER.debug("Sector Entry: Log entry associated with device %s (serial: %s)", lock_name, device_serial)
else:
_LOGGER.warning("Sector Entry: Log entry for unrecognized device: %s", lock_name)

# Create an event entity for each device with logs
entities = []
for device_serial, device_logs in logs_by_device.items():
device_info = coordinator.data["devices"].get(device_serial, {})
device_name = device_info.get("name", "Unknown Device")

_LOGGER.debug("Sector Entry: Creating event entity for device %s with logs: %d entries", device_name, len(device_logs))

# Initialize event entity for this device and add its logs
event_entity = SectorAlarmEvent(coordinator, device_serial, device_name, device_info.get("model", "Unknown Model"))
entities.append(event_entity)
for log in device_logs:
event_entity.add_event(log)

_LOGGER.debug("Sector Entry: Adding %d event entities to Home Assistant", len(entities))
async_add_entities(entities)

# Attempt listener for custom event
_LOGGER.debug("Sector Entry: Registering listener for sector_alarm_lock_setup_complete")
hass.bus.async_listen_once("sector_alarm_lock_setup_complete", lambda _: hass.async_create_task(setup_events()))

# Fallback in case the custom event never fires
_LOGGER.debug("Sector Entry: Scheduling fallback setup after 5 seconds")
async_call_later(hass, 5, lambda _: hass.create_task(setup_events()))
6 changes: 4 additions & 2 deletions custom_components/sector/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def async_setup_entry(
serial_no = device["serial_no"]
description = LockEntityDescription(
key=serial_no,
name=f"Sector {device.get('name', 'Lock')} {serial_no}",
name=f"{device.get('name', 'Lock')}",
)
entities.append(
SectorAlarmLock(coordinator, code_format, description, serial_no)
Expand All @@ -41,6 +41,8 @@ async def async_setup_entry(
else:
_LOGGER.debug("No lock entities to add.")

_LOGGER.debug("Sector Lock: Firing sector_alarm_lock_setup_complete event to notify event.py")
garnser marked this conversation as resolved.
Show resolved Hide resolved
hass.bus.async_fire("sector_alarm_lock_setup_complete")

class SectorAlarmLock(SectorAlarmBaseEntity, LockEntity):
"""Representation of a Sector Alarm lock."""
Expand All @@ -50,7 +52,7 @@ class SectorAlarmLock(SectorAlarmBaseEntity, LockEntity):
def __init__(
self,
coordinator: SectorDataUpdateCoordinator,
code_format: int,
code_format: int,
description: LockEntityDescription,
serial_no: str,
):
Expand Down
Loading