Skip to content

Commit b5df9f0

Browse files
author
valentinfrlch
committed
Fix bad request during initial setup #574
1 parent 68469b1 commit b5df9f0

File tree

10 files changed

+633
-379
lines changed

10 files changed

+633
-379
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ coverage.xml
2020
.venv/
2121
venv/
2222
ENV/
23-
env/
23+
env/
24+
.vscode/settings.json

custom_components/llmvision/__init__.py

Lines changed: 103 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -430,14 +430,102 @@ async def async_migrate_entry(hass, config_entry: ConfigEntry) -> bool:
430430
return True
431431

432432

433+
class ServiceCallData:
434+
"""Store service call data and set default values"""
435+
436+
def __init__(self, data_call):
437+
# This is the config entry id
438+
self.provider = str(data_call.data.get(PROVIDER))
439+
# If not set, the conf_default_model will be set in providers.py
440+
self.model = data_call.data.get(MODEL)
441+
self.message = str(data_call.data.get(MESSAGE, "")[0:2000])
442+
self.store_in_timeline = data_call.data.get(STORE_IN_TIMELINE, False)
443+
self.use_memory = data_call.data.get(USE_MEMORY, False)
444+
self.image_paths = (
445+
data_call.data.get(IMAGE_FILE, "").split("\n")
446+
if data_call.data.get(IMAGE_FILE)
447+
else None
448+
)
449+
self.image_entities = data_call.data.get(IMAGE_ENTITY)
450+
self.video_paths = (
451+
data_call.data.get(VIDEO_FILE, "").split("\n")
452+
if data_call.data.get(VIDEO_FILE)
453+
else None
454+
)
455+
self.event_id = (
456+
data_call.data.get(EVENT_ID, "").split("\n")
457+
if data_call.data.get(EVENT_ID)
458+
else None
459+
)
460+
self.interval = int(data_call.data.get(INTERVAL, 2))
461+
self.duration = int(data_call.data.get(DURATION, 10))
462+
self.max_frames = int(data_call.data.get(MAX_FRAMES, 3))
463+
self.target_width = data_call.data.get(TARGET_WIDTH, 3840)
464+
self.temperature = float()
465+
self.max_tokens = int(data_call.data.get(MAXTOKENS, 3000))
466+
self.include_filename = data_call.data.get(INCLUDE_FILENAME, False)
467+
self.expose_images = data_call.data.get(EXPOSE_IMAGES, False)
468+
self.generate_title = data_call.data.get(GENERATE_TITLE, False)
469+
self.sensor_entity = data_call.data.get(SENSOR_ENTITY, "")
470+
self.response_format = data_call.data.get(RESPONSE_FORMAT, "text")
471+
self.structure = data_call.data.get(STRUCTURE, None)
472+
self.title_field = data_call.data.get(TITLE_FIELD, "")
473+
self.memory: Memory | None = None
474+
475+
# ------------ Create Event ------------
476+
self.title = data_call.data.get("title")
477+
self.description = data_call.data.get("description")
478+
self.start_time = data_call.data.get("start_time", dt_util.now())
479+
self.start_time = self._convert_time_input_to_datetime(self.start_time)
480+
self.end_time = data_call.data.get(
481+
"end_time", self.start_time + timedelta(minutes=1)
482+
)
483+
self.end_time = self._convert_time_input_to_datetime(self.end_time)
484+
self.image_path = data_call.data.get("image_path", "")
485+
self.camera_entity = data_call.data.get("camera_entity", "")
486+
self.label = data_call.data.get("label", "")
487+
488+
# ------------ Added during call ------------
489+
# self.base64_images : List[str] = []
490+
# self.filenames : List[str] = []
491+
492+
def _convert_time_input_to_datetime(self, time_input) -> datetime:
493+
"""Convert time input to datetime object"""
494+
495+
if isinstance(time_input, datetime):
496+
return time_input
497+
if isinstance(time_input, (int, float)):
498+
# Assume it's a Unix timestamp (seconds since epoch)
499+
return datetime.fromtimestamp(time_input)
500+
if isinstance(time_input, str):
501+
# Try parsing ISO format first
502+
try:
503+
return datetime.fromisoformat(time_input)
504+
except ValueError:
505+
pass
506+
# Try parsing as timestamp string
507+
try:
508+
return datetime.fromtimestamp(float(time_input))
509+
except Exception:
510+
pass
511+
raise ValueError(f"Unsupported date string format: {time_input}")
512+
raise TypeError(f"Unsupported type for time_input: {type(time_input)}")
513+
514+
def get(self, key, default=None):
515+
return getattr(self, key, default)
516+
517+
def get_service_call_data(self):
518+
return self
519+
520+
433521
async def _create_event(
434522
hass,
435-
call: dict,
523+
call: ServiceCallData,
436524
start: datetime,
437525
response: dict,
438526
key_frame: str,
439527
) -> None:
440-
if call.store_in_timeline:
528+
if call.get("store_in_timeline"):
441529
# Find timeline config
442530
config_entry = None
443531
for entry in hass.config_entries.async_entries(DOMAIN):
@@ -453,17 +541,17 @@ async def _create_event(
453541

454542
timeline = Timeline(hass, config_entry)
455543

456-
if call.image_entities and len(call.image_entities) > 0:
457-
camera_name = call.image_entities[0]
458-
elif call.video_paths and len(call.video_paths) > 0:
459-
camera_name = call.video_paths[0].split("/")[-1].replace(".mp4", "")
544+
image_entities = call.get("image_entities") or []
545+
video_paths = call.get("video_paths") or []
546+
if len(image_entities) > 0:
547+
camera_name = image_entities[0]
548+
elif len(video_paths) > 0:
549+
camera_name = video_paths[0].split("/")[-1].replace(".mp4", "")
460550
else:
461551
camera_name = ""
462552

463-
if "title" in response:
464-
title = response.get("title")
465-
else:
466-
title = "Motion detected"
553+
title = response.get("title") or "Motion detected"
554+
title = str(title)
467555

468556
await timeline.create_event(
469557
start=start,
@@ -479,7 +567,7 @@ async def _create_event(
479567
async def _update_sensor(hass, sensor_entity: str, value: str | int, type: str) -> None:
480568
"""Update the value of a sensor entity."""
481569
# Attempt to parse the response
482-
value = value.strip()
570+
value = str(value).strip()
483571
if type == "boolean":
484572
if value.lower() in ["on", "off"]:
485573
new_value = value
@@ -533,90 +621,6 @@ async def _update_sensor(hass, sensor_entity: str, value: str | int, type: str)
533621
raise
534622

535623

536-
class ServiceCallData:
537-
"""Store service call data and set default values"""
538-
539-
def __init__(self, data_call):
540-
# This is the config entry id
541-
self.provider = str(data_call.data.get(PROVIDER))
542-
# If not set, the conf_default_model will be set in providers.py
543-
self.model = data_call.data.get(MODEL)
544-
self.message = str(data_call.data.get(MESSAGE, "")[0:2000])
545-
self.store_in_timeline = data_call.data.get(STORE_IN_TIMELINE, False)
546-
self.use_memory = data_call.data.get(USE_MEMORY, False)
547-
self.image_paths = (
548-
data_call.data.get(IMAGE_FILE, "").split("\n")
549-
if data_call.data.get(IMAGE_FILE)
550-
else None
551-
)
552-
self.image_entities = data_call.data.get(IMAGE_ENTITY)
553-
self.video_paths = (
554-
data_call.data.get(VIDEO_FILE, "").split("\n")
555-
if data_call.data.get(VIDEO_FILE)
556-
else None
557-
)
558-
self.event_id = (
559-
data_call.data.get(EVENT_ID, "").split("\n")
560-
if data_call.data.get(EVENT_ID)
561-
else None
562-
)
563-
self.interval = int(data_call.data.get(INTERVAL, 2))
564-
self.duration = int(data_call.data.get(DURATION, 10))
565-
self.max_frames = int(data_call.data.get(MAX_FRAMES, 3))
566-
self.target_width = data_call.data.get(TARGET_WIDTH, 3840)
567-
self.temperature = float()
568-
self.max_tokens = int(data_call.data.get(MAXTOKENS, 3000))
569-
self.include_filename = data_call.data.get(INCLUDE_FILENAME, False)
570-
self.expose_images = data_call.data.get(EXPOSE_IMAGES, False)
571-
self.generate_title = data_call.data.get(GENERATE_TITLE, False)
572-
self.sensor_entity = data_call.data.get(SENSOR_ENTITY, "")
573-
self.response_format = data_call.data.get(RESPONSE_FORMAT, "text")
574-
self.structure = data_call.data.get(STRUCTURE, None)
575-
self.title_field = data_call.data.get(TITLE_FIELD, "")
576-
577-
# ------------ Create Event ------------
578-
self.title = data_call.data.get("title")
579-
self.description = data_call.data.get("description")
580-
self.start_time = data_call.data.get("start_time", dt_util.now())
581-
self.start_time = self._convert_time_input_to_datetime(self.start_time)
582-
self.end_time = data_call.data.get(
583-
"end_time", self.start_time + timedelta(minutes=1)
584-
)
585-
self.end_time = self._convert_time_input_to_datetime(self.end_time)
586-
self.image_path = data_call.data.get("image_path", "")
587-
self.camera_entity = data_call.data.get("camera_entity", "")
588-
self.label = data_call.data.get("label", "")
589-
590-
# ------------ Added during call ------------
591-
# self.base64_images : List[str] = []
592-
# self.filenames : List[str] = []
593-
594-
def _convert_time_input_to_datetime(self, time_input) -> datetime:
595-
"""Convert time input to datetime object"""
596-
597-
if isinstance(time_input, datetime):
598-
return time_input
599-
if isinstance(time_input, (int, float)):
600-
# Assume it's a Unix timestamp (seconds since epoch)
601-
return datetime.fromtimestamp(time_input)
602-
if isinstance(time_input, str):
603-
# Try parsing ISO format first
604-
try:
605-
return datetime.fromisoformat(time_input)
606-
except ValueError:
607-
pass
608-
# Try parsing as timestamp string
609-
try:
610-
return datetime.fromtimestamp(float(time_input))
611-
except Exception:
612-
pass
613-
raise ValueError(f"Unsupported date string format: {time_input}")
614-
raise TypeError(f"Unsupported type for time_input: {type(time_input)}")
615-
616-
def get_service_call_data(self):
617-
return self
618-
619-
620624
def setup(hass, config):
621625
async def image_analyzer(data_call):
622626
"""Handle the service call to analyze an image with LLM Vision"""
@@ -660,7 +664,7 @@ async def image_analyzer(data_call):
660664

661665
await _create_event(
662666
hass=hass,
663-
call=call,
667+
call=call, # type: ignore
664668
start=start,
665669
response=response,
666670
key_frame=processor.key_frame,
@@ -698,7 +702,7 @@ async def video_analyzer(data_call):
698702

699703
await _create_event(
700704
hass=hass,
701-
call=call,
705+
call=call, # type: ignore
702706
start=start,
703707
response=response,
704708
key_frame=processor.key_frame,
@@ -739,7 +743,7 @@ async def stream_analyzer(data_call):
739743

740744
await _create_event(
741745
hass=hass,
742-
call=call,
746+
call=call, # type: ignore
743747
start=start,
744748
response=response,
745749
key_frame=processor.key_frame,
@@ -818,7 +822,7 @@ async def data_analyzer(data_call):
818822

819823
await _create_event(
820824
hass=hass,
821-
call=call,
825+
call=call, # type: ignore
822826
start=start,
823827
response=response,
824828
key_frame=processor.key_frame,

custom_components/llmvision/api.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import json
22
import logging
33
from datetime import datetime, timedelta
4-
from homeassistant.components.http import HomeAssistantView
4+
from typing import Any, Optional
5+
from homeassistant.helpers.http import HomeAssistantView
56
from homeassistant.helpers.json import json_dumps
67
from homeassistant.util import dt as dt_util
78
from .calendar import Timeline
@@ -32,6 +33,8 @@ async def get(self, request):
3233
hass = request.app["hass"]
3334

3435
settings_entry = await async_get_settings_entry(hass)
36+
if settings_entry is None:
37+
return self.json_message("Settings config entry not found", status_code=404)
3538
# Parse request params
3639
try:
3740
# Limit: minimum 1, maximum 100
@@ -170,7 +173,7 @@ async def get(self, request, event_id):
170173
if settings_entry is None:
171174
return self.json_message("Settings config entry not found", status_code=404)
172175
timeline = Timeline(hass, settings_entry)
173-
event: dict = await timeline.get_event(event_id)
176+
event: Optional[dict[str, Any]] = await timeline.get_event(event_id)
174177
if event is None:
175178
return self.json_message("Event not found", status_code=404)
176179
return self.json({"event": json.loads(json_dumps(event))})
@@ -265,7 +268,9 @@ def _parse_time(val):
265268
return self.json_message("Error updating event", status_code=500)
266269

267270
try:
268-
updated = await timeline.get_event_json(event_id)
271+
updated = await timeline.get_event(event_id)
272+
if updated is None:
273+
return self.json({"event_id": event_id, "status": "updated"})
269274
return self.json({"event": json.loads(json_dumps(updated))})
270275
except Exception:
271276
return self.json({"event_id": event_id, "status": "updated"})

custom_components/llmvision/calendar.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
from homeassistant.components.calendar import (
44
CalendarEntity,
55
CalendarEvent,
6-
CalendarEntityFeature,
76
)
7+
from homeassistant.components.calendar.const import CalendarEntityFeature
88
from homeassistant.helpers.entity_platform import AddEntitiesCallback
99
from homeassistant.config_entries import ConfigEntry
1010
from .timeline import Timeline
@@ -27,7 +27,7 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry):
2727
self._attr_supported_features = CalendarEntityFeature.DELETE_EVENT
2828

2929
@property
30-
def icon(self) -> str:
30+
def icon(self) -> str: # type: ignore
3131
"""Return the icon to use in the frontend"""
3232
return "mdi:timeline-outline"
3333

@@ -47,17 +47,25 @@ def _ensure_datetime(self, dt):
4747
async def async_update(self) -> None:
4848
"""Loads events from database"""
4949
events = await self.timeline.get_all_events()
50-
self._events = [
51-
CalendarEvent(
52-
uid=event.uid,
53-
summary=event.title,
54-
start=event.start,
55-
end=event.end,
56-
description=event.description,
50+
calendar_events: list[CalendarEvent] = []
51+
for event in events:
52+
if event.start is None or event.end is None:
53+
continue
54+
event_start = self._ensure_datetime(event.start)
55+
event_end = self._ensure_datetime(event.end)
56+
calendar_events.append(
57+
CalendarEvent(
58+
uid=event.uid,
59+
summary=event.title,
60+
start=event_start,
61+
end=event_end,
62+
description=event.description,
63+
)
5764
)
58-
for event in events
59-
]
60-
self._events.sort(key=lambda event: event.start, reverse=True)
65+
calendar_events.sort(
66+
key=lambda calendar_event: calendar_event.start, reverse=True
67+
)
68+
self._events = calendar_events
6169

6270
async def async_get_events(
6371
self,
@@ -74,6 +82,8 @@ async def async_get_events(
7482
end_date = self._ensure_datetime(end_date)
7583

7684
for event in timeline_events:
85+
if event.start is None or event.end is None:
86+
continue
7787
# Ensure event.end and event.start are datetime.datetime objects and timezone-aware
7888
event_end = self._ensure_datetime(event.end)
7989
event_start = self._ensure_datetime(event.start)
@@ -82,8 +92,8 @@ async def async_get_events(
8292
calendar_event = CalendarEvent(
8393
uid=event.uid,
8494
summary=event.title,
85-
start=event.start,
86-
end=event.end,
95+
start=event_start,
96+
end=event_end,
8797
description=event.description,
8898
)
8999
calendar_events.append(calendar_event)

0 commit comments

Comments
 (0)