homebridge-salus-cloud 0.1.2 → 0.1.4

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.
@@ -20,9 +20,15 @@ const DEFAULT_EU_SERVICE_API_HOST = 'https://service-api.eu.premium.salusconnect
20
20
  const DEFAULT_US_SERVICE_API_HOST = 'https://service-api.us.premium.salusconnect.io';
21
21
  const FALLBACK_US_SERVICE_API_HOST = 'https://service-api.us.salusconnect.io';
22
22
  const FALLBACK_EU_SERVICE_API_HOST = 'https://service-api.eu.salusconnect.io';
23
+ const DEFAULT_EU_LEGACY_API_HOST = 'https://eu.premium.salusconnect.io';
24
+ const DEFAULT_US_LEGACY_API_HOST = 'https://us.premium.salusconnect.io';
25
+ const FALLBACK_US_LEGACY_API_HOST = 'https://us.salusconnect.io';
26
+ const FALLBACK_EU_LEGACY_API_HOST = 'https://eu.salusconnect.io';
23
27
  const COGNITO_INITIATE_AUTH_TARGET = 'AWSCognitoIdentityProviderService.InitiateAuth';
24
28
  const ACCEPT_LANGUAGE = 'en-US,en;q=0.9,en;q=0.8';
25
29
  const SESSION_REFRESH_SAFETY_MS = 60_000;
30
+ const LEGACY_SESSION_REFRESH_SAFETY_MS = 60_000;
31
+ const LEGACY_DEFAULT_SESSION_TTL_MS = 45 * 60_000;
26
32
  const STATUS_ALLOW_PATH_FALLBACK = new Set([404, 405, 426]);
27
33
  const STATUS_ALLOW_WRITE_SHAPE_FALLBACK = new Set([400, 404, 405, 409, 415, 422]);
28
34
  const DEFAULT_EXPECTED_STATUSES = [200, 201, 202, 204];
@@ -37,6 +43,22 @@ const RETRIABLE_ERROR_CODES = new Set([
37
43
  'UND_ERR_CONNECT_TIMEOUT',
38
44
  'UND_ERR_SOCKET',
39
45
  ]);
46
+ const DEFAULT_COMPANY_CODE_FALLBACKS = [
47
+ 'SALUS',
48
+ 'SALUS_US',
49
+ 'HEATLINK_US',
50
+ 'MRPEX_US',
51
+ 'NEOTHERM_EU',
52
+ 'OMNIE_EU',
53
+ 'PURMO',
54
+ 'CLP_SG',
55
+ 'CLP',
56
+ 'HEATLINK',
57
+ 'MRPEX',
58
+ 'NEOTHERM',
59
+ 'OMNIE',
60
+ ];
61
+ const NO_COMPANY_CODE_SENTINEL = '__none__';
40
62
  const METADATA_FIELD_NAMES = new Set([
41
63
  'id',
42
64
  'key',
@@ -56,6 +78,7 @@ const METADATA_FIELD_NAMES = new Set([
56
78
  'reported',
57
79
  'desired',
58
80
  'version',
81
+ 'value',
59
82
  'gateway',
60
83
  'gateway_id',
61
84
  'user_id',
@@ -66,16 +89,25 @@ export class SalusCloudClient {
66
89
  config;
67
90
  session = null;
68
91
  authRequestInFlight = null;
92
+ legacySession = null;
93
+ legacyAuthRequestInFlight = null;
69
94
  requestTimeoutMs;
70
95
  maxRetries;
71
96
  retryBaseDelayMs;
72
97
  verboseLogging;
73
98
  allowInsecureTls;
74
99
  serviceApiBaseCandidates;
100
+ legacyApiBaseCandidates;
75
101
  cognitoEndpoint;
76
102
  cognitoClientId;
77
103
  configuredCompanyCode;
104
+ companyCodeCandidates = [];
105
+ activeCompanyCode = null;
106
+ hasWarnedAboutAuthCompanyCode = false;
78
107
  activeServiceApiBaseUrl = null;
108
+ activeLegacyApiBaseUrl = null;
109
+ apiTransportMode = 'modern';
110
+ hasWarnedAboutLegacyFallback = false;
79
111
  propertyCacheByDsn = new Map();
80
112
  deviceIdToDsn = new Map();
81
113
  deviceKeyToDsn = new Map();
@@ -94,22 +126,44 @@ export class SalusCloudClient {
94
126
  this.verboseLogging = config.verboseLogging ?? false;
95
127
  this.allowInsecureTls = config.allowInsecureTls ?? false;
96
128
  this.serviceApiBaseCandidates = buildServiceApiBaseCandidates(config.region, config.apiHost, config.apiVersionPreference);
129
+ this.legacyApiBaseCandidates = buildLegacyApiBaseCandidates(config.region, config.apiHost);
97
130
  const cognitoRegion = normalizeNonEmptyString(config.cognitoRegion) ?? DEFAULT_COGNITO_REGION;
98
131
  this.cognitoClientId = normalizeNonEmptyString(config.cognitoClientId) ?? DEFAULT_COGNITO_CLIENT_ID;
99
132
  this.cognitoEndpoint = `https://cognito-idp.${cognitoRegion}.amazonaws.com/`;
100
133
  this.configuredCompanyCode = normalizeNonEmptyString(config.companyCode) ?? null;
134
+ this.refreshCompanyCodeCandidates();
101
135
  if (this.allowInsecureTls) {
102
136
  this.log.warn('TLS certificate validation is disabled for Salus cloud requests (allowInsecureTls=true).');
103
137
  }
104
138
  if (this.verboseLogging) {
105
139
  this.log.debug(`Salus service-api candidates: ${this.serviceApiBaseCandidates.join(', ')}`);
140
+ this.log.debug(`Salus legacy-api candidates: ${this.legacyApiBaseCandidates.join(', ')}`);
106
141
  this.log.debug(`Salus Cognito endpoint: ${this.cognitoEndpoint}`);
142
+ this.log.debug(`Salus company-code candidates: ${this.companyCodeCandidates.map((candidate) => candidate ?? '<none>').join(', ')}`);
107
143
  }
108
144
  }
109
145
  getCloudBaseUrl() {
146
+ if (this.apiTransportMode === 'legacy') {
147
+ return this.activeLegacyApiBaseUrl ?? this.legacyApiBaseCandidates[0];
148
+ }
110
149
  return this.activeServiceApiBaseUrl ?? this.serviceApiBaseCandidates[0];
111
150
  }
112
151
  async listDevices() {
152
+ if (this.apiTransportMode === 'legacy') {
153
+ return await this.listDevicesLegacy();
154
+ }
155
+ try {
156
+ return await this.listDevicesModern();
157
+ }
158
+ catch (error) {
159
+ if (!shouldSwitchToLegacyApi(error)) {
160
+ throw error;
161
+ }
162
+ this.switchToLegacyTransport(`Modern Salus API authorization failed: ${asErrorMessage(error)}`);
163
+ return await this.listDevicesLegacy();
164
+ }
165
+ }
166
+ async listDevicesModern() {
113
167
  const payload = await this.requestServiceJsonWithPathFallback(['/devices/', '/devices'], {
114
168
  method: 'GET',
115
169
  auth: true,
@@ -138,11 +192,45 @@ export class SalusCloudClient {
138
192
  }
139
193
  return devices;
140
194
  }
195
+ async listDevicesLegacy() {
196
+ const payload = await this.requestLegacyJsonWithPathFallback(['/apiv1/devices.json', '/apiv1/devices', '/apiv1/registered_nodes.json'], {
197
+ method: 'GET',
198
+ auth: true,
199
+ });
200
+ const devices = parseDevices(payload);
201
+ this.rebuildDeviceIndex(devices);
202
+ const inlineShadows = parseDeviceShadows(payload, this.deviceIdToDsn, this.deviceKeyToDsn);
203
+ if (inlineShadows.size > 0) {
204
+ this.mergeIntoPropertyCache(inlineShadows);
205
+ if (this.verboseLogging) {
206
+ this.log.debug(`Hydrated property cache from legacy /apiv1/devices response for ${inlineShadows.size} device(s)`);
207
+ }
208
+ }
209
+ if (this.verboseLogging) {
210
+ this.log.debug(`Salus legacy cloud returned ${devices.length} device(s)`);
211
+ }
212
+ return devices;
213
+ }
141
214
  async listProperties(dsn) {
142
215
  const cached = this.propertyCacheByDsn.get(dsn);
143
216
  if (cached) {
144
217
  return cached;
145
218
  }
219
+ if (this.apiTransportMode === 'legacy') {
220
+ return await this.listPropertiesLegacy(dsn);
221
+ }
222
+ try {
223
+ return await this.listPropertiesModern(dsn);
224
+ }
225
+ catch (error) {
226
+ if (!shouldSwitchToLegacyApi(error)) {
227
+ throw error;
228
+ }
229
+ this.switchToLegacyTransport(`Modern Salus property sync failed for ${dsn}: ${asErrorMessage(error)}`);
230
+ return await this.listPropertiesLegacy(dsn);
231
+ }
232
+ }
233
+ async listPropertiesModern(dsn) {
146
234
  const shadows = await this.fetchDeviceShadows([], [dsn]);
147
235
  const fromFetch = shadows.get(dsn);
148
236
  if (fromFetch) {
@@ -154,7 +242,43 @@ export class SalusCloudClient {
154
242
  }
155
243
  return new Map();
156
244
  }
245
+ async listPropertiesLegacy(dsn) {
246
+ const encodedDsn = encodeURIComponent(dsn);
247
+ const payload = await this.requestLegacyJsonWithPathFallback([
248
+ `/apiv1/dsns/${encodedDsn}/properties.json`,
249
+ `/apiv1/dsns/${encodedDsn}/properties`,
250
+ ], {
251
+ method: 'GET',
252
+ auth: true,
253
+ });
254
+ const parsed = parseProperties(payload);
255
+ if (parsed.size > 0) {
256
+ this.propertyCacheByDsn.set(dsn, parsed);
257
+ return parsed;
258
+ }
259
+ if (this.verboseLogging) {
260
+ this.log.debug(`No legacy cloud property payload found for dsn=${dsn}. Returning empty property map.`);
261
+ }
262
+ return new Map();
263
+ }
157
264
  async setDatapoint(dsn, propertyName, value) {
265
+ if (this.apiTransportMode === 'legacy') {
266
+ await this.setDatapointLegacy(dsn, propertyName, value);
267
+ return;
268
+ }
269
+ try {
270
+ await this.setDatapointModern(dsn, propertyName, value);
271
+ return;
272
+ }
273
+ catch (error) {
274
+ if (!shouldSwitchToLegacyApi(error)) {
275
+ throw error;
276
+ }
277
+ this.switchToLegacyTransport(`Modern Salus datapoint write failed for ${dsn}/${propertyName}: ${asErrorMessage(error)}`);
278
+ await this.setDatapointLegacy(dsn, propertyName, value);
279
+ }
280
+ }
281
+ async setDatapointModern(dsn, propertyName, value) {
158
282
  const writeCacheKey = `${dsn}:${propertyName}`;
159
283
  const preferredAttempt = this.preferredWriteAttemptByKey.get(writeCacheKey) ?? null;
160
284
  const attempts = prioritizeByDescription(this.buildWriteAttempts(dsn, propertyName, value), preferredAttempt);
@@ -199,6 +323,101 @@ export class SalusCloudClient {
199
323
  }
200
324
  throw new Error(`Failed to write property ${propertyName} on ${dsn}. Attempts: ${failures.join(' | ')}`);
201
325
  }
326
+ async setDatapointLegacy(dsn, propertyName, value) {
327
+ const encodedDsn = encodeURIComponent(dsn);
328
+ const encodedPropertyName = encodeURIComponent(propertyName);
329
+ const attempts = [
330
+ {
331
+ method: 'POST',
332
+ path: `/apiv1/dsns/${encodedDsn}/properties/${encodedPropertyName}/datapoints.json`,
333
+ body: {
334
+ datapoint: {
335
+ value,
336
+ },
337
+ },
338
+ description: 'POST /apiv1/dsns/{dsn}/properties/{property}/datapoints.json {datapoint:{value}}',
339
+ },
340
+ {
341
+ method: 'POST',
342
+ path: `/apiv1/dsns/${encodedDsn}/properties/${encodedPropertyName}/datapoints`,
343
+ body: {
344
+ datapoint: {
345
+ value,
346
+ },
347
+ },
348
+ description: 'POST /apiv1/dsns/{dsn}/properties/{property}/datapoints {datapoint:{value}}',
349
+ },
350
+ {
351
+ method: 'PUT',
352
+ path: `/apiv1/dsns/${encodedDsn}/properties/${encodedPropertyName}/datapoints.json`,
353
+ body: {
354
+ datapoint: {
355
+ value,
356
+ },
357
+ },
358
+ description: 'PUT /apiv1/dsns/{dsn}/properties/{property}/datapoints.json {datapoint:{value}}',
359
+ },
360
+ {
361
+ method: 'POST',
362
+ path: `/apiv1/dsns/${encodedDsn}/properties/${encodedPropertyName}/datapoints.json`,
363
+ body: {
364
+ value,
365
+ },
366
+ description: 'POST /apiv1/dsns/{dsn}/properties/{property}/datapoints.json {value}',
367
+ },
368
+ ];
369
+ const failures = [];
370
+ let fatalError;
371
+ for (const attempt of attempts) {
372
+ try {
373
+ await this.requestLegacyJson(attempt.path, {
374
+ method: attempt.method,
375
+ body: attempt.body,
376
+ auth: true,
377
+ expectedStatuses: DEFAULT_EXPECTED_STATUSES,
378
+ });
379
+ this.updateCachedProperty(dsn, propertyName, value);
380
+ if (this.verboseLogging) {
381
+ this.log.debug(`Legacy write succeeded via ${attempt.description}`);
382
+ }
383
+ return;
384
+ }
385
+ catch (error) {
386
+ const failureMessage = error instanceof HttpStatusError
387
+ ? `${attempt.description} -> HTTP ${error.status}`
388
+ : `${attempt.description} -> ${asErrorMessage(error)}`;
389
+ failures.push(failureMessage);
390
+ if (error instanceof HttpStatusError && STATUS_ALLOW_WRITE_SHAPE_FALLBACK.has(error.status)) {
391
+ continue;
392
+ }
393
+ if (error instanceof HttpStatusError && STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
394
+ continue;
395
+ }
396
+ fatalError = error;
397
+ break;
398
+ }
399
+ }
400
+ if (fatalError) {
401
+ throw new Error(`Failed to write property ${propertyName} on ${dsn} via legacy API. Fatal error: ${asErrorMessage(fatalError)}. Attempts: ${failures.join(' | ')}`);
402
+ }
403
+ throw new Error(`Failed to write property ${propertyName} on ${dsn} via legacy API. Attempts: ${failures.join(' | ')}`);
404
+ }
405
+ switchToLegacyTransport(reason) {
406
+ if (this.apiTransportMode === 'legacy') {
407
+ return;
408
+ }
409
+ this.apiTransportMode = 'legacy';
410
+ this.activeServiceApiBaseUrl = null;
411
+ this.authorizationHeaderProfile = 'accessBearer';
412
+ this.session = null;
413
+ this.authRequestInFlight = null;
414
+ this.hasWarnedAboutAuthCompanyCode = false;
415
+ if (!this.hasWarnedAboutLegacyFallback) {
416
+ this.hasWarnedAboutLegacyFallback = true;
417
+ this.log.warn(`Switching to legacy Salus cloud compatibility mode (${reason})`);
418
+ this.log.warn('Legacy mode uses /users/sign_in.json and /apiv1 endpoints for tenant compatibility.');
419
+ }
420
+ }
202
421
  rememberPreferredWriteAttempt(cacheKey, description) {
203
422
  this.preferredWriteAttemptByKey.delete(cacheKey);
204
423
  this.preferredWriteAttemptByKey.set(cacheKey, description);
@@ -491,6 +710,37 @@ export class SalusCloudClient {
491
710
  },
492
711
  });
493
712
  }
713
+ refreshCompanyCodeCandidates() {
714
+ const candidates = buildCompanyCodeCandidates(this.configuredCompanyCode, this.session?.companyCode);
715
+ this.companyCodeCandidates = candidates;
716
+ if (this.activeCompanyCode && candidates.includes(this.activeCompanyCode)) {
717
+ return;
718
+ }
719
+ this.activeCompanyCode = candidates[0] ?? null;
720
+ }
721
+ rotateCompanyCodeCandidate(attemptedCompanyCodes, preferredCode) {
722
+ if (preferredCode) {
723
+ const normalizedPreferred = normalizeNonEmptyString(preferredCode);
724
+ if (normalizedPreferred) {
725
+ const preferredKey = companyCodeCandidateKey(normalizedPreferred);
726
+ if (!attemptedCompanyCodes.has(preferredKey)) {
727
+ attemptedCompanyCodes.add(preferredKey);
728
+ this.activeCompanyCode = normalizedPreferred;
729
+ return normalizedPreferred;
730
+ }
731
+ }
732
+ }
733
+ for (const candidate of this.companyCodeCandidates) {
734
+ const key = companyCodeCandidateKey(candidate);
735
+ if (attemptedCompanyCodes.has(key)) {
736
+ continue;
737
+ }
738
+ attemptedCompanyCodes.add(key);
739
+ this.activeCompanyCode = candidate;
740
+ return candidate;
741
+ }
742
+ return null;
743
+ }
494
744
  async ensureLoggedIn() {
495
745
  if (this.session && Date.now() + SESSION_REFRESH_SAFETY_MS < this.session.expiresAtEpochMs) {
496
746
  return;
@@ -513,6 +763,7 @@ export class SalusCloudClient {
513
763
  this.authRequestInFlight = (async () => {
514
764
  this.session = null;
515
765
  this.session = await this.authenticateWithPassword();
766
+ this.refreshCompanyCodeCandidates();
516
767
  this.log.info('Authenticated with Salus cloud');
517
768
  })();
518
769
  try {
@@ -531,11 +782,13 @@ export class SalusCloudClient {
531
782
  if (!refreshToken) {
532
783
  this.session = null;
533
784
  this.session = await this.authenticateWithPassword();
785
+ this.refreshCompanyCodeCandidates();
534
786
  this.log.info('Authenticated with Salus cloud');
535
787
  return;
536
788
  }
537
789
  try {
538
790
  this.session = await this.authenticateWithRefreshToken(refreshToken);
791
+ this.refreshCompanyCodeCandidates();
539
792
  }
540
793
  catch (error) {
541
794
  this.session = null;
@@ -552,6 +805,116 @@ export class SalusCloudClient {
552
805
  this.authRequestInFlight = null;
553
806
  }
554
807
  }
808
+ async ensureLegacyLoggedIn() {
809
+ if (this.legacySession && Date.now() + LEGACY_SESSION_REFRESH_SAFETY_MS < this.legacySession.expiresAtEpochMs) {
810
+ return;
811
+ }
812
+ await this.loginLegacy();
813
+ }
814
+ async loginLegacy() {
815
+ if (this.legacyAuthRequestInFlight) {
816
+ return this.legacyAuthRequestInFlight;
817
+ }
818
+ this.legacyAuthRequestInFlight = (async () => {
819
+ this.legacySession = await this.authenticateLegacyWithPassword();
820
+ this.log.info('Authenticated with Salus cloud (legacy API compatibility mode)');
821
+ })();
822
+ try {
823
+ await this.legacyAuthRequestInFlight;
824
+ }
825
+ finally {
826
+ this.legacyAuthRequestInFlight = null;
827
+ }
828
+ }
829
+ async authenticateLegacyWithPassword() {
830
+ const email = this.config.email?.trim();
831
+ const password = this.config.password;
832
+ if (!email || !password) {
833
+ throw new Error('Salus credentials are missing. Set email and password in plugin config.');
834
+ }
835
+ const totalAttempts = this.maxRetries + 1;
836
+ let lastError = new Error('Legacy Salus login did not return a session');
837
+ for (let attempt = 1; attempt <= totalAttempts; attempt++) {
838
+ const orderedBaseUrls = this.getOrderedLegacyApiBaseUrls();
839
+ let sawRetriableFailure = false;
840
+ let sawDefinitiveFailure = false;
841
+ let definitiveError;
842
+ for (const baseUrl of orderedBaseUrls) {
843
+ try {
844
+ const response = await this.fetchWithTimeout(buildLegacyUrl(baseUrl, '/users/sign_in.json', true), {
845
+ method: 'POST',
846
+ headers: {
847
+ Accept: 'application/json',
848
+ 'Content-Type': 'application/json',
849
+ 'Accept-Language': ACCEPT_LANGUAGE,
850
+ 'User-Agent': 'homebridge-salus-cloud/2026',
851
+ },
852
+ body: JSON.stringify({
853
+ user: {
854
+ email,
855
+ password,
856
+ },
857
+ }),
858
+ });
859
+ if (!response.ok) {
860
+ const responseText = await safeReadText(response);
861
+ const statusError = new HttpStatusError(responseText
862
+ ? `Legacy Salus login failed at ${baseUrl} (HTTP ${response.status}) :: ${responseText}`
863
+ : `Legacy Salus login failed at ${baseUrl} (HTTP ${response.status})`, response.status, responseText);
864
+ if (isRetriableStatus(response.status)) {
865
+ lastError = statusError;
866
+ sawRetriableFailure = true;
867
+ continue;
868
+ }
869
+ sawDefinitiveFailure = true;
870
+ if (!definitiveError) {
871
+ definitiveError = statusError;
872
+ }
873
+ lastError = statusError;
874
+ continue;
875
+ }
876
+ const payload = await parseResponseBody(response);
877
+ const session = parseLegacyTokens(payload);
878
+ if (!session) {
879
+ const parseError = new Error(`Legacy Salus login succeeded at ${baseUrl}, but access token was missing in response.`);
880
+ sawDefinitiveFailure = true;
881
+ if (!definitiveError) {
882
+ definitiveError = parseError;
883
+ }
884
+ lastError = parseError;
885
+ continue;
886
+ }
887
+ this.activeLegacyApiBaseUrl = baseUrl;
888
+ return session;
889
+ }
890
+ catch (error) {
891
+ if (!this.allowInsecureTls && isTlsCertificateError(error)) {
892
+ const message = `${asErrorMessage(error)}. If Salus cloud certificate is invalid, set "allowInsecureTls": true in plugin config.`;
893
+ throw new Error(message);
894
+ }
895
+ if (isRetriableFailure(error)) {
896
+ sawRetriableFailure = true;
897
+ lastError = error;
898
+ continue;
899
+ }
900
+ sawDefinitiveFailure = true;
901
+ if (!definitiveError) {
902
+ definitiveError = error;
903
+ }
904
+ lastError = error;
905
+ }
906
+ }
907
+ if (sawDefinitiveFailure) {
908
+ throw definitiveError ?? lastError;
909
+ }
910
+ if (attempt < totalAttempts && sawRetriableFailure) {
911
+ await this.retryDelay(attempt, asErrorMessage(lastError));
912
+ continue;
913
+ }
914
+ break;
915
+ }
916
+ throw new Error(`Legacy Salus login failed after retries: ${asErrorMessage(lastError)}`);
917
+ }
555
918
  async authenticateWithPassword() {
556
919
  const email = this.config.email?.trim();
557
920
  const password = this.config.password;
@@ -647,6 +1010,25 @@ export class SalusCloudClient {
647
1010
  }
648
1011
  throw new Error(`No valid path candidates for request: ${paths.join(', ')}`);
649
1012
  }
1013
+ async requestLegacyJsonWithPathFallback(paths, options) {
1014
+ let lastPathError;
1015
+ for (const path of paths) {
1016
+ try {
1017
+ return await this.requestLegacyJson(path, options);
1018
+ }
1019
+ catch (error) {
1020
+ if (error instanceof HttpStatusError && STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
1021
+ lastPathError = error;
1022
+ continue;
1023
+ }
1024
+ throw error;
1025
+ }
1026
+ }
1027
+ if (lastPathError) {
1028
+ throw lastPathError;
1029
+ }
1030
+ throw new Error(`No valid legacy path candidates for request: ${paths.join(', ')}`);
1031
+ }
650
1032
  async requestServiceJson(path, options) {
651
1033
  const authRequired = options.auth ?? true;
652
1034
  if (authRequired) {
@@ -655,7 +1037,8 @@ export class SalusCloudClient {
655
1037
  const expectedStatuses = options.expectedStatuses ?? DEFAULT_EXPECTED_STATUSES;
656
1038
  const totalAttempts = this.maxRetries + 1;
657
1039
  let hasRefreshedSessionAfter401 = false;
658
- const attemptedAuthProfiles = new Set([this.authorizationHeaderProfile]);
1040
+ let attemptedAuthProfiles = new Set([this.authorizationHeaderProfile]);
1041
+ const attemptedCompanyCodes = new Set([companyCodeCandidateKey(this.activeCompanyCode)]);
659
1042
  let lastError = new Error(`No Salus cloud response received for ${options.method} ${path}`);
660
1043
  for (let attempt = 1; attempt <= totalAttempts; attempt++) {
661
1044
  const orderedBaseUrls = this.getOrderedServiceApiBaseUrls();
@@ -677,31 +1060,49 @@ export class SalusCloudClient {
677
1060
  headers,
678
1061
  body,
679
1062
  });
680
- if (response.status === 401 && authRequired && (options.allow401Refresh ?? true) && !hasRefreshedSessionAfter401) {
681
- hasRefreshedSessionAfter401 = true;
682
- this.log.warn(`Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Refreshing session token and retrying.`);
683
- await this.refreshSession();
684
- lastError = new HttpStatusError('Unauthorized', 401, '');
685
- sawRetriableFailure = true;
686
- lastRetriableError = lastError;
687
- baseIndex -= 1;
688
- continue;
689
- }
690
1063
  if (response.status === 401 && authRequired) {
1064
+ const responseText = await safeReadText(response);
1065
+ const responseCode = extractServiceResponseCode(responseText);
1066
+ const hintedCompanyCode = extractCompanyCodeFromServiceAuthError(responseText);
1067
+ if (responseCode === '900008' && !this.hasWarnedAboutAuthCompanyCode) {
1068
+ this.hasWarnedAboutAuthCompanyCode = true;
1069
+ this.log.warn('Salus cloud returned response_code=900008 (Not authorized). This often indicates tenant/company authorization context mismatch.');
1070
+ }
1071
+ if ((options.allow401Refresh ?? true) && !hasRefreshedSessionAfter401) {
1072
+ hasRefreshedSessionAfter401 = true;
1073
+ this.log.warn(`Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Refreshing session token and retrying.`);
1074
+ await this.refreshSession();
1075
+ lastError = new HttpStatusError('Unauthorized', 401, responseText);
1076
+ sawRetriableFailure = true;
1077
+ lastRetriableError = lastError;
1078
+ baseIndex -= 1;
1079
+ continue;
1080
+ }
691
1081
  const rotatedTo = rotateAuthorizationHeaderProfile(this.authorizationHeaderProfile);
692
1082
  if (rotatedTo !== this.authorizationHeaderProfile && !attemptedAuthProfiles.has(rotatedTo)) {
693
1083
  this.authorizationHeaderProfile = rotatedTo;
694
1084
  attemptedAuthProfiles.add(rotatedTo);
695
1085
  this.log.warn(`Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Retrying with alternate auth header profile: ${rotatedTo}.`);
696
- lastError = new HttpStatusError('Unauthorized', 401, '');
1086
+ lastError = new HttpStatusError('Unauthorized', 401, responseText);
1087
+ sawRetriableFailure = true;
1088
+ lastRetriableError = lastError;
1089
+ baseIndex -= 1;
1090
+ continue;
1091
+ }
1092
+ const previousCompanyCode = this.activeCompanyCode;
1093
+ const rotatedCompanyCode = this.rotateCompanyCodeCandidate(attemptedCompanyCodes, hintedCompanyCode);
1094
+ if (companyCodeCandidateKey(rotatedCompanyCode) !== companyCodeCandidateKey(previousCompanyCode)) {
1095
+ this.authorizationHeaderProfile = 'accessBearer';
1096
+ attemptedAuthProfiles = new Set([this.authorizationHeaderProfile]);
1097
+ this.log.warn(rotatedCompanyCode
1098
+ ? `Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Retrying with alternate company code header: ${rotatedCompanyCode}.`
1099
+ : `Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Retrying without company code header.`);
1100
+ lastError = new HttpStatusError('Unauthorized', 401, responseText);
697
1101
  sawRetriableFailure = true;
698
1102
  lastRetriableError = lastError;
699
1103
  baseIndex -= 1;
700
1104
  continue;
701
1105
  }
702
- }
703
- if (response.status === 401 && authRequired) {
704
- const responseText = await safeReadText(response);
705
1106
  const unauthorizedError = new HttpStatusError(responseText
706
1107
  ? `HTTP 401 Unauthorized on ${options.method} ${path} via ${baseUrl} :: ${responseText}`
707
1108
  : `HTTP 401 Unauthorized on ${options.method} ${path} via ${baseUrl}`, 401, responseText);
@@ -760,6 +1161,9 @@ export class SalusCloudClient {
760
1161
  lastError = error;
761
1162
  sawRetriableFailure = true;
762
1163
  lastRetriableError = error;
1164
+ if (this.verboseLogging) {
1165
+ this.log.debug(`Retriable network error for ${options.method} ${path} via ${baseUrl}: ${asErrorMessage(error)}`);
1166
+ }
763
1167
  continue;
764
1168
  }
765
1169
  throw error;
@@ -778,6 +1182,125 @@ export class SalusCloudClient {
778
1182
  }
779
1183
  throw new Error(`Unexpected request state for ${options.method} ${path}`);
780
1184
  }
1185
+ async requestLegacyJson(path, options) {
1186
+ const authRequired = options.auth ?? true;
1187
+ if (authRequired) {
1188
+ await this.ensureLegacyLoggedIn();
1189
+ }
1190
+ const expectedStatuses = options.expectedStatuses ?? DEFAULT_EXPECTED_STATUSES;
1191
+ const totalAttempts = this.maxRetries + 1;
1192
+ let hasRefreshedSessionAfter401 = false;
1193
+ let lastError = new Error(`No legacy Salus cloud response received for ${options.method} ${path}`);
1194
+ for (let attempt = 1; attempt <= totalAttempts; attempt++) {
1195
+ const orderedBaseUrls = this.getOrderedLegacyApiBaseUrls();
1196
+ let sawRetriableFailure = false;
1197
+ let sawDefinitiveFailure = false;
1198
+ let definitiveError;
1199
+ let lastRetriableError;
1200
+ for (const baseUrl of orderedBaseUrls) {
1201
+ try {
1202
+ const headers = this.buildLegacyHeaders(authRequired);
1203
+ const body = options.body !== undefined ? JSON.stringify(options.body) : undefined;
1204
+ if (body !== undefined) {
1205
+ headers['Content-Type'] = 'application/json';
1206
+ }
1207
+ const response = await this.fetchWithTimeout(buildLegacyUrl(baseUrl, path, options.method === 'GET'), {
1208
+ method: options.method,
1209
+ headers,
1210
+ body,
1211
+ });
1212
+ if (response.status === 401 && authRequired) {
1213
+ const responseText = await safeReadText(response);
1214
+ if ((options.allow401Refresh ?? true) && !hasRefreshedSessionAfter401) {
1215
+ hasRefreshedSessionAfter401 = true;
1216
+ this.log.warn(`Legacy Salus cloud returned 401 for ${options.method} ${path} at ${baseUrl}. Re-authenticating and retrying.`);
1217
+ this.legacySession = null;
1218
+ await this.loginLegacy();
1219
+ lastError = new HttpStatusError('Unauthorized', 401, responseText);
1220
+ sawRetriableFailure = true;
1221
+ lastRetriableError = lastError;
1222
+ continue;
1223
+ }
1224
+ const unauthorizedError = new HttpStatusError(responseText
1225
+ ? `HTTP 401 Unauthorized on legacy ${options.method} ${path} via ${baseUrl} :: ${responseText}`
1226
+ : `HTTP 401 Unauthorized on legacy ${options.method} ${path} via ${baseUrl}`, 401, responseText);
1227
+ lastError = unauthorizedError;
1228
+ sawRetriableFailure = true;
1229
+ lastRetriableError = unauthorizedError;
1230
+ continue;
1231
+ }
1232
+ if (!expectedStatuses.includes(response.status)) {
1233
+ const responseText = await safeReadText(response);
1234
+ const statusError = new HttpStatusError(responseText
1235
+ ? `Legacy API HTTP ${response.status} ${response.statusText} on ${options.method} ${path} :: ${responseText}`
1236
+ : `Legacy API HTTP ${response.status} ${response.statusText} on ${options.method} ${path}`, response.status, responseText);
1237
+ if (STATUS_ALLOW_PATH_FALLBACK.has(response.status)) {
1238
+ lastError = statusError;
1239
+ continue;
1240
+ }
1241
+ if (isRetriableStatus(response.status)) {
1242
+ lastError = statusError;
1243
+ sawRetriableFailure = true;
1244
+ lastRetriableError = statusError;
1245
+ continue;
1246
+ }
1247
+ lastError = statusError;
1248
+ sawDefinitiveFailure = true;
1249
+ if (!definitiveError) {
1250
+ definitiveError = statusError;
1251
+ }
1252
+ continue;
1253
+ }
1254
+ this.activeLegacyApiBaseUrl = baseUrl;
1255
+ if (response.status === 204) {
1256
+ return undefined;
1257
+ }
1258
+ return await parseResponseBody(response);
1259
+ }
1260
+ catch (error) {
1261
+ if (!this.allowInsecureTls && isTlsCertificateError(error)) {
1262
+ const message = `${asErrorMessage(error)}. If Salus cloud certificate is invalid, set "allowInsecureTls": true in plugin config.`;
1263
+ throw new Error(message);
1264
+ }
1265
+ if (error instanceof HttpStatusError) {
1266
+ lastError = error;
1267
+ if (isRetriableStatus(error.status)) {
1268
+ sawRetriableFailure = true;
1269
+ lastRetriableError = error;
1270
+ }
1271
+ else if (!STATUS_ALLOW_PATH_FALLBACK.has(error.status)) {
1272
+ sawDefinitiveFailure = true;
1273
+ if (!definitiveError) {
1274
+ definitiveError = error;
1275
+ }
1276
+ }
1277
+ continue;
1278
+ }
1279
+ if (isRetriableError(error)) {
1280
+ lastError = error;
1281
+ sawRetriableFailure = true;
1282
+ lastRetriableError = error;
1283
+ if (this.verboseLogging) {
1284
+ this.log.debug(`Retriable legacy network error for ${options.method} ${path} via ${baseUrl}: ${asErrorMessage(error)}`);
1285
+ }
1286
+ continue;
1287
+ }
1288
+ throw error;
1289
+ }
1290
+ }
1291
+ if (sawDefinitiveFailure) {
1292
+ throw definitiveError ?? lastError;
1293
+ }
1294
+ if (attempt < totalAttempts && sawRetriableFailure) {
1295
+ await this.retryDelay(attempt, asErrorMessage(lastRetriableError ?? lastError));
1296
+ continue;
1297
+ }
1298
+ if (lastError) {
1299
+ throw lastError;
1300
+ }
1301
+ }
1302
+ throw new Error(`Unexpected legacy request state for ${options.method} ${path}`);
1303
+ }
781
1304
  buildServiceHeaders(authRequired) {
782
1305
  const headers = {
783
1306
  Accept: 'application/json',
@@ -790,15 +1313,32 @@ export class SalusCloudClient {
790
1313
  if (!this.session) {
791
1314
  throw new Error('Missing Salus cloud session while building authenticated request.');
792
1315
  }
1316
+ const authToken = tokenForAuthorizationProfile(this.authorizationHeaderProfile, this.session.accessToken, this.session.idToken);
793
1317
  headers.Authorization = formatAuthorizationHeader(this.authorizationHeaderProfile, this.session.accessToken, this.session.idToken);
794
1318
  headers['x-access-token'] = this.session.accessToken;
795
- headers['x-auth-token'] = this.session.idToken || this.session.accessToken;
796
- const companyCode = this.configuredCompanyCode ?? this.session.companyCode;
1319
+ headers['x-auth-token'] = authToken;
1320
+ const companyCode = this.activeCompanyCode;
797
1321
  if (companyCode) {
798
1322
  headers['x-company-code'] = companyCode;
799
1323
  }
800
1324
  return headers;
801
1325
  }
1326
+ buildLegacyHeaders(authRequired) {
1327
+ const headers = {
1328
+ Accept: 'application/json',
1329
+ 'Accept-Language': ACCEPT_LANGUAGE,
1330
+ 'User-Agent': 'homebridge-salus-cloud/2026',
1331
+ };
1332
+ if (!authRequired) {
1333
+ return headers;
1334
+ }
1335
+ if (!this.legacySession) {
1336
+ throw new Error('Missing Salus legacy session while building authenticated request.');
1337
+ }
1338
+ const tokenType = normalizeNonEmptyString(this.legacySession.tokenType) ?? 'Bearer';
1339
+ headers.Authorization = `${tokenType} ${this.legacySession.accessToken}`;
1340
+ return headers;
1341
+ }
802
1342
  getOrderedServiceApiBaseUrls() {
803
1343
  const dedupe = new Set();
804
1344
  const ordered = [];
@@ -815,6 +1355,22 @@ export class SalusCloudClient {
815
1355
  }
816
1356
  return ordered;
817
1357
  }
1358
+ getOrderedLegacyApiBaseUrls() {
1359
+ const dedupe = new Set();
1360
+ const ordered = [];
1361
+ if (this.activeLegacyApiBaseUrl) {
1362
+ dedupe.add(this.activeLegacyApiBaseUrl);
1363
+ ordered.push(this.activeLegacyApiBaseUrl);
1364
+ }
1365
+ for (const candidate of this.legacyApiBaseCandidates) {
1366
+ if (dedupe.has(candidate)) {
1367
+ continue;
1368
+ }
1369
+ dedupe.add(candidate);
1370
+ ordered.push(candidate);
1371
+ }
1372
+ return ordered;
1373
+ }
818
1374
  async retryDelay(attempt, reason) {
819
1375
  const baseDelay = Math.min(MAX_RETRY_DELAY_MS, this.retryBaseDelayMs * (2 ** (attempt - 1)));
820
1376
  const jitter = 0.85 + (Math.random() * 0.3);
@@ -886,6 +1442,57 @@ function buildServiceApiBaseCandidates(region, overrideHost, versionPreference)
886
1442
  ];
887
1443
  return dedupeStringArray(hosts.flatMap((host) => buildApiVersionCandidates(host, versionPreference)));
888
1444
  }
1445
+ function buildLegacyApiBaseCandidates(region, overrideHost) {
1446
+ const normalizedOverride = normalizeNonEmptyString(overrideHost);
1447
+ if (normalizedOverride) {
1448
+ const overrideCandidates = deriveLegacyHostCandidatesFromOverride(normalizedOverride);
1449
+ if (overrideCandidates.length > 0) {
1450
+ const withFallback = [
1451
+ ...overrideCandidates,
1452
+ ...(region === 'us'
1453
+ ? [DEFAULT_US_LEGACY_API_HOST, FALLBACK_US_LEGACY_API_HOST, DEFAULT_EU_LEGACY_API_HOST, FALLBACK_EU_LEGACY_API_HOST]
1454
+ : [DEFAULT_EU_LEGACY_API_HOST, FALLBACK_EU_LEGACY_API_HOST, DEFAULT_US_LEGACY_API_HOST, FALLBACK_US_LEGACY_API_HOST]),
1455
+ ];
1456
+ return dedupeStringArray(withFallback.map((value) => normalizeUrl(value)));
1457
+ }
1458
+ }
1459
+ const hosts = region === 'us'
1460
+ ? [
1461
+ DEFAULT_US_LEGACY_API_HOST,
1462
+ FALLBACK_US_LEGACY_API_HOST,
1463
+ DEFAULT_EU_LEGACY_API_HOST,
1464
+ FALLBACK_EU_LEGACY_API_HOST,
1465
+ ]
1466
+ : [
1467
+ DEFAULT_EU_LEGACY_API_HOST,
1468
+ FALLBACK_EU_LEGACY_API_HOST,
1469
+ DEFAULT_US_LEGACY_API_HOST,
1470
+ FALLBACK_US_LEGACY_API_HOST,
1471
+ ];
1472
+ return dedupeStringArray(hosts.map((value) => normalizeUrl(value)));
1473
+ }
1474
+ function deriveLegacyHostCandidatesFromOverride(overrideHost) {
1475
+ const normalizedOverride = normalizeUrl(overrideHost);
1476
+ const candidates = new Set();
1477
+ try {
1478
+ const parsed = new URL(normalizedOverride);
1479
+ const hostname = parsed.hostname.toLowerCase();
1480
+ const protocol = parsed.protocol;
1481
+ candidates.add(`${protocol}//${hostname}`);
1482
+ if (hostname.startsWith('service-api.')) {
1483
+ candidates.add(`${protocol}//${hostname.replace(/^service-api\./, '')}`);
1484
+ }
1485
+ }
1486
+ catch {
1487
+ // Keep best-effort fallback.
1488
+ candidates.add(normalizedOverride.replace(/\/api\/v[12](?:\/.*)?$/i, '').replace(/\/+$/, ''));
1489
+ candidates.add(normalizedOverride
1490
+ .replace(/\/api\/v[12](?:\/.*)?$/i, '')
1491
+ .replace(/\/+$/, '')
1492
+ .replace(/:\/\/service-api\./i, '://'));
1493
+ }
1494
+ return [...candidates].filter((value) => value.trim() !== '');
1495
+ }
889
1496
  function buildApiVersionCandidates(baseHost, preference) {
890
1497
  const normalized = normalizeUrl(baseHost).replace(/\/+$/, '');
891
1498
  const versions = preference === 'v1'
@@ -912,6 +1519,11 @@ function buildServiceUrl(baseUrl, path, addTimestamp) {
912
1519
  const joined = `${baseUrl}${normalizedPath}`;
913
1520
  return addTimestamp ? withTimestampQuery(joined) : joined;
914
1521
  }
1522
+ function buildLegacyUrl(baseUrl, path, addTimestamp) {
1523
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
1524
+ const joined = `${baseUrl}${normalizedPath}`;
1525
+ return addTimestamp ? withTimestampQuery(joined) : joined;
1526
+ }
915
1527
  function withTimestampQuery(url) {
916
1528
  const separator = url.includes('?') ? '&' : '?';
917
1529
  return `${url}${separator}timestamp=${Date.now()}`;
@@ -933,6 +1545,71 @@ function normalizeNonEmptyString(value) {
933
1545
  }
934
1546
  return trimmed;
935
1547
  }
1548
+ function buildCompanyCodeCandidates(configuredCompanyCode, sessionCompanyCode) {
1549
+ const normalizedConfigured = normalizeNonEmptyString(configuredCompanyCode ?? undefined) ?? null;
1550
+ const normalizedSession = normalizeNonEmptyString(sessionCompanyCode);
1551
+ const fallbackCandidates = DEFAULT_COMPANY_CODE_FALLBACKS
1552
+ .map((value) => normalizeNonEmptyString(value))
1553
+ .filter((value) => Boolean(value));
1554
+ const initialCandidates = normalizedConfigured
1555
+ ? [normalizedConfigured, normalizedSession ?? null, null]
1556
+ : [normalizedSession ?? null, null];
1557
+ const seen = new Set();
1558
+ const deduped = [];
1559
+ for (const candidate of [...initialCandidates, ...fallbackCandidates]) {
1560
+ const key = companyCodeCandidateKey(candidate);
1561
+ if (seen.has(key)) {
1562
+ continue;
1563
+ }
1564
+ seen.add(key);
1565
+ deduped.push(candidate);
1566
+ }
1567
+ return deduped;
1568
+ }
1569
+ function companyCodeCandidateKey(candidate) {
1570
+ return candidate ?? NO_COMPANY_CODE_SENTINEL;
1571
+ }
1572
+ function tokenForAuthorizationProfile(profile, accessToken, idToken) {
1573
+ if (profile === 'idBearer' || profile === 'idRaw') {
1574
+ return idToken;
1575
+ }
1576
+ return accessToken;
1577
+ }
1578
+ function extractServiceResponseCode(responseText) {
1579
+ if (!responseText) {
1580
+ return undefined;
1581
+ }
1582
+ const parsed = parseJsonRecord(responseText);
1583
+ if (!parsed) {
1584
+ return undefined;
1585
+ }
1586
+ return asString(parsed.response_code)
1587
+ ?? asString(parsed.code)
1588
+ ?? asString(parsed.error_code);
1589
+ }
1590
+ function extractCompanyCodeFromServiceAuthError(responseText) {
1591
+ if (!responseText) {
1592
+ return undefined;
1593
+ }
1594
+ const parsed = parseJsonRecord(responseText);
1595
+ if (!parsed) {
1596
+ return undefined;
1597
+ }
1598
+ const direct = asRecord(parsed.data) ?? parsed;
1599
+ return extractCompanyCodeFromTokenClaims(direct);
1600
+ }
1601
+ function parseJsonRecord(value) {
1602
+ const trimmed = value.trim();
1603
+ if (!trimmed) {
1604
+ return undefined;
1605
+ }
1606
+ try {
1607
+ return asRecord(JSON.parse(trimmed));
1608
+ }
1609
+ catch {
1610
+ return undefined;
1611
+ }
1612
+ }
936
1613
  function parseCognitoTokens(payload, refreshTokenFallback) {
937
1614
  const record = asRecord(payload);
938
1615
  if (!record) {
@@ -966,6 +1643,40 @@ function parseCognitoTokens(payload, refreshTokenFallback) {
966
1643
  companyCode: extractCompanyCodeFromTokenClaims(decodedIdTokenClaims, decodedAccessTokenClaims),
967
1644
  };
968
1645
  }
1646
+ function parseLegacyTokens(payload) {
1647
+ const root = asRecord(payload);
1648
+ if (!root) {
1649
+ return undefined;
1650
+ }
1651
+ const valueRecord = asRecord(root.value);
1652
+ const candidateRecords = [
1653
+ root,
1654
+ valueRecord,
1655
+ asRecord(root.user),
1656
+ valueRecord ? asRecord(valueRecord.user) : undefined,
1657
+ ].filter((value) => Boolean(value));
1658
+ for (const candidate of candidateRecords) {
1659
+ const accessToken = normalizeNonEmptyString(asString(candidate.access_token)
1660
+ ?? asString(candidate.accessToken)
1661
+ ?? asString(candidate.token)
1662
+ ?? asString(candidate.auth_token)
1663
+ ?? asString(candidate.bearer_token));
1664
+ if (!accessToken) {
1665
+ continue;
1666
+ }
1667
+ const tokenType = normalizeNonEmptyString(asString(candidate.token_type) ?? asString(candidate.tokenType)) ?? 'Bearer';
1668
+ const expiresInRaw = parseNumberLike(candidate.expires_in ?? candidate.expiresIn ?? candidate.expired_in);
1669
+ const expiresInMs = Number.isFinite(expiresInRaw)
1670
+ ? Math.max(60_000, Math.floor(Number(expiresInRaw) * 1_000))
1671
+ : LEGACY_DEFAULT_SESSION_TTL_MS;
1672
+ return {
1673
+ accessToken,
1674
+ tokenType,
1675
+ expiresAtEpochMs: Date.now() + expiresInMs,
1676
+ };
1677
+ }
1678
+ return undefined;
1679
+ }
969
1680
  function formatAuthorizationHeader(profile, accessToken, idToken) {
970
1681
  if (profile === 'idBearer') {
971
1682
  return `Bearer ${idToken}`;
@@ -1061,7 +1772,7 @@ function parseDevices(payload) {
1061
1772
  candidates.push(payload);
1062
1773
  }
1063
1774
  if (root) {
1064
- const arrayKeys = ['devices', 'registered_nodes', 'nodes', 'results', 'data', 'list', 'device_list'];
1775
+ const arrayKeys = ['devices', 'registered_nodes', 'nodes', 'results', 'data', 'list', 'device_list', 'value'];
1065
1776
  for (const key of arrayKeys) {
1066
1777
  const arr = asArray(root[key]);
1067
1778
  if (arr) {
@@ -1216,17 +1927,30 @@ function parseProperties(payload) {
1216
1927
  const result = new Map();
1217
1928
  const root = asRecord(payload);
1218
1929
  const candidateArrays = [];
1930
+ const candidateSingles = [];
1219
1931
  if (Array.isArray(payload)) {
1220
1932
  candidateArrays.push(payload);
1221
1933
  }
1222
1934
  if (root) {
1223
- const keys = ['properties', 'property', 'data', 'results', 'datapoints', 'list'];
1935
+ const keys = ['properties', 'property', 'data', 'results', 'datapoints', 'list', 'value'];
1224
1936
  for (const key of keys) {
1225
1937
  const maybeArray = asArray(root[key]);
1226
1938
  if (maybeArray) {
1227
1939
  candidateArrays.push(maybeArray);
1228
1940
  }
1229
1941
  }
1942
+ const valueRecord = asRecord(root.value);
1943
+ if (valueRecord) {
1944
+ candidateSingles.push(valueRecord);
1945
+ const nestedArrayKeys = ['properties', 'property', 'data', 'results', 'datapoints', 'list'];
1946
+ for (const key of nestedArrayKeys) {
1947
+ const nestedArray = asArray(valueRecord[key]);
1948
+ if (nestedArray) {
1949
+ candidateArrays.push(nestedArray);
1950
+ }
1951
+ }
1952
+ }
1953
+ candidateSingles.push(root);
1230
1954
  }
1231
1955
  for (const entries of candidateArrays) {
1232
1956
  for (const entry of entries) {
@@ -1236,8 +1960,18 @@ function parseProperties(payload) {
1236
1960
  }
1237
1961
  }
1238
1962
  }
1239
- if (root) {
1240
- parsePropertyObjectMap(root, result);
1963
+ for (const candidate of candidateSingles) {
1964
+ const property = parsePropertyEntry(candidate);
1965
+ if (property) {
1966
+ result.set(property.name, property);
1967
+ }
1968
+ }
1969
+ for (const candidate of candidateSingles) {
1970
+ const record = asRecord(candidate);
1971
+ if (!record) {
1972
+ continue;
1973
+ }
1974
+ parsePropertyObjectMap(record, result);
1241
1975
  }
1242
1976
  return result;
1243
1977
  }
@@ -1460,6 +2194,22 @@ function isRetriableFailure(error) {
1460
2194
  }
1461
2195
  return isRetriableError(error);
1462
2196
  }
2197
+ function shouldSwitchToLegacyApi(error) {
2198
+ if (!(error instanceof HttpStatusError)) {
2199
+ return false;
2200
+ }
2201
+ if (error.status === 401 || error.status === 403) {
2202
+ const responseCode = extractServiceResponseCode(error.responseBody);
2203
+ if (responseCode === '900008') {
2204
+ return true;
2205
+ }
2206
+ const body = error.responseBody.toLowerCase();
2207
+ if (body.includes('not authorized') || body.includes('unauthorized')) {
2208
+ return true;
2209
+ }
2210
+ }
2211
+ return false;
2212
+ }
1463
2213
  function isRetriableStatus(status) {
1464
2214
  return status === 408 || status === 425 || status === 429 || status >= 500;
1465
2215
  }