@butlr/butlr-mcp-server 0.5.0 → 0.6.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/README.md +1 -1
- package/dist/cache/topology-cache.d.ts +6 -6
- package/dist/cache/topology-cache.js +6 -6
- package/dist/clients/queries/topology.d.ts +14 -0
- package/dist/clients/queries/topology.d.ts.map +1 -1
- package/dist/clients/queries/topology.js +48 -0
- package/dist/clients/queries/topology.js.map +1 -1
- package/dist/clients/reporting-client.d.ts +10 -0
- package/dist/clients/reporting-client.d.ts.map +1 -1
- package/dist/clients/reporting-client.js +11 -5
- package/dist/clients/reporting-client.js.map +1 -1
- package/dist/clients/types.d.ts +1 -1
- package/dist/clients/types.d.ts.map +1 -1
- package/dist/tools/butlr-list-topology.d.ts.map +1 -1
- package/dist/tools/butlr-list-topology.js +7 -53
- package/dist/tools/butlr-list-topology.js.map +1 -1
- package/dist/tools/butlr-search-assets.d.ts +1 -0
- package/dist/tools/butlr-search-assets.d.ts.map +1 -1
- package/dist/tools/butlr-search-assets.js +59 -15
- package/dist/tools/butlr-search-assets.js.map +1 -1
- package/dist/tools/butlr-traffic-flow.d.ts.map +1 -1
- package/dist/tools/butlr-traffic-flow.js +257 -59
- package/dist/tools/butlr-traffic-flow.js.map +1 -1
- package/dist/types/responses.d.ts +2 -1
- package/dist/types/responses.d.ts.map +1 -1
- package/dist/utils/asset-flattener.d.ts.map +1 -1
- package/dist/utils/asset-flattener.js +15 -3
- package/dist/utils/asset-flattener.js.map +1 -1
- package/dist/utils/graphql-helpers.d.ts +26 -1
- package/dist/utils/graphql-helpers.d.ts.map +1 -1
- package/dist/utils/graphql-helpers.js +30 -5
- package/dist/utils/graphql-helpers.js.map +1 -1
- package/dist/utils/occupancy-helpers.d.ts +72 -3
- package/dist/utils/occupancy-helpers.d.ts.map +1 -1
- package/dist/utils/occupancy-helpers.js +129 -18
- package/dist/utils/occupancy-helpers.js.map +1 -1
- package/dist/utils/time-range-validator.d.ts.map +1 -1
- package/dist/utils/time-range-validator.js +3 -32
- package/dist/utils/time-range-validator.js.map +1 -1
- package/dist/utils/time-resolver.d.ts +18 -0
- package/dist/utils/time-resolver.d.ts.map +1 -0
- package/dist/utils/time-resolver.js +44 -0
- package/dist/utils/time-resolver.js.map +1 -0
- package/dist/utils/topology-merge.d.ts +24 -0
- package/dist/utils/topology-merge.d.ts.map +1 -0
- package/dist/utils/topology-merge.js +57 -0
- package/dist/utils/topology-merge.js.map +1 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { apolloClient } from "../clients/graphql-client.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { GET_FULL_TOPOLOGY } from "../clients/queries/topology.js";
|
|
3
|
+
import { GET_ALL_HIVES, GET_ALL_SENSORS, GET_FULL_TOPOLOGY } from "../clients/queries/topology.js";
|
|
4
4
|
import { getCachedTopology, setCachedTopology, generateTopologyCacheKey, } from "../cache/topology-cache.js";
|
|
5
5
|
import { flattenTopology } from "../utils/asset-flattener.js";
|
|
6
6
|
import { searchAssets } from "../utils/fuzzy-match.js";
|
|
7
7
|
import { buildAssetPath } from "../utils/path-builder.js";
|
|
8
|
-
import { rethrowIfGraphQLError } from "../utils/graphql-helpers.js";
|
|
8
|
+
import { isProductionHive, isProductionSensor, rethrowIfGraphQLError, throwIfGraphQLErrors, } from "../utils/graphql-helpers.js";
|
|
9
|
+
import { mergeSensorsAndHivesIntoTopology } from "../utils/topology-merge.js";
|
|
9
10
|
import { debug } from "../utils/debug.js";
|
|
10
11
|
import { withToolErrorHandling } from "../errors/mcp-errors.js";
|
|
11
12
|
const VALID_ASSET_TYPES = ["site", "building", "floor", "room", "zone", "sensor", "hive"];
|
|
@@ -85,15 +86,18 @@ export async function executeSearchAssets(args) {
|
|
|
85
86
|
debug("search-assets", `Searching for "${args.query}"` +
|
|
86
87
|
(args.asset_types ? ` in types: ${args.asset_types.join(",")}` : "") +
|
|
87
88
|
` (max: ${maxResults})`);
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// `
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
89
|
+
// `devicesMerged: true`: sensors and hives are merged onto their floors
|
|
90
|
+
// before flattening, because `flattenTopology` reads devices from
|
|
91
|
+
// `floor.sensors`/`floor.hives` and `GET_FULL_TOPOLOGY` selects neither.
|
|
92
|
+
// Without the merge this tool's corpus holds zero sensors and zero hives,
|
|
93
|
+
// while `VALID_ASSET_TYPES` advertises both and `butlr_hardware_snapshot`
|
|
94
|
+
// tells callers to find sensors here first.
|
|
95
|
+
//
|
|
96
|
+
// This is the same key `butlr_list_topology` writes, deliberately: both now
|
|
97
|
+
// hold the merged shape, so either can prime the other.
|
|
94
98
|
const cacheKey = generateTopologyCacheKey(process.env.BUTLR_ORG_ID || "default", true, // include devices for comprehensive search
|
|
95
99
|
true, // include zones
|
|
96
|
-
|
|
100
|
+
true, // devicesMerged: devices are nested onto their floors before flattening
|
|
97
101
|
undefined);
|
|
98
102
|
// Try to get cached topology
|
|
99
103
|
let sites = [];
|
|
@@ -106,10 +110,20 @@ export async function executeSearchAssets(args) {
|
|
|
106
110
|
// Fetch fresh topology
|
|
107
111
|
debug("search-assets", "Fetching fresh topology for search");
|
|
108
112
|
try {
|
|
109
|
-
const result = await
|
|
110
|
-
query
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
const [result, sensorsResult, hivesResult] = await Promise.all([
|
|
114
|
+
apolloClient.query({
|
|
115
|
+
query: GET_FULL_TOPOLOGY,
|
|
116
|
+
fetchPolicy: "network-only",
|
|
117
|
+
}),
|
|
118
|
+
apolloClient.query({
|
|
119
|
+
query: GET_ALL_SENSORS,
|
|
120
|
+
fetchPolicy: "network-only",
|
|
121
|
+
}),
|
|
122
|
+
apolloClient.query({
|
|
123
|
+
query: GET_ALL_HIVES,
|
|
124
|
+
fetchPolicy: "network-only",
|
|
125
|
+
}),
|
|
126
|
+
]);
|
|
113
127
|
// Apollo can return both data and errors - only fail if we have no data
|
|
114
128
|
if (!result.data || !result.data.sites || !result.data.sites.data) {
|
|
115
129
|
// If we have error but no data, throw
|
|
@@ -118,12 +132,41 @@ export async function executeSearchAssets(args) {
|
|
|
118
132
|
}
|
|
119
133
|
throw new Error("Invalid response structure from API");
|
|
120
134
|
}
|
|
121
|
-
//
|
|
122
|
-
|
|
135
|
+
// A failed device fetch must surface as an error, not as
|
|
136
|
+
// `total_matches: 0` — and must never be cached under the key
|
|
137
|
+
// butlr_list_topology trusts.
|
|
138
|
+
throwIfGraphQLErrors(sensorsResult);
|
|
139
|
+
throwIfGraphQLErrors(hivesResult);
|
|
140
|
+
// Track whether the topology data is partial (errors alongside data).
|
|
141
|
+
// A device query that errored counts too: caching a tree whose floors
|
|
142
|
+
// carry empty sensor arrays would make every later cache hit report
|
|
143
|
+
// "no sensors in this org" as if it were the answer.
|
|
144
|
+
const partialData = !!result.error || !!sensorsResult.error || !!hivesResult.error;
|
|
123
145
|
if (partialData) {
|
|
124
146
|
debug("search-assets", "Warning: GraphQL errors present, data may be partial");
|
|
125
147
|
}
|
|
126
148
|
sites = result.data.sites.data;
|
|
149
|
+
// Same shape contract as butlr_list_topology, which reads the cache
|
|
150
|
+
// entry this tool writes: only an explicit array survives. `|| []`
|
|
151
|
+
// here would launder a serialisation regression into a device-empty
|
|
152
|
+
// tree, cached where that tool's own contract check never runs.
|
|
153
|
+
const rawSensors = sensorsResult.data?.sensors?.data;
|
|
154
|
+
if (!Array.isArray(rawSensors)) {
|
|
155
|
+
throw new Error("Unexpected response shape from sensors query (expected array, got " +
|
|
156
|
+
`${rawSensors === null ? "null" : typeof rawSensors}). Please retry; if persistent, the upstream API contract may have changed.`);
|
|
157
|
+
}
|
|
158
|
+
const rawHives = hivesResult.data?.hives?.data;
|
|
159
|
+
if (!Array.isArray(rawHives)) {
|
|
160
|
+
throw new Error("Unexpected response shape from hives query (expected array, got " +
|
|
161
|
+
`${rawHives === null ? "null" : typeof rawHives}). Please retry; if persistent, the upstream API contract may have changed.`);
|
|
162
|
+
}
|
|
163
|
+
// Test/mirror devices are excluded from the corpus, matching
|
|
164
|
+
// butlr_list_topology. A search hit on a mirror sensor would hand the
|
|
165
|
+
// caller an ID that every data tool then refuses.
|
|
166
|
+
const allSensors = rawSensors.filter(isProductionSensor);
|
|
167
|
+
const allHives = rawHives.filter(isProductionHive);
|
|
168
|
+
sites = mergeSensorsAndHivesIntoTopology(sites, allSensors, allHives);
|
|
169
|
+
debug("search-assets", `Merged ${allSensors.length} sensors and ${allHives.length} hives into topology`);
|
|
127
170
|
// Only cache complete topology data — partial results should be re-fetched
|
|
128
171
|
if (!partialData) {
|
|
129
172
|
setCachedTopology(cacheKey, { sites });
|
|
@@ -162,6 +205,7 @@ export async function executeSearchAssets(args) {
|
|
|
162
205
|
type: match.asset.type,
|
|
163
206
|
path: buildAssetPath(match.asset),
|
|
164
207
|
match_score: match.score,
|
|
208
|
+
...(match.asset.type === "sensor" && match.asset.mode ? { mode: match.asset.mode } : {}),
|
|
165
209
|
site_id: match.asset.site_id,
|
|
166
210
|
building_id: match.asset.building_id,
|
|
167
211
|
floor_id: match.asset.floor_id,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"butlr-search-assets.js","sourceRoot":"","sources":["../../src/tools/butlr-search-assets.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"butlr-search-assets.js","sourceRoot":"","sources":["../../src/tools/butlr-search-assets.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"butlr-traffic-flow.d.ts","sourceRoot":"","sources":["../../src/tools/butlr-traffic-flow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGzE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"butlr-traffic-flow.d.ts","sourceRoot":"","sources":["../../src/tools/butlr-traffic-flow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAGzE,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAyBxB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AA4CjE,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;EAc/B,CAAC;AAiCJ,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,qBAAqB,CAAC,CAAC;AA2CrE;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,eAAe,gCAif7D;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAsB3D"}
|
|
@@ -2,11 +2,13 @@ import { apolloClient } from "../clients/graphql-client.js";
|
|
|
2
2
|
import { gql } from "@apollo/client";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { ReportingRequestBuilder } from "../clients/reporting-client.js";
|
|
5
|
-
import { GET_ALL_SENSORS, GET_FULL_TOPOLOGY } from "../clients/queries/topology.js";
|
|
6
5
|
import { executeSearchAssets } from "./butlr-search-assets.js";
|
|
7
6
|
import { getTimezoneForAsset, buildTimezoneMetadata, getLocalMidnight, } from "../utils/timezone-helpers.js";
|
|
8
7
|
import { createValidationError, withToolErrorHandling } from "../errors/mcp-errors.js";
|
|
9
|
-
import {
|
|
8
|
+
import { resolveTimeToIso } from "../utils/time-resolver.js";
|
|
9
|
+
import { rethrowIfGraphQLError, throwIfGraphQLErrors, isKnownTestSensor, } from "../utils/graphql-helpers.js";
|
|
10
|
+
import { fetchTopology, fetchSensorsByIds, fetchSensorsByRoomIds, resolveSensorContext, } from "../utils/occupancy-helpers.js";
|
|
11
|
+
import { detectAssetType } from "../utils/asset-helpers.js";
|
|
10
12
|
import { debug } from "../utils/debug.js";
|
|
11
13
|
const timeStringSchema = z.string().refine((val) => {
|
|
12
14
|
const isoMatch = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/.test(val);
|
|
@@ -23,7 +25,7 @@ const trafficFlowInputShape = {
|
|
|
23
25
|
.min(1, "space_id_or_name cannot be empty")
|
|
24
26
|
.max(200)
|
|
25
27
|
.trim()
|
|
26
|
-
.describe("
|
|
28
|
+
.describe("Room ID (room_...), sensor ID (sensor_...) for per-access-point counts, or a name search term (matches rooms and traffic sensors)"),
|
|
27
29
|
time_window: z
|
|
28
30
|
.enum(["20m", "1h", "today", "custom"])
|
|
29
31
|
.default("today")
|
|
@@ -52,6 +54,7 @@ export const TrafficFlowArgsSchema = z
|
|
|
52
54
|
path: ["custom_start"],
|
|
53
55
|
});
|
|
54
56
|
const TRAFFIC_FLOW_DESCRIPTION = "Get entry and exit counts for spaces equipped with traffic-mode sensors (typically lobbies, building entrances, elevator banks). Returns total movements, net flow (entries - exits), and hourly breakdown in the space's local timezone. Designed for space activation analysis, security/compliance, and amenity demand forecasting.\n\n" +
|
|
57
|
+
"Accepts a room ID (counts aggregate across the room's traffic sensors), a single sensor ID (sensor_...) for per-access-point counts, or a name to search. Sensors do not need to be assigned to a room to be queried directly, and a name search matches sensors as well as rooms, so an access point does not have to be modeled as a room. (Name search covers devices assigned to a floor; a floor-less or not-yet-MAC-bound sensor is still queryable by its sensor_... ID.)\n\n" +
|
|
55
58
|
"Primary Users:\n" +
|
|
56
59
|
"- Facilities Manager: Monitor building entry/exit patterns, optimize security staffing, validate badge system accuracy\n" +
|
|
57
60
|
"- Workplace Manager: Understand amenity traffic (café, gym, event spaces), measure activation of new spaces\n" +
|
|
@@ -65,7 +68,8 @@ const TRAFFIC_FLOW_DESCRIPTION = "Get entry and exit counts for spaces equipped
|
|
|
65
68
|
'5. "Show me lobby traffic for the last 20 minutes"\n' +
|
|
66
69
|
'6. "What\'s the net flow (entries - exits) for Floor 2 today?"\n' +
|
|
67
70
|
'7. "How many people entered the event space during lunch hour (12-1pm)?"\n' +
|
|
68
|
-
'8. "Compare today\'s building entrance traffic to typical Monday"\n
|
|
71
|
+
'8. "Compare today\'s building entrance traffic to typical Monday"\n' +
|
|
72
|
+
'9. "How many people came through the north elevator door today?" (a single sensor, by name or by sensor_... ID)\n\n' +
|
|
69
73
|
"When to Use:\n" +
|
|
70
74
|
"- Entry/exit counts for lobbies, building entrances, elevator banks, or amenities\n" +
|
|
71
75
|
"- Understand peak traffic hours for security, cleaning, or HVAC planning\n" +
|
|
@@ -99,69 +103,145 @@ const GET_ROOM_SENSORS = gql `
|
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
105
|
`;
|
|
106
|
+
/**
|
|
107
|
+
* Fetch a room and its floor/building context. Kept separate from the shared
|
|
108
|
+
* topology fetch so it can run in parallel with it.
|
|
109
|
+
*/
|
|
110
|
+
async function fetchRoom(roomId) {
|
|
111
|
+
try {
|
|
112
|
+
const result = await apolloClient.query({
|
|
113
|
+
query: GET_ROOM_SENSORS,
|
|
114
|
+
variables: { roomId },
|
|
115
|
+
fetchPolicy: "network-only",
|
|
116
|
+
});
|
|
117
|
+
throwIfGraphQLErrors(result);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
rethrowIfGraphQLError(error);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
102
125
|
/**
|
|
103
126
|
* Execute traffic flow tool
|
|
104
127
|
*/
|
|
105
128
|
export async function executeTrafficFlow(args) {
|
|
106
129
|
let spaceId = args.space_id_or_name;
|
|
107
|
-
//
|
|
108
|
-
|
|
130
|
+
// One prefix detector for both branches. `asset-helpers.ts` is the single
|
|
131
|
+
// place that encodes ID prefixes, so re-testing the string with a local
|
|
132
|
+
// regex here would be a second thing to keep in sync with it.
|
|
133
|
+
let assetType = detectAssetType(spaceId);
|
|
134
|
+
// If it is neither a room nor a sensor ID, treat it as a search term.
|
|
135
|
+
// Direct sensor queries skip room resolution entirely: the reporting API
|
|
136
|
+
// serves sensor-level traffic via the sensors filter, so a sensor does not
|
|
137
|
+
// need a room wrapper to be queryable.
|
|
138
|
+
if (assetType !== "sensor" && assetType !== "room") {
|
|
109
139
|
debug("traffic-flow", `Searching for space: "${args.space_id_or_name}"`);
|
|
140
|
+
// Both types, so a natural-language query can reach the sensor path too.
|
|
141
|
+
// The customer ask behind sensor support was to stop modeling each door as
|
|
142
|
+
// a room; searching rooms only would leave the capability reachable solely
|
|
143
|
+
// by callers who already know the sensor ID.
|
|
110
144
|
const searchResults = await executeSearchAssets({
|
|
111
145
|
query: args.space_id_or_name,
|
|
112
|
-
asset_types: ["room"],
|
|
146
|
+
asset_types: ["room", "sensor"],
|
|
113
147
|
max_results: 5,
|
|
114
148
|
});
|
|
115
149
|
if (searchResults.matches.length === 0) {
|
|
116
|
-
throw new Error(`No rooms found matching "${args.space_id_or_name}". Try a different search term.`);
|
|
150
|
+
throw new Error(`No rooms or sensors found matching "${args.space_id_or_name}". Try a different search term, or pass a room ID (room_...) or sensor ID (sensor_...) directly. butlr_search_assets finds both by name or MAC; butlr_hardware_snapshot lists sensors with their health.`);
|
|
117
151
|
}
|
|
118
|
-
|
|
119
|
-
|
|
152
|
+
// Take the highest-ranked candidate that can actually serve traffic: any
|
|
153
|
+
// room (validated downstream), or a traffic-mode sensor. Taking
|
|
154
|
+
// matches[0] unconditionally let a presence sensor whose name outranks
|
|
155
|
+
// the intended room dead-end the whole query with the right room sitting
|
|
156
|
+
// at index 1.
|
|
157
|
+
const viable = searchResults.matches.find((m) => m.type === "room" || (m.type === "sensor" && m.mode === "traffic"));
|
|
158
|
+
if (!viable) {
|
|
159
|
+
throw new Error(`"${args.space_id_or_name}" matched only presence-mode sensors, which have no entry/exit counts. Try butlr_get_current_occupancy for occupancy data, or search for a room or traffic-mode sensor.`);
|
|
160
|
+
}
|
|
161
|
+
spaceId = viable.id;
|
|
162
|
+
assetType = detectAssetType(spaceId);
|
|
163
|
+
debug("traffic-flow", `Using best viable match: ${viable.name} (${spaceId}, ${assetType})`);
|
|
120
164
|
}
|
|
121
|
-
|
|
122
|
-
|
|
165
|
+
const isSensorQuery = assetType === "sensor";
|
|
166
|
+
// Query asset details, topology for timezone, and sensors
|
|
167
|
+
let displayName = "";
|
|
123
168
|
let roomPath = "";
|
|
124
169
|
let timezone;
|
|
125
170
|
let tzMetadata;
|
|
126
171
|
let timezoneFallback = false;
|
|
127
172
|
let trafficSensors = [];
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
173
|
+
let installationWarning;
|
|
174
|
+
if (isSensorQuery) {
|
|
175
|
+
// One sensor by ID, not the org's whole inventory: the `sensors` root
|
|
176
|
+
// field accepts an `ids` argument, and nothing on this branch needs the
|
|
177
|
+
// full list. A capped or paged `sensors` field would otherwise turn a real
|
|
178
|
+
// sensor past the cap into a bogus "not found".
|
|
179
|
+
const [topology, sensors] = await Promise.all([fetchTopology(), fetchSensorsByIds([spaceId])]);
|
|
180
|
+
const sensor = sensors.find((s) => s.id === spaceId);
|
|
181
|
+
if (!sensor) {
|
|
182
|
+
throw new Error(`Sensor ${spaceId} not found`);
|
|
183
|
+
}
|
|
184
|
+
// Resolved up front so the rejections below can consult real topology
|
|
185
|
+
// rather than pointing the caller at an ID that may not resolve.
|
|
186
|
+
const sensorContext = resolveSensorContext(sensor, topology);
|
|
187
|
+
// Only a KNOWN test device is refused. isProductionSensor is stricter (it
|
|
188
|
+
// also drops MAC-less placeholder rows) and is the right filter for the
|
|
189
|
+
// room path's aggregate below, but applying it to a caller's explicit
|
|
190
|
+
// request would tell the owner of a real, not yet MAC bound sensor that
|
|
191
|
+
// their device is fake and leave them no way to query it.
|
|
192
|
+
if (isKnownTestSensor(sensor)) {
|
|
193
|
+
throw new Error(`Sensor "${sensor.name}" (${spaceId}) is a test/mirror device, not a production sensor — it has no real traffic data.`);
|
|
194
|
+
}
|
|
195
|
+
if (sensor.mode !== "traffic") {
|
|
196
|
+
// Only name the room when it actually resolves. A dangling room_id (the
|
|
197
|
+
// room was deleted) would otherwise send the caller to a second dead
|
|
198
|
+
// end: butlr_get_current_occupancy accepts the room_ prefix, finds no
|
|
199
|
+
// room, and answers "no sensors configured".
|
|
200
|
+
const suggestion = sensorContext.room
|
|
201
|
+
? `Try butlr_get_current_occupancy with its room (${sensorContext.room.id}) instead.`
|
|
202
|
+
: `Try butlr_get_asset_details or butlr_hardware_snapshot to inspect it.`;
|
|
203
|
+
throw new Error(`Sensor "${sensor.name}" is ${sensor.mode ? `a ${sensor.mode}-mode sensor` : "not a traffic-mode sensor"}, so it has no entry/exit counts. ${suggestion}`);
|
|
204
|
+
}
|
|
205
|
+
// An uninstalled sensor reports nothing, which otherwise reads as a
|
|
206
|
+
// confident zero. That is likelier here than on the room path: the caller
|
|
207
|
+
// picked one specific device rather than a room aggregating several.
|
|
208
|
+
if (sensor.installation_status === "UNINSTALLED") {
|
|
209
|
+
installationWarning = `Sensor "${sensor.name}" is marked UNINSTALLED in the platform. An uninstalled sensor reports no traffic, so a zero count is expected; any rows returned predate its removal. Check butlr_hardware_snapshot for its current status.`;
|
|
210
|
+
}
|
|
211
|
+
trafficSensors = [sensor];
|
|
212
|
+
displayName = sensorContext.name;
|
|
213
|
+
roomPath = sensorContext.path;
|
|
214
|
+
timezone = sensorContext.timezone;
|
|
215
|
+
tzMetadata = sensorContext.tzMetadata;
|
|
216
|
+
timezoneFallback = sensorContext.timezoneFallback;
|
|
217
|
+
debug("traffic-flow", `Direct sensor query: ${displayName} (${spaceId})`);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
// The room's own sensor rows via `sensors(room_ids:)` rather than the
|
|
221
|
+
// org-wide inventory. The rows arrive unfiltered and only KNOWN test
|
|
222
|
+
// devices (mirror/fake prefixes) are dropped: this list drives the
|
|
223
|
+
// refusal gate and sensor_count, never the totals (the reporting API
|
|
224
|
+
// aggregates by room_id server-side), so a provisioned-but-MAC-less
|
|
225
|
+
// sensor must not disqualify its room the way the stricter
|
|
226
|
+
// isProductionSensor aggregate filter would.
|
|
227
|
+
const [topology, roomResult, roomSensorRows] = await Promise.all([
|
|
228
|
+
fetchTopology(),
|
|
229
|
+
fetchRoom(spaceId),
|
|
230
|
+
fetchSensorsByRoomIds([spaceId]),
|
|
143
231
|
]);
|
|
144
|
-
|
|
145
|
-
throwIfGraphQLErrors(topoResult);
|
|
146
|
-
throwIfGraphQLErrors(sensorsResult);
|
|
147
|
-
if (!roomResult.data?.room) {
|
|
232
|
+
if (!roomResult?.data?.room) {
|
|
148
233
|
throw new Error(`Room ${spaceId} not found`);
|
|
149
234
|
}
|
|
150
|
-
room = roomResult.data.room;
|
|
235
|
+
const room = roomResult.data.room;
|
|
236
|
+
displayName = room.name;
|
|
151
237
|
const floor = room.floor;
|
|
152
238
|
const building = floor?.building;
|
|
153
239
|
roomPath = building ? `${building.name} > ${floor.name} > ${room.name}` : room.name;
|
|
154
|
-
|
|
155
|
-
const sites = topoResult.data?.sites?.data || [];
|
|
156
|
-
const buildings = sites.flatMap((s) => s.buildings || []);
|
|
157
|
-
const floors = buildings.flatMap((b) => b.floors || []);
|
|
158
|
-
const resolved = getTimezoneForAsset(spaceId, "room", floors, buildings, sites);
|
|
240
|
+
const resolved = getTimezoneForAsset(spaceId, "room", topology.floors, topology.buildings, topology.sites);
|
|
159
241
|
timezone = resolved.timezone;
|
|
160
242
|
timezoneFallback = resolved.isFallback;
|
|
161
243
|
tzMetadata = buildTimezoneMetadata(timezone);
|
|
162
|
-
|
|
163
|
-
const allSensors = sensorsResult.data?.sensors?.data || [];
|
|
164
|
-
const roomSensors = allSensors.filter((s) => (s.room_id || s.roomID) === spaceId);
|
|
244
|
+
const roomSensors = roomSensorRows.filter((s) => !isKnownTestSensor(s));
|
|
165
245
|
// Room-level traffic counts every traffic-mode sensor bound to the room.
|
|
166
246
|
// See `resolveAssetContext` in occupancy-helpers.ts for the canonical
|
|
167
247
|
// rationale: `is_entrance` is a semantic flag, not a routing one, and the
|
|
@@ -172,10 +252,6 @@ export async function executeTrafficFlow(args) {
|
|
|
172
252
|
}
|
|
173
253
|
debug("traffic-flow", `Found ${trafficSensors.length} traffic sensors for room`);
|
|
174
254
|
}
|
|
175
|
-
catch (error) {
|
|
176
|
-
rethrowIfGraphQLError(error);
|
|
177
|
-
throw error;
|
|
178
|
-
}
|
|
179
255
|
// Calculate time range
|
|
180
256
|
const timeWindow = args.time_window || "today";
|
|
181
257
|
let start;
|
|
@@ -220,36 +296,98 @@ export async function executeTrafficFlow(args) {
|
|
|
220
296
|
: `today (${tzMetadata.timezone_abbr})`;
|
|
221
297
|
debug("traffic-flow", `Today starts at ${start} (midnight ${usedUtcFallback ? "UTC fallback" : tzMetadata.timezone_abbr})`);
|
|
222
298
|
}
|
|
223
|
-
|
|
299
|
+
// Resolve relative forms ("-20m", "now") to absolute timestamps: the
|
|
300
|
+
// ETL-backed reporting API requires RFC3339, and the response period
|
|
301
|
+
// metadata should carry real timestamps rather than relative strings.
|
|
302
|
+
start = resolveTimeToIso(start);
|
|
303
|
+
stop = resolveTimeToIso(stop);
|
|
304
|
+
// Pick window granularity from the range length. The ETL backend only
|
|
305
|
+
// returns fully-closed, end-labeled buckets that fit inside [start, stop]:
|
|
306
|
+
// a 1h window over a short recent range drops the in-progress hour
|
|
307
|
+
// entirely (its bucket doesn't exist until the hour closes). Querying 1m
|
|
308
|
+
// buckets for short ranges keeps edge loss to at most the current minute;
|
|
309
|
+
// results are rolled back up to hours below.
|
|
310
|
+
const durationMs = new Date(stop).getTime() - new Date(start).getTime();
|
|
311
|
+
const windowEvery = durationMs <= 2 * 60 * 60 * 1000 ? "1m" : "1h";
|
|
312
|
+
debug("traffic-flow", `Querying traffic from ${start} to ${stop} (window ${windowEvery})`);
|
|
313
|
+
// `fine` marks 1m-bucket points whose timestamps must be rolled up to the
|
|
314
|
+
// enclosing hour; native 1h bucket labels pass through untouched (they may
|
|
315
|
+
// sit on a non-UTC-aligned local grid, e.g. :30-offset timezones).
|
|
224
316
|
let trafficData = [];
|
|
317
|
+
const queryAssetType = isSensorQuery ? "sensor" : "room";
|
|
225
318
|
try {
|
|
226
319
|
const response = await new ReportingRequestBuilder()
|
|
227
|
-
.assets(
|
|
320
|
+
.assets(queryAssetType, [spaceId])
|
|
228
321
|
.measurements(["traffic"])
|
|
229
322
|
.timeRange(start, stop)
|
|
230
|
-
.window(
|
|
323
|
+
.window(windowEvery, "sum", timezone) // Sum traffic per bucket, aligned to local timezone
|
|
231
324
|
.execute();
|
|
232
325
|
// Parse flat array response
|
|
233
326
|
// Traffic returns TWO data points per hour per sensor: "in" and "out"
|
|
234
327
|
if (!Array.isArray(response.data)) {
|
|
235
328
|
throw new Error("Expected array response from traffic query");
|
|
236
329
|
}
|
|
237
|
-
trafficData = response.data
|
|
330
|
+
trafficData = response.data.map((p) => ({
|
|
331
|
+
...p,
|
|
332
|
+
fine: windowEvery === "1m",
|
|
333
|
+
}));
|
|
238
334
|
debug("traffic-flow", `Received ${trafficData.length} data points from ${trafficSensors.length} sensors`);
|
|
335
|
+
// A 1h bucket only exists once its hour closes AND the ETL has written
|
|
336
|
+
// it, so a range ending at/near now silently misses the in-progress hour
|
|
337
|
+
// and possibly the just-closed one. Re-query the trailing 2 hours at 1m
|
|
338
|
+
// granularity and merge; hours already covered by a returned 1h bucket
|
|
339
|
+
// are deduplicated in the rollup below. The tail exists solely to
|
|
340
|
+
// recover those not-yet-materialized recent hours (materialization lag
|
|
341
|
+
// measured at up to ~1h), so it is skipped when the range's stop is old
|
|
342
|
+
// enough that every hourly bucket has long since landed.
|
|
343
|
+
if (windowEvery === "1h") {
|
|
344
|
+
const stopDate = new Date(stop);
|
|
345
|
+
const startDate = new Date(start);
|
|
346
|
+
const twoHoursMs = 2 * 60 * 60 * 1000;
|
|
347
|
+
const tailStartMs = Math.max(startDate.getTime(), stopDate.getTime() - twoHoursMs);
|
|
348
|
+
const recentStopMs = 3 * 60 * 60 * 1000;
|
|
349
|
+
if (tailStartMs < stopDate.getTime() && Date.now() - stopDate.getTime() < recentStopMs) {
|
|
350
|
+
try {
|
|
351
|
+
const tailResponse = await new ReportingRequestBuilder()
|
|
352
|
+
.assets(queryAssetType, [spaceId])
|
|
353
|
+
.measurements(["traffic"])
|
|
354
|
+
.timeRange(new Date(tailStartMs).toISOString(), stop)
|
|
355
|
+
.window("1m", "sum", timezone)
|
|
356
|
+
.execute();
|
|
357
|
+
if (Array.isArray(tailResponse.data)) {
|
|
358
|
+
trafficData = trafficData.concat(tailResponse.data.map((p) => ({ ...p, fine: true })));
|
|
359
|
+
debug("traffic-flow", `Merged ${tailResponse.data.length} tail data points (1m)`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch (tailError) {
|
|
363
|
+
// The closed-hour data is still valid without the tail
|
|
364
|
+
debug("traffic-flow", "Partial-hour tail query failed:", tailError);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
239
368
|
}
|
|
240
369
|
catch (error) {
|
|
241
370
|
rethrowIfGraphQLError(error);
|
|
242
371
|
debug("traffic-flow", "Failed to get traffic data:", error);
|
|
243
372
|
const msg = error instanceof Error ? error.message : String(error);
|
|
244
|
-
throw new Error(`Failed to get traffic data for ${
|
|
373
|
+
throw new Error(`Failed to get traffic data for ${displayName}. ${msg}`);
|
|
245
374
|
}
|
|
246
375
|
// Parse traffic data: group by time, then by sensor, then aggregate
|
|
247
|
-
// Store time separately to preserve full ISO timestamp
|
|
376
|
+
// Store time separately to preserve full ISO timestamp. The fine flag is
|
|
377
|
+
// part of the key: a 1m tail bucket ending exactly on an hour boundary
|
|
378
|
+
// carries the same label as that hour's native 1h bucket, and must not
|
|
379
|
+
// share (and overwrite) the native bucket's entry.
|
|
248
380
|
const byHourSensor = new Map();
|
|
249
381
|
for (const point of trafficData) {
|
|
250
|
-
const key = `${point.time}:${point.sensor_id}`;
|
|
382
|
+
const key = `${point.time}:${point.sensor_id}:${point.fine}`;
|
|
251
383
|
if (!byHourSensor.has(key)) {
|
|
252
|
-
byHourSensor.set(key, {
|
|
384
|
+
byHourSensor.set(key, {
|
|
385
|
+
time: point.time,
|
|
386
|
+
sensor_id: point.sensor_id,
|
|
387
|
+
fine: point.fine,
|
|
388
|
+
in: 0,
|
|
389
|
+
out: 0,
|
|
390
|
+
});
|
|
253
391
|
}
|
|
254
392
|
const counts = byHourSensor.get(key);
|
|
255
393
|
if (point.field === "in") {
|
|
@@ -259,10 +397,48 @@ export async function executeTrafficFlow(args) {
|
|
|
259
397
|
counts.out = point.value || 0;
|
|
260
398
|
}
|
|
261
399
|
}
|
|
400
|
+
// Roll a bucket timestamp up to its enclosing hour. Bucket timestamps are
|
|
401
|
+
// end-labeled (a 1m bucket "17:29" covers 17:28–17:29), so ceil to the
|
|
402
|
+
// hour to stay on the same end-labeled convention the API uses for 1h
|
|
403
|
+
// buckets.
|
|
404
|
+
const toHourEnd = (iso) => {
|
|
405
|
+
const d = new Date(iso);
|
|
406
|
+
if (d.getUTCMinutes() !== 0 || d.getUTCSeconds() !== 0 || d.getUTCMilliseconds() !== 0) {
|
|
407
|
+
d.setUTCMinutes(0, 0, 0);
|
|
408
|
+
d.setUTCHours(d.getUTCHours() + 1);
|
|
409
|
+
}
|
|
410
|
+
return d.toISOString().replace(".000Z", "Z");
|
|
411
|
+
};
|
|
412
|
+
// Native (closed) 1h buckets are authoritative for the interval they
|
|
413
|
+
// cover, so fine-grained tail minutes falling inside any of them are
|
|
414
|
+
// dropped to avoid double counting. Coverage is checked on the bucket
|
|
415
|
+
// intervals themselves — an end-labeled 1h bucket T covers (T-1h, T] —
|
|
416
|
+
// rather than on labels, so it also holds for sites whose local hour grid
|
|
417
|
+
// is not UTC-aligned (e.g. :30-offset timezones). Coverage is tracked
|
|
418
|
+
// per sensor: bucket materialization is a per-series flush, so one
|
|
419
|
+
// sensor's landed hour must not suppress another sensor's tail minutes
|
|
420
|
+
// for that same hour.
|
|
421
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
422
|
+
const nativeEndsBySensor = new Map();
|
|
423
|
+
for (const [, data] of byHourSensor) {
|
|
424
|
+
if (!data.fine) {
|
|
425
|
+
const ends = nativeEndsBySensor.get(data.sensor_id) || [];
|
|
426
|
+
ends.push(new Date(data.time).getTime());
|
|
427
|
+
nativeEndsBySensor.set(data.sensor_id, ends);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const coveredByNative = (sensorId, iso) => {
|
|
431
|
+
const t = new Date(iso).getTime();
|
|
432
|
+
const ends = nativeEndsBySensor.get(sensorId);
|
|
433
|
+
return ends ? ends.some((end) => t <= end && t > end - HOUR_MS) : false;
|
|
434
|
+
};
|
|
262
435
|
// Aggregate across sensors by hour (group by time only)
|
|
263
436
|
const byHour = new Map();
|
|
264
437
|
for (const [_key, data] of byHourSensor) {
|
|
265
|
-
|
|
438
|
+
if (data.fine && coveredByNative(data.sensor_id, data.time)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const time = data.fine ? toHourEnd(data.time) : data.time;
|
|
266
442
|
if (!byHour.has(time)) {
|
|
267
443
|
byHour.set(time, { in: 0, out: 0 });
|
|
268
444
|
}
|
|
@@ -294,13 +470,32 @@ export async function executeTrafficFlow(args) {
|
|
|
294
470
|
}
|
|
295
471
|
// Build enhanced summary
|
|
296
472
|
const netFlowStr = netFlow >= 0 ? `+${netFlow}` : `${netFlow}`;
|
|
297
|
-
const summary = `${
|
|
473
|
+
const summary = `${displayName}: ${totalTraffic.toLocaleString()} movements ${periodDescription} (${totalEntries.toLocaleString()} entries, ${totalExits.toLocaleString()} exits, net flow: ${netFlowStr})`;
|
|
474
|
+
// Compose the response warning. Two independent conditions can raise one and
|
|
475
|
+
// both matter, so they are joined rather than one silently shadowing the other.
|
|
476
|
+
const warnings = [];
|
|
477
|
+
if (installationWarning) {
|
|
478
|
+
warnings.push(installationWarning);
|
|
479
|
+
}
|
|
480
|
+
if (usedUtcFallback) {
|
|
481
|
+
// Window-aware copy. The midnight sentence is only true on the `today`
|
|
482
|
+
// branch. The unassigned-sensor path sets timezoneFallback for every
|
|
483
|
+
// room-less, floor-less sensor, so on a 20m/1h/custom window the old text
|
|
484
|
+
// fired routinely and sent the reader chasing a day-alignment problem that
|
|
485
|
+
// no part of the query involved.
|
|
486
|
+
// `timezone` holds whatever the fallback actually resolved to — UTC, or
|
|
487
|
+
// BUTLR_TIMEZONE when configured — so the copy names it rather than
|
|
488
|
+
// asserting UTC for a query that may have used something else.
|
|
489
|
+
warnings.push(timeWindow === "today"
|
|
490
|
+
? `Could not determine local timezone for this space; timestamps use midnight in the fallback timezone (${timezone}). 'Today' may not align with the site's actual local day.`
|
|
491
|
+
: `Could not determine local timezone for this space; hourly bucket alignment falls back to ${timezone}, so buckets may not line up with the site's local hours. The queried range itself is unaffected.`);
|
|
492
|
+
}
|
|
298
493
|
// Build response with timezone metadata and in/out breakdown
|
|
299
494
|
const response = {
|
|
300
495
|
space: {
|
|
301
|
-
id:
|
|
302
|
-
name:
|
|
303
|
-
type: "room",
|
|
496
|
+
id: spaceId,
|
|
497
|
+
name: displayName,
|
|
498
|
+
type: isSensorQuery ? "sensor" : "room",
|
|
304
499
|
path: roomPath,
|
|
305
500
|
sensor_mode: "traffic",
|
|
306
501
|
...tzMetadata,
|
|
@@ -330,9 +525,12 @@ export async function executeTrafficFlow(args) {
|
|
|
330
525
|
summary,
|
|
331
526
|
timestamp: new Date().toISOString(),
|
|
332
527
|
timezone_note: "All timestamps are UTC (ISO-8601). Use site_timezone to interpret in local time.",
|
|
333
|
-
|
|
334
|
-
|
|
528
|
+
// Traffic events take ~5-6 minutes to land in the reporting store, so a
|
|
529
|
+
// window ending at/near now structurally undercounts the trailing minutes
|
|
530
|
+
...(Date.now() - new Date(stop).getTime() < 10 * 60 * 1000 && {
|
|
531
|
+
freshness_note: "Traffic data becomes available roughly 5-10 minutes after events occur. Counts for the most recent ~10 minutes may still be incomplete; re-query later for final numbers.",
|
|
335
532
|
}),
|
|
533
|
+
...(warnings.length > 0 && { warning: warnings.join(" ") }),
|
|
336
534
|
};
|
|
337
535
|
return response;
|
|
338
536
|
}
|