stoop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stoop/__init__.py +55 -0
- stoop/events.py +93 -0
- stoop/memory/__init__.py +31 -0
- stoop/memory/models.py +193 -0
- stoop/memory/store.py +295 -0
- stoop/pipeline.py +86 -0
- stoop/policy/__init__.py +17 -0
- stoop/policy/engine.py +351 -0
- stoop/policy/home_rules.py +506 -0
- stoop/policy/routines.py +91 -0
- stoop/policy/rules.py +417 -0
- stoop/py.typed +0 -0
- stoop/reasoning/__init__.py +4 -0
- stoop/reasoning/base.py +53 -0
- stoop/reasoning/bedrock.py +196 -0
- stoop/reasoning/deterministic.py +45 -0
- stoop/sources/__init__.py +14 -0
- stoop/sources/ring.py +349 -0
- stoop/sources/ring_oauth.py +470 -0
- stoop/sources/synthetic.py +174 -0
- stoop-0.1.0.dist-info/METADATA +256 -0
- stoop-0.1.0.dist-info/RECORD +24 -0
- stoop-0.1.0.dist-info/WHEEL +4 -0
- stoop-0.1.0.dist-info/licenses/LICENSE +21 -0
stoop/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""stoop: turn front-door events into decisions a person can act on.
|
|
2
|
+
|
|
3
|
+
Ingest events from Ring (or anything else), remember who is expected and what is normal,
|
|
4
|
+
decide whether to ignore, log, notify or escalate, and keep every sensitive action behind
|
|
5
|
+
a human confirmation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from stoop.events import Detected, Event, EventKind, MediaRef
|
|
9
|
+
from stoop.memory import (
|
|
10
|
+
Action,
|
|
11
|
+
Decision,
|
|
12
|
+
ExpectedVisit,
|
|
13
|
+
Person,
|
|
14
|
+
Role,
|
|
15
|
+
Severity,
|
|
16
|
+
Site,
|
|
17
|
+
SiteKind,
|
|
18
|
+
Store,
|
|
19
|
+
SuggestedAction,
|
|
20
|
+
Visit,
|
|
21
|
+
)
|
|
22
|
+
from stoop.pipeline import CallbackSink, LogSink, Pipeline, Sink
|
|
23
|
+
from stoop.policy import PolicyConfig, PolicyEngine, RoutineModel
|
|
24
|
+
from stoop.reasoning import DeterministicReasoner, Reasoner, ReasoningContext, Refinement
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Action",
|
|
30
|
+
"CallbackSink",
|
|
31
|
+
"Decision",
|
|
32
|
+
"Detected",
|
|
33
|
+
"DeterministicReasoner",
|
|
34
|
+
"Event",
|
|
35
|
+
"EventKind",
|
|
36
|
+
"ExpectedVisit",
|
|
37
|
+
"LogSink",
|
|
38
|
+
"MediaRef",
|
|
39
|
+
"Person",
|
|
40
|
+
"Pipeline",
|
|
41
|
+
"PolicyConfig",
|
|
42
|
+
"PolicyEngine",
|
|
43
|
+
"Reasoner",
|
|
44
|
+
"ReasoningContext",
|
|
45
|
+
"Refinement",
|
|
46
|
+
"Role",
|
|
47
|
+
"RoutineModel",
|
|
48
|
+
"Severity",
|
|
49
|
+
"Sink",
|
|
50
|
+
"Site",
|
|
51
|
+
"SiteKind",
|
|
52
|
+
"Store",
|
|
53
|
+
"SuggestedAction",
|
|
54
|
+
"Visit",
|
|
55
|
+
]
|
stoop/events.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Normalized front-door events.
|
|
2
|
+
|
|
3
|
+
Every source (Ring webhooks, Ring history, synthetic scenarios, other devices) is
|
|
4
|
+
converted into :class:`Event` so the memory, policy and reasoning layers never see
|
|
5
|
+
vendor-specific shapes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EventKind(StrEnum):
|
|
19
|
+
MOTION = "motion"
|
|
20
|
+
BUTTON_PRESS = "button_press"
|
|
21
|
+
LIVE_VIEW = "live_view"
|
|
22
|
+
DOOR_OPENED = "door_opened"
|
|
23
|
+
DOOR_CLOSED = "door_closed"
|
|
24
|
+
DEVICE_ONLINE = "device_online"
|
|
25
|
+
DEVICE_OFFLINE = "device_offline"
|
|
26
|
+
SENSOR_ALERT = "sensor_alert"
|
|
27
|
+
SENSOR_CLEARED = "sensor_cleared"
|
|
28
|
+
ACCOUNT = "account" # link/unlink, devices shared or removed, subscription changes: not door activity
|
|
29
|
+
OTHER = "other"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Detected(StrEnum):
|
|
33
|
+
"""What a camera believes it saw. Only motion events carry this."""
|
|
34
|
+
|
|
35
|
+
HUMAN = "human"
|
|
36
|
+
VEHICLE = "vehicle"
|
|
37
|
+
ANIMAL = "animal"
|
|
38
|
+
PACKAGE = "package"
|
|
39
|
+
MOTION = "motion"
|
|
40
|
+
UNKNOWN = "unknown"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MediaRef(BaseModel):
|
|
44
|
+
"""Pointer to media that an app can fetch later (never the bytes themselves)."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
kind: str # snapshot | clip | live
|
|
49
|
+
device_id: str
|
|
50
|
+
at: datetime | None = None
|
|
51
|
+
url: str | None = None
|
|
52
|
+
content_type: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Event(BaseModel):
|
|
56
|
+
model_config = ConfigDict(extra="forbid")
|
|
57
|
+
|
|
58
|
+
id: str
|
|
59
|
+
site_id: str
|
|
60
|
+
source: str
|
|
61
|
+
kind: EventKind
|
|
62
|
+
device_id: str
|
|
63
|
+
occurred_at: datetime
|
|
64
|
+
detected: Detected | None = None
|
|
65
|
+
device_name: str | None = None
|
|
66
|
+
sensor: str | None = None # flood, freeze, tamper, co, ... for SENSOR_* kinds
|
|
67
|
+
media: list[MediaRef] = Field(default_factory=list)
|
|
68
|
+
dedupe_key: str | None = None
|
|
69
|
+
raw: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
@field_validator("occurred_at")
|
|
72
|
+
@classmethod
|
|
73
|
+
def _aware(cls, value: datetime) -> datetime:
|
|
74
|
+
if value.tzinfo is None:
|
|
75
|
+
raise ValueError("occurred_at must be timezone-aware")
|
|
76
|
+
return value.astimezone(UTC)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def key(self) -> str:
|
|
80
|
+
"""Routine-model bucket key, e.g. ``motion:human`` or ``button_press``."""
|
|
81
|
+
if self.kind is EventKind.MOTION:
|
|
82
|
+
return f"motion:{(self.detected or Detected.UNKNOWN).value}"
|
|
83
|
+
return self.kind.value
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_presence(self) -> bool:
|
|
87
|
+
"""True when a person is plausibly at the door."""
|
|
88
|
+
return self.kind is EventKind.BUTTON_PRESS or (self.kind is EventKind.MOTION and self.detected is Detected.HUMAN)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def make_event_id(source: str, dedupe_key: str) -> str:
|
|
92
|
+
"""Stable id derived from the source's own unique key, so replays never duplicate."""
|
|
93
|
+
return hashlib.sha1(f"{source}:{dedupe_key}".encode()).hexdigest()[:20]
|
stoop/memory/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from stoop.memory.models import (
|
|
2
|
+
SEVERITY_ORDER,
|
|
3
|
+
Action,
|
|
4
|
+
Decision,
|
|
5
|
+
ExpectedVisit,
|
|
6
|
+
Person,
|
|
7
|
+
Role,
|
|
8
|
+
Severity,
|
|
9
|
+
Site,
|
|
10
|
+
SiteKind,
|
|
11
|
+
SuggestedAction,
|
|
12
|
+
Visit,
|
|
13
|
+
VisitStatus,
|
|
14
|
+
)
|
|
15
|
+
from stoop.memory.store import Store
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"SEVERITY_ORDER",
|
|
19
|
+
"Action",
|
|
20
|
+
"Decision",
|
|
21
|
+
"ExpectedVisit",
|
|
22
|
+
"Person",
|
|
23
|
+
"Role",
|
|
24
|
+
"Severity",
|
|
25
|
+
"Site",
|
|
26
|
+
"SiteKind",
|
|
27
|
+
"Store",
|
|
28
|
+
"SuggestedAction",
|
|
29
|
+
"Visit",
|
|
30
|
+
"VisitStatus",
|
|
31
|
+
]
|
stoop/memory/models.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Context memory: who is expected, what a site is, what happened, what was decided."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, date, datetime, time, timedelta
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def new_id(prefix: str) -> str:
|
|
15
|
+
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def utcnow() -> datetime:
|
|
19
|
+
return datetime.now(tz=UTC)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Model(BaseModel):
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SiteKind(StrEnum):
|
|
27
|
+
HOME = "home"
|
|
28
|
+
RENTAL = "rental"
|
|
29
|
+
OFFICE = "office"
|
|
30
|
+
CLINIC = "clinic"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Site(_Model):
|
|
34
|
+
id: str
|
|
35
|
+
name: str
|
|
36
|
+
timezone: str = "America/New_York"
|
|
37
|
+
kind: SiteKind = SiteKind.HOME
|
|
38
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def zone(self) -> ZoneInfo:
|
|
42
|
+
return ZoneInfo(self.timezone)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Role(StrEnum):
|
|
46
|
+
FAMILY = "family"
|
|
47
|
+
AIDE = "aide"
|
|
48
|
+
NURSE = "nurse"
|
|
49
|
+
NEIGHBOR = "neighbor"
|
|
50
|
+
COURIER = "courier"
|
|
51
|
+
CLEANER = "cleaner"
|
|
52
|
+
CONTRACTOR = "contractor"
|
|
53
|
+
GUEST = "guest"
|
|
54
|
+
OTHER = "other"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Person(_Model):
|
|
58
|
+
id: str = Field(default_factory=lambda: new_id("per"))
|
|
59
|
+
site_id: str
|
|
60
|
+
name: str
|
|
61
|
+
role: Role = Role.OTHER
|
|
62
|
+
phone: str | None = None
|
|
63
|
+
email: str | None = None
|
|
64
|
+
notes: str | None = None
|
|
65
|
+
trusted: bool = True
|
|
66
|
+
# App-level preferences (notification channels, thresholds, ...). Free-form on purpose.
|
|
67
|
+
preferences: dict[str, Any] = Field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ExpectedVisit(_Model):
|
|
71
|
+
"""A visit window. Either one-off (``window_start``/``window_end``) or recurring
|
|
72
|
+
(``days_of_week`` + ``local_start``/``local_end``)."""
|
|
73
|
+
|
|
74
|
+
id: str = Field(default_factory=lambda: new_id("exp"))
|
|
75
|
+
site_id: str
|
|
76
|
+
label: str
|
|
77
|
+
person_id: str | None = None
|
|
78
|
+
window_start: datetime | None = None
|
|
79
|
+
window_end: datetime | None = None
|
|
80
|
+
days_of_week: list[int] | None = None # 0 = Monday
|
|
81
|
+
local_start: time | None = None
|
|
82
|
+
local_end: time | None = None
|
|
83
|
+
expected_duration_min: int | None = None
|
|
84
|
+
origin: str = "manual" # manual | calendar | booking
|
|
85
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
@field_validator("window_start", "window_end")
|
|
88
|
+
@classmethod
|
|
89
|
+
def _aware(cls, value: datetime | None) -> datetime | None:
|
|
90
|
+
if value is not None and value.tzinfo is None:
|
|
91
|
+
raise ValueError("expected-visit windows must be timezone-aware")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def recurring(self) -> bool:
|
|
96
|
+
return bool(self.days_of_week) and self.local_start is not None and self.local_end is not None
|
|
97
|
+
|
|
98
|
+
def windows_between(self, t0: datetime, t1: datetime, zone: ZoneInfo) -> list[tuple[datetime, datetime]]:
|
|
99
|
+
"""Concrete windows overlapping [t0, t1]."""
|
|
100
|
+
if not self.recurring:
|
|
101
|
+
if self.window_start and self.window_end and self.window_end >= t0 and self.window_start <= t1:
|
|
102
|
+
return [(self.window_start, self.window_end)]
|
|
103
|
+
return []
|
|
104
|
+
assert self.days_of_week is not None and self.local_start and self.local_end
|
|
105
|
+
out: list[tuple[datetime, datetime]] = []
|
|
106
|
+
day: date = (t0.astimezone(zone) - timedelta(days=1)).date()
|
|
107
|
+
last: date = (t1.astimezone(zone) + timedelta(days=1)).date()
|
|
108
|
+
while day <= last:
|
|
109
|
+
if day.weekday() in self.days_of_week:
|
|
110
|
+
ws = datetime.combine(day, self.local_start, tzinfo=zone)
|
|
111
|
+
we = datetime.combine(day, self.local_end, tzinfo=zone)
|
|
112
|
+
if we < ws:
|
|
113
|
+
we += timedelta(days=1)
|
|
114
|
+
if we >= t0 and ws <= t1:
|
|
115
|
+
out.append((ws.astimezone(UTC), we.astimezone(UTC)))
|
|
116
|
+
day += timedelta(days=1)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class VisitStatus(StrEnum):
|
|
121
|
+
OPEN = "open"
|
|
122
|
+
CLOSED = "closed"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Visit(_Model):
|
|
126
|
+
"""A cluster of events close in time at one site (someone came to the door)."""
|
|
127
|
+
|
|
128
|
+
id: str = Field(default_factory=lambda: new_id("vis"))
|
|
129
|
+
site_id: str
|
|
130
|
+
started_at: datetime
|
|
131
|
+
last_event_at: datetime
|
|
132
|
+
ended_at: datetime | None = None
|
|
133
|
+
status: VisitStatus = VisitStatus.OPEN
|
|
134
|
+
event_ids: list[str] = Field(default_factory=list)
|
|
135
|
+
presence_count: int = 0
|
|
136
|
+
rang: bool = False
|
|
137
|
+
door_opened: bool = False
|
|
138
|
+
package: bool = False
|
|
139
|
+
expected_visit_id: str | None = None
|
|
140
|
+
person_id: str | None = None
|
|
141
|
+
summary: str | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Action(StrEnum):
|
|
145
|
+
IGNORE = "ignore"
|
|
146
|
+
LOG = "log"
|
|
147
|
+
NOTIFY = "notify"
|
|
148
|
+
ESCALATE = "escalate"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Severity(StrEnum):
|
|
152
|
+
INFO = "info"
|
|
153
|
+
LOW = "low"
|
|
154
|
+
MEDIUM = "medium"
|
|
155
|
+
HIGH = "high"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
SEVERITY_ORDER = [Severity.INFO, Severity.LOW, Severity.MEDIUM, Severity.HIGH]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SuggestedAction(_Model):
|
|
162
|
+
kind: str # e.g. call_person, message_person, view_live, mark_expected, contact_emergency
|
|
163
|
+
label: str
|
|
164
|
+
sensitive: bool = False
|
|
165
|
+
target_person_id: str | None = None
|
|
166
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Decision(_Model):
|
|
170
|
+
id: str = Field(default_factory=lambda: new_id("dec"))
|
|
171
|
+
site_id: str
|
|
172
|
+
event_id: str | None
|
|
173
|
+
visit_id: str | None = None
|
|
174
|
+
created_at: datetime = Field(default_factory=utcnow)
|
|
175
|
+
action: Action
|
|
176
|
+
severity: Severity
|
|
177
|
+
rule: str
|
|
178
|
+
reason: str
|
|
179
|
+
message: str
|
|
180
|
+
confidence: float = 0.7
|
|
181
|
+
anomaly_score: float | None = None
|
|
182
|
+
requires_confirmation: bool = False
|
|
183
|
+
suggested_actions: list[SuggestedAction] = Field(default_factory=list)
|
|
184
|
+
matched_person_id: str | None = None
|
|
185
|
+
matched_expected_visit_id: str | None = None
|
|
186
|
+
refined_by: str | None = None
|
|
187
|
+
acknowledged_at: datetime | None = None
|
|
188
|
+
acknowledged_by: str | None = None
|
|
189
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def needs_attention(self) -> bool:
|
|
193
|
+
return self.action in (Action.NOTIFY, Action.ESCALATE) and self.acknowledged_at is None
|
stoop/memory/store.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""SQLite-backed store. Standard library only, one file per deployment, safe to vendor.
|
|
2
|
+
|
|
3
|
+
All rows keep the pydantic JSON as the source of truth plus a few indexed columns for
|
|
4
|
+
queries. Timestamps are stored as ISO-8601 UTC strings so lexical order == time order.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from stoop.events import Event, EventKind
|
|
17
|
+
from stoop.memory.models import Decision, ExpectedVisit, Person, Site, Visit, VisitStatus
|
|
18
|
+
|
|
19
|
+
_SCHEMA = """
|
|
20
|
+
CREATE TABLE IF NOT EXISTS sites (id TEXT PRIMARY KEY, json TEXT NOT NULL);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
22
|
+
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, kind TEXT NOT NULL, device_id TEXT NOT NULL,
|
|
23
|
+
occurred_at TEXT NOT NULL, dedupe_key TEXT, json TEXT NOT NULL,
|
|
24
|
+
UNIQUE(site_id, dedupe_key)
|
|
25
|
+
);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS ix_events_site_time ON events(site_id, occurred_at);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS persons (id TEXT PRIMARY KEY, site_id TEXT NOT NULL, json TEXT NOT NULL);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS expected_visits (id TEXT PRIMARY KEY, site_id TEXT NOT NULL, json TEXT NOT NULL);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS visits (
|
|
30
|
+
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, started_at TEXT NOT NULL, status TEXT NOT NULL, json TEXT NOT NULL
|
|
31
|
+
);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS ix_visits_site_time ON visits(site_id, started_at);
|
|
33
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
34
|
+
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, event_id TEXT, created_at TEXT NOT NULL,
|
|
35
|
+
action TEXT NOT NULL, severity TEXT NOT NULL, acknowledged_at TEXT, json TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS ix_decisions_site_time ON decisions(site_id, created_at);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS kv (site_id TEXT NOT NULL, key TEXT NOT NULL, json TEXT NOT NULL, PRIMARY KEY(site_id, key));
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _iso(dt: datetime) -> str:
|
|
43
|
+
return dt.astimezone(UTC).isoformat()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Store:
|
|
47
|
+
"""Thread-safe SQLite store. Use ``":memory:"`` for tests."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, path: str = ":memory:") -> None:
|
|
50
|
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
51
|
+
self._conn.row_factory = sqlite3.Row
|
|
52
|
+
self._lock = threading.RLock()
|
|
53
|
+
with self._lock:
|
|
54
|
+
self._conn.executescript(_SCHEMA)
|
|
55
|
+
|
|
56
|
+
def close(self) -> None:
|
|
57
|
+
with self._lock:
|
|
58
|
+
self._conn.close()
|
|
59
|
+
|
|
60
|
+
def _exec(self, sql: str, params: Iterable[Any] = ()) -> sqlite3.Cursor:
|
|
61
|
+
with self._lock:
|
|
62
|
+
cur = self._conn.execute(sql, tuple(params))
|
|
63
|
+
self._conn.commit()
|
|
64
|
+
return cur
|
|
65
|
+
|
|
66
|
+
def _rows(self, sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
|
67
|
+
with self._lock:
|
|
68
|
+
return self._conn.execute(sql, tuple(params)).fetchall()
|
|
69
|
+
|
|
70
|
+
# Public escape hatches for apps that keep their own tables in the same database
|
|
71
|
+
# (accounts, token links). Same connection, same lock, same file.
|
|
72
|
+
def execute(self, sql: str, params: Iterable[Any] = ()) -> sqlite3.Cursor:
|
|
73
|
+
return self._exec(sql, params)
|
|
74
|
+
|
|
75
|
+
def query(self, sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
|
76
|
+
return self._rows(sql, params)
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------ sites
|
|
79
|
+
def put_site(self, site: Site) -> Site:
|
|
80
|
+
self._exec("INSERT OR REPLACE INTO sites(id, json) VALUES (?, ?)", (site.id, site.model_dump_json()))
|
|
81
|
+
return site
|
|
82
|
+
|
|
83
|
+
def get_site(self, site_id: str) -> Site | None:
|
|
84
|
+
rows = self._rows("SELECT json FROM sites WHERE id=?", (site_id,))
|
|
85
|
+
return Site.model_validate_json(rows[0]["json"]) if rows else None
|
|
86
|
+
|
|
87
|
+
def delete_site(self, site_id: str) -> None:
|
|
88
|
+
"""Remove a site and everything recorded for it."""
|
|
89
|
+
with self._lock:
|
|
90
|
+
for table in ("events", "persons", "expected_visits", "visits", "decisions", "kv"):
|
|
91
|
+
self._conn.execute(f"DELETE FROM {table} WHERE site_id=?", (site_id,))
|
|
92
|
+
self._conn.execute("DELETE FROM sites WHERE id=?", (site_id,))
|
|
93
|
+
self._conn.commit()
|
|
94
|
+
|
|
95
|
+
def list_sites(self) -> list[Site]:
|
|
96
|
+
return [Site.model_validate_json(r["json"]) for r in self._rows("SELECT json FROM sites ORDER BY id")]
|
|
97
|
+
|
|
98
|
+
# ----------------------------------------------------------------- events
|
|
99
|
+
def put_event(self, event: Event) -> bool:
|
|
100
|
+
"""Insert; returns False when the event (by id or dedupe key) already exists."""
|
|
101
|
+
with self._lock:
|
|
102
|
+
cur = self._conn.execute(
|
|
103
|
+
"INSERT OR IGNORE INTO events(id, site_id, kind, device_id, occurred_at, dedupe_key, json) VALUES (?,?,?,?,?,?,?)",
|
|
104
|
+
(
|
|
105
|
+
event.id,
|
|
106
|
+
event.site_id,
|
|
107
|
+
event.kind.value,
|
|
108
|
+
event.device_id,
|
|
109
|
+
_iso(event.occurred_at),
|
|
110
|
+
event.dedupe_key,
|
|
111
|
+
event.model_dump_json(),
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
self._conn.commit()
|
|
115
|
+
return cur.rowcount == 1
|
|
116
|
+
|
|
117
|
+
def get_event(self, event_id: str) -> Event | None:
|
|
118
|
+
rows = self._rows("SELECT json FROM events WHERE id=?", (event_id,))
|
|
119
|
+
return Event.model_validate_json(rows[0]["json"]) if rows else None
|
|
120
|
+
|
|
121
|
+
def events(
|
|
122
|
+
self,
|
|
123
|
+
site_id: str,
|
|
124
|
+
*,
|
|
125
|
+
since: datetime | None = None,
|
|
126
|
+
until: datetime | None = None,
|
|
127
|
+
kinds: Iterable[EventKind] | None = None,
|
|
128
|
+
limit: int | None = None,
|
|
129
|
+
newest_first: bool = False,
|
|
130
|
+
) -> list[Event]:
|
|
131
|
+
sql = "SELECT json FROM events WHERE site_id=?"
|
|
132
|
+
params: list[Any] = [site_id]
|
|
133
|
+
if since is not None:
|
|
134
|
+
sql += " AND occurred_at>=?"
|
|
135
|
+
params.append(_iso(since))
|
|
136
|
+
if until is not None:
|
|
137
|
+
sql += " AND occurred_at<=?"
|
|
138
|
+
params.append(_iso(until))
|
|
139
|
+
if kinds:
|
|
140
|
+
ks = [k.value for k in kinds]
|
|
141
|
+
sql += f" AND kind IN ({','.join('?' * len(ks))})"
|
|
142
|
+
params.extend(ks)
|
|
143
|
+
sql += " ORDER BY occurred_at " + ("DESC" if newest_first else "ASC")
|
|
144
|
+
if limit:
|
|
145
|
+
sql += " LIMIT ?"
|
|
146
|
+
params.append(limit)
|
|
147
|
+
return [Event.model_validate_json(r["json"]) for r in self._rows(sql, params)]
|
|
148
|
+
|
|
149
|
+
def last_event(self, site_id: str) -> Event | None:
|
|
150
|
+
evs = self.events(site_id, limit=1, newest_first=True)
|
|
151
|
+
return evs[0] if evs else None
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------- persons
|
|
154
|
+
def put_person(self, person: Person) -> Person:
|
|
155
|
+
self._exec(
|
|
156
|
+
"INSERT OR REPLACE INTO persons(id, site_id, json) VALUES (?,?,?)",
|
|
157
|
+
(person.id, person.site_id, person.model_dump_json()),
|
|
158
|
+
)
|
|
159
|
+
return person
|
|
160
|
+
|
|
161
|
+
def get_person(self, person_id: str) -> Person | None:
|
|
162
|
+
rows = self._rows("SELECT json FROM persons WHERE id=?", (person_id,))
|
|
163
|
+
return Person.model_validate_json(rows[0]["json"]) if rows else None
|
|
164
|
+
|
|
165
|
+
def delete_person(self, person_id: str) -> None:
|
|
166
|
+
self._exec("DELETE FROM persons WHERE id=?", (person_id,))
|
|
167
|
+
|
|
168
|
+
def persons(self, site_id: str) -> list[Person]:
|
|
169
|
+
return [
|
|
170
|
+
Person.model_validate_json(r["json"]) for r in self._rows("SELECT json FROM persons WHERE site_id=? ORDER BY id", (site_id,))
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------- expected visits
|
|
174
|
+
def put_expected(self, ev: ExpectedVisit) -> ExpectedVisit:
|
|
175
|
+
self._exec(
|
|
176
|
+
"INSERT OR REPLACE INTO expected_visits(id, site_id, json) VALUES (?,?,?)",
|
|
177
|
+
(ev.id, ev.site_id, ev.model_dump_json()),
|
|
178
|
+
)
|
|
179
|
+
return ev
|
|
180
|
+
|
|
181
|
+
def delete_expected(self, expected_id: str) -> None:
|
|
182
|
+
self._exec("DELETE FROM expected_visits WHERE id=?", (expected_id,))
|
|
183
|
+
|
|
184
|
+
def expected(self, site_id: str) -> list[ExpectedVisit]:
|
|
185
|
+
return [
|
|
186
|
+
ExpectedVisit.model_validate_json(r["json"])
|
|
187
|
+
for r in self._rows("SELECT json FROM expected_visits WHERE site_id=? ORDER BY id", (site_id,))
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
# ----------------------------------------------------------------- visits
|
|
191
|
+
def put_visit(self, visit: Visit) -> Visit:
|
|
192
|
+
self._exec(
|
|
193
|
+
"INSERT OR REPLACE INTO visits(id, site_id, started_at, status, json) VALUES (?,?,?,?,?)",
|
|
194
|
+
(visit.id, visit.site_id, _iso(visit.started_at), visit.status.value, visit.model_dump_json()),
|
|
195
|
+
)
|
|
196
|
+
return visit
|
|
197
|
+
|
|
198
|
+
def get_visit(self, visit_id: str) -> Visit | None:
|
|
199
|
+
rows = self._rows("SELECT json FROM visits WHERE id=?", (visit_id,))
|
|
200
|
+
return Visit.model_validate_json(rows[0]["json"]) if rows else None
|
|
201
|
+
|
|
202
|
+
def open_visit(self, site_id: str) -> Visit | None:
|
|
203
|
+
rows = self._rows(
|
|
204
|
+
"SELECT json FROM visits WHERE site_id=? AND status=? ORDER BY started_at DESC LIMIT 1",
|
|
205
|
+
(site_id, VisitStatus.OPEN.value),
|
|
206
|
+
)
|
|
207
|
+
return Visit.model_validate_json(rows[0]["json"]) if rows else None
|
|
208
|
+
|
|
209
|
+
def visits(self, site_id: str, *, since: datetime | None = None, limit: int = 100) -> list[Visit]:
|
|
210
|
+
sql = "SELECT json FROM visits WHERE site_id=?"
|
|
211
|
+
params: list[Any] = [site_id]
|
|
212
|
+
if since is not None:
|
|
213
|
+
sql += " AND started_at>=?"
|
|
214
|
+
params.append(_iso(since))
|
|
215
|
+
sql += " ORDER BY started_at DESC LIMIT ?"
|
|
216
|
+
params.append(limit)
|
|
217
|
+
return [Visit.model_validate_json(r["json"]) for r in self._rows(sql, params)]
|
|
218
|
+
|
|
219
|
+
# -------------------------------------------------------------- decisions
|
|
220
|
+
def put_decision(self, d: Decision) -> Decision:
|
|
221
|
+
self._exec(
|
|
222
|
+
"INSERT OR REPLACE INTO decisions(id, site_id, event_id, created_at, action, severity, acknowledged_at, json)"
|
|
223
|
+
" VALUES (?,?,?,?,?,?,?,?)",
|
|
224
|
+
(
|
|
225
|
+
d.id,
|
|
226
|
+
d.site_id,
|
|
227
|
+
d.event_id,
|
|
228
|
+
_iso(d.created_at),
|
|
229
|
+
d.action.value,
|
|
230
|
+
d.severity.value,
|
|
231
|
+
_iso(d.acknowledged_at) if d.acknowledged_at else None,
|
|
232
|
+
d.model_dump_json(),
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
return d
|
|
236
|
+
|
|
237
|
+
def get_decision(self, decision_id: str) -> Decision | None:
|
|
238
|
+
rows = self._rows("SELECT json FROM decisions WHERE id=?", (decision_id,))
|
|
239
|
+
return Decision.model_validate_json(rows[0]["json"]) if rows else None
|
|
240
|
+
|
|
241
|
+
def decisions(
|
|
242
|
+
self,
|
|
243
|
+
site_id: str,
|
|
244
|
+
*,
|
|
245
|
+
since: datetime | None = None,
|
|
246
|
+
until: datetime | None = None,
|
|
247
|
+
unacknowledged_only: bool = False,
|
|
248
|
+
limit: int = 100,
|
|
249
|
+
) -> list[Decision]:
|
|
250
|
+
sql = "SELECT json FROM decisions WHERE site_id=?"
|
|
251
|
+
params: list[Any] = [site_id]
|
|
252
|
+
if since is not None:
|
|
253
|
+
sql += " AND created_at>=?"
|
|
254
|
+
params.append(_iso(since))
|
|
255
|
+
if until is not None:
|
|
256
|
+
sql += " AND created_at<=?"
|
|
257
|
+
params.append(_iso(until))
|
|
258
|
+
if unacknowledged_only:
|
|
259
|
+
sql += " AND acknowledged_at IS NULL AND action IN ('notify','escalate')"
|
|
260
|
+
sql += " ORDER BY created_at DESC LIMIT ?"
|
|
261
|
+
params.append(limit)
|
|
262
|
+
return [Decision.model_validate_json(r["json"]) for r in self._rows(sql, params)]
|
|
263
|
+
|
|
264
|
+
def acknowledge(self, decision_id: str, by: str, at: datetime | None = None) -> Decision | None:
|
|
265
|
+
d = self.get_decision(decision_id)
|
|
266
|
+
if d is None:
|
|
267
|
+
return None
|
|
268
|
+
d.acknowledged_at = at or datetime.now(tz=UTC)
|
|
269
|
+
d.acknowledged_by = by
|
|
270
|
+
return self.put_decision(d)
|
|
271
|
+
|
|
272
|
+
def decision_for_rule(
|
|
273
|
+
self, site_id: str, rule: str, *, since: datetime, until: datetime | None = None, key: str | None = None
|
|
274
|
+
) -> Decision | None:
|
|
275
|
+
"""Most recent decision with ``rule`` (and optional metadata ``key``) in [since, until]. Used to avoid repeats.
|
|
276
|
+
|
|
277
|
+
Pass ``until`` (normally the event time) so replayed or backdated events never see
|
|
278
|
+
decisions from their own future.
|
|
279
|
+
"""
|
|
280
|
+
for d in self.decisions(site_id, since=since, until=until, limit=200):
|
|
281
|
+
if d.rule == rule and (key is None or d.metadata.get("key") == key):
|
|
282
|
+
return d
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
# --------------------------------------------------------------------- kv
|
|
286
|
+
def set_state(self, site_id: str, key: str, value: Any) -> None:
|
|
287
|
+
self._exec("INSERT OR REPLACE INTO kv(site_id, key, json) VALUES (?,?,?)", (site_id, key, json.dumps(value)))
|
|
288
|
+
|
|
289
|
+
def get_state(self, site_id: str, key: str, default: Any = None) -> Any:
|
|
290
|
+
rows = self._rows("SELECT json FROM kv WHERE site_id=? AND key=?", (site_id, key))
|
|
291
|
+
return json.loads(rows[0]["json"]) if rows else default
|
|
292
|
+
|
|
293
|
+
def states_with_prefix(self, site_id: str, prefix: str) -> dict[str, Any]:
|
|
294
|
+
rows = self._rows("SELECT key, json FROM kv WHERE site_id=? AND key LIKE ?", (site_id, prefix + "%"))
|
|
295
|
+
return {r["key"]: json.loads(r["json"]) for r in rows}
|