seahaven-door-unlock-api/lambda/poller/lockdown-poller.ts
Adam Moussa eaa292a16f Add lockdown poller Lambda and fix lockdown handler responses
- Add VPC-connected poller Lambda that monitors lockdown status via
  Elements API every 15 seconds (4 polls per 1-min EventBridge schedule)
- Handle Elements API rate limits (429) with retry-after support
- Fix lockdown handler to use TextScreen XML instead of Execute XML
  (Execute shows globe icon on T58W, TextScreen renders properly)
- Fix Elements API status parsing to be case-insensitive
- Trust toggle action instead of re-checking status (eventual consistency)
- Configure push_xml.server = any in T58W template for Push XML support
- Clear action_url.setup_completed (poller replaces boot-time check)
- Update README with lockdown architecture and known LED limitation

Note: T58W line key LED color does not change to reflect lockdown
status. Execute LED commands are transient on the T58W - the phone's
XML Browser key type immediately overrides them.
2026-04-28 20:09:35 -04:00

86 lines
2.4 KiB
TypeScript

import {
SSMClient,
GetParameterCommand,
} from "@aws-sdk/client-ssm";
const ssm = new SSMClient({});
let cachedApiKey: string | undefined;
const LOCKDOWN_PROFILES: { key: string; id: string }[] = [
{ key: "bohemia", id: "4b4a3e6b-c903-4cce-8cd6-288612bf0542" },
{ key: "ronkonkoma", id: "ff9876bc-c54f-472e-aef9-d2bffd4b7cf7" },
];
const ELEMENTS_BASE_URL = "https://api.elementssecure.com/v1";
const POLL_COUNT = 4;
const POLL_INTERVAL_MS = 15_000;
async function getSsmParam(name: string, decrypt: boolean): Promise<string> {
const res = await ssm.send(
new GetParameterCommand({ Name: name, WithDecryption: decrypt })
);
return res.Parameter!.Value!;
}
async function loadApiKey(): Promise<string> {
if (!cachedApiKey) {
cachedApiKey = await getSsmParam(process.env.ELEMENTS_API_KEY_PARAM!, true);
}
return cachedApiKey;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function getLockdownStatus(lockdownId: string, apiKey: string): Promise<boolean> {
const response = await fetch(`${ELEMENTS_BASE_URL}/lockdowns/${lockdownId}`, {
headers: { "api-key": apiKey },
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get("retry-after") ?? "2", 10);
await sleep(retryAfter * 1000);
return getLockdownStatus(lockdownId, apiKey);
}
if (!response.ok) {
throw new Error(`Elements status check failed: ${response.status}`);
}
const data = await response.json() as Record<string, unknown>;
return String(data.status).toLowerCase() === "active";
}
export async function handler() {
let apiKey: string;
try {
apiKey = await loadApiKey();
} catch (err) {
console.error(JSON.stringify({ action: "poller_config_error", error: String(err) }));
return;
}
for (let i = 0; i < POLL_COUNT; i++) {
try {
const statuses = [];
for (const profile of LOCKDOWN_PROFILES) {
const active = await getLockdownStatus(profile.id, apiKey);
statuses.push({ profile: profile.key, active });
}
console.log(JSON.stringify({
action: "poller_cycle",
iteration: i + 1,
statuses,
}));
} catch (err) {
console.error(JSON.stringify({ action: "poller_poll_error", iteration: i + 1, error: String(err) }));
}
if (i < POLL_COUNT - 1) {
await sleep(POLL_INTERVAL_MS);
}
}
}