- C# 74.4%
- HTML 22.2%
- CSS 2.6%
- JavaScript 0.5%
- Dockerfile 0.3%
Airbnb (and every marketplace we've checked) strips the guest's name from the calendar feed entirely - since December 2019 an Airbnb export's guest-facing events carry nothing more identifying than a reservation URL and a phone last-4, so there's no reasonable way to put a guest's name on their access code. - Access code labels now lead with the stay's own dates in a sortable form (ISO start date, so it sorts correctly across a year boundary), e.g. 2026-10-16 - 10/18 Orem Hillside Retreat, so the lock provider's own app naturally shows the current/soonest guest near the top. Worker codes follow the same convention using the event's own summary. - Once a code is removed from the lock (checkout, cancellation, or a completed Worker visit), its label is prefixed with x so it's easy to tell apart from active/upcoming codes while still sorting above any manually-added codes named with a z prefix. - AccessCode.Adopted tracks codes LatchKey found already on the lock (vs. ones it created) so they can be excluded from auto-purge. - New configurable auto-purge (SyncSettings.AccessCodePurgeRetentionDays, default 30 days, nullable to disable) permanently deletes removed, non-adopted access codes once they're older than the retention window. Runs at most once per 24h from SyncEngine.RunAsync. Configurable from Settings -> Sync Safety. Adds a migration, a real-world 4-event Airbnb block regression test, and new test coverage for labels, adoption flagging, and the purge sweep (including its throttle). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|---|---|---|
| src | ||
| tests/LatchKey.UnitTests | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitignore | ||
| Directory.Build.props | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LatchKey.slnx | ||
| LICENSE | ||
| NuGet.config | ||
| README.md | ||
LatchKey
LatchKey keeps your short-term-rental smart lock in sync with your booking calendar - fully
automated. It watches your marketplace calendar feed(s) (Airbnb today; VRBO/FurnishedFinder/any
generic .ics feed are supported as plain date-range feeds already, with room to add
marketplace-specific enrichment later), and for every active reservation it creates a
time-boxed access code on the guest's assigned lock(s), then removes it automatically at
checkout - or immediately if the guest cancels. A Blazor dashboard lets you configure
properties, calendars, locks, and email alerts, and review a live log of everything the
background sync does.
How it works
Airbnb/VRBO/etc. (.ics) ---> CalendarSyncBackgroundService ---> SyncEngine ---> Lock provider (Schlage, ...)
(ticks every 1 minute) |
v
SQLite (properties,
reservations, access
codes, logs)
Every minute, the background service re-syncs each configured property:
- Fetch each property's calendar feed(s) (throttled per-feed by
PollIntervalMinutes) and upsert reservations. A reservation that disappears from the feed (guest cancelled) is marked cancelled. - Refresh each linked lock device's live battery level/locked state (grouped per provider account so this is one API call, not one per device).
- Reconcile access codes: for every active/upcoming reservation, ensure a code exists on each lock linked to that property, scheduled to activate/deactivate around check-in/check-out (with a configurable buffer). For every cancelled/past reservation, remove its code from the lock. A code that failed to push to the lock (e.g. a transient API error) is automatically retried on the next pass once fixed - it's never silently abandoned.
Multiple properties are fully isolated from each other: each property has its own calendar feed(s) and its own linked lock(s), so a code is only ever created on the lock(s) belonging to the property the guest is actually staying at.
Architecture
Clean-architecture-style layering, one project per layer:
| Project | Contents |
|---|---|
LatchKey.Domain |
Entities (Property, Reservation, LockDevice, AccessCode, ...), enums, and the pluggable CodeGenerator/ICodeGenerationStrategy logic. No dependencies on anything else. |
LatchKey.Contracts |
Interfaces and DTOs that define the two extension points - ICalendarProvider and ILockProvider - plus their result/request types. |
LatchKey.Infrastructure |
Everything real: EF Core + SQLite (LatchKeyDbContext), calendar providers (AirbnbIcsCalendarProvider, GenericIcsCalendarProvider), lock providers (SchlageLockProvider), SMTP email, the SyncEngine, and the CalendarSyncBackgroundService that drives it every minute. |
LatchKey.Api |
ASP.NET Core Blazor Server app - the dashboard, cookie auth, and Program.cs composition root. |
Tests live in tests/LatchKey.UnitTests (MSTest).
Running it
Docker Compose (recommended)
docker compose up -d --build
This builds the image, starts the container, and persists the SQLite database and Data
Protection key ring (which encrypts stored secrets like your Schlage password and SMTP
password) on a named volume (latchkey-data), so both survive container recreates/redeploys.
The dashboard is then available at http://localhost:8088.
To stop it: docker compose stop (keeps the volume) or docker compose down (removes the
container but keeps the volume; add -v to also wipe stored data).
Note: there's no
app.UseHttpsRedirection()- LatchKey is meant to run on a home/LAN network. If you expose it beyond your LAN, put a reverse proxy (Caddy, nginx, a tunnel, etc.) in front of it to terminate HTTPS.
Local development
dotnet run --project src\LatchKey.Api\LatchKey.Api.csproj
Uses a local latchkey.db SQLite file and a local App_Data/keys folder in the content root
instead of the Docker volume paths.
Tests
dotnet test tests\LatchKey.UnitTests\LatchKey.UnitTests.csproj
First-time setup
- Open the dashboard - if no admin account exists yet, you'll land on /setup to create one
(username + password). This is a one-time gate: once any account exists,
/setupjust shows "Setup has already been completed" and everything else requires signing in at/login. - Add a property (Properties -> Add). The name should match how your marketplace identifies the listing (Airbnb, for example, names each exported calendar after the listing's address/name) - it's what you'll match against when linking calendars and locks below, and it's the label used throughout the dashboard and in log/email messages.
- Add a calendar feed to the property: paste the exported
.icsURL from Airbnb (or another marketplace) and pick its provider type. LatchKey polls it on its own schedule (PollIntervalMinutes) - no manual refresh needed. - Connect a lock provider account under Settings (e.g. Settings -> Schlage): enter the same username/password you use in the Schlage Home app. LatchKey stores it encrypted and uses it to discover, read, and write codes on your locks.
- Add the lock(s) to the property: use "search for locks" to pick a discovered device from your account, or enter its details manually. Because locks are linked per-property, a second STR you add later only ever gets codes for its own guests, never another property's.
- Set your notification level under Settings -> Notifications: none, failures-only, or every notable event, plus your outbound SMTP server and recipient(s).
From here, everything is automatic - the background sync creates and removes codes as reservations come and go, and the Logs page shows exactly what happened (and lets you filter down to warnings/errors).
Pre-existing codes on a lock
If a lock already has manually-created codes (from before you added it to LatchKey, or ones a previous system left behind), LatchKey adopts them rather than duplicating or overwriting: on first sync it reads what's already on the lock and treats any code matching what it would have generated as already in place. Codes it didn't create and doesn't recognize are left alone - LatchKey only ever manages codes it created itself. Adopted codes are also exempt from the auto-purge described below - LatchKey didn't create them, so it doesn't delete them either.
Access code labels & history
Airbnb (and every other marketplace we've seen) strips the guest's name from the calendar feed entirely - since December 2019, an Airbnb export's guest-facing events carry nothing more identifying than a reservation URL and the last 4 digits of a phone number, so there's no reasonable way to put a guest's actual name on their access code.
Instead, each code's label leads with the stay's own dates in a sortable form, e.g.
2026-10-16 - 10/18 Orem Hillside Retreat - the ISO-formatted start date is the primary sort
key (so it still sorts correctly across a year boundary), which means the lock provider's own
app naturally shows the current/soonest guest near the top of its code list. Worker-category
codes are labeled the same way, using the calendar event's own summary in place of the date
range, e.g. 2026-03-05 Maria - Cleaning Orem Hillside Retreat.
Once a code checks out (or a Worker visit ends), its row is kept around for history rather than
deleted immediately, relabeled with a leading x so it's easy to tell apart from active/upcoming
codes at a glance - it sorts after every active/upcoming code (which leads with a digit) but
before any of your own manually-added lock codes, provided you name those starting with z to
keep them at the very bottom.
Auto-purge
Old, no-longer-needed history is cleaned up automatically: a removed access code LatchKey itself created is permanently deleted once it's been off the lock for longer than the configured retention window (Settings -> Sync Safety -> "Delete history this many days after checkout"), which defaults to 30 days and can be changed to any value you like, or left blank to disable auto-purge entirely and keep history forever. Adopted codes are never auto-purged, regardless of age.
Adding a new calendar marketplace
Implement ICalendarProvider (see AirbnbIcsCalendarProvider for a full example that also
extracts a reservation URL and the guest's phone last-4 from the description, or
GenericIcsCalendarProvider for the plain date-range-only fallback VRBO/FurnishedFinder
currently use), register it in DependencyInjection.AddCalendarProviders, and add a value to
the CalendarProviderType enum. No changes to SyncEngine are needed - it works against the
ICalendarProvider/ExternalReservationDto abstraction only.
Adding a new lock provider
Implement ILockProvider (see SchlageLockProvider), register it in
DependencyInjection.AddLockProviders, and add a value to the LockProviderType enum. Like
calendars, SyncEngine only depends on the ILockProvider abstraction, so no sync logic needs
to change.
Notes
- The Schlage integration uses Allegion's unofficial-but-stable cloud API (the same one the Schlage Home app uses), reverse-engineered from the open-source pyschlage project. There's no official public API for these locks.
- The Data Protection key ring is not certificate-encrypted at rest (a standard ASP.NET Core warning you'll see in the logs) since that would need its own secret to protect it. It's stored on the same private Docker volume as the database, which is the accepted trade-off for a self-hosted single-container deployment like this one.
License
Copyright (c) 2026 Eric Olsen. All rights reserved. This is proprietary, closed-source software - see LICENSE for the full terms. Third-party dependencies (ASP.NET Core, EF Core, Bootstrap, AWSSDK.CognitoIdentityProvider, etc.) remain under their own respective licenses.