stella-coder 5.2.1 → 5.3.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.
@@ -0,0 +1,415 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import os from "node:os"
4
+ import { execSync } from "node:child_process"
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), ".stella", "gmail")
7
+ const TOKEN_FILE = path.join(CONFIG_DIR, "token.json")
8
+ const CREDS_FILE = path.join(CONFIG_DIR, "credentials.json")
9
+ const SENT_DIR = path.join(CONFIG_DIR, "sent")
10
+
11
+ function ensureDirs() {
12
+ for (const dir of [CONFIG_DIR, SENT_DIR]) {
13
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
14
+ }
15
+ }
16
+
17
+ function getOAuth2URL(clientId, redirectUri) {
18
+ const scopes = [
19
+ "https://www.googleapis.com/auth/gmail.send",
20
+ "https://www.googleapis.com/auth/gmail.readonly",
21
+ "https://www.googleapis.com/auth/gmail.modify",
22
+ "https://www.googleapis.com/auth/gmail.labels",
23
+ ]
24
+ const params = new URLSearchParams({
25
+ client_id: clientId,
26
+ redirect_uri: redirectUri,
27
+ response_type: "code",
28
+ scope: scopes.join(" "),
29
+ access_type: "offline",
30
+ prompt: "consent",
31
+ })
32
+ return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`
33
+ }
34
+
35
+ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
36
+ const resp = await fetch("https://oauth2.googleapis.com/token", {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
39
+ body: new URLSearchParams({
40
+ client_id: clientId,
41
+ client_secret: clientSecret,
42
+ code,
43
+ grant_type: "authorization_code",
44
+ redirect_uri: redirectUri,
45
+ }).toString(),
46
+ })
47
+ return await resp.json()
48
+ }
49
+
50
+ async function refreshToken(clientId, clientSecret, refreshTokenValue) {
51
+ const resp = await fetch("https://oauth2.googleapis.com/token", {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
54
+ body: new URLSearchParams({
55
+ client_id: clientId,
56
+ client_secret: clientSecret,
57
+ refresh_token: refreshTokenValue,
58
+ grant_type: "refresh_token",
59
+ }).toString(),
60
+ })
61
+ return await resp.json()
62
+ }
63
+
64
+ function loadToken() {
65
+ if (!fs.existsSync(TOKEN_FILE)) return null
66
+ return JSON.parse(fs.readFileSync(TOKEN_FILE, "utf8"))
67
+ }
68
+
69
+ function saveToken(token) {
70
+ ensureDirs()
71
+ fs.writeFileSync(TOKEN_FILE, JSON.stringify(token, null, 2))
72
+ }
73
+
74
+ function loadCredentials() {
75
+ if (!fs.existsSync(CREDS_FILE)) return null
76
+ return JSON.parse(fs.readFileSync(CREDS_FILE, "utf8"))
77
+ }
78
+
79
+ function buildMimeMessage({ to, cc, bcc, subject, body, isHtml, attachments }) {
80
+ const boundary = `stella_boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`
81
+ const lines = []
82
+
83
+ lines.push(`To: ${to}`)
84
+ if (cc) lines.push(`Cc: ${cc}`)
85
+ if (bcc) lines.push(`Bcc: ${bcc}`)
86
+ lines.push(`Subject: ${subject}`)
87
+ lines.push("MIME-Version: 1.0")
88
+
89
+ if (attachments && attachments.length > 0) {
90
+ lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`)
91
+ lines.push("")
92
+ lines.push(`--${boundary}`)
93
+ if (isHtml) {
94
+ lines.push("Content-Type: text/html; charset=UTF-8")
95
+ } else {
96
+ lines.push("Content-Type: text/plain; charset=UTF-8")
97
+ }
98
+ lines.push("")
99
+ lines.push(body)
100
+ for (const att of attachments) {
101
+ const filePath = att
102
+ if (fs.existsSync(filePath)) {
103
+ const content = fs.readFileSync(filePath)
104
+ const b64 = content.toString("base64")
105
+ const name = path.basename(filePath)
106
+ lines.push("")
107
+ lines.push(`--${boundary}`)
108
+ lines.push(`Content-Type: application/octet-stream; name="${name}"`)
109
+ lines.push(`Content-Disposition: attachment; filename="${name}"`)
110
+ lines.push("Content-Transfer-Encoding: base64")
111
+ lines.push("")
112
+ lines.push(b64.match(/.{1,76}/g).join("\n"))
113
+ }
114
+ }
115
+ lines.push("")
116
+ lines.push(`--${boundary}--`)
117
+ } else {
118
+ if (isHtml) {
119
+ lines.push("Content-Type: text/html; charset=UTF-8")
120
+ } else {
121
+ lines.push("Content-Type: text/plain; charset=UTF-8")
122
+ }
123
+ lines.push("")
124
+ lines.push(body)
125
+ }
126
+
127
+ return Buffer.from(lines.join("\r\n")).toString("base64url")
128
+ }
129
+
130
+ async function gmailApiRequest(accessToken, method, url, body) {
131
+ const opts = {
132
+ method,
133
+ headers: { Authorization: `Bearer ${accessToken}` },
134
+ }
135
+ if (body) {
136
+ opts.headers["Content-Type"] = "application/json"
137
+ opts.body = JSON.stringify(body)
138
+ }
139
+ const resp = await fetch(url, opts)
140
+ return await resp.json()
141
+ }
142
+
143
+ export class GmailClient {
144
+ constructor() {
145
+ ensureDirs()
146
+ }
147
+
148
+ isConfigured() {
149
+ return fs.existsSync(CREDS_FILE) && fs.existsSync(TOKEN_FILE)
150
+ }
151
+
152
+ getSetupURL() {
153
+ const creds = loadCredentials()
154
+ if (!creds || !creds.installed || !creds.installed.client_id) return null
155
+ const redirectUri = creds.installed.redirect_uris?.[0] || "http://localhost:3456/callback"
156
+ return getOAuth2URL(creds.installed.client_id, redirectUri)
157
+ }
158
+
159
+ async completeAuth(code) {
160
+ const creds = loadCredentials()
161
+ if (!creds || !creds.installed) return { success: false, error: "No credentials.json" }
162
+ const redirectUri = creds.installed.redirect_uris?.[0] || "http://localhost:3456/callback"
163
+ const token = await exchangeCode(
164
+ creds.installed.client_id,
165
+ creds.installed.client_secret,
166
+ code,
167
+ redirectUri
168
+ )
169
+ if (token.error) return { success: false, error: token.error_description || token.error }
170
+ saveToken(token)
171
+ return { success: true }
172
+ }
173
+
174
+ async getAccessToken() {
175
+ const creds = loadCredentials()
176
+ const token = loadToken()
177
+ if (!creds || !token) return null
178
+ if (token.expiry_date && token.expiry_date > Date.now()) return token.access_token
179
+ const refreshed = await refreshToken(
180
+ creds.installed.client_id,
181
+ creds.installed.client_secret,
182
+ token.refresh_token
183
+ )
184
+ if (refreshed.access_token) {
185
+ token.access_token = refreshed.access_token
186
+ token.expiry_date = Date.now() + (refreshed.expires_in || 3600) * 1000
187
+ saveToken(token)
188
+ return refreshed.access_token
189
+ }
190
+ return null
191
+ }
192
+
193
+ async sendEmail({ to, cc, bcc, subject, body, isHtml = false, attachments = [] }) {
194
+ const accessToken = await this.getAccessToken()
195
+ if (!accessToken) return { success: false, error: "Not authenticated. Run /gmail-setup first." }
196
+
197
+ const raw = buildMimeMessage({ to, cc, bcc, subject, body, isHtml, attachments })
198
+ const result = await gmailApiRequest(
199
+ accessToken,
200
+ "POST",
201
+ "https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
202
+ { raw }
203
+ )
204
+
205
+ if (result.id) {
206
+ const sentFile = path.join(SENT_DIR, `${Date.now()}.json`)
207
+ fs.writeFileSync(sentFile, JSON.stringify({
208
+ id: result.id,
209
+ to, cc, bcc, subject,
210
+ date: new Date().toISOString(),
211
+ }, null, 2))
212
+ return { success: true, messageId: result.id }
213
+ }
214
+ return { success: false, error: result.error?.message || "Send failed" }
215
+ }
216
+
217
+ async listMessages({ query = "", maxResults = 20, labelIds = [] } = {}) {
218
+ const accessToken = await this.getAccessToken()
219
+ if (!accessToken) return { success: false, error: "Not authenticated" }
220
+
221
+ const params = new URLSearchParams({ maxResults: String(maxResults) })
222
+ if (query) params.set("q", query)
223
+ for (const lid of labelIds) params.append("labelIds", lid)
224
+
225
+ const list = await gmailApiRequest(
226
+ accessToken,
227
+ "GET",
228
+ `https://gmail.googleapis.com/gmail/v1/users/me/messages?${params.toString()}`
229
+ )
230
+
231
+ if (!list.messages) return { success: true, messages: [] }
232
+
233
+ const messages = []
234
+ for (const msg of list.messages.slice(0, maxResults)) {
235
+ const detail = await gmailApiRequest(
236
+ accessToken,
237
+ "GET",
238
+ `https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata`
239
+ )
240
+ const headers = detail.payload?.headers || []
241
+ const get = (name) => headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || ""
242
+ messages.push({
243
+ id: msg.id,
244
+ threadId: msg.threadId,
245
+ subject: get("Subject"),
246
+ from: get("From"),
247
+ to: get("To"),
248
+ date: get("Date"),
249
+ snippet: detail.snippet || "",
250
+ labels: detail.labelIds || [],
251
+ })
252
+ }
253
+ return { success: true, messages }
254
+ }
255
+
256
+ async getMessage(messageId) {
257
+ const accessToken = await this.getAccessToken()
258
+ if (!accessToken) return { success: false, error: "Not authenticated" }
259
+
260
+ const msg = await gmailApiRequest(
261
+ accessToken,
262
+ "GET",
263
+ `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`
264
+ )
265
+
266
+ if (!msg.id) return { success: false, error: "Message not found" }
267
+
268
+ const headers = msg.payload?.headers || []
269
+ const get = (name) => headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || ""
270
+
271
+ let textBody = ""
272
+ let htmlBody = ""
273
+ const attachments = []
274
+
275
+ function extractParts(payload) {
276
+ if (!payload) return
277
+ if (payload.mimeType === "text/plain" && payload.body?.data) {
278
+ textBody += Buffer.from(payload.body.data, "base64url").toString("utf8")
279
+ }
280
+ if (payload.mimeType === "text/html" && payload.body?.data) {
281
+ htmlBody += Buffer.from(payload.body.data, "base64url").toString("utf8")
282
+ }
283
+ if (payload.filename && payload.body?.attachmentId) {
284
+ attachments.push({
285
+ filename: payload.filename,
286
+ mimeType: payload.mimeType,
287
+ size: payload.body.size,
288
+ attachmentId: payload.body.attachmentId,
289
+ })
290
+ }
291
+ for (const part of payload.parts || []) {
292
+ extractParts(part)
293
+ }
294
+ }
295
+
296
+ extractParts(msg.payload)
297
+
298
+ return {
299
+ success: true,
300
+ message: {
301
+ id: msg.id,
302
+ threadId: msg.threadId,
303
+ subject: get("Subject"),
304
+ from: get("From"),
305
+ to: get("To"),
306
+ cc: get("Cc"),
307
+ date: get("Date"),
308
+ textBody,
309
+ htmlBody,
310
+ attachments,
311
+ labels: msg.labelIds || [],
312
+ snippet: msg.snippet || "",
313
+ },
314
+ }
315
+ }
316
+
317
+ async getAttachment(messageId, attachmentId, filename) {
318
+ const accessToken = await this.getAccessToken()
319
+ if (!accessToken) return { success: false, error: "Not authenticated" }
320
+
321
+ const resp = await fetch(
322
+ `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/attachments/${attachmentId}`,
323
+ { headers: { Authorization: `Bearer ${accessToken}` } }
324
+ )
325
+ const data = await resp.json()
326
+ if (data.data) {
327
+ const buffer = Buffer.from(data.data, "base64url")
328
+ const savePath = path.join(SENT_DIR, filename)
329
+ fs.writeFileSync(savePath, buffer)
330
+ return { success: true, path: savePath, size: buffer.length }
331
+ }
332
+ return { success: false, error: "Attachment not found" }
333
+ }
334
+
335
+ async listLabels() {
336
+ const accessToken = await this.getAccessToken()
337
+ if (!accessToken) return { success: false, error: "Not authenticated" }
338
+
339
+ const result = await gmailApiRequest(
340
+ accessToken,
341
+ "GET",
342
+ "https://gmail.googleapis.com/gmail/v1/users/me/labels"
343
+ )
344
+
345
+ return {
346
+ success: true,
347
+ labels: (result.labels || []).map(l => ({ id: l.id, name: l.name, type: l.type })),
348
+ }
349
+ }
350
+
351
+ async markAsRead(messageId) {
352
+ const accessToken = await this.getAccessToken()
353
+ if (!accessToken) return { success: false, error: "Not authenticated" }
354
+
355
+ await gmailApiRequest(
356
+ accessToken,
357
+ "POST",
358
+ `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`,
359
+ { removeLabelIds: ["UNREAD"] }
360
+ )
361
+ return { success: true }
362
+ }
363
+
364
+ async markAsUnread(messageId) {
365
+ const accessToken = await this.getAccessToken()
366
+ if (!accessToken) return { success: false, error: "Not authenticated" }
367
+
368
+ await gmailApiRequest(
369
+ accessToken,
370
+ "POST",
371
+ `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`,
372
+ { addLabelIds: ["UNREAD"] }
373
+ )
374
+ return { success: true }
375
+ }
376
+
377
+ async deleteMessage(messageId) {
378
+ const accessToken = await this.getAccessToken()
379
+ if (!accessToken) return { success: false, error: "Not authenticated" }
380
+
381
+ await gmailApiRequest(
382
+ accessToken,
383
+ "DELETE",
384
+ `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}`
385
+ )
386
+ return { success: true }
387
+ }
388
+
389
+ async getProfile() {
390
+ const accessToken = await this.getAccessToken()
391
+ if (!accessToken) return { success: false, error: "Not authenticated" }
392
+
393
+ const result = await gmailApiRequest(
394
+ accessToken,
395
+ "GET",
396
+ "https://gmail.googleapis.com/gmail/v1/users/me/profile"
397
+ )
398
+ return {
399
+ success: true,
400
+ email: result.emailAddress,
401
+ totalMessages: result.messagesTotal,
402
+ }
403
+ }
404
+
405
+ async searchMessages(query, maxResults = 10) {
406
+ return this.listMessages({ query, maxResults })
407
+ }
408
+
409
+ async getUnreadCount() {
410
+ const result = await this.listMessages({ query: "is:unread", maxResults: 1, labelIds: ["INBOX"] })
411
+ if (!result.success) return 0
412
+ const full = await this.listMessages({ query: "is:unread", maxResults: 100 })
413
+ return full.messages?.length || 0
414
+ }
415
+ }
@@ -0,0 +1,168 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import os from "node:os"
4
+
5
+ const CONFIG_DIR = path.join(os.homedir(), ".stella", "ha")
6
+
7
+ export class HomeAssistant {
8
+ constructor(url = "", token = "") {
9
+ this.url = (url || process.env.HA_URL || "").replace(/\/$/, "")
10
+ this.token = token || process.env.HA_TOKEN || ""
11
+ if (!this.url && fs.existsSync(path.join(CONFIG_DIR, "config.json"))) {
12
+ try {
13
+ const cfg = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "config.json"), "utf8"))
14
+ this.url = cfg.url || ""
15
+ this.token = cfg.token || ""
16
+ } catch {}
17
+ }
18
+ }
19
+
20
+ isConfigured() { return !!(this.url && this.token) }
21
+
22
+ configure(url, token) {
23
+ this.url = url.replace(/\/$/, "")
24
+ this.token = token
25
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true })
26
+ fs.writeFileSync(path.join(CONFIG_DIR, "config.json"), JSON.stringify({ url: this.url, token: this.token }, null, 2))
27
+ }
28
+
29
+ async request(method, endpoint, body) {
30
+ const resp = await fetch(`${this.url}/api/${endpoint}`, {
31
+ method,
32
+ headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
33
+ ...(body ? { body: JSON.stringify(body) } : {}),
34
+ })
35
+ if (resp.status === 404) return { success: false, error: "Not found" }
36
+ if (!resp.ok) return { success: false, error: `HTTP ${resp.status}` }
37
+ const text = await resp.text()
38
+ try { return { success: true, data: JSON.parse(text) } }
39
+ catch { return { success: true, data: text } }
40
+ }
41
+
42
+ async getStates() { return this.request("GET", "states") }
43
+ async getState(entityId) { return this.request("GET", `states/${entityId}`) }
44
+
45
+ async callService(domain, service, data = {}) {
46
+ return this.request("POST", `services/${domain}/${service}`, data)
47
+ }
48
+
49
+ async toggle(entityId) { return this.callService("homeassistant", "toggle", { entity_id: entityId }) }
50
+ async turnOn(entityId) { return this.callService("homeassistant", "turn_on", { entity_id: entityId }) }
51
+ async turnOff(entityId) { return this.callService("homeassistant", "turn_off", { entity_id: entityId }) }
52
+
53
+ async getConfig() { return this.request("GET", "config") }
54
+ async getLogbook(startTimestamp) {
55
+ const start = startTimestamp || new Date(Date.now() - 86400000).toISOString()
56
+ return this.request("GET", `logbook/${start}`)
57
+ }
58
+
59
+ async getHistory(entityId, hours = 24) {
60
+ const start = new Date(Date.now() - hours * 3600000).toISOString()
61
+ return this.request("GET", `history/period/${start}?filter_entity_id=${entityId}`)
62
+ }
63
+
64
+ async getTemplate(template) {
65
+ return this.request("POST", "template", { template })
66
+ }
67
+
68
+ async fireEvent(eventType, data = {}) {
69
+ return this.request("POST", `events/${eventType}`, data)
70
+ }
71
+
72
+ async getServices() { return this.request("GET", "services") }
73
+
74
+ async getEntitySources() { return this.request("GET", "config/entity_registry") }
75
+
76
+ async getAutoInfo() {
77
+ const s = await this.request("GET", "config")
78
+ return { success: true, haVersion: s.data?.version, locationName: s.data?.location_name, timezone: s.data?.time_zone }
79
+ }
80
+
81
+ async getLights() {
82
+ const states = await this.getStates()
83
+ if (!states.success) return states
84
+ const lights = states.data.filter(s => s.entity_id.startsWith("light."))
85
+ return { success: true, lights: lights.map(l => ({ entity: l.entity_id, state: l.state, brightness: l.attributes?.brightness, friendlyName: l.attributes?.friendly_name })) }
86
+ }
87
+
88
+ async getSensors() {
89
+ const states = await this.getStates()
90
+ if (!states.success) return states
91
+ const sensors = states.data.filter(s => s.entity_id.startsWith("sensor."))
92
+ return { success: true, sensors: sensors.map(s => ({ entity: s.entity_id, state: s.state, unit: s.attributes?.unit_of_measurement, friendlyName: s.attributes?.friendly_name })) }
93
+ }
94
+
95
+ async getSwitches() {
96
+ const states = await this.getStates()
97
+ if (!states.success) return states
98
+ const switches = states.data.filter(s => s.entity_id.startsWith("switch."))
99
+ return { success: true, switches: switches.map(s => ({ entity: s.entity_id, state: s.state, friendlyName: s.attributes?.friendly_name })) }
100
+ }
101
+
102
+ async getClimate() {
103
+ const states = await this.getStates()
104
+ if (!states.success) return states
105
+ const climate = states.data.filter(s => s.entity_id.startsWith("climate."))
106
+ return { success: true, devices: climate.map(c => ({ entity: c.entity_id, state: c.state, temp: c.attributes?.temperature, currentTemp: c.attributes?.current_temperature, mode: c.attributes?.hvac_mode, friendlyName: c.attributes?.friendly_name })) }
107
+ }
108
+
109
+ async setClimateTemp(entityId, temp) {
110
+ return this.callService("climate", "set_temperature", { entity_id: entityId, temperature: temp })
111
+ }
112
+
113
+ async setClimateMode(entityId, mode) {
114
+ return this.callService("climate", "set_hvac_mode", { entity_id: entityId, hvac_mode: mode })
115
+ }
116
+
117
+ async getMediaPlayers() {
118
+ const states = await this.getStates()
119
+ if (!states.success) return states
120
+ return { success: true, players: states.data.filter(s => s.entity_id.startsWith("media_player.")).map(p => ({ entity: p.entity_id, state: p.state, friendlyName: p.attributes?.friendly_name, source: p.attributes?.source, volume: p.attributes?.volume_level })) }
121
+ }
122
+
123
+ async mediaPlay(entityId) { return this.callService("media_player", "media_play", { entity_id: entityId }) }
124
+ async mediaPause(entityId) { return this.callService("media_player", "media_pause", { entity_id: entityId }) }
125
+ async mediaStop(entityId) { return this.callService("media_player", "media_stop", { entity_id: entityId }) }
126
+ async mediaNext(entityId) { return this.callService("media_player", "media_next_track", { entity_id: entityId }) }
127
+ async mediaPrev(entityId) { return this.callService("media_player", "media_previous_track", { entity_id: entityId }) }
128
+ async setVolume(entityId, level) { return this.callService("media_player", "volume_set", { entity_id: entityId, volume_level: level }) }
129
+ async selectSource(entityId, source) { return this.callService("media_player", "select_source", { entity_id: entityId, source }) }
130
+
131
+ async getCovers() {
132
+ const states = await this.getStates()
133
+ if (!states.success) return states
134
+ return { success: true, covers: states.data.filter(s => s.entity_id.startsWith("cover.")).map(c => ({ entity: c.entity_id, state: c.state, friendlyName: c.attributes?.friendly_name, position: c.attributes?.current_position })) }
135
+ }
136
+
137
+ async openCover(entityId) { return this.callService("cover", "open_cover", { entity_id: entityId }) }
138
+ async closeCover(entityId) { return this.callService("cover", "close_cover", { entity_id: entityId }) }
139
+ async stopCover(entityId) { return this.callService("cover", "stop_cover", { entity_id: entityId }) }
140
+ async setCoverPosition(entityId, pos) { return this.callService("cover", "set_cover_position", { entity_id: entityId, position: pos }) }
141
+
142
+ async setBrightness(entityId, brightness) {
143
+ return this.callService("light", "turn_on", { entity_id: entityId, brightness })
144
+ }
145
+
146
+ async setColor(entityId, color) {
147
+ return this.callService("light", "turn_on", { entity_id: entityId, rgb_color: color })
148
+ }
149
+
150
+ async getGroup(entityId) {
151
+ return this.request("GET", `states/${entityId}`)
152
+ }
153
+
154
+ async ping() {
155
+ try {
156
+ const resp = await fetch(`${this.url}/api/`, { headers: { Authorization: `Bearer ${this.token}` } })
157
+ return { success: resp.ok, status: resp.status }
158
+ } catch (e) {
159
+ return { success: false, error: e.message }
160
+ }
161
+ }
162
+
163
+ async getAllEntityIds() {
164
+ const states = await this.getStates()
165
+ if (!states.success) return states
166
+ return { success: true, entities: states.data.map(s => s.entity_id).sort() }
167
+ }
168
+ }