@kb1rma/libcomm14cux-ts 0.2.0-beta.7 → 0.2.1-beta.9

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.
@@ -1,4 +1,17 @@
1
1
  import type { Transport } from './types.js';
2
+ /**
3
+ * Where a memory write injected by {@link SimulatedTransport.failMemoryWritesAfter}
4
+ * goes wrong:
5
+ *
6
+ * - `'commandLost'`: the write command never reaches the ECU. No echo, and
7
+ * nothing is stored.
8
+ * - `'commandEchoCorrupted'`: the ECU takes the write command, but its echo
9
+ * is garbled on the way back. The ECU then waits for the value byte.
10
+ * - `'valueEchoLost'`: the ECU stores the value, but its echo is lost.
11
+ * - `'valueEchoCorrupted'`: the ECU stores the value, but its echo is garbled
12
+ * on the way back.
13
+ */
14
+ export type MemoryWriteFault = 'commandLost' | 'commandEchoCorrupted' | 'valueEchoLost' | 'valueEchoCorrupted';
2
15
  /**
3
16
  * Emulates the ECU's side of the serial protocol over an in-memory 64 KiB
4
17
  * address space. Used for tests and for developing a UI without a car.
@@ -15,8 +28,34 @@ export declare class SimulatedTransport implements Transport {
15
28
  silent: boolean;
16
29
  /** Fault injection: reject every write. */
17
30
  failWrites: boolean;
31
+ /**
32
+ * Fault injection: where a write failed by
33
+ * {@link SimulatedTransport.failMemoryWritesAfter} goes wrong. Defaults to
34
+ * `'commandLost'`.
35
+ */
36
+ memoryWriteFault: MemoryWriteFault;
37
+ /**
38
+ * How long the ECU waits, with nothing received, before it drops a
39
+ * half-received command, in milliseconds. A read that times out counts as
40
+ * that much silence.
41
+ */
42
+ commandTimeoutMs: number;
18
43
  /** Fault injection: stream at most this many bytes per read command. */
19
44
  streamLimit: number | undefined;
45
+ /**
46
+ * Fault injection: let this many memory writes succeed, then fail every
47
+ * later one, while reads keep working. `undefined` (the default) never
48
+ * fails; `0` fails the first. Setting it starts the count again.
49
+ *
50
+ * @returns The number of memory writes allowed to succeed.
51
+ */
52
+ get failMemoryWritesAfter(): number | undefined;
53
+ /**
54
+ * Sets the memory-write fault limit and starts the count again.
55
+ *
56
+ * @param count - Memory writes to allow before failing, or `undefined` for none.
57
+ */
58
+ set failMemoryWritesAfter(count: number | undefined);
20
59
  /**
21
60
  * Whether the transport is open.
22
61
  *
@@ -55,11 +94,12 @@ export declare class SimulatedTransport implements Transport {
55
94
  * Takes bytes from the simulated ECU's reply.
56
95
  *
57
96
  * @param length - Number of bytes wanted.
58
- * @param _timeoutMs - Ignored; the simulation answers immediately.
97
+ * @param timeoutMs - The simulation answers at once, but a read that times
98
+ * out counts as this much silence on the line.
59
99
  * @returns Exactly `length` bytes.
60
100
  * @throws {@link TimeoutError} if the ECU has not produced that many bytes.
61
101
  * @throws {@link NotConnectedError} if the transport is closed.
62
102
  */
63
- read(length: number, _timeoutMs: number): Promise<Uint8Array>;
103
+ read(length: number, timeoutMs: number): Promise<Uint8Array>;
64
104
  }
65
105
  //# sourceMappingURL=simulated.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"simulated.d.ts","sourceRoot":"","sources":["../../src/transport/simulated.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAyB5C;;;GAGG;AACH,qBAAa,kBAAmB,YAAW,SAAS;;IAClD,sEAAsE;IACtE,QAAQ,CAAC,MAAM,0BAA+B;IAC9C,+CAA+C;IAC/C,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,CAAM;IAEhC,oDAAoD;IACpD,eAAe,UAAS;IACxB,oCAAoC;IACpC,MAAM,UAAS;IACf,2CAA2C;IAC3C,UAAU,UAAS;IACnB,wEAAwE;IACxE,WAAW,EAAE,MAAM,GAAG,SAAS,CAAa;IAW5C;;;;OAIG;IACH,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAQhC;;;;OAIG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAMrB;;;;OAIG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAStB;;;;;;;OAOG;IACH,KAAK,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBtC;;;;;;;;OAQG;IACH,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;CA8E9D"}
1
+ {"version":3,"file":"simulated.d.ts","sourceRoot":"","sources":["../../src/transport/simulated.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAM5C;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,gBAAgB,GACxB,aAAa,GACb,sBAAsB,GACtB,eAAe,GACf,oBAAoB,CAAC;AA4BzB;;;GAGG;AACH,qBAAa,kBAAmB,YAAW,SAAS;;IAClD,sEAAsE;IACtE,QAAQ,CAAC,MAAM,0BAA+B;IAC9C,+CAA+C;IAC/C,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,CAAM;IAEhC,oDAAoD;IACpD,eAAe,UAAS;IACxB,oCAAoC;IACpC,MAAM,UAAS;IACf,2CAA2C;IAC3C,UAAU,UAAS;IACnB;;;;OAIG;IACH,gBAAgB,EAAE,gBAAgB,CAAiB;IACnD;;;;OAIG;IACH,gBAAgB,SAAgC;IAChD,wEAAwE;IACxE,WAAW,EAAE,MAAM,GAAG,SAAS,CAAa;IAgB5C;;;;;;OAMG;IACH,IAAI,qBAAqB,IAAI,MAAM,GAAG,SAAS,CAE9C;IAED;;;;OAIG;IACH,IAAI,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAGlD;IAED;;;;OAIG;IACH,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAQhC;;;;OAIG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAMrB;;;;OAIG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAStB;;;;;;;OAOG;IACH,KAAK,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBtC;;;;;;;;;OASG;IACH,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;CAoH7D"}
@@ -5,6 +5,12 @@
5
5
  import { NotConnectedError, TimeoutError } from '../errors.js';
6
6
  import { DataSize, MemoryOffset, ReadCountValue } from '../constants.js';
7
7
  const MEMORY_SIZE = 0x10000;
8
+ /**
9
+ * How long the simulated ECU waits, with nothing received, before it drops a
10
+ * half-received command. The real firmware does this after 256 passes of its
11
+ * main loop; the time that takes is not documented and varies with load.
12
+ */
13
+ const SIMULATED_COMMAND_TIMEOUT_MS = 250;
8
14
  function lengthFromCode(code) {
9
15
  if (code <= 0x0f) {
10
16
  return code + 1;
@@ -37,6 +43,18 @@ export class SimulatedTransport {
37
43
  silent = false;
38
44
  /** Fault injection: reject every write. */
39
45
  failWrites = false;
46
+ /**
47
+ * Fault injection: where a write failed by
48
+ * {@link SimulatedTransport.failMemoryWritesAfter} goes wrong. Defaults to
49
+ * `'commandLost'`.
50
+ */
51
+ memoryWriteFault = 'commandLost';
52
+ /**
53
+ * How long the ECU waits, with nothing received, before it drops a
54
+ * half-received command, in milliseconds. A read that times out counts as
55
+ * that much silence.
56
+ */
57
+ commandTimeoutMs = SIMULATED_COMMAND_TIMEOUT_MS;
40
58
  /** Fault injection: stream at most this many bytes per read command. */
41
59
  streamLimit = undefined;
42
60
  #open = false;
@@ -47,6 +65,30 @@ export class SimulatedTransport {
47
65
  #latched = false;
48
66
  #lengthCode = 0;
49
67
  #writeAddr = 0;
68
+ #failMemoryWritesAfter = undefined;
69
+ #memoryWritesDone = 0;
70
+ #failingWrite = false;
71
+ #lastByteAt = 0;
72
+ #quietMs = 0;
73
+ /**
74
+ * Fault injection: let this many memory writes succeed, then fail every
75
+ * later one, while reads keep working. `undefined` (the default) never
76
+ * fails; `0` fails the first. Setting it starts the count again.
77
+ *
78
+ * @returns The number of memory writes allowed to succeed.
79
+ */
80
+ get failMemoryWritesAfter() {
81
+ return this.#failMemoryWritesAfter;
82
+ }
83
+ /**
84
+ * Sets the memory-write fault limit and starts the count again.
85
+ *
86
+ * @param count - Memory writes to allow before failing, or `undefined` for none.
87
+ */
88
+ set failMemoryWritesAfter(count) {
89
+ this.#failMemoryWritesAfter = count;
90
+ this.#memoryWritesDone = 0;
91
+ }
50
92
  /**
51
93
  * Whether the transport is open.
52
94
  *
@@ -113,22 +155,24 @@ export class SimulatedTransport {
113
155
  * Takes bytes from the simulated ECU's reply.
114
156
  *
115
157
  * @param length - Number of bytes wanted.
116
- * @param _timeoutMs - Ignored; the simulation answers immediately.
158
+ * @param timeoutMs - The simulation answers at once, but a read that times
159
+ * out counts as this much silence on the line.
117
160
  * @returns Exactly `length` bytes.
118
161
  * @throws {@link TimeoutError} if the ECU has not produced that many bytes.
119
162
  * @throws {@link NotConnectedError} if the transport is closed.
120
163
  */
121
- read(length, _timeoutMs) {
164
+ read(length, timeoutMs) {
122
165
  if (!this.#open) {
123
166
  return Promise.reject(new NotConnectedError('Transport is not open'));
124
167
  }
125
168
  if (this.#output.length < length) {
126
169
  this.#output = [];
170
+ this.#quietMs += timeoutMs;
127
171
  return Promise.reject(new TimeoutError('Simulated ECU is silent'));
128
172
  }
129
173
  return Promise.resolve(Uint8Array.from(this.#output.splice(0, length)));
130
174
  }
131
- #echo(byte) {
175
+ #echo(byte, garbled = false) {
132
176
  if (this.silent) {
133
177
  return;
134
178
  }
@@ -137,13 +181,28 @@ export class SimulatedTransport {
137
181
  this.#output.push(byte ^ 0xff);
138
182
  }
139
183
  else {
140
- this.#output.push(byte);
184
+ this.#output.push(garbled ? byte ^ 0xff : byte);
141
185
  }
142
186
  }
143
187
  #receive(byte) {
188
+ const now = Date.now();
189
+ // Like the firmware, drop a half-received command after a long enough
190
+ // silence. The address already latched is kept.
191
+ if (this.#state !== 'idle' &&
192
+ now - this.#lastByteAt + this.#quietMs >= this.commandTimeoutMs) {
193
+ this.#state = 'idle';
194
+ }
195
+ this.#lastByteAt = now;
196
+ this.#quietMs = 0;
144
197
  if (this.#state === 'writeValue') {
145
198
  this.memory[this.#writeAddr] = byte;
146
- this.#echo(byte);
199
+ if (!this.#failingWrite) {
200
+ this.#memoryWritesDone += 1;
201
+ this.#echo(byte);
202
+ }
203
+ else if (this.memoryWriteFault !== 'valueEchoLost') {
204
+ this.#echo(byte, this.memoryWriteFault === 'valueEchoCorrupted');
205
+ }
147
206
  this.#state = 'idle';
148
207
  this.#latched = false;
149
208
  return;
@@ -169,11 +228,19 @@ export class SimulatedTransport {
169
228
  this.#streamRead(this.#coarse | (byte & 0x3f));
170
229
  }
171
230
  else {
231
+ this.#failingWrite = this.#memoryWriteFails();
232
+ if (this.#failingWrite && this.memoryWriteFault === 'commandLost') {
233
+ return;
234
+ }
172
235
  this.#writeAddr = this.#coarse | (byte & 0x3f);
173
- this.#echo(byte);
236
+ this.#echo(byte, this.#failingWrite && this.memoryWriteFault === 'commandEchoCorrupted');
174
237
  this.#state = 'writeValue';
175
238
  }
176
239
  }
240
+ #memoryWriteFails() {
241
+ return (this.#failMemoryWritesAfter !== undefined &&
242
+ this.#memoryWritesDone >= this.#failMemoryWritesAfter);
243
+ }
177
244
  #streamRead(address) {
178
245
  if (this.silent) {
179
246
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"simulated.js","sourceRoot":"","sources":["../../src/transport/simulated.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: GPL-3.0-only\n// Derived from libcomm14cux (https://github.com/colinbourassa/libcomm14cux)\n// Copyright (C) Colin Bourassa. Licensed under the GNU GPL v3.\n// Ported to TypeScript and modified for comm14cux-ts, 2026.\nimport { NotConnectedError, TimeoutError } from '../errors.js';\nimport { DataSize, MemoryOffset, ReadCountValue } from '../constants.js';\nimport type { Transport } from './types.js';\n\nconst MEMORY_SIZE = 0x10000;\n\ntype State = 'idle' | 'coarse2' | 'writeValue';\n\nfunction lengthFromCode(code: number): number | undefined {\n if (code <= 0x0f) {\n return code + 1;\n }\n\n switch (code) {\n case ReadCountValue.Count1:\n return 0x50;\n case ReadCountValue.Count2:\n return 0x64;\n case ReadCountValue.Count3:\n return 0x190;\n case ReadCountValue.Count4:\n return 0x200;\n default:\n return undefined;\n }\n}\n\n/**\n * Emulates the ECU's side of the serial protocol over an in-memory 64 KiB\n * address space. Used for tests and for developing a UI without a car.\n */\nexport class SimulatedTransport implements Transport {\n /** The ECU's address space. Tests may read and modify it directly. */\n readonly memory = new Uint8Array(MEMORY_SIZE);\n /** Every byte written to the ECU, in order. */\n readonly written: number[] = [];\n\n /** Fault injection: invert the next echoed byte. */\n corruptNextEcho = false;\n /** Fault injection: never reply. */\n silent = false;\n /** Fault injection: reject every write. */\n failWrites = false;\n /** Fault injection: stream at most this many bytes per read command. */\n streamLimit: number | undefined = undefined;\n\n #open = false;\n #state: State = 'idle';\n #output: number[] = [];\n #firstCoarse = 0;\n #coarse = 0;\n #latched = false;\n #lengthCode = 0;\n #writeAddr = 0;\n\n /**\n * Whether the transport is open.\n *\n * @returns `true` between {@link SimulatedTransport.open} and {@link SimulatedTransport.close}.\n */\n get isOpen(): boolean {\n return this.#open;\n }\n\n /**\n * Places a 16 KiB firmware image at 0xC000 to 0xFFFF.\n *\n * @param image - The ROM image, exactly 0x4000 bytes.\n * @throws {@link RangeError} if the image is the wrong size.\n */\n loadRom(image: Uint8Array): void {\n if (image.length !== DataSize.ROM) {\n throw new RangeError(`ROM image must be ${DataSize.ROM} bytes`);\n }\n\n this.memory.set(image, MemoryOffset.ROMAddress);\n }\n\n /**\n * Opens the simulated link.\n *\n * @returns A promise that resolves immediately.\n */\n open(): Promise<void> {\n this.#open = true;\n\n return Promise.resolve();\n }\n\n /**\n * Closes the simulated link and discards any pending ECU output.\n *\n * @returns A promise that resolves immediately.\n */\n close(): Promise<void> {\n this.#open = false;\n this.#state = 'idle';\n this.#latched = false;\n this.#output = [];\n\n return Promise.resolve();\n }\n\n /**\n * Delivers bytes to the simulated ECU, which may queue a reply.\n *\n * @param data - Bytes to send.\n * @returns A promise that resolves once the bytes are delivered.\n * @throws {@link NotConnectedError} if the transport is closed.\n * @throws Error if {@link SimulatedTransport.failWrites} is set.\n */\n write(data: Uint8Array): Promise<void> {\n if (!this.#open) {\n return Promise.reject(new NotConnectedError('Transport is not open'));\n }\n\n if (this.failWrites) {\n return Promise.reject(new Error('Simulated write failure'));\n }\n\n for (const byte of data) {\n this.written.push(byte);\n this.#receive(byte);\n }\n\n return Promise.resolve();\n }\n\n /**\n * Takes bytes from the simulated ECU's reply.\n *\n * @param length - Number of bytes wanted.\n * @param _timeoutMs - Ignored; the simulation answers immediately.\n * @returns Exactly `length` bytes.\n * @throws {@link TimeoutError} if the ECU has not produced that many bytes.\n * @throws {@link NotConnectedError} if the transport is closed.\n */\n read(length: number, _timeoutMs: number): Promise<Uint8Array> {\n if (!this.#open) {\n return Promise.reject(new NotConnectedError('Transport is not open'));\n }\n\n if (this.#output.length < length) {\n this.#output = [];\n\n return Promise.reject(new TimeoutError('Simulated ECU is silent'));\n }\n\n return Promise.resolve(Uint8Array.from(this.#output.splice(0, length)));\n }\n\n #echo(byte: number): void {\n if (this.silent) {\n return;\n }\n\n if (this.corruptNextEcho) {\n this.corruptNextEcho = false;\n this.#output.push(byte ^ 0xff);\n } else {\n this.#output.push(byte);\n }\n }\n\n #receive(byte: number): void {\n if (this.#state === 'writeValue') {\n this.memory[this.#writeAddr] = byte;\n this.#echo(byte);\n this.#state = 'idle';\n this.#latched = false;\n\n return;\n }\n\n if (this.#state === 'coarse2') {\n this.#coarse = ((this.#firstCoarse & 0x03) << 14) | (byte << 6);\n this.#lengthCode = (this.#firstCoarse >> 2) & 0x1f;\n this.#echo(byte);\n this.#state = 'idle';\n this.#latched = true;\n\n return;\n }\n\n if (byte < 0x80) {\n this.#firstCoarse = byte;\n this.#echo(byte);\n this.#state = 'coarse2';\n this.#latched = false;\n } else if (!this.#latched) {\n // A command byte without a preceding coarse address is ignored.\n } else if ((byte & 0xc0) === 0xc0) {\n this.#streamRead(this.#coarse | (byte & 0x3f));\n } else {\n this.#writeAddr = this.#coarse | (byte & 0x3f);\n this.#echo(byte);\n this.#state = 'writeValue';\n }\n }\n\n #streamRead(address: number): void {\n if (this.silent) {\n return;\n }\n\n let length = lengthFromCode(this.#lengthCode) ?? 0;\n\n if (this.streamLimit !== undefined) {\n length = Math.min(length, this.streamLimit);\n }\n\n for (let i = 0; i < length; i++) {\n this.#output.push(this.memory[(address + i) % MEMORY_SIZE] ?? 0);\n }\n }\n}\n"]}
1
+ {"version":3,"file":"simulated.js","sourceRoot":"","sources":["../../src/transport/simulated.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: GPL-3.0-only\n// Derived from libcomm14cux (https://github.com/colinbourassa/libcomm14cux)\n// Copyright (C) Colin Bourassa. Licensed under the GNU GPL v3.\n// Ported to TypeScript and modified for comm14cux-ts, 2026.\nimport { NotConnectedError, TimeoutError } from '../errors.js';\nimport { DataSize, MemoryOffset, ReadCountValue } from '../constants.js';\nimport type { Transport } from './types.js';\n\nconst MEMORY_SIZE = 0x10000;\n\ntype State = 'idle' | 'coarse2' | 'writeValue';\n\n/**\n * Where a memory write injected by {@link SimulatedTransport.failMemoryWritesAfter}\n * goes wrong:\n *\n * - `'commandLost'`: the write command never reaches the ECU. No echo, and\n * nothing is stored.\n * - `'commandEchoCorrupted'`: the ECU takes the write command, but its echo\n * is garbled on the way back. The ECU then waits for the value byte.\n * - `'valueEchoLost'`: the ECU stores the value, but its echo is lost.\n * - `'valueEchoCorrupted'`: the ECU stores the value, but its echo is garbled\n * on the way back.\n */\nexport type MemoryWriteFault =\n | 'commandLost'\n | 'commandEchoCorrupted'\n | 'valueEchoLost'\n | 'valueEchoCorrupted';\n\n/**\n * How long the simulated ECU waits, with nothing received, before it drops a\n * half-received command. The real firmware does this after 256 passes of its\n * main loop; the time that takes is not documented and varies with load.\n */\nconst SIMULATED_COMMAND_TIMEOUT_MS = 250;\n\nfunction lengthFromCode(code: number): number | undefined {\n if (code <= 0x0f) {\n return code + 1;\n }\n\n switch (code) {\n case ReadCountValue.Count1:\n return 0x50;\n case ReadCountValue.Count2:\n return 0x64;\n case ReadCountValue.Count3:\n return 0x190;\n case ReadCountValue.Count4:\n return 0x200;\n default:\n return undefined;\n }\n}\n\n/**\n * Emulates the ECU's side of the serial protocol over an in-memory 64 KiB\n * address space. Used for tests and for developing a UI without a car.\n */\nexport class SimulatedTransport implements Transport {\n /** The ECU's address space. Tests may read and modify it directly. */\n readonly memory = new Uint8Array(MEMORY_SIZE);\n /** Every byte written to the ECU, in order. */\n readonly written: number[] = [];\n\n /** Fault injection: invert the next echoed byte. */\n corruptNextEcho = false;\n /** Fault injection: never reply. */\n silent = false;\n /** Fault injection: reject every write. */\n failWrites = false;\n /**\n * Fault injection: where a write failed by\n * {@link SimulatedTransport.failMemoryWritesAfter} goes wrong. Defaults to\n * `'commandLost'`.\n */\n memoryWriteFault: MemoryWriteFault = 'commandLost';\n /**\n * How long the ECU waits, with nothing received, before it drops a\n * half-received command, in milliseconds. A read that times out counts as\n * that much silence.\n */\n commandTimeoutMs = SIMULATED_COMMAND_TIMEOUT_MS;\n /** Fault injection: stream at most this many bytes per read command. */\n streamLimit: number | undefined = undefined;\n\n #open = false;\n #state: State = 'idle';\n #output: number[] = [];\n #firstCoarse = 0;\n #coarse = 0;\n #latched = false;\n #lengthCode = 0;\n #writeAddr = 0;\n #failMemoryWritesAfter: number | undefined = undefined;\n #memoryWritesDone = 0;\n #failingWrite = false;\n #lastByteAt = 0;\n #quietMs = 0;\n\n /**\n * Fault injection: let this many memory writes succeed, then fail every\n * later one, while reads keep working. `undefined` (the default) never\n * fails; `0` fails the first. Setting it starts the count again.\n *\n * @returns The number of memory writes allowed to succeed.\n */\n get failMemoryWritesAfter(): number | undefined {\n return this.#failMemoryWritesAfter;\n }\n\n /**\n * Sets the memory-write fault limit and starts the count again.\n *\n * @param count - Memory writes to allow before failing, or `undefined` for none.\n */\n set failMemoryWritesAfter(count: number | undefined) {\n this.#failMemoryWritesAfter = count;\n this.#memoryWritesDone = 0;\n }\n\n /**\n * Whether the transport is open.\n *\n * @returns `true` between {@link SimulatedTransport.open} and {@link SimulatedTransport.close}.\n */\n get isOpen(): boolean {\n return this.#open;\n }\n\n /**\n * Places a 16 KiB firmware image at 0xC000 to 0xFFFF.\n *\n * @param image - The ROM image, exactly 0x4000 bytes.\n * @throws {@link RangeError} if the image is the wrong size.\n */\n loadRom(image: Uint8Array): void {\n if (image.length !== DataSize.ROM) {\n throw new RangeError(`ROM image must be ${DataSize.ROM} bytes`);\n }\n\n this.memory.set(image, MemoryOffset.ROMAddress);\n }\n\n /**\n * Opens the simulated link.\n *\n * @returns A promise that resolves immediately.\n */\n open(): Promise<void> {\n this.#open = true;\n\n return Promise.resolve();\n }\n\n /**\n * Closes the simulated link and discards any pending ECU output.\n *\n * @returns A promise that resolves immediately.\n */\n close(): Promise<void> {\n this.#open = false;\n this.#state = 'idle';\n this.#latched = false;\n this.#output = [];\n\n return Promise.resolve();\n }\n\n /**\n * Delivers bytes to the simulated ECU, which may queue a reply.\n *\n * @param data - Bytes to send.\n * @returns A promise that resolves once the bytes are delivered.\n * @throws {@link NotConnectedError} if the transport is closed.\n * @throws Error if {@link SimulatedTransport.failWrites} is set.\n */\n write(data: Uint8Array): Promise<void> {\n if (!this.#open) {\n return Promise.reject(new NotConnectedError('Transport is not open'));\n }\n\n if (this.failWrites) {\n return Promise.reject(new Error('Simulated write failure'));\n }\n\n for (const byte of data) {\n this.written.push(byte);\n this.#receive(byte);\n }\n\n return Promise.resolve();\n }\n\n /**\n * Takes bytes from the simulated ECU's reply.\n *\n * @param length - Number of bytes wanted.\n * @param timeoutMs - The simulation answers at once, but a read that times\n * out counts as this much silence on the line.\n * @returns Exactly `length` bytes.\n * @throws {@link TimeoutError} if the ECU has not produced that many bytes.\n * @throws {@link NotConnectedError} if the transport is closed.\n */\n read(length: number, timeoutMs: number): Promise<Uint8Array> {\n if (!this.#open) {\n return Promise.reject(new NotConnectedError('Transport is not open'));\n }\n\n if (this.#output.length < length) {\n this.#output = [];\n this.#quietMs += timeoutMs;\n\n return Promise.reject(new TimeoutError('Simulated ECU is silent'));\n }\n\n return Promise.resolve(Uint8Array.from(this.#output.splice(0, length)));\n }\n\n #echo(byte: number, garbled = false): void {\n if (this.silent) {\n return;\n }\n\n if (this.corruptNextEcho) {\n this.corruptNextEcho = false;\n this.#output.push(byte ^ 0xff);\n } else {\n this.#output.push(garbled ? byte ^ 0xff : byte);\n }\n }\n\n #receive(byte: number): void {\n const now = Date.now();\n\n // Like the firmware, drop a half-received command after a long enough\n // silence. The address already latched is kept.\n if (\n this.#state !== 'idle' &&\n now - this.#lastByteAt + this.#quietMs >= this.commandTimeoutMs\n ) {\n this.#state = 'idle';\n }\n\n this.#lastByteAt = now;\n this.#quietMs = 0;\n\n if (this.#state === 'writeValue') {\n this.memory[this.#writeAddr] = byte;\n\n if (!this.#failingWrite) {\n this.#memoryWritesDone += 1;\n this.#echo(byte);\n } else if (this.memoryWriteFault !== 'valueEchoLost') {\n this.#echo(byte, this.memoryWriteFault === 'valueEchoCorrupted');\n }\n\n this.#state = 'idle';\n this.#latched = false;\n\n return;\n }\n\n if (this.#state === 'coarse2') {\n this.#coarse = ((this.#firstCoarse & 0x03) << 14) | (byte << 6);\n this.#lengthCode = (this.#firstCoarse >> 2) & 0x1f;\n this.#echo(byte);\n this.#state = 'idle';\n this.#latched = true;\n\n return;\n }\n\n if (byte < 0x80) {\n this.#firstCoarse = byte;\n this.#echo(byte);\n this.#state = 'coarse2';\n this.#latched = false;\n } else if (!this.#latched) {\n // A command byte without a preceding coarse address is ignored.\n } else if ((byte & 0xc0) === 0xc0) {\n this.#streamRead(this.#coarse | (byte & 0x3f));\n } else {\n this.#failingWrite = this.#memoryWriteFails();\n\n if (this.#failingWrite && this.memoryWriteFault === 'commandLost') {\n return;\n }\n\n this.#writeAddr = this.#coarse | (byte & 0x3f);\n this.#echo(\n byte,\n this.#failingWrite && this.memoryWriteFault === 'commandEchoCorrupted',\n );\n this.#state = 'writeValue';\n }\n }\n\n #memoryWriteFails(): boolean {\n return (\n this.#failMemoryWritesAfter !== undefined &&\n this.#memoryWritesDone >= this.#failMemoryWritesAfter\n );\n }\n\n #streamRead(address: number): void {\n if (this.silent) {\n return;\n }\n\n let length = lengthFromCode(this.#lengthCode) ?? 0;\n\n if (this.streamLimit !== undefined) {\n length = Math.min(length, this.streamLimit);\n }\n\n for (let i = 0; i < length; i++) {\n this.#output.push(this.memory[(address + i) % MEMORY_SIZE] ?? 0);\n }\n }\n}\n"]}
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-only
2
2
  // Copyright (C) 2026 comm14cux-ts contributors
3
3
  /** Updated by release-please; must match `version` in package.json (enforced by a test). */
4
- const PACKAGE_VERSION = '0.2.0'; // x-release-please-version
4
+ const PACKAGE_VERSION = '0.2.1'; // x-release-please-version
5
5
  const [major = 0, minor = 0, patch = 0] = PACKAGE_VERSION.split('.').map(Number);
6
6
  export /**
7
7
  *
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,wCAAwC;AACxC,+CAA+C;AAkB/C,4FAA4F;AAC5F,MAAM,eAAe,GAAG,OAAO,CAAC,CAAC,2BAA2B;AAE5D,MAAM,CAAC,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC,GACrC,eAAe,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;AAEzC,MAAM,CAAC;;GAEJ,CACH,MAAM,eAAe,GAAY,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC","sourcesContent":["// SPDX-License-Identifier: GPL-3.0-only\n// Copyright (C) 2026 comm14cux-ts contributors\n\n/** Major/minor/patch version of this library (`c14cux_version`). */\nexport interface Version {\n /**\n * Major version number.\n */\n major: number;\n /**\n * Minor version number.\n */\n minor: number;\n /**\n * Patch version number.\n */\n patch: number;\n}\n\n/** Updated by release-please; must match `version` in package.json (enforced by a test). */\nconst PACKAGE_VERSION = '0.2.0'; // x-release-please-version\n\nconst [major = 0, minor = 0, patch = 0] =\n PACKAGE_VERSION.split('.').map(Number);\n\nexport /**\n *\n */\nconst LIBRARY_VERSION: Version = { major, minor, patch };\n"]}
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,wCAAwC;AACxC,+CAA+C;AAkB/C,4FAA4F;AAC5F,MAAM,eAAe,GAAG,OAAO,CAAC,CAAC,2BAA2B;AAE5D,MAAM,CAAC,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC,GACrC,eAAe,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;AAEzC,MAAM,CAAC;;GAEJ,CACH,MAAM,eAAe,GAAY,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC","sourcesContent":["// SPDX-License-Identifier: GPL-3.0-only\n// Copyright (C) 2026 comm14cux-ts contributors\n\n/** Major/minor/patch version of this library (`c14cux_version`). */\nexport interface Version {\n /**\n * Major version number.\n */\n major: number;\n /**\n * Minor version number.\n */\n minor: number;\n /**\n * Patch version number.\n */\n patch: number;\n}\n\n/** Updated by release-please; must match `version` in package.json (enforced by a test). */\nconst PACKAGE_VERSION = '0.2.1'; // x-release-please-version\n\nconst [major = 0, minor = 0, patch = 0] =\n PACKAGE_VERSION.split('.').map(Number);\n\nexport /**\n *\n */\nconst LIBRARY_VERSION: Version = { major, minor, patch };\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kb1rma/libcomm14cux-ts",
3
- "version": "0.2.0-beta.7",
3
+ "version": "0.2.1-beta.9",
4
4
  "description": "TypeScript library for communicating with the Lucas 14CUX engine ECU over a serial port using the Web Serial API",
5
5
  "license": "GPL-3.0-only",
6
6
  "repository": {
package/src/constants.ts CHANGED
@@ -173,3 +173,10 @@ export type PurgeValveState =
173
173
 
174
174
  /** Default per-read silence timeout, matching libcomm14cux (100 ms). */
175
175
  export const DEFAULT_READ_TIMEOUT_MS = 100;
176
+
177
+ /**
178
+ * Default quiet time after a failed command (500 ms). The ECU drops a
179
+ * half-received command after 256 passes of its main loop with nothing
180
+ * received; this is a conservative estimate of how long that takes.
181
+ */
182
+ export const DEFAULT_COMMAND_RESET_MS = 500;
package/src/ecu.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  Bank,
10
10
  DataOffsetRev,
11
11
  DataSize,
12
+ DEFAULT_COMMAND_RESET_MS,
12
13
  DEFAULT_READ_TIMEOUT_MS,
13
14
  FUEL_MAP_COLUMNS,
14
15
  MemoryOffset,
@@ -76,6 +77,15 @@ export interface EcuOptions {
76
77
  * libcomm14cux.
77
78
  */
78
79
  readTimeoutMs?: number;
80
+ /**
81
+ * After a command fails part-way, the ECU may still be waiting for the rest
82
+ * of it, and would take the next byte sent as that rest (for a write, as the
83
+ * value to store). So before the next command, the `Ecu` waits until the
84
+ * line has been quiet this long, in milliseconds, discarding anything that
85
+ * arrives late. It should exceed the time the ECU takes to drop a
86
+ * half-received command. Defaults to 500.
87
+ */
88
+ commandResetMs?: number;
79
89
  }
80
90
 
81
91
  /**
@@ -147,6 +157,7 @@ export class Ecu {
147
157
  this.#protocol = new Protocol(
148
158
  transport,
149
159
  options.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS,
160
+ options.commandResetMs ?? DEFAULT_COMMAND_RESET_MS,
150
161
  );
151
162
  }
152
163
 
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from './constants.js';
6
6
  export * from './errors.js';
7
7
  export type { Transport } from './transport/types.js';
8
8
  export { SimulatedTransport } from './transport/simulated.js';
9
+ export type { MemoryWriteFault } from './transport/simulated.js';
9
10
  export {
10
11
  WebSerialTransport,
11
12
  type WebSerialTransportOptions,
@@ -2,8 +2,11 @@
2
2
  // Derived from libcomm14cux (https://github.com/colinbourassa/libcomm14cux)
3
3
  // Copyright (C) Colin Bourassa. Licensed under the GNU GPL v3.
4
4
  // Ported to TypeScript and modified for comm14cux-ts, 2026.
5
- import { DEFAULT_READ_TIMEOUT_MS } from '../constants.js';
6
- import { ProtocolError, ReadCancelledError } from '../errors.js';
5
+ import {
6
+ DEFAULT_COMMAND_RESET_MS,
7
+ DEFAULT_READ_TIMEOUT_MS,
8
+ } from '../constants.js';
9
+ import { ProtocolError, ReadCancelledError, TimeoutError } from '../errors.js';
7
10
  import type { Transport } from '../transport/types.js';
8
11
  import { nextRead } from './readCount.js';
9
12
 
@@ -11,6 +14,9 @@ const ADDRESS_SPACE = 0x10000;
11
14
  const COARSE_WINDOW = 64;
12
15
  // Writes use the coarse-address command with a length code of 0.
13
16
  const WRITE_LENGTH_CODE = 0;
17
+ // The longest the ECU can keep talking after a failed command: a whole
18
+ // 512-byte read reply, plus a few echoes.
19
+ const MAX_LATE_BYTES = 0x200 + 4;
14
20
 
15
21
  /**
16
22
  * Memory read and write commands over a `Transport`
@@ -23,6 +29,8 @@ const WRITE_LENGTH_CODE = 0;
23
29
  export class Protocol {
24
30
  readonly #transport: Transport;
25
31
  readonly #timeoutMs: number;
32
+ readonly #commandResetMs: number;
33
+ #resyncNeeded = false;
26
34
  #lastReadCoarseAddress = 0;
27
35
  #lastReadQuantity = 0;
28
36
 
@@ -31,10 +39,17 @@ export class Protocol {
31
39
  *
32
40
  * @param transport - The link to the ECU.
33
41
  * @param timeoutMs - Silence timeout for each read, in milliseconds.
42
+ * @param commandResetMs - Quiet time to wait for after a failed command,
43
+ * in milliseconds.
34
44
  */
35
- constructor(transport: Transport, timeoutMs = DEFAULT_READ_TIMEOUT_MS) {
45
+ constructor(
46
+ transport: Transport,
47
+ timeoutMs = DEFAULT_READ_TIMEOUT_MS,
48
+ commandResetMs = DEFAULT_COMMAND_RESET_MS,
49
+ ) {
36
50
  this.#transport = transport;
37
51
  this.#timeoutMs = timeoutMs;
52
+ this.#commandResetMs = commandResetMs;
38
53
  }
39
54
 
40
55
  /** Forgets the last coarse address, forcing the next read to set it. */
@@ -74,6 +89,8 @@ export class Protocol {
74
89
  let totalRead = 0;
75
90
 
76
91
  try {
92
+ await this.#resync();
93
+
77
94
  while (totalRead < length) {
78
95
  if (multiChunk && isCancelled()) {
79
96
  throw new ReadCancelledError('Read cancelled');
@@ -105,6 +122,8 @@ export class Protocol {
105
122
  }
106
123
  } catch (error) {
107
124
  this.resetCache();
125
+ // Cancellation happens between chunks, when the ECU is idle.
126
+ this.#resyncNeeded ||= !(error instanceof ReadCancelledError);
108
127
  throw error;
109
128
  }
110
129
 
@@ -128,9 +147,16 @@ export class Protocol {
128
147
  }
129
148
 
130
149
  this.resetCache();
131
- await this.setCoarseAddr(addr, WRITE_LENGTH_CODE);
132
- await this.#sendEchoed(0x80 | (addr & 0x3f));
133
- await this.#sendEchoed(value);
150
+
151
+ try {
152
+ await this.#resync();
153
+ await this.setCoarseAddr(addr, WRITE_LENGTH_CODE);
154
+ await this.#sendEchoed(0x80 | (addr & 0x3f));
155
+ await this.#sendEchoed(value);
156
+ } catch (error) {
157
+ this.#resyncNeeded = true;
158
+ throw error;
159
+ }
134
160
  }
135
161
 
136
162
  /**
@@ -147,6 +173,36 @@ export class Protocol {
147
173
  await this.#sendEchoed((addr >> 6) & 0xff);
148
174
  }
149
175
 
176
+ /**
177
+ * After a failed command the ECU may still be waiting for the rest of it,
178
+ * and would take the next byte sent as that rest: for a write, as the value
179
+ * to store. Waits until the line has been quiet long enough for the ECU to
180
+ * drop the half-received command, discarding any late bytes.
181
+ *
182
+ * @throws {@link ProtocolError} if the ECU never goes quiet.
183
+ */
184
+ async #resync(): Promise<void> {
185
+ if (!this.#resyncNeeded) {
186
+ return;
187
+ }
188
+
189
+ for (let i = 0; i <= MAX_LATE_BYTES; i++) {
190
+ try {
191
+ await this.#transport.read(1, this.#commandResetMs);
192
+ } catch (error) {
193
+ if (!(error instanceof TimeoutError)) {
194
+ throw error;
195
+ }
196
+
197
+ this.#resyncNeeded = false;
198
+
199
+ return;
200
+ }
201
+ }
202
+
203
+ throw new ProtocolError('ECU did not go quiet after a failed command');
204
+ }
205
+
150
206
  async #sendEchoed(byte: number): Promise<void> {
151
207
  await this.#transport.write(Uint8Array.of(byte));
152
208
 
@@ -10,6 +10,31 @@ const MEMORY_SIZE = 0x10000;
10
10
 
11
11
  type State = 'idle' | 'coarse2' | 'writeValue';
12
12
 
13
+ /**
14
+ * Where a memory write injected by {@link SimulatedTransport.failMemoryWritesAfter}
15
+ * goes wrong:
16
+ *
17
+ * - `'commandLost'`: the write command never reaches the ECU. No echo, and
18
+ * nothing is stored.
19
+ * - `'commandEchoCorrupted'`: the ECU takes the write command, but its echo
20
+ * is garbled on the way back. The ECU then waits for the value byte.
21
+ * - `'valueEchoLost'`: the ECU stores the value, but its echo is lost.
22
+ * - `'valueEchoCorrupted'`: the ECU stores the value, but its echo is garbled
23
+ * on the way back.
24
+ */
25
+ export type MemoryWriteFault =
26
+ | 'commandLost'
27
+ | 'commandEchoCorrupted'
28
+ | 'valueEchoLost'
29
+ | 'valueEchoCorrupted';
30
+
31
+ /**
32
+ * How long the simulated ECU waits, with nothing received, before it drops a
33
+ * half-received command. The real firmware does this after 256 passes of its
34
+ * main loop; the time that takes is not documented and varies with load.
35
+ */
36
+ const SIMULATED_COMMAND_TIMEOUT_MS = 250;
37
+
13
38
  function lengthFromCode(code: number): number | undefined {
14
39
  if (code <= 0x0f) {
15
40
  return code + 1;
@@ -45,6 +70,18 @@ export class SimulatedTransport implements Transport {
45
70
  silent = false;
46
71
  /** Fault injection: reject every write. */
47
72
  failWrites = false;
73
+ /**
74
+ * Fault injection: where a write failed by
75
+ * {@link SimulatedTransport.failMemoryWritesAfter} goes wrong. Defaults to
76
+ * `'commandLost'`.
77
+ */
78
+ memoryWriteFault: MemoryWriteFault = 'commandLost';
79
+ /**
80
+ * How long the ECU waits, with nothing received, before it drops a
81
+ * half-received command, in milliseconds. A read that times out counts as
82
+ * that much silence.
83
+ */
84
+ commandTimeoutMs = SIMULATED_COMMAND_TIMEOUT_MS;
48
85
  /** Fault injection: stream at most this many bytes per read command. */
49
86
  streamLimit: number | undefined = undefined;
50
87
 
@@ -56,6 +93,32 @@ export class SimulatedTransport implements Transport {
56
93
  #latched = false;
57
94
  #lengthCode = 0;
58
95
  #writeAddr = 0;
96
+ #failMemoryWritesAfter: number | undefined = undefined;
97
+ #memoryWritesDone = 0;
98
+ #failingWrite = false;
99
+ #lastByteAt = 0;
100
+ #quietMs = 0;
101
+
102
+ /**
103
+ * Fault injection: let this many memory writes succeed, then fail every
104
+ * later one, while reads keep working. `undefined` (the default) never
105
+ * fails; `0` fails the first. Setting it starts the count again.
106
+ *
107
+ * @returns The number of memory writes allowed to succeed.
108
+ */
109
+ get failMemoryWritesAfter(): number | undefined {
110
+ return this.#failMemoryWritesAfter;
111
+ }
112
+
113
+ /**
114
+ * Sets the memory-write fault limit and starts the count again.
115
+ *
116
+ * @param count - Memory writes to allow before failing, or `undefined` for none.
117
+ */
118
+ set failMemoryWritesAfter(count: number | undefined) {
119
+ this.#failMemoryWritesAfter = count;
120
+ this.#memoryWritesDone = 0;
121
+ }
59
122
 
60
123
  /**
61
124
  * Whether the transport is open.
@@ -134,18 +197,20 @@ export class SimulatedTransport implements Transport {
134
197
  * Takes bytes from the simulated ECU's reply.
135
198
  *
136
199
  * @param length - Number of bytes wanted.
137
- * @param _timeoutMs - Ignored; the simulation answers immediately.
200
+ * @param timeoutMs - The simulation answers at once, but a read that times
201
+ * out counts as this much silence on the line.
138
202
  * @returns Exactly `length` bytes.
139
203
  * @throws {@link TimeoutError} if the ECU has not produced that many bytes.
140
204
  * @throws {@link NotConnectedError} if the transport is closed.
141
205
  */
142
- read(length: number, _timeoutMs: number): Promise<Uint8Array> {
206
+ read(length: number, timeoutMs: number): Promise<Uint8Array> {
143
207
  if (!this.#open) {
144
208
  return Promise.reject(new NotConnectedError('Transport is not open'));
145
209
  }
146
210
 
147
211
  if (this.#output.length < length) {
148
212
  this.#output = [];
213
+ this.#quietMs += timeoutMs;
149
214
 
150
215
  return Promise.reject(new TimeoutError('Simulated ECU is silent'));
151
216
  }
@@ -153,7 +218,7 @@ export class SimulatedTransport implements Transport {
153
218
  return Promise.resolve(Uint8Array.from(this.#output.splice(0, length)));
154
219
  }
155
220
 
156
- #echo(byte: number): void {
221
+ #echo(byte: number, garbled = false): void {
157
222
  if (this.silent) {
158
223
  return;
159
224
  }
@@ -162,14 +227,35 @@ export class SimulatedTransport implements Transport {
162
227
  this.corruptNextEcho = false;
163
228
  this.#output.push(byte ^ 0xff);
164
229
  } else {
165
- this.#output.push(byte);
230
+ this.#output.push(garbled ? byte ^ 0xff : byte);
166
231
  }
167
232
  }
168
233
 
169
234
  #receive(byte: number): void {
235
+ const now = Date.now();
236
+
237
+ // Like the firmware, drop a half-received command after a long enough
238
+ // silence. The address already latched is kept.
239
+ if (
240
+ this.#state !== 'idle' &&
241
+ now - this.#lastByteAt + this.#quietMs >= this.commandTimeoutMs
242
+ ) {
243
+ this.#state = 'idle';
244
+ }
245
+
246
+ this.#lastByteAt = now;
247
+ this.#quietMs = 0;
248
+
170
249
  if (this.#state === 'writeValue') {
171
250
  this.memory[this.#writeAddr] = byte;
172
- this.#echo(byte);
251
+
252
+ if (!this.#failingWrite) {
253
+ this.#memoryWritesDone += 1;
254
+ this.#echo(byte);
255
+ } else if (this.memoryWriteFault !== 'valueEchoLost') {
256
+ this.#echo(byte, this.memoryWriteFault === 'valueEchoCorrupted');
257
+ }
258
+
173
259
  this.#state = 'idle';
174
260
  this.#latched = false;
175
261
 
@@ -196,12 +282,28 @@ export class SimulatedTransport implements Transport {
196
282
  } else if ((byte & 0xc0) === 0xc0) {
197
283
  this.#streamRead(this.#coarse | (byte & 0x3f));
198
284
  } else {
285
+ this.#failingWrite = this.#memoryWriteFails();
286
+
287
+ if (this.#failingWrite && this.memoryWriteFault === 'commandLost') {
288
+ return;
289
+ }
290
+
199
291
  this.#writeAddr = this.#coarse | (byte & 0x3f);
200
- this.#echo(byte);
292
+ this.#echo(
293
+ byte,
294
+ this.#failingWrite && this.memoryWriteFault === 'commandEchoCorrupted',
295
+ );
201
296
  this.#state = 'writeValue';
202
297
  }
203
298
  }
204
299
 
300
+ #memoryWriteFails(): boolean {
301
+ return (
302
+ this.#failMemoryWritesAfter !== undefined &&
303
+ this.#memoryWritesDone >= this.#failMemoryWritesAfter
304
+ );
305
+ }
306
+
205
307
  #streamRead(address: number): void {
206
308
  if (this.silent) {
207
309
  return;