stoop 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- stoop-0.1.0/PKG-INFO +256 -0
- stoop-0.1.0/README.md +226 -0
- stoop-0.1.0/pyproject.toml +75 -0
- stoop-0.1.0/pyproject.toml.orig +62 -0
- stoop-0.1.0/src/stoop/__init__.py +55 -0
- stoop-0.1.0/src/stoop/events.py +93 -0
- stoop-0.1.0/src/stoop/memory/__init__.py +31 -0
- stoop-0.1.0/src/stoop/memory/models.py +193 -0
- stoop-0.1.0/src/stoop/memory/store.py +295 -0
- stoop-0.1.0/src/stoop/pipeline.py +86 -0
- stoop-0.1.0/src/stoop/policy/__init__.py +17 -0
- stoop-0.1.0/src/stoop/policy/engine.py +351 -0
- stoop-0.1.0/src/stoop/policy/home_rules.py +506 -0
- stoop-0.1.0/src/stoop/policy/routines.py +91 -0
- stoop-0.1.0/src/stoop/policy/rules.py +417 -0
- stoop-0.1.0/src/stoop/py.typed +0 -0
- stoop-0.1.0/src/stoop/reasoning/__init__.py +4 -0
- stoop-0.1.0/src/stoop/reasoning/base.py +53 -0
- stoop-0.1.0/src/stoop/reasoning/bedrock.py +196 -0
- stoop-0.1.0/src/stoop/reasoning/deterministic.py +45 -0
- stoop-0.1.0/src/stoop/sources/__init__.py +14 -0
- stoop-0.1.0/src/stoop/sources/ring.py +349 -0
- stoop-0.1.0/src/stoop/sources/ring_oauth.py +470 -0
- stoop-0.1.0/src/stoop/sources/synthetic.py +174 -0
stoop-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Uladzislau Bayouski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
stoop-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stoop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn front-door events (Ring and others) into decisions a person can act on: memory, routines, policy, and human-gated actions.
|
|
5
|
+
Keywords: ring,doorbell,caregiving,agent,mcp,bedrock
|
|
6
|
+
Author: Uladzislau Bayouski
|
|
7
|
+
Author-email: Uladzislau Bayouski <uladzislaubayouski@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: pydantic>=2
|
|
21
|
+
Requires-Dist: tzdata>=2025.1 ; sys_platform == 'win32'
|
|
22
|
+
Requires-Dist: boto3>=1.40 ; extra == 'aws'
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Project-URL: Homepage, https://github.com/iot-forge/Stoop
|
|
25
|
+
Project-URL: Repository, https://github.com/iot-forge/Stoop
|
|
26
|
+
Project-URL: Issues, https://github.com/iot-forge/Stoop/issues
|
|
27
|
+
Project-URL: Changelog, https://github.com/iot-forge/Stoop/blob/main/CHANGELOG.md
|
|
28
|
+
Provides-Extra: aws
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Stoop
|
|
32
|
+
|
|
33
|
+
[](https://github.com/iot-forge/Stoop/actions/workflows/ci.yml)
|
|
34
|
+
[](https://pypi.org/project/stoop/)
|
|
35
|
+
[](https://pypi.org/project/stoop/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
|
|
38
|
+
Turn front-door events into decisions a person can act on.
|
|
39
|
+
|
|
40
|
+
`stoop` is a small Python library that sits between a doorbell or camera feed (Ring today,
|
|
41
|
+
anything tomorrow) and the humans who care about what happens at that door. It remembers who
|
|
42
|
+
is expected, learns what is normal for that door, decides whether an event should be
|
|
43
|
+
ignored, logged, sent as a notification, or escalated, and keeps every sensitive action behind
|
|
44
|
+
a human confirmation.
|
|
45
|
+
|
|
46
|
+
It was built during the [Build, Ship, Shape: Amazon Developer Hackathon](https://amazonappdev2026.devpost.com/)
|
|
47
|
+
as the shared core of two products: a caregiving app on the Ring track and a conversational
|
|
48
|
+
property concierge on the Alexa+ track. The library is product-agnostic and MIT licensed.
|
|
49
|
+
|
|
50
|
+
## What it does
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Ring webhook / history ──┐
|
|
54
|
+
Synthetic scenarios ─────┼─▶ Event ─▶ Store (SQLite) ─▶ PolicyEngine ─▶ Decision ─▶ Sinks
|
|
55
|
+
Your own source ─────────┘ ▲ ▲ │
|
|
56
|
+
│ └── RoutineModel ─┘ (what is normal here?)
|
|
57
|
+
└───── ExpectedVisit / Person (who is expected?)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **Events** (`stoop.events`): one normalized shape for motion, doorbell presses, door
|
|
61
|
+
sensors, device health and environmental sensors. Ring's `X-Signature` HMAC is verified
|
|
62
|
+
over the raw body.
|
|
63
|
+
- **Memory** (`stoop.memory`): sites, people and their roles, expected visits (one-off or
|
|
64
|
+
recurring), visits (events clustered into "someone came to the door"), decisions, and a
|
|
65
|
+
small key/value state. SQLite, standard library only.
|
|
66
|
+
- **Routines** (`stoop.policy.routines`): weekly rates per weekday and hour, an anomaly
|
|
67
|
+
score for "how surprising is this right now", and "does this door usually see someone
|
|
68
|
+
every day".
|
|
69
|
+
- **Policy** (`stoop.policy.engine`): deterministic rules that always produce a complete
|
|
70
|
+
decision, plus a sweep for things that did not happen: no-shows, inactivity, doors left
|
|
71
|
+
open. Notifications about the same visit are suppressed so one visitor is one alert.
|
|
72
|
+
- **Reasoning** (`stoop.reasoning`): an optional refinement step. The deterministic reasoner
|
|
73
|
+
adds context with no model call. The Bedrock reasoner uses the Converse API (Amazon Nova 2
|
|
74
|
+
Lite by default, multimodal when a snapshot is available). A reasoner can reword and move
|
|
75
|
+
severity by one step, and can never remove a confirmation requirement.
|
|
76
|
+
- **Pipeline** (`stoop.pipeline`): source → engine → sinks, with backfill for routine learning.
|
|
77
|
+
|
|
78
|
+
Reasoning runs on the ingestion path only. Anything that answers a question (a dashboard, an
|
|
79
|
+
MCP tool for Alexa+) reads precomputed state and stays fast.
|
|
80
|
+
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install stoop # core
|
|
85
|
+
pip install "stoop[aws]" # + boto3 for the Bedrock reasoner
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Python 3.13 or newer.
|
|
89
|
+
|
|
90
|
+
## Quick start
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from datetime import UTC, datetime, time
|
|
94
|
+
|
|
95
|
+
from stoop import ExpectedVisit, Person, Pipeline, PolicyEngine, Role, Site, Store, LogSink
|
|
96
|
+
from stoop.sources.synthetic import BUILTIN, generate_baseline, play_scenario
|
|
97
|
+
|
|
98
|
+
store = Store("stoop.db")
|
|
99
|
+
site = store.put_site(Site(id="moms-house", name="Mom's house", timezone="America/New_York"))
|
|
100
|
+
maria = store.put_person(Person(site_id=site.id, name="Maria", role=Role.AIDE))
|
|
101
|
+
store.put_expected(ExpectedVisit(site_id=site.id, label="Morning aide", person_id=maria.id,
|
|
102
|
+
days_of_week=[0, 2, 4], local_start=time(9, 0), local_end=time(10, 30)))
|
|
103
|
+
|
|
104
|
+
pipeline = Pipeline(PolicyEngine(store), sinks=[LogSink()])
|
|
105
|
+
|
|
106
|
+
# Teach it what "normal" looks like (synthetic here; real history from RingHistory in production).
|
|
107
|
+
now = datetime.now(tz=UTC)
|
|
108
|
+
pipeline.backfill(generate_baseline(site_id=site.id, days=28, end=now))
|
|
109
|
+
|
|
110
|
+
# Live events.
|
|
111
|
+
for decision in pipeline.ingest_many(play_scenario(BUILTIN["lingering_stranger"], site_id=site.id, start=now)):
|
|
112
|
+
print(decision.action, decision.severity, decision.message)
|
|
113
|
+
|
|
114
|
+
# Periodic checks for what did not happen.
|
|
115
|
+
pipeline.sweep()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Ring webhooks
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from stoop.sources.ring import parse_ring_webhook, RingSignatureError
|
|
122
|
+
|
|
123
|
+
@app.post("/webhooks/ring")
|
|
124
|
+
async def ring_webhook(request):
|
|
125
|
+
body = await request.body()
|
|
126
|
+
try:
|
|
127
|
+
event = parse_ring_webhook(body, site_id=site_id_for(request), signing_key=RING_HMAC_KEY,
|
|
128
|
+
signature=request.headers.get("X-Signature"))
|
|
129
|
+
except RingSignatureError:
|
|
130
|
+
return Response(status_code=401)
|
|
131
|
+
pipeline.ingest(event)
|
|
132
|
+
return Response(status_code=200)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Ring account linking
|
|
136
|
+
|
|
137
|
+
Ring links accounts two ways. The default, **one-way** flow starts in the Ring Appstore: Ring
|
|
138
|
+
posts an authorization code to your Token Exchange URL, then sends the user's browser to your
|
|
139
|
+
Account Link URL with a `nonce` and `time`. `RingLinker` handles both halves and the
|
|
140
|
+
**partner-initiated** PKCE flow, and keeps tokens fresh.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from stoop import Store
|
|
144
|
+
from stoop.sources.ring_oauth import RingLinker, RingOAuth, RingOAuthConfig, SqliteTokenStore
|
|
145
|
+
|
|
146
|
+
oauth = RingOAuth(RingOAuthConfig(client_id=..., client_secret=..., hmac_key=...))
|
|
147
|
+
linker = RingLinker(oauth, SqliteTokenStore(store, cipher=Fernet(key))) # any encrypt/decrypt pair
|
|
148
|
+
|
|
149
|
+
# Token Exchange URL (server to server): park the tokens, look up the Ring account id.
|
|
150
|
+
link_id = linker.receive_code(code)
|
|
151
|
+
|
|
152
|
+
# Account Link URL (browser, after your own sign-in): prove which parked token is this user's.
|
|
153
|
+
link_id = linker.claim_by_nonce(nonce=nonce, time_value=time, owner=user.id)
|
|
154
|
+
|
|
155
|
+
# Later: a valid access token, refreshed and persisted when needed.
|
|
156
|
+
with RingHistory(linker.access_token(link_id)) as ring: ...
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Ring history backfill
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from stoop.sources.ring import RingHistory
|
|
163
|
+
|
|
164
|
+
with RingHistory(token) as ring: # Playground token or OAuth access token
|
|
165
|
+
for device in ring.devices():
|
|
166
|
+
pipeline.backfill(ring.events(site.id, device["id"], device_name=device["name"]))
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Documented Ring history carries no person/vehicle/package classification. Only webhooks do,
|
|
170
|
+
so backfilled motion is stored as `detected=unknown`.
|
|
171
|
+
|
|
172
|
+
### Bedrock reasoning
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from stoop import PolicyConfig, PolicyEngine
|
|
176
|
+
from stoop.reasoning.bedrock import BedrockReasoner
|
|
177
|
+
|
|
178
|
+
engine = PolicyEngine(store, config=PolicyConfig(refine_min_severity="medium"),
|
|
179
|
+
reasoner=BedrockReasoner(region_name="us-east-1"),
|
|
180
|
+
snapshot_fetcher=fetch_ring_snapshot) # optional: bytes for multimodal context
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Custom rules
|
|
184
|
+
|
|
185
|
+
Rules live in an ordered registry. The first rule that returns a decision wins, so specific
|
|
186
|
+
rules go before general ones. `home_rules()` returns a fresh copy of the default set; add,
|
|
187
|
+
move, replace or disable without forking:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from stoop import Action, Decision, EventKind, PolicyEngine, Severity, SiteKind
|
|
191
|
+
from stoop.policy import RuleContext, home_rules, home_sweeps
|
|
192
|
+
|
|
193
|
+
rules = home_rules()
|
|
194
|
+
|
|
195
|
+
@rules.add(before="unknown_visitor", kinds={EventKind.BUTTON_PRESS})
|
|
196
|
+
def lunch_courier(ctx: RuleContext) -> Decision | None:
|
|
197
|
+
"""Weekday lunch doorbells at an office are couriers, not strangers."""
|
|
198
|
+
if 11 <= ctx.local.hour < 14 and ctx.site.kind is SiteKind.OFFICE:
|
|
199
|
+
return ctx.decide(Action.LOG, Severity.INFO, "lunch_courier", "Lunch delivery window.", f"Lunch delivery at {ctx.where}.")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
rules.disable("package_at_risk")
|
|
203
|
+
|
|
204
|
+
engine = PolicyEngine(store, rules=rules, sweeps=home_sweeps())
|
|
205
|
+
print(rules.names()) # evaluation order
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`RuleContext` gives a rule the event, site, current visit, matched expected visit, anomaly
|
|
209
|
+
score, quiet-hours flag, known people, and builders: `ctx.decide(...)`, `ctx.actions(...)`,
|
|
210
|
+
`ctx.recent_decision(...)`. Sweep checks work the same way through `SweepRegistry` and
|
|
211
|
+
`SweepContext`, and every default rule is a plain function in `stoop.policy.home_rules` you
|
|
212
|
+
can import and reuse.
|
|
213
|
+
|
|
214
|
+
## Decisions
|
|
215
|
+
|
|
216
|
+
Every decision carries `action` (ignore, log, notify, escalate), `severity` (info, low,
|
|
217
|
+
medium, high), the `rule` that fired, a human-readable `message` and `reason`, an
|
|
218
|
+
`anomaly_score`, `suggested_actions` (some marked `sensitive`), and
|
|
219
|
+
`requires_confirmation`, which is true whenever any suggested action is sensitive.
|
|
220
|
+
|
|
221
|
+
Rules today: `expected_arrival`, `expected_entry`, `unknown_visitor`, `night_doorbell`,
|
|
222
|
+
`night_presence`, `night_door_open`, `lingering`, `package_delivered`, `package_at_risk`,
|
|
223
|
+
`unusual_time`, `sensor_alert`, `device_offline`, `no_show`, `inactivity`, `door_left_open`,
|
|
224
|
+
plus quiet `log`/`ignore` outcomes for routine motion.
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
uv sync --all-extras
|
|
230
|
+
uv run pytest
|
|
231
|
+
uv run ruff check src tests
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Integration tests use the community [`ring-sandbox`](https://github.com/josepha-mayo/ring-sandbox)
|
|
235
|
+
emulator in-process, so no Ring account is needed to run them.
|
|
236
|
+
|
|
237
|
+
## Status
|
|
238
|
+
|
|
239
|
+
Version 0.1. The core pipeline, store, rules and reasoners run in production in two apps, but
|
|
240
|
+
the API may still change between minor versions. Every change is listed in
|
|
241
|
+
[`CHANGELOG.md`](CHANGELOG.md). Python 3.12 and 3.13 on Linux, macOS and Windows.
|
|
242
|
+
|
|
243
|
+
## Contributing
|
|
244
|
+
|
|
245
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md). Security reports: [`SECURITY.md`](SECURITY.md).
|
|
246
|
+
|
|
247
|
+
## Docs
|
|
248
|
+
|
|
249
|
+
- [`docs/roadmap.md`](docs/roadmap.md): what is reusable today, what is not yet, and what is
|
|
250
|
+
planned (OAuth token client, rule registry, FastAPI router, PyPI release).
|
|
251
|
+
- [`docs/friction-log.md`](docs/friction-log.md): platform obstacles met while building on
|
|
252
|
+
Ring, Alexa+ and AWS, and the workarounds.
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
MIT. See `LICENSE`.
|
stoop-0.1.0/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Stoop
|
|
2
|
+
|
|
3
|
+
[](https://github.com/iot-forge/Stoop/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/stoop/)
|
|
5
|
+
[](https://pypi.org/project/stoop/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Turn front-door events into decisions a person can act on.
|
|
9
|
+
|
|
10
|
+
`stoop` is a small Python library that sits between a doorbell or camera feed (Ring today,
|
|
11
|
+
anything tomorrow) and the humans who care about what happens at that door. It remembers who
|
|
12
|
+
is expected, learns what is normal for that door, decides whether an event should be
|
|
13
|
+
ignored, logged, sent as a notification, or escalated, and keeps every sensitive action behind
|
|
14
|
+
a human confirmation.
|
|
15
|
+
|
|
16
|
+
It was built during the [Build, Ship, Shape: Amazon Developer Hackathon](https://amazonappdev2026.devpost.com/)
|
|
17
|
+
as the shared core of two products: a caregiving app on the Ring track and a conversational
|
|
18
|
+
property concierge on the Alexa+ track. The library is product-agnostic and MIT licensed.
|
|
19
|
+
|
|
20
|
+
## What it does
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Ring webhook / history ──┐
|
|
24
|
+
Synthetic scenarios ─────┼─▶ Event ─▶ Store (SQLite) ─▶ PolicyEngine ─▶ Decision ─▶ Sinks
|
|
25
|
+
Your own source ─────────┘ ▲ ▲ │
|
|
26
|
+
│ └── RoutineModel ─┘ (what is normal here?)
|
|
27
|
+
└───── ExpectedVisit / Person (who is expected?)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **Events** (`stoop.events`): one normalized shape for motion, doorbell presses, door
|
|
31
|
+
sensors, device health and environmental sensors. Ring's `X-Signature` HMAC is verified
|
|
32
|
+
over the raw body.
|
|
33
|
+
- **Memory** (`stoop.memory`): sites, people and their roles, expected visits (one-off or
|
|
34
|
+
recurring), visits (events clustered into "someone came to the door"), decisions, and a
|
|
35
|
+
small key/value state. SQLite, standard library only.
|
|
36
|
+
- **Routines** (`stoop.policy.routines`): weekly rates per weekday and hour, an anomaly
|
|
37
|
+
score for "how surprising is this right now", and "does this door usually see someone
|
|
38
|
+
every day".
|
|
39
|
+
- **Policy** (`stoop.policy.engine`): deterministic rules that always produce a complete
|
|
40
|
+
decision, plus a sweep for things that did not happen: no-shows, inactivity, doors left
|
|
41
|
+
open. Notifications about the same visit are suppressed so one visitor is one alert.
|
|
42
|
+
- **Reasoning** (`stoop.reasoning`): an optional refinement step. The deterministic reasoner
|
|
43
|
+
adds context with no model call. The Bedrock reasoner uses the Converse API (Amazon Nova 2
|
|
44
|
+
Lite by default, multimodal when a snapshot is available). A reasoner can reword and move
|
|
45
|
+
severity by one step, and can never remove a confirmation requirement.
|
|
46
|
+
- **Pipeline** (`stoop.pipeline`): source → engine → sinks, with backfill for routine learning.
|
|
47
|
+
|
|
48
|
+
Reasoning runs on the ingestion path only. Anything that answers a question (a dashboard, an
|
|
49
|
+
MCP tool for Alexa+) reads precomputed state and stays fast.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install stoop # core
|
|
55
|
+
pip install "stoop[aws]" # + boto3 for the Bedrock reasoner
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Python 3.13 or newer.
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from datetime import UTC, datetime, time
|
|
64
|
+
|
|
65
|
+
from stoop import ExpectedVisit, Person, Pipeline, PolicyEngine, Role, Site, Store, LogSink
|
|
66
|
+
from stoop.sources.synthetic import BUILTIN, generate_baseline, play_scenario
|
|
67
|
+
|
|
68
|
+
store = Store("stoop.db")
|
|
69
|
+
site = store.put_site(Site(id="moms-house", name="Mom's house", timezone="America/New_York"))
|
|
70
|
+
maria = store.put_person(Person(site_id=site.id, name="Maria", role=Role.AIDE))
|
|
71
|
+
store.put_expected(ExpectedVisit(site_id=site.id, label="Morning aide", person_id=maria.id,
|
|
72
|
+
days_of_week=[0, 2, 4], local_start=time(9, 0), local_end=time(10, 30)))
|
|
73
|
+
|
|
74
|
+
pipeline = Pipeline(PolicyEngine(store), sinks=[LogSink()])
|
|
75
|
+
|
|
76
|
+
# Teach it what "normal" looks like (synthetic here; real history from RingHistory in production).
|
|
77
|
+
now = datetime.now(tz=UTC)
|
|
78
|
+
pipeline.backfill(generate_baseline(site_id=site.id, days=28, end=now))
|
|
79
|
+
|
|
80
|
+
# Live events.
|
|
81
|
+
for decision in pipeline.ingest_many(play_scenario(BUILTIN["lingering_stranger"], site_id=site.id, start=now)):
|
|
82
|
+
print(decision.action, decision.severity, decision.message)
|
|
83
|
+
|
|
84
|
+
# Periodic checks for what did not happen.
|
|
85
|
+
pipeline.sweep()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Ring webhooks
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from stoop.sources.ring import parse_ring_webhook, RingSignatureError
|
|
92
|
+
|
|
93
|
+
@app.post("/webhooks/ring")
|
|
94
|
+
async def ring_webhook(request):
|
|
95
|
+
body = await request.body()
|
|
96
|
+
try:
|
|
97
|
+
event = parse_ring_webhook(body, site_id=site_id_for(request), signing_key=RING_HMAC_KEY,
|
|
98
|
+
signature=request.headers.get("X-Signature"))
|
|
99
|
+
except RingSignatureError:
|
|
100
|
+
return Response(status_code=401)
|
|
101
|
+
pipeline.ingest(event)
|
|
102
|
+
return Response(status_code=200)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Ring account linking
|
|
106
|
+
|
|
107
|
+
Ring links accounts two ways. The default, **one-way** flow starts in the Ring Appstore: Ring
|
|
108
|
+
posts an authorization code to your Token Exchange URL, then sends the user's browser to your
|
|
109
|
+
Account Link URL with a `nonce` and `time`. `RingLinker` handles both halves and the
|
|
110
|
+
**partner-initiated** PKCE flow, and keeps tokens fresh.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from stoop import Store
|
|
114
|
+
from stoop.sources.ring_oauth import RingLinker, RingOAuth, RingOAuthConfig, SqliteTokenStore
|
|
115
|
+
|
|
116
|
+
oauth = RingOAuth(RingOAuthConfig(client_id=..., client_secret=..., hmac_key=...))
|
|
117
|
+
linker = RingLinker(oauth, SqliteTokenStore(store, cipher=Fernet(key))) # any encrypt/decrypt pair
|
|
118
|
+
|
|
119
|
+
# Token Exchange URL (server to server): park the tokens, look up the Ring account id.
|
|
120
|
+
link_id = linker.receive_code(code)
|
|
121
|
+
|
|
122
|
+
# Account Link URL (browser, after your own sign-in): prove which parked token is this user's.
|
|
123
|
+
link_id = linker.claim_by_nonce(nonce=nonce, time_value=time, owner=user.id)
|
|
124
|
+
|
|
125
|
+
# Later: a valid access token, refreshed and persisted when needed.
|
|
126
|
+
with RingHistory(linker.access_token(link_id)) as ring: ...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Ring history backfill
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from stoop.sources.ring import RingHistory
|
|
133
|
+
|
|
134
|
+
with RingHistory(token) as ring: # Playground token or OAuth access token
|
|
135
|
+
for device in ring.devices():
|
|
136
|
+
pipeline.backfill(ring.events(site.id, device["id"], device_name=device["name"]))
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Documented Ring history carries no person/vehicle/package classification. Only webhooks do,
|
|
140
|
+
so backfilled motion is stored as `detected=unknown`.
|
|
141
|
+
|
|
142
|
+
### Bedrock reasoning
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from stoop import PolicyConfig, PolicyEngine
|
|
146
|
+
from stoop.reasoning.bedrock import BedrockReasoner
|
|
147
|
+
|
|
148
|
+
engine = PolicyEngine(store, config=PolicyConfig(refine_min_severity="medium"),
|
|
149
|
+
reasoner=BedrockReasoner(region_name="us-east-1"),
|
|
150
|
+
snapshot_fetcher=fetch_ring_snapshot) # optional: bytes for multimodal context
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Custom rules
|
|
154
|
+
|
|
155
|
+
Rules live in an ordered registry. The first rule that returns a decision wins, so specific
|
|
156
|
+
rules go before general ones. `home_rules()` returns a fresh copy of the default set; add,
|
|
157
|
+
move, replace or disable without forking:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from stoop import Action, Decision, EventKind, PolicyEngine, Severity, SiteKind
|
|
161
|
+
from stoop.policy import RuleContext, home_rules, home_sweeps
|
|
162
|
+
|
|
163
|
+
rules = home_rules()
|
|
164
|
+
|
|
165
|
+
@rules.add(before="unknown_visitor", kinds={EventKind.BUTTON_PRESS})
|
|
166
|
+
def lunch_courier(ctx: RuleContext) -> Decision | None:
|
|
167
|
+
"""Weekday lunch doorbells at an office are couriers, not strangers."""
|
|
168
|
+
if 11 <= ctx.local.hour < 14 and ctx.site.kind is SiteKind.OFFICE:
|
|
169
|
+
return ctx.decide(Action.LOG, Severity.INFO, "lunch_courier", "Lunch delivery window.", f"Lunch delivery at {ctx.where}.")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
rules.disable("package_at_risk")
|
|
173
|
+
|
|
174
|
+
engine = PolicyEngine(store, rules=rules, sweeps=home_sweeps())
|
|
175
|
+
print(rules.names()) # evaluation order
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`RuleContext` gives a rule the event, site, current visit, matched expected visit, anomaly
|
|
179
|
+
score, quiet-hours flag, known people, and builders: `ctx.decide(...)`, `ctx.actions(...)`,
|
|
180
|
+
`ctx.recent_decision(...)`. Sweep checks work the same way through `SweepRegistry` and
|
|
181
|
+
`SweepContext`, and every default rule is a plain function in `stoop.policy.home_rules` you
|
|
182
|
+
can import and reuse.
|
|
183
|
+
|
|
184
|
+
## Decisions
|
|
185
|
+
|
|
186
|
+
Every decision carries `action` (ignore, log, notify, escalate), `severity` (info, low,
|
|
187
|
+
medium, high), the `rule` that fired, a human-readable `message` and `reason`, an
|
|
188
|
+
`anomaly_score`, `suggested_actions` (some marked `sensitive`), and
|
|
189
|
+
`requires_confirmation`, which is true whenever any suggested action is sensitive.
|
|
190
|
+
|
|
191
|
+
Rules today: `expected_arrival`, `expected_entry`, `unknown_visitor`, `night_doorbell`,
|
|
192
|
+
`night_presence`, `night_door_open`, `lingering`, `package_delivered`, `package_at_risk`,
|
|
193
|
+
`unusual_time`, `sensor_alert`, `device_offline`, `no_show`, `inactivity`, `door_left_open`,
|
|
194
|
+
plus quiet `log`/`ignore` outcomes for routine motion.
|
|
195
|
+
|
|
196
|
+
## Development
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
uv sync --all-extras
|
|
200
|
+
uv run pytest
|
|
201
|
+
uv run ruff check src tests
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Integration tests use the community [`ring-sandbox`](https://github.com/josepha-mayo/ring-sandbox)
|
|
205
|
+
emulator in-process, so no Ring account is needed to run them.
|
|
206
|
+
|
|
207
|
+
## Status
|
|
208
|
+
|
|
209
|
+
Version 0.1. The core pipeline, store, rules and reasoners run in production in two apps, but
|
|
210
|
+
the API may still change between minor versions. Every change is listed in
|
|
211
|
+
[`CHANGELOG.md`](CHANGELOG.md). Python 3.12 and 3.13 on Linux, macOS and Windows.
|
|
212
|
+
|
|
213
|
+
## Contributing
|
|
214
|
+
|
|
215
|
+
See [`CONTRIBUTING.md`](CONTRIBUTING.md). Security reports: [`SECURITY.md`](SECURITY.md).
|
|
216
|
+
|
|
217
|
+
## Docs
|
|
218
|
+
|
|
219
|
+
- [`docs/roadmap.md`](docs/roadmap.md): what is reusable today, what is not yet, and what is
|
|
220
|
+
planned (OAuth token client, rule registry, FastAPI router, PyPI release).
|
|
221
|
+
- [`docs/friction-log.md`](docs/friction-log.md): platform obstacles met while building on
|
|
222
|
+
Ring, Alexa+ and AWS, and the workarounds.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "stoop"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Turn front-door events (Ring and others) into decisions a person can act on: memory, routines, policy, and human-gated actions."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
keywords = [
|
|
10
|
+
"ring",
|
|
11
|
+
"doorbell",
|
|
12
|
+
"caregiving",
|
|
13
|
+
"agent",
|
|
14
|
+
"mcp",
|
|
15
|
+
"bedrock",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Home Automation",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"httpx>=0.27",
|
|
30
|
+
"pydantic>=2",
|
|
31
|
+
"tzdata>=2025.1; sys_platform == 'win32'",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[[project.authors]]
|
|
35
|
+
name = "Uladzislau Bayouski"
|
|
36
|
+
email = "uladzislaubayouski@gmail.com"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
aws = ["boto3>=1.40"]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/iot-forge/Stoop"
|
|
43
|
+
Repository = "https://github.com/iot-forge/Stoop"
|
|
44
|
+
Issues = "https://github.com/iot-forge/Stoop/issues"
|
|
45
|
+
Changelog = "https://github.com/iot-forge/Stoop/blob/main/CHANGELOG.md"
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[dependency-groups]
|
|
52
|
+
dev = [
|
|
53
|
+
"pytest>=8",
|
|
54
|
+
"pytest-asyncio>=1.4.0",
|
|
55
|
+
"ring-sandbox[server]>=0.2.0",
|
|
56
|
+
"ruff>=0.16.9",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff]
|
|
63
|
+
line-length = 140
|
|
64
|
+
target-version = "py313"
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint]
|
|
67
|
+
select = [
|
|
68
|
+
"E",
|
|
69
|
+
"F",
|
|
70
|
+
"I",
|
|
71
|
+
"B",
|
|
72
|
+
"UP",
|
|
73
|
+
"SIM",
|
|
74
|
+
]
|
|
75
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "stoop"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Turn front-door events (Ring and others) into decisions a person can act on: memory, routines, policy, and human-gated actions."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Uladzislau Bayouski", email = "uladzislaubayouski@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
keywords = ["ring", "doorbell", "caregiving", "agent", "mcp", "bedrock"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Home Automation",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
"pydantic>=2",
|
|
27
|
+
"tzdata>=2025.1; sys_platform == 'win32'",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
aws = [
|
|
32
|
+
"boto3>=1.40",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/iot-forge/Stoop"
|
|
37
|
+
Repository = "https://github.com/iot-forge/Stoop"
|
|
38
|
+
Issues = "https://github.com/iot-forge/Stoop/issues"
|
|
39
|
+
Changelog = "https://github.com/iot-forge/Stoop/blob/main/CHANGELOG.md"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["uv_build>=0.12.19,<0.13.0"]
|
|
43
|
+
build-backend = "uv_build"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8",
|
|
48
|
+
"pytest-asyncio>=1.4.0",
|
|
49
|
+
"ring-sandbox[server]>=0.2.0",
|
|
50
|
+
"ruff>=0.16.9",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 140
|
|
58
|
+
target-version = "py313"
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "I", "B", "UP", "SIM"]
|
|
62
|
+
ignore = ["E501"]
|