guesty-mcp-server 0.6.0 → 0.7.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to the Guesty MCP Server will be documented in this file.
4
4
 
5
+ ## [0.7.0] - 2026-04-15
6
+
7
+ ### Added
8
+ - **IoT/Property Health Monitoring** (Enterprise tier)
9
+ - `get_property_health` — Real-time device status for any property
10
+ - `submit_checkout_photos` — Photo submission for post-checkout analysis
11
+ - `get_maintenance_alerts` — Active IoT alerts filtered by property/severity
12
+ - `get_readiness_score` — 0-100 Physical Readiness Score with 6 weighted checks
13
+ - **IoT Webhook Receiver** (`POST /webhooks/iot`)
14
+ - Supports Tuya, Google Nest, SmartThings, and generic payloads
15
+ - Auto-normalizes all formats to standard schema
16
+ - Auto-creates alerts for out-of-range readings
17
+ - **IoT Data Layer** (`iot-db.js`)
18
+ - Zero-dependency JSON file store for devices, readings, alerts, baselines
19
+ - Auto-pruning at 50K readings and 10K alerts
20
+ - Tool count: 38 → 42 (4 new Enterprise-tier tools)
21
+
5
22
  ## [0.6.0] - 2026-04-10
6
23
 
7
24
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guesty-mcp-server",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The first MCP server for Guesty property management. 38 tools for reservations, guests, messaging, pricing, financials, calendars, reviews, tasks, and webhooks.",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -14,11 +14,22 @@
14
14
  },
15
15
  "keywords": [
16
16
  "mcp",
17
+ "mcp-server",
18
+ "model-context-protocol",
17
19
  "guesty",
18
20
  "property-management",
19
21
  "short-term-rental",
20
22
  "airbnb",
21
- "ai-agent"
23
+ "vrbo",
24
+ "vacation-rental",
25
+ "ai-agent",
26
+ "claude",
27
+ "claude-code",
28
+ "openclaw",
29
+ "pms",
30
+ "hospitality",
31
+ "real-estate",
32
+ "automation"
22
33
  ],
23
34
  "author": "DLJ Properties",
24
35
  "license": "MIT",
package/server.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.DLJRealty/guesty",
4
4
  "title": "Guesty MCP Server",
5
- "description": "The first MCP server for Guesty property management. 38 tools for reservations, guests, messaging, pricing, financials, calendars, reviews, tasks, and webhooks.",
6
- "version": "0.6.0",
5
+ "description": "MCP server for Guesty property management 38 tools for STR operations.",
6
+ "version": "0.7.0",
7
7
  "repository": {
8
8
  "url": "https://github.com/DLJRealty/guesty-mcp-server",
9
9
  "source": "github"
@@ -11,7 +11,7 @@
11
11
  "packages": [{
12
12
  "registryType": "npm",
13
13
  "identifier": "guesty-mcp-server",
14
- "version": "0.6.0",
14
+ "version": "0.7.0",
15
15
  "transport": { "type": "stdio" },
16
16
  "environmentVariables": [
17
17
  {
@@ -5,10 +5,16 @@
5
5
  */
6
6
  import express from "express";
7
7
  import { randomUUID } from "crypto";
8
+ import iotRouter from "./iot-webhook.js";
9
+ import { initDB } from "./iot-db.js";
8
10
 
9
11
  const app = express();
10
12
  app.use(express.json());
11
13
 
14
+ // Initialize IoT database and mount webhook routes
15
+ initDB();
16
+ app.use(iotRouter);
17
+
12
18
  const PORT = process.env.PORT || 3001;
13
19
 
14
20
  // Request counter
@@ -23,8 +29,8 @@ app.use((req, res, next) => {
23
29
  // Server info
24
30
  const SERVER_INFO = {
25
31
  name: "guesty-mcp-server",
26
- version: "0.6.0",
27
- description: "The first MCP server for Guesty property management. 38 tools for reservations, guests, messaging, pricing, financials, calendars, reviews, tasks, and webhooks.",
32
+ version: "0.7.0",
33
+ description: "MCP server for Guesty property management. 42 tools including IoT monitoring, property health scores, and checkout photo analysis.",
28
34
  capabilities: {
29
35
  tools: { listChanged: false },
30
36
  resources: { listChanged: false }
package/src/iot-db.js ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * IoT Device Data Layer — Guesty MCP Server Enterprise
3
+ *
4
+ * Zero-dependency JSON file-based store for IoT device readings,
5
+ * alerts, and property baselines.
6
+ *
7
+ * Storage: ~/.dlj-scripts/data/iot-devices.json
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+ import { randomUUID } from "crypto";
14
+
15
+ const DATA_DIR = join(homedir(), ".dlj-scripts", "data");
16
+ const DB_PATH = join(DATA_DIR, "iot-devices.json");
17
+
18
+ // ── Schema shape ───────────────────────────────────────────────────────────
19
+
20
+ function emptyDB() {
21
+ return {
22
+ devices: {}, // keyed by device_id
23
+ readings: [], // append-only log
24
+ alerts: [], // append-only log
25
+ baselines: {}, // keyed by property_id
26
+ _meta: {
27
+ version: 1,
28
+ created_at: new Date().toISOString(),
29
+ last_write: new Date().toISOString()
30
+ }
31
+ };
32
+ }
33
+
34
+ // ── Persistence helpers ────────────────────────────────────────────────────
35
+
36
+ let _db = null;
37
+
38
+ function load() {
39
+ if (_db) return _db;
40
+ if (!existsSync(DATA_DIR)) {
41
+ mkdirSync(DATA_DIR, { recursive: true });
42
+ }
43
+ if (existsSync(DB_PATH)) {
44
+ try {
45
+ _db = JSON.parse(readFileSync(DB_PATH, "utf-8"));
46
+ // Ensure all top-level keys exist (forward compat)
47
+ const empty = emptyDB();
48
+ for (const key of Object.keys(empty)) {
49
+ if (!(key in _db)) _db[key] = empty[key];
50
+ }
51
+ } catch {
52
+ console.error("[iot-db] Corrupt JSON store — reinitializing");
53
+ _db = emptyDB();
54
+ }
55
+ } else {
56
+ _db = emptyDB();
57
+ }
58
+ return _db;
59
+ }
60
+
61
+ function flush() {
62
+ const db = load();
63
+ db._meta.last_write = new Date().toISOString();
64
+ writeFileSync(DB_PATH, JSON.stringify(db, null, 2), "utf-8");
65
+ }
66
+
67
+ // ── Public API ─────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Initialise the database (creates the file if absent).
71
+ * Safe to call multiple times.
72
+ */
73
+ export function initDB() {
74
+ load();
75
+ flush();
76
+ console.log(`[iot-db] Initialized at ${DB_PATH}`);
77
+ return DB_PATH;
78
+ }
79
+
80
+ // ── Devices ────────────────────────────────────────────────────────────────
81
+
82
+ const VALID_DEVICE_TYPES = [
83
+ "temp_sensor",
84
+ "humidity_sensor",
85
+ "leak_sensor",
86
+ "smart_lock",
87
+ "motion_sensor"
88
+ ];
89
+
90
+ /**
91
+ * Register or update a device. Automatically called on first reading
92
+ * if the device isn't already known.
93
+ */
94
+ export function upsertDevice({ device_id, device_type, property_id, location }) {
95
+ const db = load();
96
+ const existing = db.devices[device_id];
97
+ db.devices[device_id] = {
98
+ device_id,
99
+ device_type: VALID_DEVICE_TYPES.includes(device_type) ? device_type : (existing?.device_type || "temp_sensor"),
100
+ property_id: property_id || existing?.property_id || null,
101
+ location: location || existing?.location || null,
102
+ installed_at: existing?.installed_at || new Date().toISOString(),
103
+ last_seen: new Date().toISOString()
104
+ };
105
+ flush();
106
+ return db.devices[device_id];
107
+ }
108
+
109
+ // ── Readings ───────────────────────────────────────────────────────────────
110
+
111
+ const VALID_READING_TYPES = [
112
+ "temperature",
113
+ "humidity",
114
+ "leak_detected",
115
+ "lock_status",
116
+ "motion"
117
+ ];
118
+
119
+ /**
120
+ * Persist a normalised reading. Returns the stored record.
121
+ */
122
+ export function saveReading({ device_id, reading_type, value, raw_payload }) {
123
+ const db = load();
124
+
125
+ if (!VALID_READING_TYPES.includes(reading_type)) {
126
+ throw new Error(`Invalid reading_type: ${reading_type}`);
127
+ }
128
+
129
+ const record = {
130
+ id: randomUUID(),
131
+ device_id,
132
+ reading_type,
133
+ value: String(value),
134
+ timestamp: new Date().toISOString(),
135
+ raw_payload: raw_payload || null
136
+ };
137
+
138
+ db.readings.push(record);
139
+
140
+ // Update device last_seen
141
+ if (db.devices[device_id]) {
142
+ db.devices[device_id].last_seen = record.timestamp;
143
+ }
144
+
145
+ // Keep readings bounded (last 50 000)
146
+ if (db.readings.length > 50000) {
147
+ db.readings = db.readings.slice(-40000);
148
+ }
149
+
150
+ flush();
151
+ return record;
152
+ }
153
+
154
+ /**
155
+ * Return the most recent reading per device for a given property.
156
+ * If no propertyId, returns all.
157
+ */
158
+ export function getLatestReadings(propertyId) {
159
+ const db = load();
160
+
161
+ // Find devices for this property
162
+ const deviceIds = propertyId
163
+ ? Object.values(db.devices)
164
+ .filter(d => d.property_id === propertyId)
165
+ .map(d => d.device_id)
166
+ : Object.keys(db.devices);
167
+
168
+ const latest = {};
169
+ // Walk backwards for efficiency
170
+ for (let i = db.readings.length - 1; i >= 0; i--) {
171
+ const r = db.readings[i];
172
+ if (!deviceIds.includes(r.device_id)) continue;
173
+ const key = `${r.device_id}:${r.reading_type}`;
174
+ if (!latest[key]) {
175
+ latest[key] = r;
176
+ }
177
+ }
178
+
179
+ return Object.values(latest);
180
+ }
181
+
182
+ // ── Alerts ─────────────────────────────────────────────────────────────────
183
+
184
+ const VALID_ALERT_TYPES = [
185
+ "leak",
186
+ "temp_out_of_range",
187
+ "lock_offline",
188
+ "motion_unusual"
189
+ ];
190
+
191
+ const VALID_SEVERITIES = ["critical", "warning", "info"];
192
+
193
+ /**
194
+ * Create and persist an alert.
195
+ */
196
+ export function saveAlert({ device_id, property_id, alert_type, severity, message }) {
197
+ const db = load();
198
+
199
+ const record = {
200
+ id: randomUUID(),
201
+ device_id,
202
+ property_id: property_id || db.devices[device_id]?.property_id || null,
203
+ alert_type: VALID_ALERT_TYPES.includes(alert_type) ? alert_type : "leak",
204
+ severity: VALID_SEVERITIES.includes(severity) ? severity : "warning",
205
+ message: message || "",
206
+ created_at: new Date().toISOString(),
207
+ resolved_at: null,
208
+ resolved_by: null
209
+ };
210
+
211
+ db.alerts.push(record);
212
+
213
+ // Keep alerts bounded (last 10 000)
214
+ if (db.alerts.length > 10000) {
215
+ db.alerts = db.alerts.slice(-8000);
216
+ }
217
+
218
+ flush();
219
+ return record;
220
+ }
221
+
222
+ /**
223
+ * Retrieve alerts for a property. Defaults to unresolved only.
224
+ */
225
+ export function getAlerts(propertyId, { includeResolved = false } = {}) {
226
+ const db = load();
227
+ return db.alerts.filter(a => {
228
+ if (propertyId && a.property_id !== propertyId) return false;
229
+ if (!includeResolved && a.resolved_at) return false;
230
+ return true;
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Mark an alert as resolved.
236
+ */
237
+ export function resolveAlert(alertId, resolvedBy = "system") {
238
+ const db = load();
239
+ const alert = db.alerts.find(a => a.id === alertId);
240
+ if (!alert) return null;
241
+ alert.resolved_at = new Date().toISOString();
242
+ alert.resolved_by = resolvedBy;
243
+ flush();
244
+ return alert;
245
+ }
246
+
247
+ // ── Baselines ──────────────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * Save a property condition baseline (photos + notes).
251
+ */
252
+ export function saveBaseline({ property_id, photo_urls, notes }) {
253
+ const db = load();
254
+ db.baselines[property_id] = {
255
+ property_id,
256
+ photo_urls: Array.isArray(photo_urls) ? photo_urls : [],
257
+ created_at: new Date().toISOString(),
258
+ notes: notes || ""
259
+ };
260
+ flush();
261
+ return db.baselines[property_id];
262
+ }
263
+
264
+ /**
265
+ * Retrieve the current baseline for a property.
266
+ */
267
+ export function getBaseline(propertyId) {
268
+ const db = load();
269
+ return db.baselines[propertyId] || null;
270
+ }
271
+
272
+ /**
273
+ * Save checkout photos for a property/reservation.
274
+ * Returns a submission object with a unique ID.
275
+ */
276
+ export function savePhotos({ property_id, reservation_id, photos }) {
277
+ const db = load();
278
+ if (!db.photo_submissions) db.photo_submissions = [];
279
+ const submission = {
280
+ id: `sub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
281
+ property_id,
282
+ reservation_id: reservation_id || null,
283
+ photo_urls: Array.isArray(photos) ? photos : [],
284
+ submitted_at: new Date().toISOString(),
285
+ analysis_status: "pending"
286
+ };
287
+ db.photo_submissions.push(submission);
288
+ // Prune old submissions (keep last 1000)
289
+ if (db.photo_submissions.length > 1000) {
290
+ db.photo_submissions = db.photo_submissions.slice(-1000);
291
+ }
292
+ flush();
293
+ return submission;
294
+ }
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Guesty MCP Server — IoT / Property Health Monitoring Tools
3
+ *
4
+ * Enterprise-tier gated tools for IoT device monitoring,
5
+ * checkout photo submission, maintenance alerts, and
6
+ * physical readiness scoring.
7
+ *
8
+ * Phase 1: Core data collection and scoring.
9
+ * Phase 2: Vision-based photo comparison (planned).
10
+ */
11
+
12
+ import { z } from "zod";
13
+ import { getTier, gatedHandler } from "./license.js";
14
+ import {
15
+ getLatestReadings,
16
+ getAlerts,
17
+ saveBaseline,
18
+ getBaseline,
19
+ savePhotos,
20
+ } from "./iot-db.js";
21
+
22
+ /**
23
+ * Enterprise-tier gate. Returns an error response if the current
24
+ * license is not enterprise. Wraps the inner handler otherwise.
25
+ */
26
+ function enterpriseGated(toolName, handler) {
27
+ return async (params) => {
28
+ const tier = getTier();
29
+ if (tier !== "enterprise") {
30
+ return {
31
+ content: [
32
+ {
33
+ type: "text",
34
+ text:
35
+ `This tool (${toolName}) requires an Enterprise license. ` +
36
+ `Your current tier is "${tier}". ` +
37
+ "IoT monitoring, property health, and readiness scoring are Enterprise-only features. " +
38
+ "Upgrade at https://guestycopilot.com/pricing -- " +
39
+ "Set GUESTY_MCP_LICENSE_KEY with an Enterprise key (gmcp_ent_*) to unlock.",
40
+ },
41
+ ],
42
+ isError: true,
43
+ };
44
+ }
45
+ return handler(params);
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Derive an overall status from a set of device readings.
51
+ * - "critical" if ANY device has a critical alert
52
+ * - "warning" if ANY device has a warning alert
53
+ * - "healthy" otherwise
54
+ */
55
+ function deriveOverallStatus(devices) {
56
+ let status = "healthy";
57
+ for (const d of devices) {
58
+ if (d.alert_level === "critical") return "critical";
59
+ if (d.alert_level === "warning") status = "warning";
60
+ }
61
+ return status;
62
+ }
63
+
64
+ /**
65
+ * Register all 4 IoT / Property Health tools on the given MCP server.
66
+ *
67
+ * @param {import("@modelcontextprotocol/sdk/server/mcp.js").McpServer} server
68
+ */
69
+ export function registerIoTTools(server) {
70
+ // ── Tool 1: get_property_health ─────────────────────────────────
71
+ server.tool(
72
+ "get_property_health",
73
+ "Get the current health status of all IoT devices at a property. Returns latest readings for every device (temperature, humidity, leak, lock, etc.) and an overall property status.",
74
+ {
75
+ listingId: z.string().describe("The Guesty listing ID for the property"),
76
+ },
77
+ {
78
+ readOnlyHint: true,
79
+ destructiveHint: false,
80
+ idempotentHint: true,
81
+ openWorldHint: false,
82
+ },
83
+ enterpriseGated("get_property_health", async (params) => {
84
+ try {
85
+ const readings = await getLatestReadings(params.listingId);
86
+ const devices = (readings || []).map((r) => ({
87
+ device_id: r.device_id,
88
+ type: r.type,
89
+ location: r.location,
90
+ last_reading: r.last_reading,
91
+ last_seen: r.last_seen,
92
+ alert_level: r.alert_level || null,
93
+ }));
94
+
95
+ const overallStatus = deriveOverallStatus(devices);
96
+
97
+ return {
98
+ content: [
99
+ {
100
+ type: "text",
101
+ text: JSON.stringify(
102
+ {
103
+ property_id: params.listingId,
104
+ devices,
105
+ overall_status: overallStatus,
106
+ },
107
+ null,
108
+ 2
109
+ ),
110
+ },
111
+ ],
112
+ };
113
+ } catch (e) {
114
+ return {
115
+ content: [
116
+ {
117
+ type: "text",
118
+ text: JSON.stringify(
119
+ {
120
+ error: "Failed to retrieve property health data.",
121
+ details: e.message,
122
+ },
123
+ null,
124
+ 2
125
+ ),
126
+ },
127
+ ],
128
+ isError: true,
129
+ };
130
+ }
131
+ })
132
+ );
133
+
134
+ // ── Tool 2: submit_checkout_photos ──────────────────────────────
135
+ server.tool(
136
+ "submit_checkout_photos",
137
+ "Submit post-checkout photos for a property. Stores photo URLs/paths with reservation metadata for later comparison against baseline images. (Vision analysis is Phase 2.)",
138
+ {
139
+ listingId: z
140
+ .string()
141
+ .describe("The Guesty listing ID for the property"),
142
+ reservationId: z
143
+ .string()
144
+ .describe("The reservation ID this checkout is for"),
145
+ photos: z
146
+ .array(z.string())
147
+ .describe("Array of photo URLs or file paths to submit"),
148
+ },
149
+ {
150
+ readOnlyHint: false,
151
+ destructiveHint: false,
152
+ idempotentHint: false,
153
+ openWorldHint: false,
154
+ },
155
+ enterpriseGated("submit_checkout_photos", async (params) => {
156
+ try {
157
+ if (!params.photos || params.photos.length === 0) {
158
+ return {
159
+ content: [
160
+ {
161
+ type: "text",
162
+ text: JSON.stringify(
163
+ { error: "No photos provided. Include at least one photo URL or path." },
164
+ null,
165
+ 2
166
+ ),
167
+ },
168
+ ],
169
+ isError: true,
170
+ };
171
+ }
172
+
173
+ const result = await savePhotos({
174
+ listingId: params.listingId,
175
+ reservationId: params.reservationId,
176
+ photos: params.photos,
177
+ timestamp: new Date().toISOString(),
178
+ });
179
+
180
+ return {
181
+ content: [
182
+ {
183
+ type: "text",
184
+ text: JSON.stringify(
185
+ {
186
+ submission_id: result.submission_id,
187
+ photo_count: params.photos.length,
188
+ status: "received",
189
+ message: "Photos queued for analysis",
190
+ },
191
+ null,
192
+ 2
193
+ ),
194
+ },
195
+ ],
196
+ };
197
+ } catch (e) {
198
+ return {
199
+ content: [
200
+ {
201
+ type: "text",
202
+ text: JSON.stringify(
203
+ {
204
+ error: "Failed to submit checkout photos.",
205
+ details: e.message,
206
+ },
207
+ null,
208
+ 2
209
+ ),
210
+ },
211
+ ],
212
+ isError: true,
213
+ };
214
+ }
215
+ })
216
+ );
217
+
218
+ // ── Tool 3: get_maintenance_alerts ──────────────────────────────
219
+ server.tool(
220
+ "get_maintenance_alerts",
221
+ "Get active maintenance alerts from IoT devices. Filter by property, severity, or active-only status. Returns alerts with device info, severity, and timestamps.",
222
+ {
223
+ listingId: z
224
+ .string()
225
+ .optional()
226
+ .describe("Filter alerts to a specific listing ID. Omit for all properties."),
227
+ severity: z
228
+ .enum(["critical", "warning", "info", "all"])
229
+ .optional()
230
+ .default("all")
231
+ .describe("Filter by alert severity level (default: all)"),
232
+ active_only: z
233
+ .boolean()
234
+ .optional()
235
+ .default(true)
236
+ .describe("Only return active (unresolved) alerts (default: true)"),
237
+ },
238
+ {
239
+ readOnlyHint: true,
240
+ destructiveHint: false,
241
+ idempotentHint: true,
242
+ openWorldHint: false,
243
+ },
244
+ enterpriseGated("get_maintenance_alerts", async (params) => {
245
+ try {
246
+ const filters = {
247
+ listingId: params.listingId || null,
248
+ severity: params.severity === "all" ? null : params.severity,
249
+ activeOnly: params.active_only,
250
+ };
251
+
252
+ const alertData = await getAlerts(filters);
253
+ const alerts = (alertData || []).map((a) => ({
254
+ id: a.id,
255
+ device_id: a.device_id,
256
+ property_id: a.property_id,
257
+ alert_type: a.alert_type,
258
+ severity: a.severity,
259
+ message: a.message,
260
+ created_at: a.created_at,
261
+ }));
262
+
263
+ return {
264
+ content: [
265
+ {
266
+ type: "text",
267
+ text: JSON.stringify(
268
+ {
269
+ alerts,
270
+ total_count: alerts.length,
271
+ },
272
+ null,
273
+ 2
274
+ ),
275
+ },
276
+ ],
277
+ };
278
+ } catch (e) {
279
+ return {
280
+ content: [
281
+ {
282
+ type: "text",
283
+ text: JSON.stringify(
284
+ {
285
+ error: "Failed to retrieve maintenance alerts.",
286
+ details: e.message,
287
+ },
288
+ null,
289
+ 2
290
+ ),
291
+ },
292
+ ],
293
+ isError: true,
294
+ };
295
+ }
296
+ })
297
+ );
298
+
299
+ // ── Tool 4: get_readiness_score ─────────────────────────────────
300
+ server.tool(
301
+ "get_readiness_score",
302
+ "Calculate a 0-100 Physical Readiness Score for a property before guest check-in. Evaluates temperature, leak detection, door lock, humidity, critical alerts, and baseline photos.",
303
+ {
304
+ listingId: z.string().describe("The Guesty listing ID for the property"),
305
+ },
306
+ {
307
+ readOnlyHint: true,
308
+ destructiveHint: false,
309
+ idempotentHint: true,
310
+ openWorldHint: false,
311
+ },
312
+ enterpriseGated("get_readiness_score", async (params) => {
313
+ try {
314
+ const [readings, alerts, baseline] = await Promise.all([
315
+ getLatestReadings(params.listingId),
316
+ getAlerts({
317
+ listingId: params.listingId,
318
+ severity: null,
319
+ activeOnly: true,
320
+ }),
321
+ getBaseline(params.listingId),
322
+ ]);
323
+
324
+ const checks = [];
325
+ let score = 0;
326
+ const MAX_SCORE = 100;
327
+
328
+ // ── Check 1: Temperature in range (10 pts) ──
329
+ const tempDevice = (readings || []).find(
330
+ (r) => r.type === "temperature"
331
+ );
332
+ const tempOk =
333
+ tempDevice &&
334
+ tempDevice.last_reading !== null &&
335
+ tempDevice.last_reading >= 60 &&
336
+ tempDevice.last_reading <= 80;
337
+ checks.push({
338
+ name: "temperature_in_range",
339
+ passed: !!tempOk,
340
+ points: tempOk ? 10 : 0,
341
+ detail: tempDevice
342
+ ? `Temperature: ${tempDevice.last_reading}F (range: 60-80F)`
343
+ : "No temperature sensor found",
344
+ });
345
+ if (tempOk) score += 10;
346
+
347
+ // ── Check 2: No leak alerts (25 pts) ──
348
+ const leakAlerts = (alerts || []).filter(
349
+ (a) => a.alert_type === "leak" || a.alert_type === "water_leak"
350
+ );
351
+ const noLeaks = leakAlerts.length === 0;
352
+ checks.push({
353
+ name: "no_leak_alerts",
354
+ passed: noLeaks,
355
+ points: noLeaks ? 25 : 0,
356
+ detail: noLeaks
357
+ ? "No active leak alerts"
358
+ : `${leakAlerts.length} active leak alert(s)`,
359
+ });
360
+ if (noLeaks) score += 25;
361
+
362
+ // ── Check 3: Door lock responsive (25 pts) ──
363
+ const lockDevice = (readings || []).find(
364
+ (r) => r.type === "lock" || r.type === "door_lock"
365
+ );
366
+ const lockOk =
367
+ lockDevice &&
368
+ lockDevice.last_seen &&
369
+ Date.now() - new Date(lockDevice.last_seen).getTime() <
370
+ 30 * 60 * 1000; // seen within 30 min
371
+ checks.push({
372
+ name: "door_lock_responsive",
373
+ passed: !!lockOk,
374
+ points: lockOk ? 25 : 0,
375
+ detail: lockDevice
376
+ ? `Lock last seen: ${lockDevice.last_seen}`
377
+ : "No door lock device found",
378
+ });
379
+ if (lockOk) score += 25;
380
+
381
+ // ── Check 4: Humidity OK (10 pts) ──
382
+ const humidDevice = (readings || []).find(
383
+ (r) => r.type === "humidity"
384
+ );
385
+ const humidOk =
386
+ humidDevice &&
387
+ humidDevice.last_reading !== null &&
388
+ humidDevice.last_reading >= 30 &&
389
+ humidDevice.last_reading <= 60;
390
+ checks.push({
391
+ name: "humidity_ok",
392
+ passed: !!humidOk,
393
+ points: humidOk ? 10 : 0,
394
+ detail: humidDevice
395
+ ? `Humidity: ${humidDevice.last_reading}% (range: 30-60%)`
396
+ : "No humidity sensor found",
397
+ });
398
+ if (humidOk) score += 10;
399
+
400
+ // ── Check 5: No critical alerts (20 pts) ──
401
+ const criticals = (alerts || []).filter(
402
+ (a) => a.severity === "critical"
403
+ );
404
+ const noCritical = criticals.length === 0;
405
+ checks.push({
406
+ name: "no_critical_alerts",
407
+ passed: noCritical,
408
+ points: noCritical ? 20 : 0,
409
+ detail: noCritical
410
+ ? "No critical alerts"
411
+ : `${criticals.length} critical alert(s) active`,
412
+ });
413
+ if (noCritical) score += 20;
414
+
415
+ // ── Check 6: Baseline photos exist (10 pts) ──
416
+ const hasBaseline =
417
+ baseline && baseline.photos && baseline.photos.length > 0;
418
+ checks.push({
419
+ name: "baseline_photos_exist",
420
+ passed: !!hasBaseline,
421
+ points: hasBaseline ? 10 : 0,
422
+ detail: hasBaseline
423
+ ? `${baseline.photos.length} baseline photo(s) on file`
424
+ : "No baseline photos uploaded",
425
+ });
426
+ if (hasBaseline) score += 10;
427
+
428
+ // ── Derive status ──
429
+ let status;
430
+ if (score >= 80) status = "ready";
431
+ else if (score >= 50) status = "warning";
432
+ else status = "not_ready";
433
+
434
+ return {
435
+ content: [
436
+ {
437
+ type: "text",
438
+ text: JSON.stringify(
439
+ {
440
+ score,
441
+ max_score: MAX_SCORE,
442
+ status,
443
+ checks,
444
+ },
445
+ null,
446
+ 2
447
+ ),
448
+ },
449
+ ],
450
+ };
451
+ } catch (e) {
452
+ return {
453
+ content: [
454
+ {
455
+ type: "text",
456
+ text: JSON.stringify(
457
+ {
458
+ error: "Failed to calculate readiness score.",
459
+ details: e.message,
460
+ },
461
+ null,
462
+ 2
463
+ ),
464
+ },
465
+ ],
466
+ isError: true,
467
+ };
468
+ }
469
+ })
470
+ );
471
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * IoT Webhook Receiver — Guesty MCP Server Enterprise
3
+ *
4
+ * POST /webhooks/iot
5
+ *
6
+ * Accepts payloads from Tuya, Google Nest, SmartThings, and a
7
+ * generic format. Normalises everything, stores readings, and
8
+ * auto-creates alerts when thresholds are breached.
9
+ */
10
+
11
+ import { Router } from "express";
12
+ import {
13
+ saveReading,
14
+ saveAlert,
15
+ upsertDevice,
16
+ getLatestReadings,
17
+ getAlerts
18
+ } from "./iot-db.js";
19
+
20
+ const router = Router();
21
+
22
+ // ── Threshold config ───────────────────────────────────────────────────────
23
+
24
+ const THRESHOLDS = {
25
+ temperature: { min: 60, max: 85 }, // Fahrenheit
26
+ humidity: { max: 70 }, // percent
27
+ leak_detected: { trigger: "true" },
28
+ lock_status: { unlocked_max_minutes: 60 }
29
+ };
30
+
31
+ // ── Format normalisation ───────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Detect the payload format and return a normalised reading object
35
+ * (or array of objects).
36
+ */
37
+ function normalise(body) {
38
+ // Tuya format
39
+ if (body.devId && body.status && Array.isArray(body.status)) {
40
+ return normaliseTuya(body);
41
+ }
42
+
43
+ // Google Nest format
44
+ if (body.name && body.traits && typeof body.traits === "object") {
45
+ return normaliseNest(body);
46
+ }
47
+
48
+ // SmartThings format
49
+ if (body.deviceId && body.capability) {
50
+ return normaliseSmartThings(body);
51
+ }
52
+
53
+ // Generic format (pass-through)
54
+ if (body.device_id && body.reading_type) {
55
+ return [normaliseGeneric(body)];
56
+ }
57
+
58
+ throw new Error("Unrecognised payload format");
59
+ }
60
+
61
+ // ── Tuya ───────────────────────────────────────────────────────────────────
62
+
63
+ const TUYA_CODE_MAP = {
64
+ temp_current: { reading_type: "temperature", device_type: "temp_sensor" },
65
+ va_temperature: { reading_type: "temperature", device_type: "temp_sensor" },
66
+ humidity_value: { reading_type: "humidity", device_type: "humidity_sensor" },
67
+ va_humidity: { reading_type: "humidity", device_type: "humidity_sensor" },
68
+ watersensor_state: { reading_type: "leak_detected", device_type: "leak_sensor" },
69
+ doorcontact_state: { reading_type: "lock_status", device_type: "smart_lock" },
70
+ pir: { reading_type: "motion", device_type: "motion_sensor" },
71
+ switch: { reading_type: "lock_status", device_type: "smart_lock" }
72
+ };
73
+
74
+ function normaliseTuya(body) {
75
+ const results = [];
76
+ for (const item of body.status) {
77
+ const mapping = TUYA_CODE_MAP[item.code];
78
+ if (!mapping) continue;
79
+
80
+ let value = String(item.value);
81
+ // Tuya often sends temp * 10
82
+ if (mapping.reading_type === "temperature" && Number(value) > 200) {
83
+ value = String(Number(value) / 10);
84
+ }
85
+ // Tuya booleans / water states
86
+ if (mapping.reading_type === "leak_detected") {
87
+ value = (value === "1" || value === "true" || value === "alarm") ? "true" : "false";
88
+ }
89
+ if (mapping.reading_type === "lock_status") {
90
+ value = (value === "true" || value === "1" || value === "open") ? "unlocked" : "locked";
91
+ }
92
+
93
+ results.push({
94
+ device_id: body.devId,
95
+ device_type: mapping.device_type,
96
+ property_id: body.property_id || null,
97
+ reading_type: mapping.reading_type,
98
+ value,
99
+ raw_payload: body
100
+ });
101
+ }
102
+ return results;
103
+ }
104
+
105
+ // ── Google Nest ────────────────────────────────────────────────────────────
106
+
107
+ const NEST_TRAIT_MAP = {
108
+ "sdm.devices.traits.Temperature": {
109
+ field: "ambientTemperatureCelsius",
110
+ reading_type: "temperature",
111
+ device_type: "temp_sensor",
112
+ transform: (c) => String(Math.round(c * 9 / 5 + 32)) // C → F
113
+ },
114
+ "sdm.devices.traits.Humidity": {
115
+ field: "ambientHumidityPercent",
116
+ reading_type: "humidity",
117
+ device_type: "humidity_sensor",
118
+ transform: (v) => String(v)
119
+ },
120
+ "sdm.devices.traits.CameraMotion": {
121
+ field: null,
122
+ reading_type: "motion",
123
+ device_type: "motion_sensor",
124
+ transform: () => "true"
125
+ }
126
+ };
127
+
128
+ function normaliseNest(body) {
129
+ const results = [];
130
+ const deviceId = body.name.split("/").pop() || body.name;
131
+
132
+ for (const [trait, data] of Object.entries(body.traits)) {
133
+ const mapping = NEST_TRAIT_MAP[trait];
134
+ if (!mapping) continue;
135
+
136
+ const raw = mapping.field ? data[mapping.field] : true;
137
+ const value = mapping.transform(raw);
138
+
139
+ results.push({
140
+ device_id: deviceId,
141
+ device_type: mapping.device_type,
142
+ property_id: body.property_id || null,
143
+ reading_type: mapping.reading_type,
144
+ value,
145
+ raw_payload: body
146
+ });
147
+ }
148
+ return results;
149
+ }
150
+
151
+ // ── SmartThings ────────────────────────────────────────────────────────────
152
+
153
+ const ST_CAPABILITY_MAP = {
154
+ temperatureMeasurement: { reading_type: "temperature", device_type: "temp_sensor" },
155
+ relativeHumidityMeasurement: { reading_type: "humidity", device_type: "humidity_sensor" },
156
+ waterSensor: { reading_type: "leak_detected", device_type: "leak_sensor" },
157
+ lock: { reading_type: "lock_status", device_type: "smart_lock" },
158
+ motionSensor: { reading_type: "motion", device_type: "motion_sensor" }
159
+ };
160
+
161
+ function normaliseSmartThings(body) {
162
+ const mapping = ST_CAPABILITY_MAP[body.capability];
163
+ if (!mapping) {
164
+ throw new Error(`Unsupported SmartThings capability: ${body.capability}`);
165
+ }
166
+
167
+ let value = String(body.value);
168
+ if (mapping.reading_type === "leak_detected") {
169
+ value = (value === "wet" || value === "true" || value === "1") ? "true" : "false";
170
+ }
171
+ if (mapping.reading_type === "lock_status") {
172
+ value = (value === "unlocked" || value === "open") ? "unlocked" : "locked";
173
+ }
174
+ if (mapping.reading_type === "motion") {
175
+ value = (value === "active" || value === "true" || value === "1") ? "true" : "false";
176
+ }
177
+
178
+ return [{
179
+ device_id: body.deviceId,
180
+ device_type: mapping.device_type,
181
+ property_id: body.property_id || null,
182
+ reading_type: mapping.reading_type,
183
+ value,
184
+ raw_payload: body
185
+ }];
186
+ }
187
+
188
+ // ── Generic ────────────────────────────────────────────────────────────────
189
+
190
+ function normaliseGeneric(body) {
191
+ return {
192
+ device_id: body.device_id,
193
+ device_type: body.device_type || "temp_sensor",
194
+ property_id: body.property_id || null,
195
+ reading_type: body.reading_type,
196
+ value: String(body.value),
197
+ raw_payload: body
198
+ };
199
+ }
200
+
201
+ // ── Alert evaluation ───────────────────────────────────────────────────────
202
+
203
+ function evaluateAlerts(reading, device) {
204
+ const alerts = [];
205
+ const propertyId = reading.property_id || device?.property_id || null;
206
+
207
+ switch (reading.reading_type) {
208
+ case "temperature": {
209
+ const temp = parseFloat(reading.value);
210
+ if (!isNaN(temp) && (temp < THRESHOLDS.temperature.min || temp > THRESHOLDS.temperature.max)) {
211
+ alerts.push({
212
+ device_id: reading.device_id,
213
+ property_id: propertyId,
214
+ alert_type: "temp_out_of_range",
215
+ severity: temp < 45 || temp > 95 ? "critical" : "warning",
216
+ message: `Temperature ${temp}°F is outside safe range (${THRESHOLDS.temperature.min}-${THRESHOLDS.temperature.max}°F)`
217
+ });
218
+ }
219
+ break;
220
+ }
221
+
222
+ case "humidity": {
223
+ const hum = parseFloat(reading.value);
224
+ if (!isNaN(hum) && hum > THRESHOLDS.humidity.max) {
225
+ alerts.push({
226
+ device_id: reading.device_id,
227
+ property_id: propertyId,
228
+ alert_type: "temp_out_of_range", // closest match — humidity threshold
229
+ severity: hum > 85 ? "critical" : "warning",
230
+ message: `Humidity ${hum}% exceeds threshold (${THRESHOLDS.humidity.max}%)`
231
+ });
232
+ }
233
+ break;
234
+ }
235
+
236
+ case "leak_detected": {
237
+ if (reading.value === "true") {
238
+ alerts.push({
239
+ device_id: reading.device_id,
240
+ property_id: propertyId,
241
+ alert_type: "leak",
242
+ severity: "critical",
243
+ message: "Water leak detected!"
244
+ });
245
+ }
246
+ break;
247
+ }
248
+
249
+ case "lock_status": {
250
+ if (reading.value === "unlocked") {
251
+ // For now, create an info-level alert; a background job could
252
+ // escalate after THRESHOLDS.lock_status.unlocked_max_minutes.
253
+ alerts.push({
254
+ device_id: reading.device_id,
255
+ property_id: propertyId,
256
+ alert_type: "lock_offline",
257
+ severity: "info",
258
+ message: "Smart lock is unlocked — will escalate if still unlocked after 1 hour"
259
+ });
260
+ }
261
+ break;
262
+ }
263
+
264
+ case "motion": {
265
+ // Motion alerts only fire if there is no active reservation
266
+ // (would need Guesty integration). For now, log as info.
267
+ if (reading.value === "true") {
268
+ alerts.push({
269
+ device_id: reading.device_id,
270
+ property_id: propertyId,
271
+ alert_type: "motion_unusual",
272
+ severity: "info",
273
+ message: "Motion detected — verify if property should be occupied"
274
+ });
275
+ }
276
+ break;
277
+ }
278
+ }
279
+
280
+ return alerts;
281
+ }
282
+
283
+ // ── Route ──────────────────────────────────────────────────────────────────
284
+
285
+ router.post("/webhooks/iot", (req, res) => {
286
+ try {
287
+ const body = req.body;
288
+ if (!body || typeof body !== "object") {
289
+ return res.status(400).json({ error: "Empty or invalid payload" });
290
+ }
291
+
292
+ const normalised = normalise(body);
293
+
294
+ if (!normalised || normalised.length === 0) {
295
+ return res.status(422).json({ error: "No recognisable readings in payload" });
296
+ }
297
+
298
+ let readingsSaved = 0;
299
+ let alertsCreated = 0;
300
+ let deviceId = null;
301
+
302
+ for (const reading of normalised) {
303
+ // Auto-register / update device
304
+ const device = upsertDevice({
305
+ device_id: reading.device_id,
306
+ device_type: reading.device_type,
307
+ property_id: reading.property_id,
308
+ location: reading.location || null
309
+ });
310
+
311
+ // Save reading
312
+ saveReading({
313
+ device_id: reading.device_id,
314
+ reading_type: reading.reading_type,
315
+ value: reading.value,
316
+ raw_payload: reading.raw_payload
317
+ });
318
+ readingsSaved++;
319
+ deviceId = reading.device_id;
320
+
321
+ // Evaluate thresholds
322
+ const alerts = evaluateAlerts(reading, device);
323
+ for (const alert of alerts) {
324
+ saveAlert(alert);
325
+ alertsCreated++;
326
+ }
327
+ }
328
+
329
+ console.log(`[iot-webhook] ${readingsSaved} reading(s) saved, ${alertsCreated} alert(s) from device ${deviceId}`);
330
+
331
+ return res.status(200).json({
332
+ status: "ok",
333
+ device_id: deviceId,
334
+ readings_saved: readingsSaved,
335
+ alerts_created: alertsCreated
336
+ });
337
+ } catch (err) {
338
+ console.error("[iot-webhook] Error:", err.message);
339
+ return res.status(400).json({ error: err.message });
340
+ }
341
+ });
342
+
343
+ // ── Read-only endpoints (optional convenience) ─────────────────────────────
344
+
345
+ router.get("/webhooks/iot/readings/:propertyId", (req, res) => {
346
+ try {
347
+ const readings = getLatestReadings(req.params.propertyId);
348
+ return res.status(200).json({ property_id: req.params.propertyId, readings });
349
+ } catch (err) {
350
+ return res.status(500).json({ error: err.message });
351
+ }
352
+ });
353
+
354
+ router.get("/webhooks/iot/alerts/:propertyId", (req, res) => {
355
+ try {
356
+ const includeResolved = req.query.resolved === "true";
357
+ const alerts = getAlerts(req.params.propertyId, { includeResolved });
358
+ return res.status(200).json({ property_id: req.params.propertyId, alerts });
359
+ } catch (err) {
360
+ return res.status(500).json({ error: err.message });
361
+ }
362
+ });
363
+
364
+ export default router;
package/src/server.js CHANGED
@@ -3,6 +3,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  import { getTier, getTierInfo, gatedHandler, FREE_TOOLS } from './license.js';
6
+ import { registerIoTTools } from './iot-tools.js';
7
+ import { initDB } from './iot-db.js';
6
8
 
7
9
  // Guesty API Configuration
8
10
  const GUESTY_CLIENT_ID = process.env.GUESTY_CLIENT_ID;
@@ -125,7 +127,7 @@ async function guestyDelete(path, retries = 2) {
125
127
  // Create MCP Server
126
128
  const server = new McpServer({
127
129
  name: "guesty-mcp-server",
128
- version: "0.6.0",
130
+ version: "0.7.0",
129
131
  });
130
132
  // License tier check
131
133
  const _tier = getTier();
@@ -1413,6 +1415,10 @@ server.tool(
1413
1415
  }
1414
1416
  );
1415
1417
 
1418
+ // Initialize IoT database and register IoT/Property Health tools (Enterprise tier)
1419
+ initDB();
1420
+ registerIoTTools(server);
1421
+
1416
1422
  // Start server
1417
1423
  const transport = new StdioServerTransport();
1418
1424
  await server.connect(transport);
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Integration tests for IoT/Property Health Enterprise tier
4
+ * Tests: iot-db.js, iot-webhook.js, iot-tools.js
5
+ */
6
+ import { initDB, upsertDevice, saveReading, getLatestReadings, saveAlert, getAlerts, resolveAlert, saveBaseline, getBaseline, savePhotos } from '../src/iot-db.js';
7
+ import { registerIoTTools } from '../src/iot-tools.js';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+
12
+ // Use temp DB for tests
13
+ const TEST_DB = path.join(os.tmpdir(), 'iot-test-' + Date.now() + '.json');
14
+ process.env.IOT_DB_PATH = TEST_DB;
15
+
16
+ let passed = 0;
17
+ let failed = 0;
18
+
19
+ function assert(condition, msg) {
20
+ if (condition) {
21
+ passed++;
22
+ console.log(` ✓ ${msg}`);
23
+ } else {
24
+ failed++;
25
+ console.log(` ✗ FAIL: ${msg}`);
26
+ }
27
+ }
28
+
29
+ async function run() {
30
+ console.log("=== IoT Integration Tests ===\n");
31
+
32
+ // Test 1: Database initialization
33
+ console.log("1. Database Init");
34
+ initDB();
35
+ assert(fs.existsSync(TEST_DB), "DB file created");
36
+
37
+ // Test 2: Device management
38
+ console.log("\n2. Device Management");
39
+ upsertDevice({ device_id: "sensor-001", device_type: "temp_sensor", property_id: "listing-abc", location: "living_room" });
40
+ upsertDevice({ device_id: "lock-001", device_type: "smart_lock", property_id: "listing-abc", location: "front_door" });
41
+ upsertDevice({ device_id: "leak-001", device_type: "leak_sensor", property_id: "listing-abc", location: "bathroom" });
42
+ const readings1 = getLatestReadings("listing-abc");
43
+ // getLatestReadings returns an array of readings, not { devices: [...] }
44
+ // With no readings yet, returns empty. Check devices were created by querying again after readings.
45
+ assert(Array.isArray(readings1), `getLatestReadings returns array (got ${typeof readings1})`);
46
+
47
+ // Test 3: Save readings
48
+ console.log("\n3. Readings");
49
+ saveReading({ device_id: "sensor-001", reading_type: "temperature", value: "72.5", raw_payload: { source: "test" } });
50
+ saveReading({ device_id: "sensor-001", reading_type: "temperature", value: "73.0", raw_payload: { source: "test" } });
51
+ saveReading({ device_id: "lock-001", reading_type: "lock_status", value: "locked", raw_payload: { source: "test" } });
52
+ saveReading({ device_id: "leak-001", reading_type: "leak_detected", value: "false", raw_payload: { source: "test" } });
53
+ const readings2 = getLatestReadings("listing-abc");
54
+ assert(readings2.length >= 3, `At least 3 readings for listing-abc (got ${readings2.length})`);
55
+ const tempReading = readings2.find(r => r.device_id === "sensor-001" && r.reading_type === "temperature");
56
+ assert(tempReading !== undefined, "Temp reading found");
57
+ assert(tempReading.value === "73.0", `Latest temp is 73.0 (got ${tempReading?.value})`);
58
+
59
+ // Test 4: Alerts
60
+ console.log("\n4. Alerts");
61
+ const alertId = saveAlert({ device_id: "leak-001", property_id: "listing-abc", alert_type: "leak", severity: "critical", message: "Water detected in bathroom!" });
62
+ assert(typeof alertId === 'number' || typeof alertId === 'string', `Alert created with ID: ${alertId}`);
63
+ const alerts = getAlerts("listing-abc");
64
+ assert(alerts.length >= 1, `At least 1 alert (got ${alerts.length})`);
65
+ assert(alerts[0].severity === "critical", "Alert severity is critical");
66
+
67
+ // Test 5: Resolve alert
68
+ console.log("\n5. Alert Resolution");
69
+ resolveAlert(alertId, "CTO");
70
+ const alertsAfter = getAlerts("listing-abc");
71
+ const resolvedAlert = getAlerts("listing-abc", { includeResolved: true }).find(a =>
72
+ a.message === "Water detected in bathroom!"
73
+ );
74
+ assert(resolvedAlert?.resolved_by === "CTO" || alertsAfter.length === 0, "Alert resolved by CTO");
75
+
76
+ // Test 6: Baselines
77
+ console.log("\n6. Baselines");
78
+ saveBaseline({ property_id: "listing-abc", photo_urls: ["https://example.com/clean1.jpg", "https://example.com/clean2.jpg"], notes: "Post deep-clean baseline" });
79
+ const baseline = getBaseline("listing-abc");
80
+ assert(baseline !== null, "Baseline saved");
81
+ assert(baseline.photo_urls?.length === 2, `2 baseline photos (got ${baseline?.photo_urls?.length})`);
82
+
83
+ // Test 7: Readiness Score components
84
+ console.log("\n7. Readiness Score Data");
85
+ // Add a fresh alert to test readiness impact
86
+ saveAlert({ device_id: "sensor-001", property_id: "listing-abc", alert_type: "temp_out_of_range", severity: "warning", message: "Temperature is 58F" });
87
+ const latestAll = getLatestReadings("listing-abc");
88
+ const activeAlerts = getAlerts("listing-abc");
89
+ assert(latestAll.length >= 3, `Multiple readings available (got ${latestAll.length})`);
90
+ assert(activeAlerts.length >= 1, "Active alerts present for readiness check");
91
+
92
+ // Test 8: Multi-property isolation
93
+ console.log("\n8. Multi-Property Isolation");
94
+ upsertDevice({ device_id: "sensor-002", device_type: "temp_sensor", property_id: "listing-xyz", location: "bedroom" });
95
+ saveReading({ device_id: "sensor-002", reading_type: "temperature", value: "70.0", raw_payload: { source: "test" } });
96
+ const readingsXYZ = getLatestReadings("listing-xyz");
97
+ const readingsABC = getLatestReadings("listing-abc");
98
+ assert(readingsXYZ.length >= 1, `listing-xyz has readings (got ${readingsXYZ.length})`);
99
+ assert(readingsABC.length >= 3, `listing-abc still has readings (got ${readingsABC.length})`);
100
+
101
+ // Cleanup
102
+ try { fs.unlinkSync(TEST_DB); } catch(e) {}
103
+
104
+ console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
105
+ process.exit(failed > 0 ? 1 : 0);
106
+ }
107
+
108
+ run().catch(e => { console.error("Test error:", e); process.exit(1); });