Skip to content

Commit 85fcb98

Browse files
Merge pull request #657 from valentinfrlch/v1.7.0-beta
V1.7.0 beta
2 parents 408887c + 93468b7 commit 85fcb98

61 files changed

Lines changed: 12866 additions & 2183 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
"tests"
55
],
66
"python.testing.unittestEnabled": false,
7-
"python.testing.pytestEnabled": true
7+
"python.testing.pytestEnabled": true,
8+
"python-envs.defaultEnvManager": "ms-python.python:system"
89
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<p align=center>
88
<img src=https://img.shields.io/badge/HACS-Default-orange.svg>
99
<img src="https://img.shields.io/maintenance/yes/2026.svg">
10-
<img src=https://img.shields.io/badge/version-1.6.0-blue>
10+
<img src=https://img.shields.io/badge/version-1.7.0-blue>
1111
<img alt="Issues" src="https://img.shields.io/github/issues/valentinfrlch/ha-llmvision?color=0088ff">
1212
<img alt="Static Badge" src="https://img.shields.io/badge/support-buymeacoffee?logo=buymeacoffee&logoColor=black&color=%23FFDD00&link=https%3A%2F%2Fbuymeacoffee.com%2Fllmvision">
1313
<h2 align=center style="font-weight:bold">

custom_components/llmvision/__init__.py

Lines changed: 84 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from homeassistant.core import SupportsResponse
1111
from homeassistant.exceptions import ServiceValidationError
1212
import homeassistant.helpers.config_validation as cv
13+
from homeassistant.helpers.dispatcher import async_dispatcher_send
1314
from .api import TimelineEventView, TimelineEventsView, TimelineEventCreateView
1415

1516
import logging
@@ -74,6 +75,10 @@
7475
STRUCTURE,
7576
TITLE_FIELD,
7677
DESCRIPTION_FIELD,
78+
SIGNAL_TIMELINE_UPDATED,
79+
CONF_THINKING_BUDGET,
80+
CONF_THINK,
81+
CONF_REASONING_EFFORT,
7782
)
7883

7984
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -116,6 +121,10 @@ async def async_setup_entry(hass, entry):
116121
CONF_MEMORY_STRINGS: entry.data.get(CONF_MEMORY_STRINGS),
117122
CONF_SYSTEM_PROMPT: entry.data.get(CONF_SYSTEM_PROMPT),
118123
CONF_TITLE_PROMPT: entry.data.get(CONF_TITLE_PROMPT),
124+
# Thinking/reasoning parameters
125+
CONF_THINKING_BUDGET: entry.data.get(CONF_THINKING_BUDGET),
126+
CONF_THINK: entry.data.get(CONF_THINK),
127+
CONF_REASONING_EFFORT: entry.data.get(CONF_REASONING_EFFORT),
119128
}
120129

121130
# Filter out None values
@@ -460,34 +469,47 @@ def __init__(self, data_call):
460469
if data_call.data.get(EVENT_ID)
461470
else None
462471
)
463-
self.interval = int(data_call.data.get(INTERVAL, 2))
464-
self.duration = int(data_call.data.get(DURATION, 10))
465-
self.max_frames = int(data_call.data.get(MAX_FRAMES, 3))
466-
self.target_width = data_call.data.get(TARGET_WIDTH, 3840)
467-
self.temperature = float()
468-
self.max_tokens = int(data_call.data.get(MAXTOKENS, 3000))
469-
self.include_filename = data_call.data.get(INCLUDE_FILENAME, False)
470-
self.expose_images = data_call.data.get(EXPOSE_IMAGES, False)
471-
self.generate_title = data_call.data.get(GENERATE_TITLE, False)
472-
self.sensor_entity = data_call.data.get(SENSOR_ENTITY, "")
473-
self.response_format = data_call.data.get(RESPONSE_FORMAT, "text")
474-
self.structure = data_call.data.get(STRUCTURE, None)
475-
self.title_field = data_call.data.get(TITLE_FIELD, "")
476-
self.description_field = data_call.data.get(DESCRIPTION_FIELD, "")
472+
self.interval: int = int(data_call.data.get(INTERVAL, 2))
473+
self.duration: int = int(data_call.data.get(DURATION, 10))
474+
self.max_frames: int = int(data_call.data.get(MAX_FRAMES, 3))
475+
self.target_width: int = data_call.data.get(TARGET_WIDTH, 3840)
476+
self.temperature: float = float()
477+
self.max_tokens: int = int(data_call.data.get(MAXTOKENS, 3000))
478+
self.include_filename: bool = data_call.data.get(INCLUDE_FILENAME, False)
479+
self.expose_images: bool = data_call.data.get(EXPOSE_IMAGES, False)
480+
self.generate_title: bool = data_call.data.get(GENERATE_TITLE, False)
481+
self.sensor_entity: str = data_call.data.get(SENSOR_ENTITY, "")
482+
self.response_format: str = data_call.data.get(RESPONSE_FORMAT, "text")
483+
self.structure: dict | None = data_call.data.get(STRUCTURE, None)
484+
self.title_field: str = data_call.data.get(TITLE_FIELD, "")
485+
self.description_field: str = data_call.data.get(DESCRIPTION_FIELD, "")
477486
self.memory: Memory | None = None
478487

479488
# ------------ Create Event ------------
480-
self.title = data_call.data.get("title")
481-
self.description = data_call.data.get("description")
482-
self.start_time = data_call.data.get("start_time", dt_util.now())
489+
self.title: str = data_call.data.get("title")
490+
self.description: str = data_call.data.get("description")
491+
self.start_time: datetime = data_call.data.get("start_time", dt_util.now())
483492
self.start_time = self._convert_time_input_to_datetime(self.start_time)
484-
self.end_time = data_call.data.get(
493+
self.end_time: datetime = data_call.data.get(
485494
"end_time", self.start_time + timedelta(minutes=1)
486495
)
487496
self.end_time = self._convert_time_input_to_datetime(self.end_time)
488-
self.image_path = data_call.data.get("image_path", "")
489-
self.camera_entity = data_call.data.get("camera_entity", "")
490-
self.label = data_call.data.get("label", "")
497+
self.image_path: str = data_call.data.get("image_path", "")
498+
self.camera_entity: str = data_call.data.get("camera_entity", "")
499+
self.label: str = data_call.data.get("label", "")
500+
501+
# ------------- Get Events --------------
502+
self.start: datetime = data_call.data.get(
503+
"start", dt_util.now() - timedelta(days=7)
504+
)
505+
self.start = self._convert_time_input_to_datetime(self.start)
506+
self.end: datetime = data_call.data.get("end", dt_util.now())
507+
self.end = self._convert_time_input_to_datetime(self.end)
508+
self.cameras: list = data_call.data.get("cameras", "")
509+
self.categories: list = data_call.data.get("categories", "")
510+
self.labels: list = data_call.data.get("labels", "")
511+
self.limit: int = int(data_call.data.get("limit", 100))
512+
self.include_no_activity: bool = data_call.data.get("include_no_activity", True)
491513

492514
# ------------ Added during call ------------
493515
# self.base64_images : List[str] = []
@@ -521,6 +543,10 @@ def get(self, key, default=None):
521543
def get_service_call_data(self):
522544
return self
523545

546+
def model_is_glimpse(self) -> bool:
547+
"""Check if model is Glimpse-v1 like based on model name"""
548+
return bool(self.model and "glimpse-v1" in self.model.lower())
549+
524550

525551
async def _create_event(
526552
hass,
@@ -579,6 +605,7 @@ async def _create_event(
579605
camera_name=camera_name,
580606
label="",
581607
)
608+
async_dispatcher_send(hass, SIGNAL_TIMELINE_UPDATED)
582609

583610

584611
async def _update_sensor(hass, sensor_entity: str, value: str | int, type: str) -> None:
@@ -850,7 +877,7 @@ async def data_analyzer(data_call):
850877
await _update_sensor(hass, sensor_entity, response["response_text"], type)
851878
return response
852879

853-
async def create_event(data_call):
880+
async def create_event(data_call) -> None:
854881
"""Handle the service call to create an event"""
855882
start = dt_util.now()
856883
call = ServiceCallData(data_call).get_service_call_data()
@@ -868,7 +895,7 @@ async def create_event(data_call):
868895
f"Config entry not found. Please create the 'Settings' config entry first."
869896
)
870897

871-
timeline = Timeline(hass, config_entry)
898+
timeline: Timeline = Timeline(hass, config_entry)
872899

873900
await timeline.create_event(
874901
start=call.start_time,
@@ -879,6 +906,34 @@ async def create_event(data_call):
879906
camera_name=call.camera_entity,
880907
label=call.label.lower(),
881908
)
909+
async_dispatcher_send(hass, SIGNAL_TIMELINE_UPDATED)
910+
911+
async def get_events(data_call) -> dict | None:
912+
"""Handle the service call to get events"""
913+
# Find timeline config
914+
config_entry = None
915+
for entry in hass.config_entries.async_entries(DOMAIN):
916+
# Check if the config entry is empty
917+
if entry.data[CONF_PROVIDER] == "Settings":
918+
config_entry = entry
919+
break
920+
921+
if config_entry is None:
922+
raise ServiceValidationError(
923+
f"Config entry not found. Please create the 'Settings' config entry first."
924+
)
925+
926+
timeline: Timeline = Timeline(hass, config_entry)
927+
events: list[dict] | None = await timeline.get_events_json(
928+
start=data_call.data.get("start"),
929+
end=data_call.data.get("end"),
930+
cameras=[camera.lower() for camera in data_call.data.get("cameras", [])],
931+
categories=[category.lower() for category in data_call.data.get("categories", [])],
932+
labels=[label.lower() for label in data_call.data.get("labels", [])],
933+
limit=data_call.data.get("limit"),
934+
include_no_activity=data_call.data.get("include_no_activity", True),
935+
)
936+
return {"events": events or []}
882937

883938
# Register actions
884939
hass.services.register(
@@ -907,6 +962,12 @@ async def create_event(data_call):
907962
"create_event",
908963
create_event,
909964
)
965+
hass.services.register(
966+
DOMAIN,
967+
"get_events",
968+
get_events,
969+
supports_response=SupportsResponse.ONLY,
970+
)
910971
hass.http.register_view(TimelineEventsView)
911972
hass.http.register_view(TimelineEventView)
912973
hass.http.register_view(TimelineEventCreateView)

custom_components/llmvision/api.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import logging
33
from datetime import datetime, timedelta
44
from typing import Any, Optional
5+
from homeassistant.helpers.dispatcher import async_dispatcher_send
56
from homeassistant.helpers.http import HomeAssistantView
67
from homeassistant.helpers.json import json_dumps
78
from homeassistant.util import dt as dt_util
89
from .calendar import Timeline
9-
from .const import DOMAIN, CONF_PROVIDER
10+
from .const import DOMAIN, CONF_PROVIDER, SIGNAL_TIMELINE_UPDATED
1011

1112
_LOGGER = logging.getLogger(__name__)
1213

@@ -37,14 +38,17 @@ async def get(self, request):
3738
return self.json_message("Settings config entry not found", status_code=404)
3839
# Parse request params
3940
try:
40-
# Limit: minimum 1, maximum 100
41-
limit = max(1, min(int(request.query.get("limit", 10)), 100))
41+
# Limit: minimum 1, maximum 10000
42+
limit = max(1, min(int(request.query.get("limit", 10)), 10000))
4243
except ValueError:
4344
limit = 10
4445

4546
cameras = request.query.get("cameras", None)
4647
categories = request.query.get("categories", None)
4748
days = request.query.get("days", None)
49+
hours = request.query.get("hours", None)
50+
include_no_activity_raw = request.query.get("include_no_activity", "false")
51+
include_no_activity = include_no_activity_raw.lower() in ("1", "true", "yes")
4852

4953
def _parse_list_param(val):
5054
if val is None:
@@ -60,20 +64,42 @@ def _parse_list_param(val):
6064

6165
cameras = _parse_list_param(cameras)
6266
categories = _parse_list_param(categories)
63-
start = None
64-
end = None
6567

66-
# If days is provided, calculate start and end dates
67-
if days is not None:
68-
try:
69-
days = int(days)
70-
end_date = dt_util.now()
71-
start_date = end_date - timedelta(days=days)
68+
# Parse start/end params (ISO datetime strings)
69+
start = request.query.get("start", None)
70+
end = request.query.get("end", None)
71+
72+
if start is not None:
73+
parsed_start = dt_util.parse_datetime(start)
74+
if parsed_start is None:
75+
return self.json_message("Invalid 'start' datetime", status_code=400)
76+
start = parsed_start.isoformat()
77+
if end is not None:
78+
parsed_end = dt_util.parse_datetime(end)
79+
if parsed_end is None:
80+
return self.json_message("Invalid 'end' datetime", status_code=400)
81+
end = parsed_end.isoformat()
82+
83+
# If start/end aren't provided, preserve the legacy days/hours behavior.
84+
if days is not None and start is None and end is None:
85+
end_date = dt_util.now()
86+
start_date = None
87+
88+
if hours is not None:
89+
try:
90+
start_date = end_date - timedelta(hours=int(hours))
91+
except (TypeError, ValueError):
92+
start_date = None
93+
94+
if start_date is None:
95+
try:
96+
start_date = end_date - timedelta(days=int(days))
97+
except (TypeError, ValueError):
98+
start_date = None
99+
100+
if start_date is not None:
72101
start = start_date.isoformat()
73102
end = end_date.isoformat()
74-
except ValueError:
75-
start = None
76-
end = None
77103

78104
timeline = Timeline(hass, settings_entry)
79105
events = await timeline.get_events_json(
@@ -82,6 +108,7 @@ def _parse_list_param(val):
82108
categories=categories,
83109
start=start,
84110
end=end,
111+
include_no_activity=include_no_activity,
85112
)
86113
return self.json({"events": json.loads(json_dumps(events))})
87114

@@ -148,6 +175,7 @@ def _parse_time(val):
148175
_LOGGER.error(f"Error creating timeline event: {e}")
149176
return self.json_message("Error creating event", status_code=500)
150177

178+
async_dispatcher_send(hass, SIGNAL_TIMELINE_UPDATED)
151179
payload = {"status": "created"}
152180

153181
return self.json(payload)
@@ -189,6 +217,7 @@ async def delete(self, request, event_id):
189217
except Exception as e:
190218
_LOGGER.error(f"Error deleting event {event_id}: {e}")
191219
return self.json_message("Error deleting event", status_code=500)
220+
async_dispatcher_send(hass, SIGNAL_TIMELINE_UPDATED)
192221
return self.json({"event_id": event_id, "status": "deleted"})
193222

194223
async def post(self, request, event_id):
@@ -267,6 +296,8 @@ def _parse_time(val):
267296
_LOGGER.error(f"Error updating event {event_id}: {e}")
268297
return self.json_message("Error updating event", status_code=500)
269298

299+
async_dispatcher_send(hass, SIGNAL_TIMELINE_UPDATED)
300+
270301
try:
271302
updated = await timeline.get_event(event_id)
272303
if updated is None:

0 commit comments

Comments
 (0)