tesla-fleet-api 0.4.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.
Files changed (215) hide show
  1. package/README.md +269 -1
  2. package/dist/_virtual/_rolldown/runtime.cjs +10 -0
  3. package/dist/bluetooth/adapters/shared.cjs +38 -0
  4. package/dist/bluetooth/adapters/shared.mjs +36 -0
  5. package/dist/bluetooth/async.cjs +121 -0
  6. package/dist/bluetooth/async.d.cts +10 -0
  7. package/dist/bluetooth/async.d.mts +10 -0
  8. package/dist/bluetooth/async.mjs +116 -0
  9. package/dist/bluetooth/broadcast.cjs +153 -0
  10. package/dist/bluetooth/broadcast.d.cts +59 -0
  11. package/dist/bluetooth/broadcast.d.mts +59 -0
  12. package/dist/bluetooth/broadcast.mjs +151 -0
  13. package/dist/bluetooth/constants.cjs +96 -0
  14. package/dist/bluetooth/constants.d.cts +68 -0
  15. package/dist/bluetooth/constants.d.mts +68 -0
  16. package/dist/bluetooth/constants.mjs +70 -0
  17. package/dist/bluetooth/framing.cjs +125 -0
  18. package/dist/bluetooth/framing.d.cts +45 -0
  19. package/dist/bluetooth/framing.d.mts +45 -0
  20. package/dist/bluetooth/framing.mjs +122 -0
  21. package/dist/bluetooth/homey.cjs +165 -0
  22. package/dist/bluetooth/homey.d.cts +84 -0
  23. package/dist/bluetooth/homey.d.mts +84 -0
  24. package/dist/bluetooth/homey.mjs +159 -0
  25. package/dist/bluetooth/names.cjs +24 -0
  26. package/dist/bluetooth/names.d.cts +10 -0
  27. package/dist/bluetooth/names.d.mts +10 -0
  28. package/dist/bluetooth/names.mjs +20 -0
  29. package/dist/bluetooth/noble.cjs +178 -0
  30. package/dist/bluetooth/noble.d.cts +58 -0
  31. package/dist/bluetooth/noble.d.mts +58 -0
  32. package/dist/bluetooth/noble.mjs +177 -0
  33. package/dist/bluetooth/streamglue.cjs +157 -0
  34. package/dist/bluetooth/streamglue.d.cts +37 -0
  35. package/dist/bluetooth/streamglue.d.mts +37 -0
  36. package/dist/bluetooth/streamglue.mjs +154 -0
  37. package/dist/bluetooth/teslabluetooth.cjs +67 -0
  38. package/dist/bluetooth/teslabluetooth.d.cts +41 -0
  39. package/dist/bluetooth/teslabluetooth.d.mts +41 -0
  40. package/dist/bluetooth/teslabluetooth.mjs +66 -0
  41. package/dist/bluetooth/transport.cjs +10 -0
  42. package/dist/bluetooth/transport.d.cts +97 -0
  43. package/dist/bluetooth/transport.d.mts +97 -0
  44. package/dist/bluetooth/transport.mjs +10 -0
  45. package/dist/bluetooth/vehiclebluetooth.cjs +1272 -0
  46. package/dist/bluetooth/vehiclebluetooth.d.cts +495 -0
  47. package/dist/bluetooth/vehiclebluetooth.d.mts +495 -0
  48. package/dist/bluetooth/vehiclebluetooth.mjs +1270 -0
  49. package/dist/bluetooth/verify.cjs +93 -0
  50. package/dist/bluetooth/verify.d.cts +35 -0
  51. package/dist/bluetooth/verify.d.mts +35 -0
  52. package/dist/bluetooth/verify.mjs +91 -0
  53. package/dist/bluetooth.cjs +52 -0
  54. package/dist/bluetooth.d.cts +10 -0
  55. package/dist/bluetooth.d.mts +10 -0
  56. package/dist/bluetooth.mjs +9 -0
  57. package/dist/commands.cjs +1080 -85
  58. package/dist/commands.d.cts +585 -54
  59. package/dist/commands.d.mts +583 -51
  60. package/dist/commands.mjs +1084 -93
  61. package/dist/const.cjs +22 -0
  62. package/dist/const.d.cts +21 -0
  63. package/dist/const.d.mts +21 -0
  64. package/dist/const.mjs +20 -1
  65. package/dist/energy.cjs +24 -19
  66. package/dist/energy.d.cts +21 -15
  67. package/dist/energy.d.mts +21 -15
  68. package/dist/energy.mjs +24 -19
  69. package/dist/energyisland.cjs +38 -0
  70. package/dist/energyisland.mjs +37 -0
  71. package/dist/energyparsers.cjs +118 -0
  72. package/dist/energyparsers.d.cts +39 -0
  73. package/dist/energyparsers.d.mts +39 -0
  74. package/dist/energyparsers.mjs +116 -0
  75. package/dist/energyspecific.cjs +375 -17
  76. package/dist/energyspecific.d.cts +199 -19
  77. package/dist/energyspecific.d.mts +197 -16
  78. package/dist/energyspecific.mjs +370 -18
  79. package/dist/exceptions.cjs +3 -2
  80. package/dist/exceptions.d.cts +3 -2
  81. package/dist/exceptions.d.mts +3 -2
  82. package/dist/exceptions.mjs +3 -2
  83. package/dist/funnel.cjs +297 -0
  84. package/dist/funnel.d.cts +118 -0
  85. package/dist/funnel.d.mts +118 -0
  86. package/dist/funnel.mjs +292 -0
  87. package/dist/index.cjs +61 -0
  88. package/dist/index.d.cts +22 -8
  89. package/dist/index.d.mts +22 -8
  90. package/dist/index.mjs +19 -6
  91. package/dist/log.cjs +75 -0
  92. package/dist/log.d.cts +4 -0
  93. package/dist/log.d.mts +4 -0
  94. package/dist/log.mjs +71 -0
  95. package/dist/oauth.cjs +143 -0
  96. package/dist/oauth.d.cts +69 -0
  97. package/dist/oauth.d.mts +69 -0
  98. package/dist/oauth.mjs +143 -0
  99. package/dist/powerwall/client.cjs +818 -0
  100. package/dist/powerwall/client.d.cts +297 -0
  101. package/dist/powerwall/client.d.mts +297 -0
  102. package/dist/powerwall/client.mjs +807 -0
  103. package/dist/powerwall/components.cjs +188 -0
  104. package/dist/powerwall/components.d.cts +45 -0
  105. package/dist/powerwall/components.d.mts +45 -0
  106. package/dist/powerwall/components.mjs +182 -0
  107. package/dist/powerwall/energysite.cjs +216 -0
  108. package/dist/powerwall/energysite.d.cts +104 -0
  109. package/dist/powerwall/energysite.d.mts +104 -0
  110. package/dist/powerwall/energysite.mjs +214 -0
  111. package/dist/powerwall/exceptions.cjs +59 -0
  112. package/dist/powerwall/exceptions.d.cts +38 -0
  113. package/dist/powerwall/exceptions.d.mts +38 -0
  114. package/dist/powerwall/exceptions.mjs +51 -0
  115. package/dist/powerwall/gateway.cjs +102 -0
  116. package/dist/powerwall/gateway.d.cts +57 -0
  117. package/dist/powerwall/gateway.d.mts +57 -0
  118. package/dist/powerwall/gateway.mjs +101 -0
  119. package/dist/powerwall/merge.cjs +163 -0
  120. package/dist/powerwall/merge.d.cts +63 -0
  121. package/dist/powerwall/merge.d.mts +63 -0
  122. package/dist/powerwall/merge.mjs +159 -0
  123. package/dist/powerwall/models.d.cts +95 -0
  124. package/dist/powerwall/models.d.mts +95 -0
  125. package/dist/powerwall/queries.cjs +17 -0
  126. package/dist/powerwall/queries.mjs +10 -0
  127. package/dist/powerwall/transport.cjs +232 -0
  128. package/dist/powerwall/transport.mjs +227 -0
  129. package/dist/powerwall.cjs +47 -0
  130. package/dist/powerwall.d.cts +9 -0
  131. package/dist/powerwall.d.mts +9 -0
  132. package/dist/powerwall.mjs +8 -0
  133. package/dist/router/base.cjs +203 -0
  134. package/dist/router/base.d.cts +112 -0
  135. package/dist/router/base.d.mts +112 -0
  136. package/dist/router/base.mjs +200 -0
  137. package/dist/router/energysite.cjs +65 -0
  138. package/dist/router/energysite.d.cts +47 -0
  139. package/dist/router/energysite.d.mts +47 -0
  140. package/dist/router/energysite.mjs +60 -0
  141. package/dist/router.cjs +13 -0
  142. package/dist/router.d.cts +3 -1
  143. package/dist/router.d.mts +3 -1
  144. package/dist/router.mjs +3 -1
  145. package/dist/signing/crypto.cjs +28 -0
  146. package/dist/signing/crypto.mjs +26 -1
  147. package/dist/signing/energy.cjs +123 -0
  148. package/dist/signing/energy.d.cts +45 -0
  149. package/dist/signing/energy.d.mts +45 -0
  150. package/dist/signing/energy.mjs +120 -0
  151. package/dist/signing/jws.cjs +229 -0
  152. package/dist/signing/jws.d.cts +45 -0
  153. package/dist/signing/jws.d.mts +45 -0
  154. package/dist/signing/jws.mjs +211 -0
  155. package/dist/signing/keys.cjs +191 -0
  156. package/dist/signing/keys.d.cts +21 -0
  157. package/dist/signing/keys.d.mts +21 -0
  158. package/dist/signing/keys.mjs +182 -0
  159. package/dist/signing/session.cjs +104 -30
  160. package/dist/signing/session.d.cts +60 -23
  161. package/dist/signing/session.d.mts +60 -23
  162. package/dist/signing/session.mjs +105 -31
  163. package/dist/tariff.cjs +27 -0
  164. package/dist/tariff.d.cts +11 -0
  165. package/dist/tariff.d.mts +11 -0
  166. package/dist/tariff.mjs +27 -1
  167. package/dist/teslafleetapi.cjs +256 -55
  168. package/dist/teslafleetapi.d.cts +110 -14
  169. package/dist/teslafleetapi.d.mts +110 -14
  170. package/dist/teslafleetapi.mjs +258 -57
  171. package/dist/teslemetry.cjs +152 -5
  172. package/dist/teslemetry.d.cts +95 -5
  173. package/dist/teslemetry.d.mts +93 -3
  174. package/dist/teslemetry.mjs +151 -6
  175. package/dist/teslemetryenergy.cjs +287 -0
  176. package/dist/teslemetryenergy.d.cts +154 -0
  177. package/dist/teslemetryenergy.d.mts +154 -0
  178. package/dist/teslemetryenergy.mjs +282 -0
  179. package/dist/teslemetryvehicle.cjs +407 -0
  180. package/dist/teslemetryvehicle.d.cts +238 -0
  181. package/dist/teslemetryvehicle.d.mts +238 -0
  182. package/dist/teslemetryvehicle.mjs +405 -0
  183. package/dist/tessie.cjs +42 -5
  184. package/dist/tessie.d.cts +51 -5
  185. package/dist/tessie.d.mts +49 -3
  186. package/dist/tessie.mjs +42 -5
  187. package/dist/tessievehicle.cjs +514 -0
  188. package/dist/tessievehicle.d.cts +333 -0
  189. package/dist/tessievehicle.d.mts +333 -0
  190. package/dist/tessievehicle.mjs +513 -0
  191. package/dist/types/live_status.d.cts +16 -16
  192. package/dist/types/live_status.d.mts +16 -16
  193. package/dist/types/responses.d.cts +5 -1
  194. package/dist/types/responses.d.mts +5 -1
  195. package/dist/types/vehicle.d.cts +7 -1
  196. package/dist/types/vehicle.d.mts +7 -1
  197. package/dist/types.d.cts +3 -3
  198. package/dist/types.d.mts +3 -3
  199. package/dist/util.cjs +41 -0
  200. package/dist/util.d.cts +18 -0
  201. package/dist/util.d.mts +18 -0
  202. package/dist/util.mjs +40 -0
  203. package/dist/vehicle.cjs +79 -36
  204. package/dist/vehicle.d.cts +42 -26
  205. package/dist/vehicle.d.mts +42 -26
  206. package/dist/vehicle.mjs +80 -37
  207. package/dist/vehiclesigned.cjs +20 -5
  208. package/dist/vehiclesigned.d.cts +14 -5
  209. package/dist/vehiclesigned.d.mts +14 -5
  210. package/dist/vehiclesigned.mjs +20 -5
  211. package/dist/vehiclespecific.cjs +126 -15
  212. package/dist/vehiclespecific.d.cts +82 -18
  213. package/dist/vehiclespecific.d.mts +82 -18
  214. package/dist/vehiclespecific.mjs +126 -15
  215. package/package.json +56 -3
@@ -0,0 +1,1270 @@
1
+ import { BluetoothVehicleData } from "../const.mjs";
2
+ import { infotainment_command_name, vcsec_command_name } from "../commands.mjs";
3
+ import { logRequestError, logRequestResult } from "../log.mjs";
4
+ import { BluetoothCloudUnavailableError, BluetoothCommandFailed, BluetoothTimeout, BluetoothTransportError, BluetoothUnconfirmedCommand, InvalidResponse, NotOnWhitelistFault, SessionInfoAuthenticationFault, SignedCommandResponseReplayed, SigningDisabled, TeslaFleetError, TeslaFleetMessageFaultResponseSizeExceedsMTU, WhitelistOperationStatus } from "../exceptions.mjs";
5
+ import { AsyncLock, BleCallTimeout, ReplyQueue, abortable, sleep, withTimeout } from "./async.mjs";
6
+ import { BroadcastListeners, ListenerRegistry, decodeVcsecStatus } from "./broadcast.mjs";
7
+ import { CHARGE_PORT_VERIFY_TIMEOUT_MS, DEFAULT_KEEPALIVE_INTERVAL_MS, DEFAULT_PAIR_POLL_INTERVAL_MS, DEFAULT_PAIR_TIMEOUT_MS, DEFAULT_WAKE_TIMEOUT_MS, GATT_READ_TIMEOUT_MS, GATT_WRITE_TIMEOUT_MS, INFOTAINMENT_BOOT_TIMEOUT_MS, LINK_TEARDOWN_TIMEOUT_MS, WAKE_RESEND_INTERVAL_MS } from "./constants.mjs";
8
+ import { ReassemblingBuffer, prependLength, splitFrame } from "./framing.mjs";
9
+ import { get_name, name_matches_vin } from "./names.mjs";
10
+ import { BleNotSubmittedError } from "./transport.mjs";
11
+ import { chargePortConnected, infotainmentVerifyPlan, vcsecVerifyPlan } from "./verify.mjs";
12
+ import crypto from "crypto";
13
+ import { Domain, MessageFaultE, OperationStatusE, RoutableMessage } from "@teslemetry/tesla-protocol/command/universal_message";
14
+ import { ClosureMoveRequest, ClosureMoveTypeE, FromVCSECMessage, InformationRequest, InformationRequestType, KeyFormFactor, OperationStatusE as OperationStatusE$1, RKEActionE, UnsignedMessage, VehicleSleepStatusE, WhitelistOperation } from "@teslemetry/tesla-protocol/command/vcsec";
15
+ import { Action, GetAlertState, GetChargeScheduleState, GetChargeState, GetChildPresenceDetectionState, GetClimateState, GetClosuresState, GetDisplayState, GetDriveState, GetGuiSettings, GetLegacyVehicleState, GetLightShowState, GetLocationState, GetMediaDetailState, GetMediaState, GetParentalControlsState, GetParkedAccessoryState, GetPreconditioningScheduleState, GetSoftwareUpdateState, GetSohState, GetSuspensionState, GetTirePressureState, GetVehicleConfig, GetVehicleData, GetVehicleDetailState, GetVehicleState, VehicleAction } from "@teslemetry/tesla-protocol/command/car_server";
16
+ import { Role } from "@teslemetry/tesla-protocol/command/keys";
17
+ // src/bluetooth/vehiclebluetooth.ts
18
+ const VCSEC = Domain.DOMAIN_VEHICLE_SECURITY;
19
+ const INFOTAINMENT = Domain.DOMAIN_INFOTAINMENT;
20
+ /** What Device Name starts with once the vehicle's name is available. */
21
+ const DISPLAY_NAME_PREFIX = "🔑 ";
22
+ const DISPLAY_NAME_RETRY_MS = 1e3;
23
+ function success() {
24
+ return { response: {
25
+ result: true,
26
+ reason: ""
27
+ } };
28
+ }
29
+ /** One GATT connection, or an attempt at one, with everything that must not outlive it. */
30
+ var Link = class {
31
+ connection;
32
+ /** Holds one adapter call at a time, so frames and reads never interleave. */
33
+ gatt = new AsyncLock();
34
+ /** Per link, so a partial frame from a dead link can never join the next link's. */
35
+ buffer;
36
+ /** Aborts when the link is lost, ending every wait on it. */
37
+ closed = new AbortController();
38
+ constructor(onMessage) {
39
+ this.buffer = new ReassemblingBuffer(onMessage);
40
+ }
41
+ get lost() {
42
+ return this.closed.signal.aborted;
43
+ }
44
+ close() {
45
+ this.closed.abort(/* @__PURE__ */ new Error("The Bluetooth link was lost."));
46
+ }
47
+ };
48
+ /**
49
+ * A vehicle reached directly over Bluetooth, with commands signed by AES-GCM.
50
+ *
51
+ * All Tesla protocol logic lives here and in `Commands`. A `BleTransport`
52
+ * supplies raw GATT only, so the same class runs on any backend, Homey's
53
+ * included. Backends may hang and may misreport their link, so every backend
54
+ * call is bounded, and a failed or timed-out operation is what counts as a dead
55
+ * link.
56
+ *
57
+ * `Commands.dispatch` holds a VCSEC session's lock across `_send`, and that lock
58
+ * is not reentrant, so `_send` serializes on its own `domainSendLock` and must
59
+ * never take `Session.withLock`.
60
+ *
61
+ * Sessions survive a reconnect, and the vehicle key they pinned at first contact
62
+ * survives with them: a new link may re-handshake, but nothing here clears a
63
+ * session or lets a new link trust a new key.
64
+ *
65
+ * A mutating command that times out is unresolved, not failed: in field data 2
66
+ * of 3 failed writes had executed anyway. So a timed-out command is never sent
67
+ * again. The outcomes a caller can see:
68
+ *
69
+ * - `BluetoothTransportError`: provably never reached the radio. Safe to replay.
70
+ * - `BluetoothCommandFailed`: state proved it did not apply. Safe to replay.
71
+ * - `BluetoothUnconfirmedCommand`: it may have run. Never replay it.
72
+ * - A plain `BluetoothTimeout`: a read, or a step before any write.
73
+ * - Best-effort success, when `raise_unconfirmed` is off and nothing resolved it.
74
+ * `charge_port_door_open` under `"verify"` is the exception, and raises.
75
+ */
76
+ var VehicleBluetooth = class extends BroadcastListeners {
77
+ _transport_name = "bluetooth";
78
+ authMethod = "aes";
79
+ /** Reply wait with data. */
80
+ defaultTimeoutMs = 5e3;
81
+ /** Reply wait for a VCSEC actuation. A lost ack is inconclusive, so a shorter wait loses nothing. */
82
+ actuationTimeoutMs = 2e3;
83
+ /** Wait for data after a bare ack. */
84
+ ackFollowupTimeoutMs = 2e3;
85
+ /** How long a command woken by `wake_if_asleep` waits for infotainment. */
86
+ wakeTimeoutMs = DEFAULT_WAKE_TIMEOUT_MS;
87
+ /** How often a wake is resent while infotainment stays silent. */
88
+ wakeResendIntervalMs = WAKE_RESEND_INTERVAL_MS;
89
+ /** How long an awake car's infotainment may take to finish booting. */
90
+ infotainmentBootTimeoutMs = INFOTAINMENT_BOOT_TIMEOUT_MS;
91
+ /** How long a charge-port opening has to show in charge state. */
92
+ chargePortVerifyTimeoutMs = CHARGE_PORT_VERIFY_TIMEOUT_MS;
93
+ /** Between charge-state polls. */
94
+ chargePortVerifyIntervalMs = 250;
95
+ /** Bound on one keepalive read. */
96
+ keepaliveTimeoutMs = 2e3;
97
+ /** The vehicle's BLE name, with the usual `C` suffix. */
98
+ ble_name;
99
+ confirmation;
100
+ raise_unconfirmed;
101
+ wake_if_asleep;
102
+ keepalive_interval_ms;
103
+ transport;
104
+ queues = {
105
+ [VCSEC]: new ReplyQueue(),
106
+ [INFOTAINMENT]: new ReplyQueue()
107
+ };
108
+ maxConnectAttempts;
109
+ connectTimeoutMs;
110
+ scanTimeoutMs;
111
+ wakeProbeTimeoutMs;
112
+ domainSendLock = {
113
+ [VCSEC]: new AsyncLock(),
114
+ [INFOTAINMENT]: new AsyncLock()
115
+ };
116
+ connectLock = new AsyncLock();
117
+ broadcastWatchers = {};
118
+ device;
119
+ link = null;
120
+ /** Closing a lost link. A new connect waits for it: a late disconnect could end the new link. */
121
+ teardown = Promise.resolve();
122
+ keepaliveTimer = null;
123
+ /** When GATT traffic last moved, on the monotonic clock. */
124
+ lastActivity = 0;
125
+ connected = false;
126
+ connectionListeners = new ListenerRegistry((message) => this.log(message));
127
+ constructor(parent, vin, options) {
128
+ super(parent, vin, options.privateKey);
129
+ const confirmation = options.confirmation ?? "ack";
130
+ if (![
131
+ "optimistic",
132
+ "ack",
133
+ "verify"
134
+ ].includes(confirmation)) throw new Error("confirmation must be one of \"optimistic\", \"ack\" or \"verify\".");
135
+ this.confirmation = confirmation;
136
+ this.raise_unconfirmed = options.raise_unconfirmed ?? false;
137
+ this.transport = options.transport;
138
+ this.device = options.device;
139
+ this.keepalive_interval_ms = options.keepalive_interval_ms === void 0 ? DEFAULT_KEEPALIVE_INTERVAL_MS : options.keepalive_interval_ms;
140
+ this.wake_if_asleep = options.wake_if_asleep ?? true;
141
+ this.maxConnectAttempts = options.max_connect_attempts ?? 3;
142
+ this.connectTimeoutMs = options.connect_timeout_ms ?? 2e4;
143
+ this.scanTimeoutMs = options.scan_timeout_ms ?? options.transport.scanTimeoutMs ?? 1e4;
144
+ this.wakeProbeTimeoutMs = options.wake_probe_timeout_ms ?? 1e3;
145
+ if (this.wakeProbeTimeoutMs <= 0) throw new RangeError("wake_probe_timeout_ms must be greater than 0.");
146
+ this.ble_name = get_name(vin);
147
+ }
148
+ /** Whether `confirmation` is `"optimistic"`. */
149
+ get optimistic() {
150
+ return this.confirmation === "optimistic";
151
+ }
152
+ /** Whether `confirmation` is `"verify"`. */
153
+ get verify_commands() {
154
+ return this.confirmation === "verify";
155
+ }
156
+ /**
157
+ * Find this vehicle. Never filters by service, because the vehicle advertises
158
+ * none. A device with free connection slots wins, then the strongest signal.
159
+ * @throws BluetoothTransportError when the scan fails or finds nothing.
160
+ */
161
+ async find_vehicle({ name, address, timeoutMs = this.scanTimeoutMs } = {}) {
162
+ const matchesName = (seen) => name !== void 0 ? seen === name : name_matches_vin(seen, this.vin);
163
+ const accepts = (device) => address !== void 0 ? device.id === address : device.name !== void 0 && matchesName(device.name);
164
+ let seen;
165
+ try {
166
+ seen = await withTimeout(() => this.transport.scan({
167
+ timeoutMs,
168
+ matches: address === void 0 ? matchesName : void 0
169
+ }), timeoutMs);
170
+ } catch (cause) {
171
+ throw new BluetoothTransportError(null, null, { cause });
172
+ }
173
+ const [device] = seen.filter(accepts).sort((a, b) => Number(a.connectable === false) - Number(b.connectable === false) || (b.rssi ?? -Infinity) - (a.rssi ?? -Infinity));
174
+ if (!device) throw new BluetoothTransportError("Vehicle not found in the scan.");
175
+ this.device = device;
176
+ return device;
177
+ }
178
+ /** Assign the device later connections use. */
179
+ set_device(device) {
180
+ this.device = device;
181
+ }
182
+ /** The device later connections use, if one has been found or set. */
183
+ get_device() {
184
+ return this.device;
185
+ }
186
+ /**
187
+ * Connect, unless a link is up. Scans first if no device is known. Python
188
+ * raises there instead, but a Homey app has no scan of its own to run.
189
+ * @throws BluetoothTransportError when every attempt fails, with the last failure as `cause`.
190
+ */
191
+ async connect(maxAttempts = this.maxConnectAttempts) {
192
+ await this.connectLock.run(async () => {
193
+ const current = this.link;
194
+ if (current?.connection.isConnected) return;
195
+ if (current) this.loseLink(current, "backend reports it down");
196
+ await this.teardown;
197
+ const device = this.device ?? await this.find_vehicle();
198
+ let cause;
199
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
200
+ await this.teardown;
201
+ await this.connectOnce(device);
202
+ return;
203
+ } catch (error) {
204
+ this.log(`BLE connect attempt ${attempt}/${maxAttempts} failed: ${describe(error)}`);
205
+ cause = error;
206
+ }
207
+ throw new BluetoothTransportError(null, null, { cause });
208
+ });
209
+ }
210
+ /** Connect if the link is down, as every send does. */
211
+ async connect_if_needed(maxAttempts = this.maxConnectAttempts) {
212
+ await this.connect(maxAttempts);
213
+ }
214
+ async connectOnce(device) {
215
+ const link = new Link((message) => this.onMessage(message));
216
+ const connecting = (async () => this.transport.connect(device, {
217
+ onNotify: (chunk) => this.onNotify(link, chunk),
218
+ onDisconnect: () => this.loseLink(link, "backend reported a disconnect")
219
+ }))();
220
+ let connection;
221
+ try {
222
+ connection = await withTimeout(() => connecting, this.connectTimeoutMs);
223
+ } catch (error) {
224
+ link.close();
225
+ if (error instanceof BleCallTimeout) {
226
+ const closing = connecting.then((late) => this.closeConnection(late), () => void 0);
227
+ this.teardown = Promise.all([this.teardown, withTimeout(() => closing, LINK_TEARDOWN_TIMEOUT_MS).catch(() => void 0)]);
228
+ }
229
+ throw error;
230
+ }
231
+ if (link.lost || !connection.isConnected) {
232
+ link.close();
233
+ await this.closeConnection(connection);
234
+ throw new Error("The link dropped while connecting.");
235
+ }
236
+ link.connection = connection;
237
+ this.link = link;
238
+ this.startKeepalive();
239
+ this.setConnected(true);
240
+ }
241
+ /** Close a connection, bounded and never throwing: the failure behind it is what gets reported. */
242
+ async closeConnection(connection) {
243
+ try {
244
+ await withTimeout(() => connection.disconnect(), LINK_TEARDOWN_TIMEOUT_MS);
245
+ } catch (error) {
246
+ this.log(`BLE link teardown failed: ${describe(error)}`);
247
+ }
248
+ }
249
+ /**
250
+ * Mark a link lost and start closing it. Only the current link has state to
251
+ * clear, so a late event from an old link or an abandoned attempt ends there.
252
+ */
253
+ loseLink(link, reason) {
254
+ link.close();
255
+ if (this.link !== link) return;
256
+ this.link = null;
257
+ this.log(`BLE link lost: ${reason}`);
258
+ this.stopKeepalive();
259
+ this.teardown = Promise.all([this.teardown, this.closeConnection(link.connection)]);
260
+ this.setConnected(false);
261
+ }
262
+ /** Tear down the link and wait for it. The next send reconnects. */
263
+ async dropLink(reason) {
264
+ if (this.link) this.loseLink(this.link, reason);
265
+ await this.teardown;
266
+ }
267
+ /** Drop the link after a failed session setup, unless the failure never touched it. */
268
+ async dropLinkOnFailure(reason, error) {
269
+ if (!(error instanceof SigningDisabled)) await this.dropLink(`${reason}: ${describe(error)}`);
270
+ }
271
+ /**
272
+ * Disconnect, waiting out any connect in progress.
273
+ * @returns Whether there was a connection.
274
+ */
275
+ async disconnect() {
276
+ return this.connectLock.run(async () => {
277
+ const connected = this.link !== null;
278
+ await this.dropLink("disconnect");
279
+ return connected;
280
+ });
281
+ }
282
+ /**
283
+ * Listen for the link coming up and going down. `true` fires once
284
+ * notifications are live, and `false` once the link is lost, for any reason
285
+ * and however it was noticed. Only real changes fire. A backend that drops
286
+ * silently makes `false` as late as the next operation to time out.
287
+ */
288
+ listen_connection_status(callback) {
289
+ return this.connectionListeners.add(callback);
290
+ }
291
+ setConnected(connected) {
292
+ if (this.connected !== connected) {
293
+ this.connected = connected;
294
+ this.connectionListeners.emit(connected);
295
+ }
296
+ }
297
+ startKeepalive() {
298
+ this.stopKeepalive();
299
+ if (this.keepalive_interval_ms) {
300
+ this.lastActivity = performance.now();
301
+ this.scheduleKeepalive(this.keepalive_interval_ms);
302
+ }
303
+ }
304
+ stopKeepalive() {
305
+ if (this.keepaliveTimer !== null) {
306
+ clearTimeout(this.keepaliveTimer);
307
+ this.keepaliveTimer = null;
308
+ }
309
+ }
310
+ scheduleKeepalive(intervalMs) {
311
+ const idleMs = performance.now() - this.lastActivity;
312
+ this.keepaliveTimer = setTimeout(() => void this.keepaliveTick(intervalMs), Math.max(0, intervalMs - idleMs));
313
+ this.keepaliveTimer.unref?.();
314
+ }
315
+ /** Idle-triggered, so an active link pays nothing. It never raises, never reconnects and never wakes the car. */
316
+ async keepaliveTick(intervalMs) {
317
+ this.keepaliveTimer = null;
318
+ const link = this.link;
319
+ if (!link) return;
320
+ if (performance.now() - this.lastActivity >= intervalMs) {
321
+ await this.keepaliveRead(link);
322
+ this.lastActivity = performance.now();
323
+ }
324
+ if (this.link === link) this.scheduleKeepalive(intervalMs);
325
+ }
326
+ /**
327
+ * One bounded read, with every failure swallowed. A read that times out
328
+ * still marks the link lost, as any read does here: on a backend that
329
+ * misreports its link, that is how a dead idle link is found before the next
330
+ * command writes to it. Python only drops the read.
331
+ */
332
+ async keepaliveRead(link) {
333
+ if (!link.connection.isConnected) {
334
+ this.loseLink(link, "backend reports it down");
335
+ return;
336
+ }
337
+ try {
338
+ await this.gattRead("version", this.keepaliveTimeoutMs);
339
+ } catch (error) {
340
+ this.log(`BLE keepalive read failed: ${describe(error)}`);
341
+ }
342
+ }
343
+ /** A backend calls this, so it must never throw. */
344
+ onNotify(link, chunk) {
345
+ if (link.lost) return;
346
+ this.lastActivity = performance.now();
347
+ try {
348
+ link.buffer.receive(chunk);
349
+ } catch (error) {
350
+ this.log(`BLE notification failed: ${describe(error)}`);
351
+ }
352
+ }
353
+ /** Route one whole message: replies to their domain's queue, the rest to whoever listens. */
354
+ onMessage(message) {
355
+ const address = message.toDestination?.routingAddress;
356
+ const domain = message.fromDestination?.domain ?? Domain.DOMAIN_BROADCAST;
357
+ if (!address || !this.fromDestination.equals(address)) {
358
+ this.broadcastWatchers[domain]?.onBroadcast(message);
359
+ this.dispatchBroadcast(domain, message);
360
+ return;
361
+ }
362
+ const queue = this.queues[domain];
363
+ if (!queue) {
364
+ this.log(`BLE ignored a reply from unhandled domain ${domain}`);
365
+ return;
366
+ }
367
+ queue.push(message);
368
+ }
369
+ /** Write one frame, with its chunks together, each bounded. */
370
+ writeFrame(link, frame, signal) {
371
+ const { connection } = link;
372
+ const maxWriteSize = connection.maxWriteSize === void 0 ? 20 : connection.maxWriteSize;
373
+ return link.gatt.run(async () => {
374
+ signal?.throwIfAborted();
375
+ let written = 0;
376
+ for (const chunk of splitFrame(frame, maxWriteSize)) {
377
+ if (link.lost) throw new BleNotSubmittedError("The link was lost before the frame was fully written.");
378
+ try {
379
+ await withTimeout(() => connection.write(chunk), GATT_WRITE_TIMEOUT_MS, link.closed.signal);
380
+ } catch (error) {
381
+ if (error instanceof BleNotSubmittedError && written > 0) this.loseLink(link, "a frame stopped part way");
382
+ throw error;
383
+ }
384
+ written++;
385
+ this.lastActivity = performance.now();
386
+ }
387
+ });
388
+ }
389
+ /**
390
+ * Read a characteristic, bounded. A rejected or timed-out read marks the link
391
+ * lost, whatever `isConnected` says, as that is how a dead link is found on a
392
+ * backend that misreports it. The exception is `BleNotSubmittedError` with the
393
+ * link reported up: no radio I/O happened, and a stack that hides Generic
394
+ * Access rejects those reads that way on a healthy link.
395
+ * @throws BluetoothTransportError when there is no link.
396
+ */
397
+ async gattRead(characteristic, timeoutMs = GATT_READ_TIMEOUT_MS) {
398
+ const link = this.link;
399
+ if (!link) throw new BluetoothTransportError();
400
+ const { connection } = link;
401
+ return link.gatt.run(async () => {
402
+ if (link.lost) throw new BluetoothTransportError();
403
+ try {
404
+ return await withTimeout(() => connection.read(characteristic), timeoutMs, link.closed.signal);
405
+ } catch (error) {
406
+ if (!(error instanceof BleNotSubmittedError) || !connection.isConnected) this.loseLink(link, `read failed: ${describe(error)}`);
407
+ throw error;
408
+ }
409
+ });
410
+ }
411
+ async _send(msg, requires, options = {}) {
412
+ const { expectsData = true, optimistic = false, confirmBroadcast, onSubmitted, signal } = options;
413
+ const timeoutMs = options.timeoutMs ?? (expectsData ? this.defaultTimeoutMs : this.actuationTimeoutMs);
414
+ const domain = msg.toDestination?.domain;
415
+ const lock = domain === void 0 ? void 0 : this.domainSendLock[domain];
416
+ const queue = domain === void 0 ? void 0 : this.queues[domain];
417
+ if (domain === void 0 || !lock || !queue) throw new Error(`No Bluetooth reply queue for domain ${domain}.`);
418
+ return lock.run(async () => {
419
+ const frame = prependLength(RoutableMessage.encode(msg).finish());
420
+ for (const stale of queue.drain()) this.log(`BLE discarded a stale reply from domain ${stale.fromDestination?.domain}`);
421
+ await this.connect_if_needed();
422
+ const link = this.link;
423
+ if (!link?.connection.isConnected) {
424
+ if (link) this.loseLink(link, "backend reports it down");
425
+ throw new BluetoothTransportError();
426
+ }
427
+ const watcher = confirmBroadcast && !optimistic ? this.armBroadcastWatcher(domain, confirmBroadcast) : null;
428
+ const awaitReply = (signal) => this.awaitReply(queue, msg, requires, expectsData, timeoutMs, signal);
429
+ const awaitOutcome = () => watcher ? this.awaitReplyOrBroadcast(link, watcher, awaitReply) : awaitReply(link.closed.signal);
430
+ try {
431
+ try {
432
+ await this.writeFrame(link, frame, signal);
433
+ } catch (error) {
434
+ if (signal?.aborted && error === signal.reason) throw error;
435
+ if (error instanceof BleNotSubmittedError) {
436
+ if (!link.connection.isConnected) this.loseLink(link, describe(error));
437
+ throw new BluetoothTransportError(null, null, { cause: error });
438
+ }
439
+ onSubmitted?.();
440
+ if (watcher) watcher.writeEnded = true;
441
+ if (optimistic) {
442
+ this.loseLink(link, `write failed: ${describe(error)}`);
443
+ throw new BluetoothTimeout(null, null, { cause: error });
444
+ }
445
+ try {
446
+ return await awaitOutcome();
447
+ } catch (timeout) {
448
+ if (!(timeout instanceof BluetoothTimeout)) throw timeout;
449
+ this.loseLink(link, `write failed: ${describe(error)}`);
450
+ throw new BluetoothTimeout(timeout.data, timeout.status, { cause: error });
451
+ }
452
+ }
453
+ onSubmitted?.();
454
+ if (watcher) {
455
+ watcher.writeEnded = true;
456
+ watcher.writeComplete = true;
457
+ }
458
+ if (optimistic) return RoutableMessage.create({});
459
+ return await awaitOutcome();
460
+ } finally {
461
+ if (watcher && this.broadcastWatchers[domain] === watcher) delete this.broadcastWatchers[domain];
462
+ }
463
+ });
464
+ }
465
+ /**
466
+ * Watch for broadcasts showing the requested state. A lost ack does not stop
467
+ * the car broadcasting the change it caused. Only a status after the write
468
+ * counts, as one from before it says nothing.
469
+ */
470
+ armBroadcastWatcher(domain, matches) {
471
+ let resolve;
472
+ const watcher = {
473
+ confirmed: new Promise((done) => {
474
+ resolve = done;
475
+ }),
476
+ mismatched: false,
477
+ writeEnded: false,
478
+ writeComplete: false,
479
+ onBroadcast: (message) => {
480
+ const status = decodeVcsecStatus(message);
481
+ if (status === null || watcher.confirmation || !watcher.writeEnded) return;
482
+ const executed = matches(status);
483
+ if (executed) {
484
+ watcher.confirmation = message;
485
+ resolve(message);
486
+ } else if (executed === false && watcher.writeComplete) watcher.mismatched = true;
487
+ }
488
+ };
489
+ this.broadcastWatchers[domain] = watcher;
490
+ return watcher;
491
+ }
492
+ /**
493
+ * Race the addressed reply against a matching broadcast. A mismatch does not
494
+ * fail fast, because it may predate the actuation and a later broadcast can
495
+ * still confirm it. Only a mismatch still standing when the whole window has
496
+ * run out proves the command did not apply, and a lost link cuts the window
497
+ * short, so it proves nothing then.
498
+ */
499
+ async awaitReplyOrBroadcast(link, watcher, awaitReply) {
500
+ const raceOver = new AbortController();
501
+ const reply = awaitReply(AbortSignal.any([link.closed.signal, raceOver.signal]));
502
+ try {
503
+ const winner = await Promise.race([reply.then(() => "reply", () => "reply"), watcher.confirmed]);
504
+ if (winner !== "reply") return winner;
505
+ return await reply;
506
+ } catch (error) {
507
+ if (error instanceof BluetoothTimeout) {
508
+ if (watcher.confirmation) return watcher.confirmation;
509
+ if (watcher.mismatched && !link.lost) throw new BluetoothCommandFailed(error.data, error.status, { cause: error });
510
+ }
511
+ throw error;
512
+ } finally {
513
+ raceOver.abort();
514
+ await reply.catch(() => void 0);
515
+ }
516
+ }
517
+ /**
518
+ * Wait for the reply carrying `requires`, or a bare ack of this request.
519
+ * @param signal Ends the wait early, as a timeout does.
520
+ */
521
+ async awaitReply(queue, msg, requires, expectsData, timeoutMs, signal) {
522
+ const uuid = Buffer.from(msg.uuid);
523
+ const domain = msg.toDestination?.domain;
524
+ const hasRequired = (resp) => (requires === "session_info" ? resp.sessionInfo : resp.protobufMessageAsBytes) !== void 0;
525
+ const deadline = performance.now() + timeoutMs;
526
+ for (;;) {
527
+ const resp = await queue.get(deadline - performance.now(), signal);
528
+ if (resp === null) throw new BluetoothTimeout();
529
+ if (this.isForeignReply(resp, msg, expectsData)) {
530
+ this.log(`BLE ignored a reply that is not for this request, domain ${resp.fromDestination?.domain}`);
531
+ continue;
532
+ }
533
+ this.validate_msg(resp, uuid, domain);
534
+ if (hasRequired(resp)) return resp;
535
+ if (uuid.length === 0 || !uuid.equals(resp.requestUuid)) continue;
536
+ if (!expectsData) return resp;
537
+ const followupDeadline = performance.now() + this.ackFollowupTimeoutMs;
538
+ for (;;) {
539
+ const limit = Math.min(followupDeadline, deadline);
540
+ const followup = await queue.get(limit - performance.now(), signal);
541
+ if (followup === null) {
542
+ if (followupDeadline > deadline && !signal.aborted) throw new BluetoothTimeout();
543
+ return resp;
544
+ }
545
+ if (this.isForeignReply(followup, msg, expectsData)) {
546
+ this.log(`BLE ignored a reply that is not for this request, domain ${followup.fromDestination?.domain}`);
547
+ continue;
548
+ }
549
+ this.validate_msg(followup, uuid, domain);
550
+ if (hasRequired(followup)) return followup;
551
+ }
552
+ }
553
+ }
554
+ /**
555
+ * Whether a reply must be ignored rather than acted on. The link is shared, so
556
+ * an addressed reply can be a late answer to an earlier request, and acting on
557
+ * one can resend a command or report one that never ran.
558
+ *
559
+ * A reply naming another request is never ours. One naming no request must
560
+ * still be heard, because real VCSEC leaves `request_uuid` empty, but nothing
561
+ * ties it to this request unless its session-info tag or its encryption does,
562
+ * and both of those are checked later. So it may ack a command or answer a
563
+ * read, and it may not make anything happen: not a resend or a fault, and a
564
+ * status does not stand in for an actuation's ack.
565
+ */
566
+ isForeignReply(resp, msg, expectsData) {
567
+ if (resp.requestUuid.length > 0) return !Buffer.from(msg.uuid).equals(resp.requestUuid);
568
+ if (msg.sessionInfoRequest || resp.sessionInfo && resp.sessionInfo.length > 0) return false;
569
+ const status = resp.signedMessageStatus;
570
+ if (status?.operationStatus === OperationStatusE.OPERATIONSTATUS_WAIT || status && status.signedMessageFault !== MessageFaultE.MESSAGEFAULT_ERROR_NONE) return true;
571
+ if (msg.toDestination?.domain !== VCSEC || !resp.protobufMessageAsBytes || resp.signatureData?.AESGCMResponseData) return false;
572
+ try {
573
+ const reply = FromVCSECMessage.decode(resp.protobufMessageAsBytes);
574
+ return reply.commandStatus?.operationStatus === OperationStatusE$1.OPERATIONSTATUS_WAIT || !expectsData && reply.vehicleStatus !== void 0;
575
+ } catch {
576
+ return false;
577
+ }
578
+ }
579
+ /** A session that a reply left unready cannot sign, and that reply proved nothing. */
580
+ async ensureHandshake(domain) {
581
+ await super.ensureHandshake(domain);
582
+ if (!this.sessionFor(domain).ready) throw new BluetoothTimeout();
583
+ }
584
+ /** A failed handshake drops the link, so a retry starts on a fresh one. */
585
+ async handshakeVehicleSecurity() {
586
+ try {
587
+ await super.handshakeVehicleSecurity();
588
+ } catch (error) {
589
+ await this.dropLinkOnFailure("vehicle security handshake failed", error);
590
+ throw error;
591
+ }
592
+ }
593
+ /**
594
+ * A failed handshake drops the link, unless VCSEC reports the car asleep:
595
+ * its infotainment is meant to be silent then, and that read proves the link.
596
+ */
597
+ async handshakeInfotainment() {
598
+ try {
599
+ await super.handshakeInfotainment();
600
+ } catch (error) {
601
+ if (!(error instanceof BluetoothTimeout)) await this.dropLinkOnFailure("infotainment handshake failed", error);
602
+ else if (!await this.vehicleAsleep()) await this.dropLink("infotainment handshake failed");
603
+ throw error;
604
+ }
605
+ }
606
+ /**
607
+ * Sign and write a mutating command, with no reply wait. A write that fails
608
+ * provably before submission still raises. One that entered the backend may
609
+ * have reached the car, so even here it must not let a router replay it, and
610
+ * it follows `raise_unconfirmed`.
611
+ */
612
+ async sendOptimistic(domain, command, name) {
613
+ const session = this.sessionFor(domain);
614
+ const send = (msg) => this._send(msg, "protobuf_message_as_bytes", { optimistic: true });
615
+ try {
616
+ if (domain === VCSEC) await session.withLock(() => send(this.buildMessage(session, command)));
617
+ else await send(await session.withLock(() => this.buildMessage(session, command)));
618
+ } catch (error) {
619
+ if (!(error instanceof BluetoothTimeout)) throw error;
620
+ return this.unconfirmedOutcome(name, new BluetoothUnconfirmedCommand(error.data, error.status, { cause: error }));
621
+ }
622
+ return success();
623
+ }
624
+ /** End a ladder that could not determine what happened. */
625
+ unconfirmedOutcome(name, unconfirmed) {
626
+ if (this.raise_unconfirmed) throw unconfirmed;
627
+ this.log(`command=${name} transport=${this._transport_name} raise_unconfirmed=False result=success (best-effort)`);
628
+ return success();
629
+ }
630
+ /**
631
+ * Confirm a timed-out command by state, when `"verify"` asks for it.
632
+ * @returns Success, or `null` if the state could not be read. An unreadable car is never woken to verify.
633
+ * @throws BluetoothCommandFailed when the state shows it did not apply, whatever `raise_unconfirmed` says.
634
+ */
635
+ async verifyTimeout(name, plan, timeout) {
636
+ if (this.confirmation !== "verify" || !plan) return null;
637
+ let state;
638
+ try {
639
+ state = await this[plan.reader]();
640
+ } catch (error) {
641
+ if (error instanceof TeslaFleetError) {
642
+ this.log(`command=${name} transport=${this._transport_name} verify_commands=unresolved`);
643
+ return null;
644
+ }
645
+ throw error;
646
+ }
647
+ const executed = plan.executed(state);
648
+ if (executed === void 0) {
649
+ this.log(`command=${name} transport=${this._transport_name} verify_commands=unresolved`);
650
+ return null;
651
+ }
652
+ this.log(`command=${name} transport=${this._transport_name} verify_commands=resolved result=${executed}`);
653
+ if (!executed) throw new BluetoothCommandFailed(timeout.data, timeout.status, { cause: timeout });
654
+ return success();
655
+ }
656
+ /**
657
+ * Open the flap or release a cable. Under `"verify"`, the charging subsystem
658
+ * is made ready first, and only physical state counts as success afterwards:
659
+ * an opening has been reported waking a sleeping car without releasing the
660
+ * latch. No readiness failure has sent anything, so a router may fail over on
661
+ * any of them. Once the one opening write has gone, an unresolved result always
662
+ * raises `BluetoothUnconfirmedCommand`, whatever `raise_unconfirmed` says, and
663
+ * it is never sent again: a best-effort success would hide a latch that never
664
+ * released, and a replay could release a cable the caller had plugged back in.
665
+ * Other modes keep the ordinary VCSEC ladder.
666
+ * @throws BluetoothCommandFailed when the car is asleep with `wake_if_asleep` off, or the cable state is unknown.
667
+ * @throws BluetoothTimeout when the car is not ready in time.
668
+ * @throws BluetoothUnconfirmedCommand when physical state never showed the opening.
669
+ */
670
+ async charge_port_door_open() {
671
+ if (this.confirmation !== "verify") return super.charge_port_door_open();
672
+ const connected = await this.chargePortPreflight();
673
+ let cause;
674
+ let submitted = false;
675
+ try {
676
+ const result = await super.sendVehicleSecurity(UnsignedMessage.create({ closureMoveRequest: ClosureMoveRequest.create({ chargePort: ClosureMoveTypeE.CLOSURE_MOVE_TYPE_OPEN }) }), { onSubmitted: () => submitted = true });
677
+ if (result.response.result === false) return result;
678
+ } catch (error) {
679
+ if (!submitted || !leavesOutcomeUnknown(error)) throw error;
680
+ cause = error;
681
+ }
682
+ return this.verifyChargePortOpened(connected, cause);
683
+ }
684
+ /**
685
+ * Everything before the opening, under one `wakeTimeoutMs` deadline.
686
+ * @returns Whether a cable is connected, which picks the proof of success.
687
+ */
688
+ async chargePortPreflight() {
689
+ const deadline = performance.now() + this.wakeTimeoutMs;
690
+ const over = new AbortController();
691
+ try {
692
+ const connected = await withTimeout(() => this.chargePortReadiness(over.signal), this.wakeTimeoutMs);
693
+ try {
694
+ await withTimeout(() => this.ensureHandshake(VCSEC), deadline - performance.now());
695
+ } catch (error) {
696
+ await this.dropLinkOnFailure("vehicle security handshake failed", error);
697
+ throw error;
698
+ }
699
+ return connected;
700
+ } catch (error) {
701
+ if (error instanceof BleCallTimeout) throw new BluetoothTimeout("Charge-port readiness timed out", null, { cause: error });
702
+ throw error;
703
+ } finally {
704
+ over.abort();
705
+ }
706
+ }
707
+ async chargePortReadiness(over) {
708
+ if (await this.vehicleAsleep()) {
709
+ if (!this.wake_if_asleep) throw new BluetoothCommandFailed("Vehicle asleep; wake disabled");
710
+ await this.wakeForCommand(new BluetoothTimeout());
711
+ } else await this.awaitInfotainment(this.infotainmentBootTimeoutMs, false, over);
712
+ for (;;) {
713
+ try {
714
+ const connected = chargePortConnected(await this.charge_state());
715
+ if (connected === null) throw new BluetoothCommandFailed("Charge-port cable state unknown; no opening command sent");
716
+ return connected;
717
+ } catch (error) {
718
+ if (error instanceof InvalidResponse) throw new BluetoothCommandFailed("Charge-port cable state unknown; no opening command sent", null, { cause: error });
719
+ if (!(error instanceof BluetoothTimeout) || over.aborted) throw error;
720
+ }
721
+ await sleep(this.chargePortVerifyIntervalMs);
722
+ }
723
+ }
724
+ /**
725
+ * Poll for the physical result, holding to the goal set before the opening:
726
+ * an open flap must never stand in for a latch that did not release. Nothing
727
+ * here may send the opening again, and no failed read may reach a router as
728
+ * anything but unconfirmed.
729
+ */
730
+ async verifyChargePortOpened(connected, opening) {
731
+ const name = "closureMoveRequest";
732
+ const log = (message) => this.log(message);
733
+ let cause = opening;
734
+ const deadline = performance.now() + this.chargePortVerifyTimeoutMs;
735
+ for (let remaining = this.chargePortVerifyTimeoutMs; remaining > 0; remaining = deadline - performance.now()) {
736
+ try {
737
+ const after = await withTimeout(() => this.charge_state(), remaining);
738
+ if (connected ? after.chargePortLatch?.Disengaged !== void 0 : chargePortConnected(after) === false && after.chargePortDoorOpen === true) {
739
+ log(`command=${name} transport=${this._transport_name} physical_confirmation=confirmed`);
740
+ logRequestResult(log, name, this._transport_name, success());
741
+ return success();
742
+ }
743
+ } catch (error) {
744
+ if (!(error instanceof BleCallTimeout)) cause = error;
745
+ }
746
+ await sleep(Math.min(this.chargePortVerifyIntervalMs, Math.max(0, deadline - performance.now())));
747
+ }
748
+ const unconfirmed = new BluetoothUnconfirmedCommand("Charge-port opening was not confirmed by physical state", null, { cause });
749
+ log(`command=${name} transport=${this._transport_name} physical_confirmation=unconfirmed`);
750
+ logRequestError(log, name, this._transport_name, unconfirmed);
751
+ throw unconfirmed;
752
+ }
753
+ /**
754
+ * Send a VCSEC actuation through the confirmation ladder. A plan's check also
755
+ * races the ack, so `options` is ignored: the plan is the only source of it.
756
+ */
757
+ async sendVehicleSecurity(command, _options = {}) {
758
+ try {
759
+ await this.ensureHandshake(VCSEC);
760
+ } catch (error) {
761
+ await this.dropLinkOnFailure("vehicle security handshake failed", error);
762
+ throw error;
763
+ }
764
+ const name = vcsec_command_name(command);
765
+ if (this.confirmation === "optimistic") return this.sendOptimistic(VCSEC, Buffer.from(UnsignedMessage.encode(command).finish()), name);
766
+ const plan = vcsecVerifyPlan(command);
767
+ let submitted = false;
768
+ try {
769
+ return await super.sendVehicleSecurity(command, {
770
+ confirmBroadcast: plan?.executed,
771
+ onSubmitted: () => submitted = true
772
+ });
773
+ } catch (error) {
774
+ if (!submitted || !leavesOutcomeUnknown(error)) throw error;
775
+ const unconfirmed = new BluetoothUnconfirmedCommand(null, null, { cause: error });
776
+ return await this.verifyTimeout(name, plan, unconfirmed) ?? this.unconfirmedOutcome(name, unconfirmed);
777
+ }
778
+ }
779
+ /**
780
+ * Send an infotainment command through the confirmation ladder. `mutating:
781
+ * false` is exempt: `ping` always waits for its real reply.
782
+ */
783
+ async sendInfotainment(action, { mutating = true } = {}) {
784
+ return this.infotainmentLadder(action, mutating, this.wake_if_asleep);
785
+ }
786
+ async infotainmentLadder(action, mutating, wake) {
787
+ try {
788
+ await this.ensureHandshake(INFOTAINMENT);
789
+ } catch (error) {
790
+ if (!(error instanceof BluetoothTimeout)) {
791
+ await this.dropLinkOnFailure("infotainment handshake failed", error);
792
+ throw error;
793
+ }
794
+ const sleepStatus = await this.sleepStatus();
795
+ if (sleepStatus === VehicleSleepStatusE.VEHICLE_SLEEP_STATUS_ASLEEP) {
796
+ if (!wake) throw error;
797
+ await this.wakeForCommand(error);
798
+ wake = false;
799
+ } else if (sleepStatus === VehicleSleepStatusE.VEHICLE_SLEEP_STATUS_AWAKE) try {
800
+ await this.awaitInfotainment(this.infotainmentBootTimeoutMs, false);
801
+ } catch (bootError) {
802
+ if (!(bootError instanceof BluetoothTimeout)) throw bootError;
803
+ await this.dropLink("infotainment handshake failed");
804
+ throw error;
805
+ }
806
+ else {
807
+ await this.dropLink("infotainment handshake failed");
808
+ throw error;
809
+ }
810
+ }
811
+ const name = infotainment_command_name(action);
812
+ if (this.confirmation === "optimistic" && mutating) return this.sendOptimistic(INFOTAINMENT, Buffer.from(Action.encode(action).finish()), name);
813
+ let submitted = false;
814
+ try {
815
+ return await super.sendInfotainment(action, { onSubmitted: () => submitted = true });
816
+ } catch (error) {
817
+ if (!mutating || !submitted || !leavesOutcomeUnknown(error)) throw error;
818
+ const unconfirmed = new BluetoothUnconfirmedCommand(null, null, { cause: error });
819
+ const verified = await this.verifyTimeout(name, infotainmentVerifyPlan(action.vehicleAction), unconfirmed);
820
+ if (verified) return verified;
821
+ if (!this.raise_unconfirmed && await this.vehicleAsleep()) {
822
+ if (!wake) throw new BluetoothCommandFailed(null, null, { cause: error });
823
+ await this.wakeForCommand(unconfirmed);
824
+ return this.infotainmentLadder(action, mutating, false);
825
+ }
826
+ return this.unconfirmedOutcome(name, unconfirmed);
827
+ }
828
+ }
829
+ /**
830
+ * Wake a sleeping car until infotainment can take a command.
831
+ * @throws BluetoothCommandFailed when it cannot: the command provably never ran.
832
+ */
833
+ async wakeForCommand(timeout) {
834
+ try {
835
+ await this.wake_up({
836
+ wait: true,
837
+ timeoutMs: this.wakeTimeoutMs
838
+ });
839
+ } catch (cause) {
840
+ if (cause instanceof TeslaFleetError) throw new BluetoothCommandFailed(timeout.data, timeout.status, { cause });
841
+ throw cause;
842
+ }
843
+ }
844
+ /** VCSEC's sleep status, or `null` if unreadable. */
845
+ async sleepStatus() {
846
+ try {
847
+ return (await this.vehicle_state()).vehicleSleepStatus;
848
+ } catch (error) {
849
+ if (error instanceof TeslaFleetError) return null;
850
+ throw error;
851
+ }
852
+ }
853
+ /** Whether VCSEC reports the car asleep; `false` if unreadable. */
854
+ async vehicleAsleep() {
855
+ return await this.sleepStatus() === VehicleSleepStatusE.VEHICLE_SLEEP_STATUS_ASLEEP;
856
+ }
857
+ /** The vehicle security computer's `VehicleStatus`. It answers while the car sleeps. */
858
+ async vehicle_state() {
859
+ return this._getVehicleSecurity(UnsignedMessage.create({ InformationRequest: InformationRequest.create({ informationRequestType: InformationRequestType.INFORMATION_REQUEST_TYPE_GET_STATUS }) }));
860
+ }
861
+ async readVehicleData(request, field) {
862
+ const value = (await this._getInfotainment(Action.create({ vehicleAction: VehicleAction.create({ getVehicleData: GetVehicleData.create(request) }) })))[field];
863
+ if (value === void 0 || value === null) throw new InvalidResponse({ missing: field });
864
+ return value;
865
+ }
866
+ /** The charging state. */
867
+ async charge_state() {
868
+ return this.readVehicleData({ getChargeState: GetChargeState.create({}) }, "chargeState");
869
+ }
870
+ /** The HVAC and cabin climate state. */
871
+ async climate_state() {
872
+ return this.readVehicleData({ getClimateState: GetClimateState.create({}) }, "climateState");
873
+ }
874
+ /** The driving state, including speed and power. */
875
+ async drive_state() {
876
+ return this.readVehicleData({ getDriveState: GetDriveState.create({}) }, "driveState");
877
+ }
878
+ /** The vehicle's location. */
879
+ async location_state() {
880
+ return this.readVehicleData({ getLocationState: GetLocationState.create({}) }, "locationState");
881
+ }
882
+ /** The doors, windows, trunks and locks, as infotainment reports them. */
883
+ async closures_state() {
884
+ return this.readVehicleData({ getClosuresState: GetClosuresState.create({}) }, "closuresState");
885
+ }
886
+ /** The charge schedules. */
887
+ async charge_schedule_state() {
888
+ return this.readVehicleData({ getChargeScheduleState: GetChargeScheduleState.create({}) }, "chargeScheduleState");
889
+ }
890
+ /** The preconditioning schedules. */
891
+ async preconditioning_schedule_state() {
892
+ return this.readVehicleData({ getPreconditioningScheduleState: GetPreconditioningScheduleState.create({}) }, "preconditioningScheduleState");
893
+ }
894
+ /** The tire pressures. */
895
+ async tire_pressure_state() {
896
+ return this.readVehicleData({ getTirePressureState: GetTirePressureState.create({}) }, "tirePressureState");
897
+ }
898
+ /** The media playback state. */
899
+ async media_state() {
900
+ return this.readVehicleData({ getMediaState: GetMediaState.create({}) }, "mediaState");
901
+ }
902
+ /** Detail on the media playing now. */
903
+ async media_detail_state() {
904
+ return this.readVehicleData({ getMediaDetailState: GetMediaDetailState.create({}) }, "mediaDetailState");
905
+ }
906
+ /** The software update state. */
907
+ async software_update_state() {
908
+ return this.readVehicleData({ getSoftwareUpdateState: GetSoftwareUpdateState.create({}) }, "softwareUpdateState");
909
+ }
910
+ /** The parental controls state. */
911
+ async parental_controls_state() {
912
+ return this.readVehicleData({ getParentalControlsState: GetParentalControlsState.create({}) }, "parentalControlsState");
913
+ }
914
+ /** The display units and formats. */
915
+ async gui_settings() {
916
+ return this.readVehicleData({ getGuiSettings: GetGuiSettings.create({}) }, "guiSettings");
917
+ }
918
+ /** The state of parked accessories, such as a tent. */
919
+ async parked_accessory_state() {
920
+ return this.readVehicleData({ getParkedAccessoryState: GetParkedAccessoryState.create({}) }, "parkedAccessoryState");
921
+ }
922
+ /**
923
+ * The older infotainment vehicle state, tag 6, for older firmware. Not `current_vehicle_state`, nor VCSEC's `vehicle_state`.
924
+ */
925
+ async legacy_vehicle_state() {
926
+ return this.readVehicleData({ getLegacyVehicleState: GetLegacyVehicleState.create({}) }, "legacyVehicleState");
927
+ }
928
+ /**
929
+ * The infotainment vehicle state, tag 18, on current firmware. Not `legacy_vehicle_state`, nor VCSEC's `vehicle_state`.
930
+ */
931
+ async current_vehicle_state() {
932
+ return this.readVehicleData({ getVehicleState: GetVehicleState.create({}) }, "vehicleState");
933
+ }
934
+ /** The vehicle's fixed configuration. */
935
+ async vehicle_config() {
936
+ return this.readVehicleData({ getVehicleConfig: GetVehicleConfig.create({}) }, "vehicleConfig");
937
+ }
938
+ /** The battery state of health. */
939
+ async soh_state() {
940
+ return this.readVehicleData({ getSohState: GetSohState.create({}) }, "sohState");
941
+ }
942
+ /** The vehicle's name and software details. */
943
+ async vehicle_detail_state() {
944
+ return this.readVehicleData({ getVehicleDetailState: GetVehicleDetailState.create({}) }, "vehicleDetailState");
945
+ }
946
+ /** The display state. */
947
+ async display_state() {
948
+ return this.readVehicleData({ getDisplayState: GetDisplayState.create({}) }, "displayState");
949
+ }
950
+ /** The active alerts. */
951
+ async alert_state() {
952
+ return this.readVehicleData({ getAlertState: GetAlertState.create({}) }, "alertState");
953
+ }
954
+ /** The light show state. */
955
+ async light_show_state() {
956
+ return this.readVehicleData({ getLightShowState: GetLightShowState.create({}) }, "lightShowState");
957
+ }
958
+ /** The suspension state. */
959
+ async suspension_state() {
960
+ return this.readVehicleData({ getSuspensionState: GetSuspensionState.create({}) }, "suspensionState");
961
+ }
962
+ /** The child presence detection state. */
963
+ async child_presence_detection_state() {
964
+ return this.readVehicleData({ getChildPresenceDetectionState: GetChildPresenceDetectionState.create({}) }, "childPresenceDetectionState");
965
+ }
966
+ /**
967
+ * Several sub-states in one signed read. This is not the cloud method: it
968
+ * takes `BluetoothVehicleData` values, not `VehicleDataEndpoint`, and resolves
969
+ * to `VehicleData`, not a REST envelope. There is no default of every endpoint,
970
+ * because as few as two together have exceeded the response size limit, which
971
+ * raises `TeslaFleetMessageFaultResponseSizeExceedsMTU`. Prefer one reader each.
972
+ * @param endpoints An explicit list. `[]` asks for nothing.
973
+ * @throws BluetoothCloudUnavailableError for anything else, sending nothing: no argument and REST endpoint names are the cloud call, which a router can fail over to.
974
+ */
975
+ async vehicle_data(endpoints) {
976
+ const known = Object.values(BluetoothVehicleData);
977
+ if (!Array.isArray(endpoints) || !endpoints.every((endpoint) => known.includes(endpoint))) throw new BluetoothCloudUnavailableError();
978
+ const request = {};
979
+ for (const endpoint of endpoints) request[`${endpoint[0].toLowerCase()}${endpoint.slice(1)}`] = {};
980
+ return this._getInfotainment(Action.create({ vehicleAction: VehicleAction.create({ getVehicleData: GetVehicleData.create(request) }) }));
981
+ }
982
+ /**
983
+ * The name set in the vehicle, read from GATT Device Name. It is not advertised.
984
+ * The vehicle prefixes it with a key emoji once it is available.
985
+ * @returns The name, or `null` if it never appeared: a backend may hide Generic Access.
986
+ */
987
+ async query_display_name(max_attempts = 5) {
988
+ for (let attempt = 1; attempt <= max_attempts; attempt++) try {
989
+ await this.connect_if_needed();
990
+ const name = Buffer.from(await this.gattRead("deviceName")).toString("utf8");
991
+ if (name.startsWith(DISPLAY_NAME_PREFIX)) return name.slice(3);
992
+ await sleep(DISPLAY_NAME_RETRY_MS);
993
+ } catch (error) {
994
+ this.log(`BLE display name read failed: ${describe(error)}`);
995
+ }
996
+ return null;
997
+ }
998
+ /** GATT Appearance, or `null` if it cannot be read. */
999
+ async query_appearance() {
1000
+ try {
1001
+ await this.connect_if_needed();
1002
+ return await this.gattRead("appearance");
1003
+ } catch (error) {
1004
+ this.log(`BLE appearance read failed: ${describe(error)}`);
1005
+ return null;
1006
+ }
1007
+ }
1008
+ /** The version characteristic as a big-endian integer, or `null` if it cannot be read. */
1009
+ async query_version() {
1010
+ try {
1011
+ await this.connect_if_needed();
1012
+ const version = await this.gattRead("version");
1013
+ return version.length === 0 ? null : version.reduce((value, byte) => value * 256 + byte, 0);
1014
+ } catch (error) {
1015
+ this.log(`BLE version read failed: ${describe(error)}`);
1016
+ return null;
1017
+ }
1018
+ }
1019
+ moveClosure(closure, move) {
1020
+ return this.sendVehicleSecurity(UnsignedMessage.create({ closureMoveRequest: ClosureMoveRequest.create({ [closure]: move }) }));
1021
+ }
1022
+ /** Opens the front driver door. */
1023
+ async open_front_driver_door() {
1024
+ return this.moveClosure("frontDriverDoor", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_OPEN);
1025
+ }
1026
+ /** Closes the front driver door. Doors have no reliable powered close on a Model 3. */
1027
+ async close_front_driver_door() {
1028
+ return this.moveClosure("frontDriverDoor", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_CLOSE);
1029
+ }
1030
+ /** Opens the front passenger door. */
1031
+ async open_front_passenger_door() {
1032
+ return this.moveClosure("frontPassengerDoor", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_OPEN);
1033
+ }
1034
+ /** Closes the front passenger door. Doors have no reliable powered close on a Model 3. */
1035
+ async close_front_passenger_door() {
1036
+ return this.moveClosure("frontPassengerDoor", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_CLOSE);
1037
+ }
1038
+ /** Opens the rear driver door. */
1039
+ async open_rear_driver_door() {
1040
+ return this.moveClosure("rearDriverDoor", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_OPEN);
1041
+ }
1042
+ /** Closes the rear driver door. Doors have no reliable powered close on a Model 3. */
1043
+ async close_rear_driver_door() {
1044
+ return this.moveClosure("rearDriverDoor", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_CLOSE);
1045
+ }
1046
+ /** Opens the rear passenger door. */
1047
+ async open_rear_passenger_door() {
1048
+ return this.moveClosure("rearPassengerDoor", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_OPEN);
1049
+ }
1050
+ /** Closes the rear passenger door. Doors have no reliable powered close on a Model 3. */
1051
+ async close_rear_passenger_door() {
1052
+ return this.moveClosure("rearPassengerDoor", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_CLOSE);
1053
+ }
1054
+ /** Opens the rear trunk. */
1055
+ async open_rear_trunk() {
1056
+ return this.moveClosure("rearTrunk", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_OPEN);
1057
+ }
1058
+ /** Closes the rear trunk. */
1059
+ async close_rear_trunk() {
1060
+ return this.moveClosure("rearTrunk", ClosureMoveTypeE.CLOSURE_MOVE_TYPE_CLOSE);
1061
+ }
1062
+ /**
1063
+ * Ask the vehicle to add this instance's key, which someone confirms by tapping
1064
+ * a key card on the console. Resolves once the key is on the whitelist.
1065
+ *
1066
+ * The whitelist operation is written exactly once, because a resend prompts
1067
+ * the user again. Its reply is one frame, which a link that cycles during the
1068
+ * walk to the car can lose, so completion is also proved by polling: a VCSEC
1069
+ * handshake only succeeds once this key is whitelisted, and it reconnects, so
1070
+ * the wait survives a dropped link.
1071
+ *
1072
+ * Success needs positive evidence: an explicit success reply, or an
1073
+ * authenticated handshake with this key. Python takes any reply without a
1074
+ * whitelist fault, which a WAIT or a bare ack satisfies before the tap.
1075
+ *
1076
+ * Tesla's Go SDK sends a bare `ToVCSECMessage` here. This wraps the
1077
+ * `UnsignedMessage` in a `RoutableMessage`, as Python does, and that has paired
1078
+ * a real car. If pairing fails on another vehicle, look there first.
1079
+ * @throws RangeError when `pollIntervalMs` is not positive, before anything is sent.
1080
+ * @throws SigningDisabled when this instance has no key, before anything is sent.
1081
+ * @throws BluetoothTimeout when nothing confirmed it by `timeoutMs`, with the link dropped.
1082
+ * @throws WhitelistOperationStatus or a subclass when the vehicle refused the key.
1083
+ * @throws The reason of `signal`, once it aborts. The link is kept.
1084
+ */
1085
+ async pair({ role = Role.ROLE_OWNER, form = KeyFormFactor.KEY_FORM_FACTOR_CLOUD_KEY, timeoutMs = DEFAULT_PAIR_TIMEOUT_MS, pollIntervalMs = DEFAULT_PAIR_POLL_INTERVAL_MS, signal } = {}) {
1086
+ if (pollIntervalMs <= 0) throw new RangeError("pollIntervalMs must be greater than 0");
1087
+ if (this.publicKey.length === 0) throw new SigningDisabled();
1088
+ signal?.throwIfAborted();
1089
+ const request = UnsignedMessage.create({ WhitelistOperation: WhitelistOperation.fromPartial({
1090
+ addKeyToWhitelistAndAddPermissions: {
1091
+ key: { PublicKeyRaw: this.publicKey },
1092
+ keyRole: role
1093
+ },
1094
+ metadataForKey: { keyFormFactor: form }
1095
+ }) });
1096
+ const msg = RoutableMessage.create({
1097
+ toDestination: { domain: VCSEC },
1098
+ fromDestination: { routingAddress: this.fromDestination },
1099
+ protobufMessageAsBytes: UnsignedMessage.encode(request).finish(),
1100
+ uuid: crypto.randomBytes(16)
1101
+ });
1102
+ try {
1103
+ await this.confirmPairing(msg, timeoutMs, pollIntervalMs, signal);
1104
+ } catch (error) {
1105
+ if (!signal?.aborted) await this.dropLinkOnFailure("pairing failed", error);
1106
+ throw error;
1107
+ }
1108
+ }
1109
+ async confirmPairing(msg, timeoutMs, pollIntervalMs, signal) {
1110
+ const deadline = performance.now() + timeoutMs;
1111
+ try {
1112
+ const send = this._send(msg, "protobuf_message_as_bytes", {
1113
+ timeoutMs: Math.max(0, Math.min(pollIntervalMs, deadline - performance.now())),
1114
+ signal
1115
+ });
1116
+ if (this.whitelistReplyConfirms(await abortable(send, signal))) return;
1117
+ } catch (error) {
1118
+ if (!(error instanceof BluetoothTimeout)) throw error;
1119
+ }
1120
+ const confirmed = async () => this.consumeLateWhitelistReply() || await abortable(this.pairProbe(signal), signal);
1121
+ while (performance.now() < deadline) {
1122
+ signal?.throwIfAborted();
1123
+ if (await confirmed()) return;
1124
+ const remaining = deadline - performance.now();
1125
+ if (remaining <= 0) break;
1126
+ await sleep(Math.min(pollIntervalMs, remaining), signal);
1127
+ }
1128
+ signal?.throwIfAborted();
1129
+ if (await confirmed()) return;
1130
+ throw new BluetoothTimeout();
1131
+ }
1132
+ /**
1133
+ * Whether this key is on the whitelist: a VCSEC handshake only completes once
1134
+ * it is. Any failure means "not yet", so polling carries on across a dropped
1135
+ * link. It sends one request, unlike `handshake`, because the next poll is the
1136
+ * retry, and nothing may be written after a `pair` is aborted.
1137
+ */
1138
+ async pairProbe(signal) {
1139
+ const session = this.sessionFor(VCSEC);
1140
+ try {
1141
+ return await session.withLock(async () => {
1142
+ const reply = await this._send(this.sessionInfoRequest(VCSEC), "session_info", { signal });
1143
+ return session.ready && (reply.sessionInfo?.length ?? 0) > 0;
1144
+ });
1145
+ } catch (error) {
1146
+ if (error instanceof TeslaFleetError) return false;
1147
+ throw error;
1148
+ }
1149
+ }
1150
+ /**
1151
+ * Look for a whitelist reply that arrived after `_send` stopped waiting. This
1152
+ * must run before a probe, whose `_send` discards what is queued. Anything else
1153
+ * in the queue stays there.
1154
+ * @returns Whether one was there and explicitly reported success.
1155
+ * @throws WhitelistOperationStatus or a subclass when it reported a refusal.
1156
+ */
1157
+ consumeLateWhitelistReply() {
1158
+ const queue = this.queues[VCSEC];
1159
+ let reply;
1160
+ for (const queued of queue.drain()) if (reply === void 0 && isWhitelistReply(queued)) reply = queued;
1161
+ else queue.push(queued);
1162
+ return reply !== void 0 && this.whitelistReplyConfirms(reply);
1163
+ }
1164
+ /**
1165
+ * Whether a reply to the whitelist operation says the key was added. Only an
1166
+ * OK with a whitelist status and no failure does. A WAIT means the car is
1167
+ * still waiting for the tap, and a bare ack, a status or anything else says
1168
+ * nothing, so `pair` goes on polling.
1169
+ * @throws WhitelistOperationStatus or a subclass when the reply is a refusal.
1170
+ */
1171
+ whitelistReplyConfirms(reply) {
1172
+ let message;
1173
+ try {
1174
+ message = FromVCSECMessage.decode(reply.protobufMessageAsBytes ?? /* @__PURE__ */ new Uint8Array());
1175
+ } catch {
1176
+ return false;
1177
+ }
1178
+ const whitelist = message.commandStatus?.whitelistOperationStatus;
1179
+ this.raiseWhitelistFault(whitelist?.whitelistOperationInformation);
1180
+ const { OPERATIONSTATUS_ERROR, OPERATIONSTATUS_OK } = OperationStatusE$1;
1181
+ if (message.nominalError || message.commandStatus?.operationStatus === OPERATIONSTATUS_ERROR || whitelist?.operationStatus === OPERATIONSTATUS_ERROR) throw new WhitelistOperationStatus("The vehicle refused the whitelist operation.");
1182
+ return message.commandStatus?.operationStatus === OPERATIONSTATUS_OK && whitelist?.operationStatus === OPERATIONSTATUS_OK;
1183
+ }
1184
+ /**
1185
+ * Wake the vehicle. Takes `VehicleSpecific`'s options, but resolves to a
1186
+ * `CommandResponse`, not the REST `VehicleResponse`.
1187
+ *
1188
+ * Without `wait`, this sends the RKE wake to the vehicle security computer,
1189
+ * which acks well before infotainment can answer, so an infotainment command
1190
+ * sent straight after can still time out.
1191
+ *
1192
+ * With `wait`, it blocks until infotainment completes a fresh handshake, the
1193
+ * only proof it can take commands: VCSEC reports awake seconds before that.
1194
+ * The wake is idempotent, so it is resent every 3 s until then.
1195
+ * @param options.timeoutMs Deadline for `wait`, in milliseconds. Defaults to 30000.
1196
+ * @throws BluetoothTimeout at the deadline, with the link dropped.
1197
+ */
1198
+ async wake_up({ wait = false, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS } = {}) {
1199
+ if (!wait) return this.sendVehicleSecurity(UnsignedMessage.create({ RKEAction: RKEActionE.RKE_ACTION_WAKE_VEHICLE }));
1200
+ try {
1201
+ await this.awaitInfotainment(timeoutMs, true);
1202
+ } catch (error) {
1203
+ await this.dropLinkOnFailure("wake failed", error);
1204
+ throw error;
1205
+ }
1206
+ return success();
1207
+ }
1208
+ /**
1209
+ * Probe infotainment until it answers. With `wake`, the RKE wake is sent
1210
+ * first and resent every `wakeResendIntervalMs`.
1211
+ * @param over Stops the probing, for a caller that has stopped waiting.
1212
+ * @throws BluetoothTimeout at the deadline.
1213
+ */
1214
+ async awaitInfotainment(timeoutMs, wake, over) {
1215
+ const deadline = performance.now() + timeoutMs;
1216
+ let nextWake = performance.now();
1217
+ for (;;) {
1218
+ if (wake && performance.now() >= nextWake) {
1219
+ try {
1220
+ await this.wake_up();
1221
+ } catch (error) {
1222
+ if (!(error instanceof BluetoothTimeout)) throw error;
1223
+ }
1224
+ nextWake = performance.now() + this.wakeResendIntervalMs;
1225
+ }
1226
+ const remaining = deadline - performance.now();
1227
+ if (remaining <= 0 || over?.aborted) throw new BluetoothTimeout();
1228
+ try {
1229
+ await this.probeInfotainment(Math.min(this.wakeProbeTimeoutMs, remaining));
1230
+ return;
1231
+ } catch (error) {
1232
+ if (!(error instanceof BluetoothTimeout || error instanceof SessionInfoAuthenticationFault)) throw error;
1233
+ }
1234
+ }
1235
+ }
1236
+ /**
1237
+ * Handshake with infotainment, always with a fresh request: only a live reply
1238
+ * proves it is awake, so a cached session counts for nothing.
1239
+ */
1240
+ async probeInfotainment(timeoutMs) {
1241
+ if (this.publicKey.length === 0) throw new SigningDisabled();
1242
+ await this._send(this.sessionInfoRequest(INFOTAINMENT), "session_info", { timeoutMs });
1243
+ if (!this.sessionFor(INFOTAINMENT).ready) throw new BluetoothTimeout();
1244
+ }
1245
+ };
1246
+ function isWhitelistReply(reply) {
1247
+ try {
1248
+ return FromVCSECMessage.decode(reply.protobufMessageAsBytes ?? /* @__PURE__ */ new Uint8Array()).commandStatus?.whitelistOperationStatus !== void 0;
1249
+ } catch {
1250
+ return false;
1251
+ }
1252
+ }
1253
+ /**
1254
+ * Whether a failure after a write says nothing about whether the command ran.
1255
+ * Only a car-side refusal rules that out, as the only `TeslaFleetError`s not
1256
+ * listed here are. A silent, unauthenticated, replayed or undecodable reply
1257
+ * proves nothing, a key fault may be a stale reply, a reply too big for the MTU
1258
+ * can follow execution, and a transport failure now belongs to a resend, after
1259
+ * the first frame was written. A router fails over on several of these, which is
1260
+ * right before a write and a double execution after one, so the ladders turn all
1261
+ * of them into an unresolved outcome first.
1262
+ */
1263
+ function leavesOutcomeUnknown(error) {
1264
+ return !(error instanceof TeslaFleetError) || error instanceof BluetoothTimeout || error instanceof BluetoothTransportError || error instanceof NotOnWhitelistFault || error instanceof SessionInfoAuthenticationFault || error instanceof SignedCommandResponseReplayed || error instanceof TeslaFleetMessageFaultResponseSizeExceedsMTU;
1265
+ }
1266
+ /** An error's class and message, for a log line. */
1267
+ function describe(error) {
1268
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1269
+ }
1270
+ export { VehicleBluetooth as default };