tasmota-esp-web-tools 11.1.10 → 11.2.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.
@@ -6,11 +6,16 @@ export declare class EwtConsole extends HTMLElement {
6
6
  onReset?: () => Promise<void>;
7
7
  private _console?;
8
8
  private _cancelConnection?;
9
+ private _commandHistory;
10
+ private _historyIndex;
11
+ private _currentInput;
9
12
  logs(): string;
10
13
  connectedCallback(): void;
11
14
  private _connect;
15
+ private _navigateHistory;
12
16
  private _sendCommand;
13
17
  disconnect(): Promise<void>;
18
+ disconnectedCallback(): void;
14
19
  reset(): Promise<void>;
15
20
  }
16
21
  declare global {
@@ -1,20 +1,26 @@
1
1
  import { ColoredConsole, coloredConsoleStyles } from "../util/console-color";
2
2
  import { sleep } from "../util/sleep";
3
3
  import { LineBreakTransformer } from "../util/line-break-transformer";
4
+ import { TimestampTransformer } from "../util/timestamp-transformer";
4
5
  export class EwtConsole extends HTMLElement {
5
6
  constructor() {
6
7
  super(...arguments);
7
8
  this.allowInput = true;
9
+ this._commandHistory = [];
10
+ this._historyIndex = -1;
11
+ this._currentInput = "";
8
12
  }
9
13
  logs() {
10
14
  var _a;
11
15
  return ((_a = this._console) === null || _a === void 0 ? void 0 : _a.logs()) || "";
12
16
  }
13
17
  connectedCallback() {
18
+ var _a;
14
19
  if (this._console) {
15
20
  return;
16
21
  }
17
- const shadowRoot = this.attachShadow({ mode: "open" });
22
+ // attachShadow throws if a shadow root already exists; reuse it on reattach
23
+ const shadowRoot = (_a = this.shadowRoot) !== null && _a !== void 0 ? _a : this.attachShadow({ mode: "open" });
18
24
  shadowRoot.innerHTML = `
19
25
  <style>
20
26
  :host, input {
@@ -65,6 +71,18 @@ export class EwtConsole extends HTMLElement {
65
71
  ev.stopPropagation();
66
72
  this._sendCommand();
67
73
  }
74
+ else if (ev.key === "ArrowUp") {
75
+ ev.preventDefault();
76
+ this._navigateHistory(input, 1);
77
+ }
78
+ else if (ev.key === "ArrowDown") {
79
+ ev.preventDefault();
80
+ this._navigateHistory(input, -1);
81
+ }
82
+ else {
83
+ // User is editing — reset history navigation
84
+ this._historyIndex = -1;
85
+ }
68
86
  });
69
87
  }
70
88
  const abortController = new AbortController();
@@ -74,64 +92,116 @@ export class EwtConsole extends HTMLElement {
74
92
  return connection;
75
93
  };
76
94
  }
77
- async _connect(abortSignal) {
95
+ async _connect(signal) {
78
96
  this.logger.debug("Starting console read loop");
79
- // Check if port.readable is available
97
+ // Capture a stable reference; addLine() becomes a no-op after destroy()
98
+ const consoleView = this._console;
80
99
  if (!this.port.readable) {
81
- this._console.addLine("");
82
- this._console.addLine("");
83
- this._console.addLine(`Terminal disconnected: Port readable stream not available`);
100
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine("");
101
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine("");
102
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine("Terminal disconnected: Port readable stream not available");
84
103
  this.logger.error("Port readable stream not available - port may need to be reopened at correct baudrate");
85
104
  return;
86
105
  }
87
106
  try {
88
- await this.port
89
- .readable.pipeThrough(new TextDecoderStream(), {
90
- signal: abortSignal,
91
- })
107
+ await this.port.readable
108
+ .pipeThrough(new TextDecoderStream(), { signal })
92
109
  .pipeThrough(new TransformStream(new LineBreakTransformer()))
110
+ .pipeThrough(new TransformStream(new TimestampTransformer()))
93
111
  .pipeTo(new WritableStream({
94
- write: (chunk) => {
95
- this._console.addLine(chunk.replace("\r", ""));
112
+ write: (line) => {
113
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine(line);
96
114
  },
97
115
  }));
98
- if (!abortSignal.aborted) {
99
- this._console.addLine("");
100
- this._console.addLine("");
101
- this._console.addLine("Terminal disconnected");
116
+ if (!signal.aborted) {
117
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine("");
118
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine("");
119
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine("Terminal disconnected");
102
120
  }
103
121
  }
104
- catch (e) {
105
- this._console.addLine("");
106
- this._console.addLine("");
107
- this._console.addLine(`Terminal disconnected: ${e}`);
122
+ catch (err) {
123
+ if (!signal.aborted) {
124
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine("");
125
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine("");
126
+ consoleView === null || consoleView === void 0 ? void 0 : consoleView.addLine(`Terminal disconnected: ${err}`);
127
+ }
108
128
  }
109
129
  finally {
110
130
  await sleep(100);
111
131
  this.logger.debug("Finished console read loop");
112
132
  }
113
133
  }
134
+ _navigateHistory(input, direction) {
135
+ if (this._commandHistory.length === 0)
136
+ return;
137
+ // Save current input before navigating away
138
+ if (this._historyIndex === -1) {
139
+ this._currentInput = input.value;
140
+ }
141
+ const newIndex = this._historyIndex + direction;
142
+ if (newIndex < 0) {
143
+ // Back to current (unsent) input
144
+ this._historyIndex = -1;
145
+ input.value = this._currentInput;
146
+ }
147
+ else if (newIndex < this._commandHistory.length) {
148
+ this._historyIndex = newIndex;
149
+ input.value = this._commandHistory[this._historyIndex];
150
+ }
151
+ // Move cursor to end
152
+ const len = input.value.length;
153
+ input.setSelectionRange(len, len);
154
+ }
114
155
  async _sendCommand() {
115
- const input = this.shadowRoot.querySelector("input");
116
- const command = input.value;
117
- const encoder = new TextEncoder();
156
+ var _a, _b;
157
+ const input = (_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector("input");
158
+ if (!input || !this.port.writable)
159
+ return;
160
+ const value = input.value;
118
161
  const writer = this.port.writable.getWriter();
119
- await writer.write(encoder.encode(command + "\r\n"));
120
- this._console.addLine(`> ${command}\r\n`);
121
- input.value = "";
122
- input.focus();
123
162
  try {
124
- writer.releaseLock();
163
+ await writer.write(new TextEncoder().encode(`${value}\r\n`));
164
+ (_b = this._console) === null || _b === void 0 ? void 0 : _b.addLine(`> ${value}\r\n`);
165
+ if (input.isConnected) {
166
+ // Add to history (skip empty, skip consecutive duplicates, cap at 100)
167
+ if (value && value !== this._commandHistory[0]) {
168
+ this._commandHistory.unshift(value);
169
+ if (this._commandHistory.length > 100) {
170
+ this._commandHistory.pop();
171
+ }
172
+ }
173
+ this._historyIndex = -1;
174
+ this._currentInput = "";
175
+ input.value = "";
176
+ input.focus();
177
+ }
125
178
  }
126
- catch (err) {
127
- console.error("Ignoring release lock error", err);
179
+ finally {
180
+ try {
181
+ writer.releaseLock();
182
+ }
183
+ catch (err) {
184
+ console.error("Ignoring release lock error", err);
185
+ }
128
186
  }
129
187
  }
130
188
  async disconnect() {
189
+ var _a;
131
190
  if (this._cancelConnection) {
132
191
  await this._cancelConnection();
133
192
  this._cancelConnection = undefined;
134
193
  }
194
+ (_a = this._console) === null || _a === void 0 ? void 0 : _a.destroy();
195
+ this._console = undefined;
196
+ }
197
+ disconnectedCallback() {
198
+ var _a;
199
+ if (this._cancelConnection) {
200
+ this._cancelConnection();
201
+ this._cancelConnection = undefined;
202
+ }
203
+ (_a = this._console) === null || _a === void 0 ? void 0 : _a.destroy();
204
+ this._console = undefined;
135
205
  }
136
206
  async reset() {
137
207
  this.logger.debug("Triggering reset.");
@@ -6,6 +6,7 @@ interface ConsoleState {
6
6
  foregroundColor: string | null;
7
7
  backgroundColor: string | null;
8
8
  carriageReturn: boolean;
9
+ lines: string[];
9
10
  secret: boolean;
10
11
  blink: boolean;
11
12
  rapidBlink: boolean;
@@ -13,8 +14,19 @@ interface ConsoleState {
13
14
  export declare class ColoredConsole {
14
15
  targetElement: HTMLElement;
15
16
  state: ConsoleState;
17
+ private _destroyed;
18
+ private _rafId;
19
+ private _timeoutId;
20
+ private _atBottom;
21
+ private _intersectionObserver?;
22
+ private _sentinel;
23
+ private _exportLines;
24
+ private _visibilityHandler;
16
25
  constructor(targetElement: HTMLElement);
17
26
  logs(): string;
27
+ destroy(): void;
28
+ processLine(line: string): Element;
29
+ processLines(): void;
18
30
  addLine(line: string): void;
19
31
  }
20
32
  export declare const coloredConsoleStyles = "\n .log {\n flex: 1;\n background-color: #1c1c1c;\n font-family: \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, Courier,\n monospace;\n font-size: 12px;\n padding: 16px;\n overflow: auto;\n line-height: 1.45;\n border-radius: 3px;\n white-space: pre-wrap;\n overflow-wrap: break-word;\n color: #ddd;\n }\n\n .log-bold {\n font-weight: bold;\n }\n .log-italic {\n font-style: italic;\n }\n .log-underline {\n text-decoration: underline;\n }\n .log-strikethrough {\n text-decoration: line-through;\n }\n .log-underline.log-strikethrough {\n text-decoration: underline line-through;\n }\n .log-blink {\n animation: blink 1s step-end infinite;\n }\n .log-rapid-blink {\n animation: blink 0.4s step-end infinite;\n }\n @keyframes blink {\n 50% {\n opacity: 0;\n }\n }\n .log-secret {\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n }\n .log-secret-redacted {\n opacity: 0;\n width: 1px;\n font-size: 1px;\n }\n .log-fg-black {\n color: rgb(128, 128, 128);\n }\n .log-fg-red {\n color: rgb(255, 0, 0);\n }\n .log-fg-green {\n color: rgb(0, 255, 0);\n }\n .log-fg-yellow {\n color: rgb(255, 255, 0);\n }\n .log-fg-blue {\n color: rgb(0, 0, 255);\n }\n .log-fg-magenta {\n color: rgb(255, 0, 255);\n }\n .log-fg-cyan {\n color: rgb(0, 255, 255);\n }\n .log-fg-white {\n color: rgb(187, 187, 187);\n }\n .log-bg-black {\n background-color: rgb(0, 0, 0);\n }\n .log-bg-red {\n background-color: rgb(255, 0, 0);\n }\n .log-bg-green {\n background-color: rgb(0, 255, 0);\n }\n .log-bg-yellow {\n background-color: rgb(255, 255, 0);\n }\n .log-bg-blue {\n background-color: rgb(0, 0, 255);\n }\n .log-bg-magenta {\n background-color: rgb(255, 0, 255);\n }\n .log-bg-cyan {\n background-color: rgb(0, 255, 255);\n }\n .log-bg-white {\n background-color: rgb(255, 255, 255);\n }\n";
@@ -1,3 +1,4 @@
1
+ const MAX_LINES = 2000;
1
2
  export class ColoredConsole {
2
3
  constructor(targetElement) {
3
4
  this.targetElement = targetElement;
@@ -9,30 +10,74 @@ export class ColoredConsole {
9
10
  foregroundColor: null,
10
11
  backgroundColor: null,
11
12
  carriageReturn: false,
13
+ lines: [],
12
14
  secret: false,
13
15
  blink: false,
14
16
  rapidBlink: false,
15
17
  };
18
+ this._destroyed = false;
19
+ this._rafId = 0;
20
+ this._timeoutId = 0;
21
+ this._atBottom = true;
22
+ this._sentinel = null;
23
+ // Full history for log export — never trimmed, unlike the DOM cap
24
+ this._exportLines = [];
25
+ this._visibilityHandler = null;
26
+ // Track whether the user is scrolled to the bottom via IntersectionObserver
27
+ // on a sentinel element, avoiding forced reflows on every processLines call.
28
+ const sentinel = document.createElement("div");
29
+ sentinel.style.height = "1px";
30
+ this._sentinel = sentinel;
31
+ targetElement.appendChild(sentinel);
32
+ this._intersectionObserver = new IntersectionObserver((entries) => {
33
+ this._atBottom = entries[0].isIntersecting;
34
+ }, { root: targetElement, threshold: 0 });
35
+ this._intersectionObserver.observe(sentinel);
36
+ // When the page becomes hidden, rAF is paused. Switch any pending rAF to
37
+ // a timeout so state.lines doesn't accumulate unbounded while backgrounded.
38
+ this._visibilityHandler = () => {
39
+ if (document.hidden && this._rafId) {
40
+ cancelAnimationFrame(this._rafId);
41
+ this._rafId = 0;
42
+ if (!this._timeoutId) {
43
+ this._timeoutId = window.setTimeout(() => this.processLines(), 50);
44
+ }
45
+ }
46
+ };
47
+ document.addEventListener("visibilitychange", this._visibilityHandler);
16
48
  }
17
49
  logs() {
18
- return this.targetElement.innerText;
50
+ return this._exportLines.join("");
19
51
  }
20
- addLine(line) {
21
- const re = /(?:\x1B|\\x1B)(?:\[(.*?)[@-~]|\].*?(?:\x07|\x1B\\))/g;
22
- let i = 0;
23
- if (this.state.carriageReturn) {
24
- if (line !== "\n") {
25
- // don't remove if \r\n
26
- this.targetElement.removeChild(this.targetElement.lastChild);
27
- }
28
- this.state.carriageReturn = false;
52
+ destroy() {
53
+ var _a;
54
+ this._destroyed = true;
55
+ this.state.carriageReturn = false;
56
+ this.state.lines = [];
57
+ (_a = this._intersectionObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
58
+ if (this._visibilityHandler) {
59
+ document.removeEventListener("visibilitychange", this._visibilityHandler);
60
+ this._visibilityHandler = null;
61
+ }
62
+ // Remove the sentinel from the DOM to avoid leaking it on teardown
63
+ if (this._sentinel) {
64
+ this._sentinel.remove();
65
+ this._sentinel = null;
29
66
  }
30
- if (line.includes("\r")) {
31
- this.state.carriageReturn = true;
67
+ if (this._rafId) {
68
+ cancelAnimationFrame(this._rafId);
69
+ this._rafId = 0;
32
70
  }
71
+ if (this._timeoutId) {
72
+ clearTimeout(this._timeoutId);
73
+ this._timeoutId = 0;
74
+ }
75
+ }
76
+ processLine(line) {
77
+ const re = /(?:\x1B|\\x1B)(?:\[(.*?)[@-~]|\].*?(?:\x07|\x1B\\))/g;
78
+ let i = 0;
33
79
  const lineSpan = document.createElement("span");
34
80
  lineSpan.classList.add("line");
35
- this.targetElement.appendChild(lineSpan);
36
81
  const addSpan = (content) => {
37
82
  if (content === "")
38
83
  return;
@@ -182,14 +227,88 @@ export class ColoredConsole {
182
227
  }
183
228
  }
184
229
  }
185
- const atBottom = this.targetElement.scrollTop >
186
- this.targetElement.scrollHeight - this.targetElement.offsetHeight - 50;
187
230
  addSpan(line.substring(i));
188
- // Keep scroll at bottom
189
- if (atBottom) {
231
+ return lineSpan;
232
+ }
233
+ processLines() {
234
+ this._rafId = 0;
235
+ this._timeoutId = 0;
236
+ if (this._destroyed || this.state.lines.length === 0) {
237
+ return;
238
+ }
239
+ const prevCarriageReturn = this.state.carriageReturn;
240
+ const fragment = document.createDocumentFragment();
241
+ for (const line of this.state.lines) {
242
+ if (this.state.carriageReturn && line !== "\n") {
243
+ if (fragment.childElementCount) {
244
+ fragment.removeChild(fragment.lastChild);
245
+ }
246
+ }
247
+ const hadCarriageReturn = line.endsWith("\r");
248
+ fragment.appendChild(this.processLine(line.replace(/\r/g, "")));
249
+ this.state.carriageReturn = hadCarriageReturn;
250
+ }
251
+ // Use the tracked sentinel reference instead of lastChild! so this is
252
+ // safe even if the container is empty or the sentinel was removed.
253
+ const sentinel = this._sentinel;
254
+ if (!sentinel) {
255
+ this.state.lines = [];
256
+ return;
257
+ }
258
+ if (prevCarriageReturn &&
259
+ this.state.lines[0] !== "\n" &&
260
+ sentinel.previousSibling) {
261
+ this.targetElement.replaceChild(fragment, sentinel.previousSibling);
262
+ }
263
+ else {
264
+ this.targetElement.insertBefore(fragment, sentinel);
265
+ }
266
+ this.state.lines = [];
267
+ // Trim oldest line-spans when DOM grows too large
268
+ const children = this.targetElement.children;
269
+ // -1 to exclude the sentinel div
270
+ const excess = children.length - 1 - MAX_LINES;
271
+ if (excess > 0) {
272
+ if (!this._atBottom) {
273
+ // Anchor the viewport: subtract the height of removed nodes so the
274
+ // visible content doesn't jump when we're not scrolled to the bottom.
275
+ let removedHeight = 0;
276
+ for (let i = 0; i < excess; i++) {
277
+ removedHeight += children[i].getBoundingClientRect()
278
+ .height;
279
+ }
280
+ for (let i = 0; i < excess; i++) {
281
+ this.targetElement.removeChild(children[0]);
282
+ }
283
+ this.targetElement.scrollTop -= removedHeight;
284
+ }
285
+ else {
286
+ for (let i = 0; i < excess; i++) {
287
+ this.targetElement.removeChild(children[0]);
288
+ }
289
+ }
290
+ }
291
+ if (this._atBottom) {
190
292
  this.targetElement.scrollTop = this.targetElement.scrollHeight;
191
293
  }
192
294
  }
295
+ addLine(line) {
296
+ if (this._destroyed)
297
+ return;
298
+ this._exportLines.push(line);
299
+ this.state.lines.push(line);
300
+ // Schedule a flush if none is pending yet
301
+ if (!this._rafId && !this._timeoutId) {
302
+ if (document.hidden) {
303
+ // rAF is paused when the page is hidden — use a timeout fallback
304
+ // so state.lines doesn't accumulate unbounded while backgrounded
305
+ this._timeoutId = window.setTimeout(() => this.processLines(), 50);
306
+ }
307
+ else {
308
+ this._rafId = requestAnimationFrame(() => this.processLines());
309
+ }
310
+ }
311
+ }
193
312
  }
194
313
  export const coloredConsoleStyles = `
195
314
  .log {
@@ -5,10 +5,19 @@ export class LineBreakTransformer {
5
5
  transform(chunk, controller) {
6
6
  // Append new chunks to existing chunks.
7
7
  this.chunks += chunk;
8
- // For each line breaks in chunks, send the parsed lines out.
9
- const lines = this.chunks.split("\r\n");
10
- this.chunks = lines.pop();
11
- lines.forEach((line) => controller.enqueue(line + "\r\n"));
8
+ // Split on \r\n, lone \r, or lone \n — capturing the separator so we can
9
+ // distinguish a lone \r (overwrite intent) from a normal newline.
10
+ const re = /\r\n|\r|\n/g;
11
+ let lastIndex = 0;
12
+ let match;
13
+ while ((match = re.exec(this.chunks)) !== null) {
14
+ const line = this.chunks.substring(lastIndex, match.index);
15
+ // Emit with \r suffix only for lone \r (overwrite), \n for everything else.
16
+ const suffix = match[0] === "\r" ? "\r" : "\n";
17
+ controller.enqueue(line + suffix);
18
+ lastIndex = re.lastIndex;
19
+ }
20
+ this.chunks = this.chunks.substring(lastIndex);
12
21
  }
13
22
  flush(controller) {
14
23
  // When the stream is closed, flush any remaining chunks out.
@@ -0,0 +1,3 @@
1
+ export declare class TimestampTransformer implements Transformer<string, string> {
2
+ transform(chunk: string, controller: TransformStreamDefaultController<string>): void;
3
+ }
@@ -0,0 +1,32 @@
1
+ // Matches lines that already carry a wall-clock or tick timestamp so we don't
2
+ // add a redundant one. Does NOT match bare log-level prefixes like ESPHome's
3
+ // [I][tag:line]: — those have no time information.
4
+ //
5
+ // Covered formats:
6
+ // (123456) FreeRTOS ms-tick e.g. "(12345) "
7
+ // [HH:MM:SS] wall-clock bracket
8
+ // [HH:MM:SS.mmm] wall-clock bracket with millis
9
+ // I (1234) tag: ESP-IDF log level + tick e.g. "I (1234) wifi: ..."
10
+ // HH:MM:SS.mmm plain wall-clock
11
+ const DEVICE_TIMESTAMP_RE = /^\s*(?:\(\d+\)\s|\[\d{2}:\d{2}:\d{2}(?:\.\d+)?\]|[DIWEACV] \(\d+\) \w|(?:\d{2}:){2}\d{2}\.\d)/;
12
+ export class TimestampTransformer {
13
+ transform(chunk, controller) {
14
+ // Pass through pure newline (blank-line sentinel) and empty chunks unchanged
15
+ // so that carriage-return overwrite logic in console-color.ts can still
16
+ // detect them via line !== "\n".
17
+ if (chunk === "" || chunk === "\n") {
18
+ controller.enqueue(chunk);
19
+ return;
20
+ }
21
+ // Skip prefixing if the line already starts with a timestamp
22
+ if (DEVICE_TIMESTAMP_RE.test(chunk)) {
23
+ controller.enqueue(chunk);
24
+ return;
25
+ }
26
+ const date = new Date();
27
+ const h = date.getHours().toString().padStart(2, "0");
28
+ const m = date.getMinutes().toString().padStart(2, "0");
29
+ const s = date.getSeconds().toString().padStart(2, "0");
30
+ controller.enqueue(`[${h}:${m}:${s}]${chunk}`);
31
+ }
32
+ }