dm_mac.webhook module

Status-change webhook notifier for external consumers (e.g. the ESB).

class dm_mac.webhook.WebhookNotifier(url: str)

Bases: object

Fire status-change webhooks to a configured URL.

Enabled only when STATUS_WEBHOOK_URL is set (see from_env()). Deliveries are fire-and-forget: notify() spawns an asyncio.create_task() so callers – the MCU update handler and the Slack command handlers – never block on the HTTP request, exactly as the Slack notifications do. Each delivery retries with exponential backoff and a per-attempt timeout; once the retries are exhausted the failure is logged and dropped (external consumers can reconcile via GET /api/machines).

The webhook only fires on meaningful status changes (login, logout, unauthorized/unknown fob, override login, oops, un-oops, lockout, unlock, reboot) because notify() is called only from those code paths, never from ordinary MCU heartbeat updates.

BACKOFF_BASE_SEC: float = 1.0

Base backoff in seconds; before attempt N (1-indexed) we wait BACKOFF_BASE_SEC * 2 ** (N - 2) (no wait before the first attempt).

MAX_ATTEMPTS: int = 4

Total number of POST attempts per event (initial try plus retries).

REQUEST_TIMEOUT_SEC: float = 5.0

Per-attempt HTTP timeout in seconds.

async _deliver(payload: Dict[str, Any]) None

Deliver one webhook with retries and exponential backoff.

Returns as soon as an attempt gets a non-error (< 400) response. On network errors or 4xx/5xx responses it retries up to MAX_ATTEMPTS times, backing off exponentially between attempts, then logs a single error and gives up. A single aiohttp.ClientSession is reused across attempts so retries can benefit from connection pooling.

static _safe_url(url: str) str

Return scheme://host[:port] of url for safe logging.

Strips any userinfo, path, and query string so credentials embedded in the URL (userinfo or query params) are never written to logs.

_tasks: Set[Task[None]]

Strong references to in-flight delivery tasks. asyncio only holds a weak reference to tasks created via asyncio.create_task(), so without this a long-running _deliver() (up to ~27 s of backoff + timeouts) could be garbage-collected mid-flight. Each task removes itself via a done-callback (the pattern from the asyncio docs).

build_payload(machine: Machine, event: str, user: User | None = None) Dict[str, Any]

Build the webhook payload for event on machine.

The payload is Machine.status_dict (so current_user means the same thing here as in GET /api/machines) plus three fields:

  • event – the status-change event name.

  • timestamp – epoch seconds when the event fired.

  • user – the user involved in this event (the actor), as {"account_id", "full_name"} or None. This differs from current_user for events like logout/unauthorized where a user acted but is not (or no longer) logged in.

classmethod from_env() WebhookNotifier | None

Build a notifier from STATUS_WEBHOOK_URL, or None if unset.

notify(machine: Machine, event: str, user: User | None = None) None

Build the payload and spawn a fire-and-forget delivery task.