← Cloud Compute
Private project · Remote management

Remote Apartment Automation

Fully automated short-term rental for a private apartment managed remotely: reservations, smart-lock access, identity verification, invoicing, statutory guest reporting and guest messaging run end-to-end without on-site presence — an event-driven serverless stack on AWS: 8 Lambda functions, 3 DynamoDB tables and EventBridge schedules, running for well under $1/month.

Python 3.12 AWS SAM / Lambda DynamoDB EventBridge Scheduler Secrets Manager Smoobu API Nuki API SOAP + mTLS (eTurizem)
Built with agentic AI. Development on this project runs through multi-agent Claude Code workflows — developer → independent-reviewer cycles with evidence gates, batched into unattended runs that execute overnight and resume from committed ledgers. How the agent system works →

What these tools are

The stack is built from off-the-shelf services wired together with Python:

Project overview — event-driven serverless

A booking arrives as a Smoobu webhook (API Gateway → Lambda) and upserts reservation state into DynamoDB. From there everything is autonomous: two days before arrival the stack creates and fiscalises the invoice, creates a time-bound Nuki keypad PIN and publishes it into the guest's automated welcome message; on arrival day it arms the building intercom into continuous mode and polls for the guest's first keypad entry; guest ID scans are filed to the Slovenian eTurizem registry over mTLS SOAP; the monthly statutory return files itself; and a linen-inventory forecast texts the laundry pickup date when it changes. Every time-based job is an EventBridge schedule with a real timezone (Europe/Berlin) so DST is AWS's problem, not manual UTC arithmetic.

Reservations & smart lock

  • Smoobu webhooks upsert reservation state into DynamoDB; an hourly sync reconciles drift and cancellations.
  • Creates 6-digit Nuki PINs for guests in the arrival window; deletes expired PINs after departure.
  • Writes PINs into Smoobu custom placeholders so guests see them in confirmations.
  • Uses Nuki lock logs to detect first keypad entry and trigger check-in/cleaning flows.

Identity, billing & ops

  • Check-in scan polling: identity-verification API, guest de-duplication in DynamoDB, and Smoobu sync.
  • Regulatory guest reporting: XML generation and automatic reporting to the Slovenian eTurizem platform (AJES). After check-in, guest data from the identity scan is built into the required knjigaGostov XML format and submitted via the official SOAP API so the stay is registered for compliance.
  • Invoicing via Cebelca API (partner ensure, invoice-sent, line items, finalize).
  • Cleaning alerts: on check-in days a scheduled check reads the Nuki lock logs; if no cleaning-service entry is recorded by a set time before check-in, the cleaning boss gets a WhatsApp/email alert so they can chase the cleaner.
  • Structured logging in CloudWatch; every external call has fallbacks and alert-latched error handling with all-clears.
  • Invoice PDFs archived to S3; all tokens and the mTLS client certificate live in Secrets Manager.

Engineering highlights

The interesting parts came from real incidents — each fix is now a permanent property of the system.

Trust-nothing device control

Nuki's action endpoint returns "accepted", not "applied" — and one accepted command that never reached the intercom locked a guest out of the building for hours. The check-in monitor now reads state back from the device and re-asserts it on every 5-minute run until the guest is detected, so a dropped command self-heals in ≤5 minutes instead of never.

Two independent check-in signals

Nuki's cloud log was observed silently dropping keypad entries the lock itself recorded. A fallback reads the keypad code's usage counter from the live auth list — keyed by PIN code rather than auth id, because ids change when a lost PIN is recreated.

Atomic claims, not read-then-write

Every "send this once" decision (laundry forecasts, reminders, monthly statutory report, guest de-dup) is a DynamoDB conditional write (attribute_not_exists), so a concurrent webhook and a scheduled run can never double-send or double-file. The statutory sequence number is an atomic counter.

Alert storms & all-clears

Alerts are one-shot latches persisted in DynamoDB, re-armed on recovery and bounded, so a flapping device can't email every 5 minutes — and an all-clear is sent on self-heal, because previously the alert was the last thing you ever heard.

Reconciliation as a safety net

When the cancellation webhook silently stopped matching for weeks, ghost bookings kept being invoiced and PIN'd. The hourly sync now reconciles cancellations too — hardened so a failed Smoobu fetch can never masquerade as "everything was cancelled".

Structural secret containment

The repo's early history contains an mTLS private key, so publication is guarded twice: a pre-push hook rejects any ref whose history ever touched key files, and a mirror-publisher script credential-sweeps the tree and force-pushes a single parentless squashed commit. The leak is impossible, not remembered.

Representative code excerpts

Self-healing device control and timezone-correct serverless scheduling.

Re-assert until the guest is in

src/handlers/checkin_monitor.py
def _ensure_cm_armed(record, name):
    """Make sure the Opener really is in continuous mode - re-checked EVERY run."""
    if not record.get("todayCheckin"):
        store.set_flag(record["reservationId"], todayCheckin=True)
        record["todayCheckin"] = True

    if nuki.cm_is_active():        # read back from the device, never assume
        _cm_armed_ok(record, name)
        return
    if nuki.set_cm(True):
        _cm_armed_ok(record, name)
        return
    _alert_once(record, "Continuous Mode not active - action needed", ...)

Scheduling with a real timezone

aws-serverless/template.yaml
CheckinMonitorFn:
  Type: AWS::Serverless::Function
  Properties:
    Handler: handlers.checkin_monitor.handler
    Events:
      Every5Min:
        Type: ScheduleV2
        Properties:
          ScheduleExpression: 'cron(0/5 8-22 * * ? *)'
          ScheduleExpressionTimezone: Europe/Berlin   # DST handled by AWS