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 12 commits
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
8 changes: 2 additions & 6 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 @@ -10,7 +10,6 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback

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

Expand All @@ -23,13 +22,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
):
"""Set up Sector Alarm cameras."""
coordinator: SectorDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: SectorDataUpdateCoordinator = entry.runtime_data
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
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
80 changes: 64 additions & 16 deletions custom_components/sector/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
type SectorAlarmConfigEntry = ConfigEntry[SectorDataUpdateCoordinator]

_LOGGER = logging.getLogger(__name__)

_lock_event_types = ["lock", "unlock", "lock_failed"]

class SectorDataUpdateCoordinator(DataUpdateCoordinator):
"""Coordinator to manage data fetching from Sector Alarm."""
Expand All @@ -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 All @@ -47,6 +48,36 @@ def __init__(self, hass: HomeAssistant, entry: SectorAlarmConfigEntry) -> None:
update_interval=timedelta(seconds=60),
)

def process_events(self):
"""Process events and group them by device, deduplicating by time and event type."""
logs = self.data.get("logs", [])
device_map = {device_info["name"]: serial for serial, device_info in self.data["devices"].items()}

grouped_events = {}
unique_log_keys = set() # Track unique log entries by "time-eventtype"

for log in logs:
device_serial = device_map.get(log.get("DeviceName") or log.get("LockName"))
log_time = log.get("Time")
event_type = log.get("EventType")
unique_key = f"{log_time}-{event_type}"

# Only add the log if it hasn't been processed before
if device_serial and unique_key not in unique_log_keys:
unique_log_keys.add(unique_key)

if device_serial not in grouped_events:
grouped_events[device_serial] = {"lock": []}

if event_type in _lock_event_types:
grouped_events[device_serial]["lock"].append(log)

return grouped_events

def get_device_info(self, serial):
"""Fetch device information by serial number."""
return self.data["devices"].get(serial, {"name": "Unknown Device", "model": "Unknown Model"})

async def _async_update_data(self):
"""Fetch data from Sector Alarm API."""
data = {}
Expand All @@ -60,8 +91,23 @@ async def _async_update_data(self):
data[key] = value
data[key]["code_format"] = self.code_format

# Retrieve and filter logs
raw_logs = api_data.get("Logs", [])
logs = []

# Ensure raw_logs is a list; handle unexpected formats
if isinstance(raw_logs, list):
logs = self._filter_duplicate_logs(raw_logs)
elif isinstance(raw_logs, dict) and "Records" in raw_logs:
logs = self._filter_duplicate_logs(raw_logs.get("Records", []))

# Update last_processed_events to track processed logs
self.last_processed_events.update(
garnser marked this conversation as resolved.
Show resolved Hide resolved
{self._get_event_id(log) for log in logs}
)

# Process devices, panel status, and lock status as usual
devices = {}
logs = api_data.get("Logs", [])
panel_status = api_data.get("Panel Status", {})
locks_data = api_data.get("Lock Status", [])

Expand Down Expand Up @@ -92,12 +138,10 @@ async def _async_update_data(self):
for category_name, category_data in data.items():
_LOGGER.debug("Processing category: %s", category_name)
model_name = CATEGORY_MODEL_MAPPING.get(category_name, category_name)
if category_name in [
"Doors and Windows",
"Smoke Detectors",
"Leakage Detectors",
"Cameras",
"Keypad",
if category_name not in [
garnser marked this conversation as resolved.
Show resolved Hide resolved
"Panel Status",
"Lock Status",
"Logs",
]:
for section in category_data.get("Sections", []):
for place in section.get("Places", []):
Expand Down Expand Up @@ -338,14 +382,6 @@ async def _async_update_data(self):
"Unexpected smartplug data format: %s", category_data
)

elif category_name == "Lock Status":
# Locks data is already retrieved in locks_data
pass

elif category_name == "Panel Status":
# Panel status is already retrieved
pass

else:
_LOGGER.debug("Unhandled category %s", category_data)

Expand All @@ -360,3 +396,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']}"
2 changes: 1 addition & 1 deletion custom_components/sector/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def get_data_endpoints(panel_id):
f"{API_URL}/api/panel/GetSmartplugStatus?panelId={panel_id}",
),
"Lock Status": ("GET", f"{API_URL}/api/panel/GetLockStatus?panelId={panel_id}"),
"Logs": ("GET", f"{API_URL}/api/panel/GetLogs?panelId={panel_id}"),
"Logs": ("GET", f"{API_URL}/api/v2/panel/logs?panelid={panel_id}&pageNumber=1&pageSize=40"),
}
return endpoints

Expand Down
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
from datetime import datetime, timezone
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.update_coordinator import CoordinatorEntity

from .coordinator import SectorAlarmConfigEntry, SectorDataUpdateCoordinator
from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

async def async_setup_entry(
hass: HomeAssistant,
entry: SectorAlarmConfigEntry,
async_add_entities: AddEntitiesCallback,
):
"""Set up event entities based on processed data in Sector Alarm coordinator."""
coordinator: SectorDataUpdateCoordinator = entry.runtime_data
grouped_events = coordinator.process_events()

entities = []

for device_serial, events in grouped_events.items():
garnser marked this conversation as resolved.
Show resolved Hide resolved
device_info = coordinator.get_device_info(device_serial)
device_name = device_info["name"]
device_model = device_info["model"]

if events["lock"]:
lock_entity = LockEventEntity(coordinator, device_serial, device_name, device_model)
lock_entity.queue_events(events["lock"])
entities.append(lock_entity)

async_add_entities(entities)
_LOGGER.debug("Added %d event entities", len(entities))

class SectorAlarmEvent(CoordinatorEntity, EventEntity):
"""Representation of a general event entity for Sector Alarm integration."""

def __init__(self, coordinator, device_serial, device_name, device_model):
"""Initialize the general event entity."""
super().__init__(coordinator)
self._serial_no = device_serial
self._device_name = device_name
self._device_model = device_model
self._events = [] # Store all general events
self._event_queue = [] # Queue to store events before entity is added to Home Assistant
self._attr_unique_id = f"{device_serial}_event"
self._attr_name = f"{device_name} Event Log"
self._attr_device_class = "sector_alarm_timestamp" # Use a custom string identifier
_LOGGER.debug("Created SectorAlarmEvent for device: %s", device_name)

@property
def device_info(self):
"""Return device information to associate this entity with a device."""
return {
"identifiers": {(DOMAIN, self._serial_no)},
"name": self._device_name,
"manufacturer": "Sector Alarm",
"model": self._device_model,
}

async def async_added_to_hass(self):
"""Handle entity addition to Home Assistant and process queued events."""
await super().async_added_to_hass()
for event in self._event_queue:
self.add_event(event)
self._event_queue.clear() # Clear the queue after processing

def queue_events(self, logs):
"""Queue multiple events at once."""
for log in logs:
self.queue_event(log)

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

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

recent_event = self._events[-1]
timestamp_str = self._format_timestamp(recent_event.get("Time"))

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

def queue_event(self, log):
"""Queue an event if the entity is not yet added to Home Assistant."""
if self.hass:
self.add_event(log)
else:
self._event_queue.append(log)

def add_event(self, log):
"""Add a general event to the entity."""
self._events.append(log)
if self.hass: # Ensure entity is fully initialized before calling write_ha_state
_LOGGER.debug("Adding event to %s: %s", self._attr_name, log)
self.async_write_ha_state()
else:
_LOGGER.warning("Tried to add event to uninitialized entity: %s", self._attr_name)

@staticmethod
def _format_timestamp(time_str):
"""Helper to format the timestamp for state attributes."""
if not time_str:
return "unknown"
try:
timestamp = datetime.fromisoformat(time_str.replace("Z", "+00:00")).astimezone(timezone.utc)
return timestamp.isoformat()
except ValueError:
_LOGGER.warning("Invalid timestamp format: %s", time_str)
return "unknown"


class LockEventEntity(SectorAlarmEvent):
"""Representation of a lock-specific event entity for Sector Alarm integration."""

_attr_event_types = ["lock", "unlock", "lock_failed"]

def __init__(self, coordinator, device_serial, device_name, device_model):
"""Initialize the lock-specific event entity."""
super().__init__(coordinator, device_serial, device_name, device_model)
_LOGGER.debug("Created LockEventEntity for device: %s", device_name)

def queue_event(self, log):
"""Queue a lock event if the event type is lock-related."""
if log.get("EventType") in self._attr_event_types:
super().queue_event(log)
3 changes: 1 addition & 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,7 +41,6 @@ async def async_setup_entry(
else:
_LOGGER.debug("No lock entities to add.")


class SectorAlarmLock(SectorAlarmBaseEntity, LockEntity):
"""Representation of a Sector Alarm lock."""

Expand Down
Loading